4、Python文件操作:二进制文件读写与内存映射技巧
做FPGA逆向,说白了就是跟二进制打交道。比特流、配置文件、固件镜像,哪个不是一堆0和1?我刚开始做这行时,用记事本打开比特流文件,满屏乱码,差点以为文件坏了。后来才明白——二进制文件,就得用二进制的方式去读。
这一章,我带你搞定Python操作二进制文件的几个核心技巧。包括基本的open读写、struct模块解析、array模块批量处理、大文件分块,以及内存映射mmap这种高阶玩法。嗯,都是我在实际项目中反复用到的。
4.1 二进制文件基础:open与读写模式
Python的open函数,加个'b'就是二进制模式。就这么简单。
# 读取二进制文件
with open('bitstream.bit', 'rb') as f:
data = f.read() # 一次性读入内存
# 写入二进制文件
with open('output.bin', 'wb') as f:
f.write(data)
这里有个坑,我提醒你一下:rb和wb是必须的。不加b的话,Python会自作主张做换行符转换,Windows下尤其容易出问题。我曾经因为这个,解析一个Xilinx比特流时,文件大小对不上,排查了半天才发现是模式写错了。
4.2 struct模块:解析二进制结构体
FPGA的比特流文件,通常有固定的头部结构。比如Xilinx的比特流,开头是同步字、长度、设备ID等字段。这时候,struct模块就派上用场了。
import struct
# 假设比特流头部结构:4字节同步字 + 2字节长度 + 1字节类型
header_format = '>4s H B' # >表示大端序,4s是4字节字符串,H是unsigned short,B是unsigned char
with open('bitstream.bit', 'rb') as f:
header_data = f.read(struct.calcsize(header_format))
sync_word, length, type_byte = struct.unpack(header_format, header_data)
print(f'同步字: {sync_word.hex()}')
print(f'长度: {length}')
print(f'类型: {type_byte}')
struct的格式字符串,说白了就是告诉Python:这段二进制数据,哪些字节对应什么类型。我个人习惯用>或<明确指定字节序,FPGA相关的大多是big-endian(大端序)。
4.3 array模块:高效处理数值数组
如果你要处理大量相同类型的数据——比如从比特流中提取的配置数据、或者ADC采样值——用array模块比用list快得多,内存占用也小。
from array import array
# 读取16位无符号整数数组
with open('samples.bin', 'rb') as f:
data = array('H') # 'H'表示unsigned short
data.frombytes(f.read())
print(f'共 {len(data)} 个采样点')
print(f'前10个值: {data[:10]}')
array的类型码有很多:'B'是unsigned char,'H'是unsigned short,'I'是unsigned int,'f'是float。你想想看,如果直接用list存100万个整数,内存开销至少是array的3-4倍。我在处理一个几百MB的FPGA配置数据时,用array直接把内存占用从2GB降到了500MB。
4.4 大文件分块处理
FPGA的比特流文件,动辄几十MB甚至上百MB。一次性读入内存?不现实。尤其是嵌入式设备上,内存本来就紧张。这时候就得用分块读取。
def read_bitstream_in_chunks(filepath, chunk_size=1024*1024):
"""分块读取比特流文件,每块1MB"""
with open(filepath, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
# 处理每一块数据
process_chunk(chunk)
def process_chunk(chunk):
"""处理单个数据块,比如查找同步字"""
sync_pattern = b'\xAA\x99\x55\x66' # 假设的同步字
if sync_pattern in chunk:
print(f'找到同步字,偏移量: {chunk.find(sync_pattern)}')
分块处理的关键是:块与块之间的边界。比如你要查找一个跨块的关键字,就得保留上一块的末尾数据。我遇到过这种情况:同步字刚好被切在两块之间,结果死活找不到。后来我改成每块保留前一块的最后几个字节,才解决问题。
4.5 内存映射(mmap):把文件当内存用
mmap是我最喜欢的技巧。它把文件的一部分或全部映射到进程的虚拟地址空间,操作文件就像操作内存一样。对于大文件随机访问,效率极高。
import mmap
with open('large_bitstream.bin', 'r+b') as f:
# 映射整个文件
with mmap.mmap(f.fileno(), 0) as mm:
# 现在可以像操作bytes一样操作mm
print(f'文件大小: {len(mm)} 字节')
# 随机访问:读取偏移0x100处的4字节
value = mm[0x100:0x104]
print(f'偏移0x100处的数据: {value.hex()}')
# 查找特定模式
pos = mm.find(b'\xAA\x99\x55\x66')
if pos != -1:
print(f'找到同步字,偏移: {hex(pos)}')
# 修改数据(文件必须是以r+b模式打开)
mm[0x200:0x204] = b'\x00\x00\x00\x01'
mmap的好处是什么?按需加载。你只访问文件的一小部分,操作系统只会把那一部分加载到内存。对于几十GB的FPGA配置数据,你只需要在内存中保留当前处理的部分。
- 文件较小(<100MB):直接用read(),简单粗暴
- 文件较大但顺序访问:用分块读取,控制内存
- 文件很大且需要随机访问:用mmap,性能最优
4.6 实战:解析FPGA比特流头部
来,我们综合运用一下。假设我们要解析一个Xilinx风格的比特流文件头部。
import struct
import mmap
def parse_bitstream_header(filepath):
"""解析比特流文件头部"""
with open(filepath, 'rb') as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
# 跳过前导填充(通常是0xFF)
pos = 0
while pos < len(mm) and mm[pos] == 0xFF:
pos += 1
# 读取同步字(0xAA995566)
if mm[pos:pos+4] == b'\xAA\x99\x55\x66':
print(f'同步字偏移: {hex(pos)}')
pos += 4
else:
print('未找到同步字')
return
# 读取长度字段(大端序,4字节)
length = struct.unpack('>I', mm[pos:pos+4])[0]
pos += 4
print(f'数据长度: {length} 字节')
# 读取设备ID(2字节)
device_id = struct.unpack('>H', mm[pos:pos+2])[0]
pos += 2
print(f'设备ID: {hex(device_id)}')
# 后续就是配置数据了
config_data = mm[pos:pos+length]
print(f'配置数据大小: {len(config_data)} 字节')
# 使用示例
parse_bitstream_header('sample.bit')
这段代码,我实际用在好几个项目里。有一次客户给了一个损坏的比特流,用这个方法一跑,发现同步字偏移不对,原来是文件开头多了几个填充字节。手动修正后,比特流就能正常加载了。
4.7 知识体系总览
下面这张图,帮你理清本章的知识结构:
这张图把本章的核心知识点串起来了。从基础的open读写,到struct和array两个专用模块,再到大文件分块和mmap高阶技巧,最后落到比特流解析这个实战场景。你想想看,是不是每个知识点都有它的用武之地?
好了,这一章的内容就到这里。二进制文件操作是FPGA逆向的基本功,多练练,熟能生巧。下一章我们会深入比特流的具体格式,到时候这些技巧都会用上。