在Python里处理图片、音频、PDF、可执行文件这类内容时,最常踩的坑就是用文本模式打开。文本模式返回str,二进制模式返回bytes,两者的处理方式完全不同。本文围绕rb/wb读写、struct结构化打包、base64编码转换和魔术字节识别文件类型四个部分,把一条完整的二进制数据处理链路串起来,代码均可直接运行。
一、文本模式与二进制模式的差别
文本文件写入的是可读字符,二进制文件写入的是字节序列。下面两段代码能直观看出差别:
- # 文本文件——内容人类可读
- with open("test.txt", "w", encoding="utf-8") as f:
- f.write("Hello World")
- # 二进制文件——内容是十六进制字节序列
- data = b'\x48\x65\x6c\x6c\x6f\x00\xff\xfe'
- with open("test.bin", "wb") as f:
- f.write(data)
复制代码
需要记住的对应关系是:文本模式读写str,二进制模式读写bytes。二进制模式下不要传入encoding参数,因为不存在字符解码这一步。
二、rb/wb 基本读写操作
写入时必须传入bytes或bytearray对象,读取时拿到的也是bytes。示例中通过bytes([...])把ASCII码列表转成字节串,再用f.read()整体读出,并用hex()查看十六进制内容:
- # 二进制写
- data = bytes([72, 101, 108, 108, 111]) # "Hello"的ASCII码
- with open("binary_data.bin", "wb") as f:
- f.write(data)
- f.write(b"\n")
- f.write(b"More binary data")
- # 二进制读
- with open("binary_data.bin", "rb") as f:
- content = f.read()
- print(f"类型: {type(content)}") # <class 'bytes'>
- print(f"长度: {len(content)}字节")
- print(f"内容: {content}")
- print(f"十六进制: {content.hex()}")
- print(f"前5字节: {content[:5]}")
复制代码
大文件或需要逐字节分析时,用f.read(1)循环读取,每次取一个字节,再按十进制、十六进制和可打印字符三种形式输出,便于排查文件结构:
- with open("binary_data.bin", "rb") as f:
- byte = f.read(1)
- while byte:
- ch = chr(byte[0]) if 32 <= byte[0] < 127 else '?'
- print(f" 字节: {byte[0]:3d} (0x{byte[0]:02x}) = {ch}")
- byte = f.read(1)
复制代码
三、struct模块:在Python值与C结构体之间转换
struct解决的是“固定布局二进制数据”的读写问题。pack()把Python值按格式字符串打包为bytes,unpack()按同样的格式还原为元组。格式字符中i表示int,f表示float,s表示定长字符串,b是byte,h是short,q是long。
- import struct
- packed = struct.pack("i f 10s", 42, 3.14, b"Hello")
- print(f"打包: {packed} ({len(packed)}字节)")
- unpacked = struct.unpack("i f 10s", packed)
- print(f"解包: {unpacked}")
- # (42, 3.140000104904175, b'Hello\x00\x00\x00\x00\x00')
复制代码
注意10s这类定长字段会用\x00补齐,解包后要rstrip('\0')去掉填充字节;float解包出现3.140000104904175是单精度浮点本身的精度表现,不是代码错误。
把struct封装进类,就是一份可直接落地的存档方案:写入时按固定格式打包,读取时用struct.calcsize()算出应当读取的字节数,避免多读或少读。
- import struct
- class GameSave:
- def __init__(self, level, score, lives, name):
- self.level = level
- self.score = score
- self.lives = lives
- self.name = name
- def save(self, filepath):
- """保存到二进制文件"""
- name_bytes = self.name.encode("utf-8")[:32].ljust(32, b'\0')
- data = struct.pack("i i i 32s", self.level, self.score, self.lives, name_bytes)
- with open(filepath, "wb") as f:
- f.write(data)
- @classmethod
- def load(cls, filepath):
- """从二进制文件加载"""
- with open(filepath, "rb") as f:
- data = f.read(struct.calcsize("i i i 32s"))
- level, score, lives, name_bytes = struct.unpack("i i i 32s", data)
- name = name_bytes.decode("utf-8").rstrip('\0')
- return cls(level, score, lives, name)
- save = GameSave(5, 99999, 3, "Player1")
- save.save("savegame.dat")
- loaded = GameSave.load("savegame.dat")
- print(f"加载存档: 关卡{loaded.level}, 分数{loaded.score}, 生命{loaded.lives}")
复制代码
这里的32s字段先把名字截断到32字节,再用ljust补\0,保证每条记录的尺寸固定,这是定长二进制记录能随机定位读取的前提。
四、Base64编码:让二进制数据走文本通道
二进制数据无法直接放进只接受文本的协议里,Base64的作用就是把任意字节编码成纯ASCII字符串。它的基本规律是每3个字节编码为4个ASCII字符,体积增加约33%,字符集仅包含A-Z、a-z、0-9、+、/和填充符=。邮件附件、图片Data URI、JWT、API中的二进制字段都依赖这套编码。
- import base64
- original = b"Hello, World! This is binary data \x00\xff\xfe"
- encoded = base64.b64encode(original)
- print(f"原始数据: {original}")
- print(f"Base64: {encoded}")
- # b'SGVsbG8sIFdvcmxkISBUaGlzIGlzIGJpbmFyeSBkYXRhIAD//g=='
- decoded = base64.b64decode(encoded)
- print(f"解码: {decoded}")
- print(f"匹配: {original == decoded}")
复制代码
把图片嵌入HTML或接口返回体,是Base64最常见的用途。下面两个函数分别负责图片到字符串、字符串回图片,其中decode("ascii")是为了把b64encode返回的bytes转成可放入JSON或HTML的str:
- import base64
- def image_to_base64(image_path):
- """读取图片并转为Base64字符串"""
- with open(image_path, "rb") as f:
- image_data = f.read()
- return base64.b64encode(image_data).decode("ascii")
- def base64_to_image(base64_str, output_path):
- """从Base64字符串还原为图片"""
- image_data = base64.b64decode(base64_str)
- with open(output_path, "wb") as f:
- f.write(image_data)
复制代码
为了不依赖外部素材,可以用struct和zlib手工拼一个1x1像素的合法PNG做测试。这段代码同时演示了PNG的分块结构:8字节签名,随后依次是IHDR、IDAT、IEND,每个块都由长度、类型、数据和CRC四部分组成,其中'>'表示大端字节序:
- import struct, zlib
- def create_minimal_png(filepath):
- """创建最小的合法PNG文件"""
- signature = b'\x89PNG\r\n\x1a\n'
- ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
- ihdr_crc = struct.pack('>I', zlib.crc32(b'IHDR' + ihdr_data) & 0xFFFFFFFF)
- ihdr = struct.pack('>I', 13) + b'IHDR' + ihdr_data + ihdr_crc
- raw_data = b'\x00\xff\x00\x00' # filter + RGB
- compressed = zlib.compress(raw_data)
- idat_crc = struct.pack('>I', zlib.crc32(b'IDAT' + compressed) & 0xFFFFFFFF)
- idat = struct.pack('>I', len(compressed)) + b'IDAT' + compressed + idat_crc
- iend_crc = struct.pack('>I', zlib.crc32(b'IEND') & 0xFFFFFFFF)
- iend = struct.pack('>I', 0) + b'IEND' + iend_crc
- with open(filepath, "wb") as f:
- f.write(signature + ihdr + idat + iend)
- create_minimal_png("test.png")
- b64 = image_to_base64("test.png")
- print(f"PNG的Base64: {b64[:80]}...")
- # 可嵌入HTML:<img src="data:image/png;base64,..." />
- base64_to_image(b64, "restored.png")
- print(f"还原文件大小: {__import__('os').path.getsize('restored.png')}字节")
复制代码
标准Base64的+和/在URL中会被转义或产生歧义,因此需要URL安全变体,把+替换为-、/替换为_,解码时改用urlsafe_b64decode。JWT的各个段、URL参数中的编码数据都使用这一变体:
- import base64
- data = b"test data for URL"
- std = base64.b64encode(data)
- print(f"标准: {std}")
- url_safe = base64.urlsafe_b64encode(data)
- print(f"URL安全: {url_safe}")
- decoded = base64.urlsafe_b64decode(url_safe)
- print(f"解码: {decoded}")
复制代码
五、实战:用魔术字节识别文件类型
很多文件格式的前几个字节是固定标识,称为魔术字节。以文件头对照表为基础,先取所有签名中最长的长度作为读取量,再用startswith逐一比对,就能在不解析文件内容的情况下判断类型:
- FILE_SIGNATURES = {
- b'\xff\xd8\xff': "JPEG图片",
- b'\x89PNG\r\n\x1a\n': "PNG图片",
- b'GIF87a': "GIF图片",
- b'GIF89a': "GIF图片",
- b'%PDF': "PDF文档",
- b'PK\x03\x04': "ZIP压缩包",
- b'\x1f\x8b\x08': "GZIP压缩包",
- }
- def detect_file_type(filepath):
- """通过魔术字节检测文件类型"""
- max_header = max(len(sig) for sig in FILE_SIGNATURES)
- try:
- with open(filepath, "rb") as f:
- header = f.read(max_header)
- for signature, file_type in FILE_SIGNATURES.items():
- if header.startswith(signature):
- return file_type
- return "未知类型"
- except Exception as e:
- return f"读取错误: {e}"
- print(f"test.png 类型: {detect_file_type('test.png')}")
- print(f"savegame.dat 类型: {detect_file_type('savegame.dat')}")
复制代码
六、实践要点与排错思路
第一,模式写错是最常见的错误来源。用"r"读二进制文件会出现解码异常或乱码,用"w"写bytes会直接抛出类型错误,读写二进制一律使用"rb"和"wb"。
第二,struct的格式字符串必须收发一致。pack和unpack的格式、字段顺序、字段长度任一不同,解出来的值就会错位;读取前用calcsize()计算长度,能避免读到一半或读多覆盖后续数据。
第三,定长字符串字段要处理填充字节。写入时截断加ljust补\0,读取时用rstrip('\0')清理,否则名字后面会带一串空字符。
第四,Base64只解决传输形态,不解决压缩。编码后体积约增大三分之一,大文件不要长期以Base64形式存储;同时标准变体和URL安全变体不能混用,解码函数要和编码函数配对。
第五,魔术字节检测只能做初步判断。签名表需要覆盖目标格式,读取长度要取最长签名,遇到未知类型或读取异常时返回明确提示,而不是直接抛出异常中断流程。
整体脉络可以归纳为一句话:文本模式对应str,二进制模式对应bytes,struct负责结构化布局,base64负责可传输文本,魔术字节负责类型识别。掌握这四块,图片处理、存档持久化、接口二进制字段传输和上传文件校验都能落到具体代码上。 |