二进制文件操作:不只是文本的世界
说实话,很多初学者学文件操作时,都默认文件里存的是文字。但现实世界哪有这么简单?图片、音频、视频、可执行程序……这些都不是纯文本。我刚开始做项目时,就吃过这个亏——用文本模式去读一张图片,结果全是乱码,程序直接崩溃。
二进制模式,说白了就是让Python把文件当成「一串字节」来处理。没有编码转换,没有换行符处理,你给什么就读什么。这才是文件操作的底层真相。
二进制模式的三种打开方式
Python里用 'b' 来表示二进制模式。配合读写操作,有三种常用组合:
| 模式 | 含义 | 文件指针位置 | 如果文件不存在 |
|---|---|---|---|
rb |
二进制读取 | 开头 | 报错 |
wb |
二进制写入 | 开头(覆盖) | 新建 |
ab |
二进制追加 | 末尾 | 新建 |
嗯,这里要注意:wb 模式会直接清空原文件。我曾经在调试时手滑写错了变量名,把一个重要的配置文件给覆盖了……从那以后,我写二进制写入前都会先备份。
实战:复制一张图片
咱们直接上手。复制图片,就是二进制文件操作最典型的应用:
with open('input.jpg', 'rb') as src:
data = src.read()
with open('output.jpg', 'wb') as dst:
dst.write(data)
print('图片复制完成!')
你看,就这么几行。图片、音频、视频,原理都一样。我有个朋友做爬虫,每天要下载上千张商品图,用的就是这套逻辑。
但有个坑——如果图片很大(比如几百MB),一次性 read() 会吃掉大量内存。我建议用分块读取:
with open('input.jpg', 'rb') as src, open('output.jpg', 'wb') as dst:
while True:
chunk = src.read(4096) # 每次读4KB
if not chunk:
break
dst.write(chunk)
为什么是4096?其实这是个经验值。4KB是大多数文件系统的簇大小,读写效率比较高。你想想看,如果每次只读1个字节,那得调用多少次系统函数?
处理音频文件:小心头部信息
音频文件比图片更「娇气」。比如WAV文件,开头有一段44字节的文件头,里面记录了采样率、位深、声道数等信息。如果你直接修改后面的音频数据,但忘了更新文件头……嗯,播放器会直接罢工。
我做过一个音频剪辑工具,踩过这个坑。当时我直接修改了PCM数据,结果播放出来全是噪音。排查了半天,才发现是文件头里的数据长度字段没更新。
import struct
# 读取WAV文件头(前44字节)
with open('audio.wav', 'rb') as f:
header = f.read(44)
# 解析文件头
chunk_id = header[0:4] # b'RIFF'
file_size = struct.unpack('<I', header[4:8])[0]
wave_id = header[8:12] # b'WAVE'
fmt_id = header[12:16] # b'fmt '
fmt_size = struct.unpack('<I', header[16:20])[0]
audio_format = struct.unpack('<H', header[20:22])[0]
num_channels = struct.unpack('<H', header[22:24])[0]
sample_rate = struct.unpack('<I', header[24:28])[0]
byte_rate = struct.unpack('<I', header[28:32])[0]
block_align = struct.unpack('<H', header[32:34])[0]
bits_per_sample = struct.unpack('<H', header[34:36])[0]
print(f'采样率: {sample_rate} Hz')
print(f'声道数: {num_channels}')
print(f'位深: {bits_per_sample} bit')
核心要点:二进制文件操作的关键,就是你要知道文件的「结构」。图片有图片的格式规范,音频有音频的协议标准。不了解结构,你就是在瞎改。
struct模块:打包与解包的利器
刚才的代码里用到了 struct 模块。它的作用很简单:把Python的数据类型(整数、浮点数、字符串)转换成固定长度的字节串,或者反过来。
我个人习惯用 struct.pack() 来打包数据,用 struct.unpack() 来解包。格式字符串是它的灵魂:
| 格式符 | 含义 | 字节数 |
|---|---|---|
i |
有符号整数 | 4 |
I |
无符号整数 | 4 |
h |
有符号短整数 | 2 |
H |
无符号短整数 | 2 |
f |
浮点数 | 4 |
d |
双精度浮点数 | 8 |
s |
字节串 | 指定长度 |
还有字节序的修饰符:< 表示小端序,> 表示大端序。为什么要有这个?因为不同CPU架构存储多字节数据的方式不一样。Intel的CPU用小端序,网络协议通常用大端序。我在做跨平台工具时,就因为这个字节序问题折腾了一整天。
import struct
# 打包:把Python数据变成字节串
packed = struct.pack('<I 2H f', 100, 1, 2, 3.14)
print(packed) # 输出 b'd\x00\x00\x00\x01\x00\x02\x00\xc3\xf5H@'
# 解包:把字节串变回Python数据
unpacked = struct.unpack('<I 2H f', packed)
print(unpacked) # 输出 (100, 1, 2, 3.140000104904175)
小技巧:用 struct.calcsize() 可以提前计算格式字符串占用的字节数。比如 struct.calcsize('<I 2H f') 返回12,这样你就能知道需要读取多少字节。
实战:自定义二进制文件格式
有时候,你需要自己定义一种文件格式。比如保存一组传感器的数据:
import struct
def save_sensor_data(filename, readings):
"""保存传感器数据到二进制文件"""
with open(filename, 'wb') as f:
# 文件头:魔数(4字节) + 版本号(2字节) + 数据条数(4字节)
magic = b'SENS'
version = 1
count = len(readings)
f.write(struct.pack('<4s H I', magic, version, count))
# 每条数据:时间戳(4字节) + 温度(4字节float) + 湿度(4字节float)
for ts, temp, hum in readings:
f.write(struct.pack('<I f f', ts, temp, hum))
def load_sensor_data(filename):
"""从二进制文件加载传感器数据"""
readings = []
with open(filename, 'rb') as f:
# 读取文件头
magic, version, count = struct.unpack('<4s H I', f.read(10))
if magic != b'SENS':
raise ValueError('不是有效的传感器数据文件')
# 读取每条数据
for _ in range(count):
data = f.read(12) # 4 + 4 + 4
ts, temp, hum = struct.unpack('<I f f', data)
readings.append((ts, temp, hum))
return readings
# 使用示例
data = [(1000, 25.5, 60.0), (1001, 26.0, 59.5), (1002, 25.8, 61.0)]
save_sensor_data('sensor.bin', data)
loaded = load_sensor_data('sensor.bin')
print(loaded)
注意:自定义二进制格式一定要设计好「魔数」和「版本号」。魔数用来快速识别文件类型,版本号用来兼容后续升级。我见过太多项目,一开始没考虑版本兼容,后来数据格式一改,旧文件全废了。
避坑指南
最后,分享几个我踩过的坑:
- 不要用文本模式处理二进制文件——Windows下
'\n'会被自动转换成'\r\n',图片会损坏。 - 注意文件指针的位置——
read()之后指针会移动,如果需要重新读取,记得seek(0)。 - 大文件不要一次读完——用分块读取,或者用
memoryview零拷贝处理。 - struct的格式字符串要严格匹配——多一个字节少一个字节,解包就会报错。
我曾经在做一个嵌入式项目时,用二进制格式存储配置参数。因为结构体定义没对齐,导致在不同平台上读取的数据完全不一样。后来我强制在格式字符串里加了填充字节,问题才解决。
二进制文件操作,说白了就是「按规则读写字节」。规则清楚了,剩下的就是细心。你想想看,计算机底层不都是0和1吗?掌握了二进制操作,你就真正理解了文件系统的本质。