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)

这里有个坑,我提醒你一下:rbwb是必须的。不加b的话,Python会自作主张做换行符转换,Windows下尤其容易出问题。我曾经因为这个,解析一个Xilinx比特流时,文件大小对不上,排查了半天才发现是模式写错了。

注意:二进制模式下,read()返回的是bytes对象,不是字符串。别想着用encode/decode去转,直接操作字节就好。

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(大端序)。

小技巧:struct.calcsize()可以提前计算格式占用的字节数,方便你决定读取多少数据。我经常用它来验证我对结构体大小的理解是否正确。

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 知识体系总览

下面这张图,帮你理清本章的知识结构:

Python二进制文件操作知识体系 二进制文件操作 open() 基础读写 struct 结构体解析 array 数值数组 大文件分块处理 mmap 内存映射 比特流解析实战 chunk_size控制 跨块边界处理 按需加载 随机访问高效 同步字定位 字段解析 选择策略:小文件read → 大文件分块 → 超大文件mmap

这张图把本章的核心知识点串起来了。从基础的open读写,到struct和array两个专用模块,再到大文件分块和mmap高阶技巧,最后落到比特流解析这个实战场景。你想想看,是不是每个知识点都有它的用武之地?

好了,这一章的内容就到这里。二进制文件操作是FPGA逆向的基本功,多练练,熟能生巧。下一章我们会深入比特流的具体格式,到时候这些技巧都会用上。


专注资料整理