查看: 143|回复: 3

Python二进制文件读写与struct打包、Base64编码实践

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
在Python里处理图片、音频、PDF、可执行文件这类内容时,最常踩的坑就是用文本模式打开。文本模式返回str,二进制模式返回bytes,两者的处理方式完全不同。本文围绕rb/wb读写、struct结构化打包、base64编码转换和魔术字节识别文件类型四个部分,把一条完整的二进制数据处理链路串起来,代码均可直接运行。

一、文本模式与二进制模式的差别

文本文件写入的是可读字符,二进制文件写入的是字节序列。下面两段代码能直观看出差别:
  1. # 文本文件——内容人类可读
  2. with open("test.txt", "w", encoding="utf-8") as f:
  3.     f.write("Hello World")
  4. # 二进制文件——内容是十六进制字节序列
  5. data = b'\x48\x65\x6c\x6c\x6f\x00\xff\xfe'
  6. with open("test.bin", "wb") as f:
  7.     f.write(data)
复制代码

需要记住的对应关系是:文本模式读写str,二进制模式读写bytes。二进制模式下不要传入encoding参数,因为不存在字符解码这一步。

二、rb/wb 基本读写操作

写入时必须传入bytes或bytearray对象,读取时拿到的也是bytes。示例中通过bytes([...])把ASCII码列表转成字节串,再用f.read()整体读出,并用hex()查看十六进制内容:
  1. # 二进制写
  2. data = bytes([72, 101, 108, 108, 111])  # "Hello"的ASCII码
  3. with open("binary_data.bin", "wb") as f:
  4.     f.write(data)
  5.     f.write(b"\n")
  6.     f.write(b"More binary data")
  7. # 二进制读
  8. with open("binary_data.bin", "rb") as f:
  9.     content = f.read()
  10. print(f"类型: {type(content)}")       # <class 'bytes'>
  11. print(f"长度: {len(content)}字节")
  12. print(f"内容: {content}")
  13. print(f"十六进制: {content.hex()}")
  14. print(f"前5字节: {content[:5]}")
复制代码

大文件或需要逐字节分析时,用f.read(1)循环读取,每次取一个字节,再按十进制、十六进制和可打印字符三种形式输出,便于排查文件结构:
  1. with open("binary_data.bin", "rb") as f:
  2.     byte = f.read(1)
  3.     while byte:
  4.         ch = chr(byte[0]) if 32 <= byte[0] < 127 else '?'
  5.         print(f" 字节: {byte[0]:3d} (0x{byte[0]:02x}) = {ch}")
  6.         byte = f.read(1)
复制代码

三、struct模块:在Python值与C结构体之间转换

struct解决的是“固定布局二进制数据”的读写问题。pack()把Python值按格式字符串打包为bytes,unpack()按同样的格式还原为元组。格式字符中i表示int,f表示float,s表示定长字符串,b是byte,h是short,q是long。
  1. import struct
  2. packed = struct.pack("i f 10s", 42, 3.14, b"Hello")
  3. print(f"打包: {packed} ({len(packed)}字节)")
  4. unpacked = struct.unpack("i f 10s", packed)
  5. print(f"解包: {unpacked}")
  6. # (42, 3.140000104904175, b'Hello\x00\x00\x00\x00\x00')
复制代码

注意10s这类定长字段会用\x00补齐,解包后要rstrip('\0')去掉填充字节;float解包出现3.140000104904175是单精度浮点本身的精度表现,不是代码错误。

把struct封装进类,就是一份可直接落地的存档方案:写入时按固定格式打包,读取时用struct.calcsize()算出应当读取的字节数,避免多读或少读。
  1. import struct
  2. class GameSave:
  3.     def __init__(self, level, score, lives, name):
  4.         self.level = level
  5.         self.score = score
  6.         self.lives = lives
  7.         self.name = name
  8.     def save(self, filepath):
  9.         """保存到二进制文件"""
  10.         name_bytes = self.name.encode("utf-8")[:32].ljust(32, b'\0')
  11.         data = struct.pack("i i i 32s", self.level, self.score, self.lives, name_bytes)
  12.         with open(filepath, "wb") as f:
  13.             f.write(data)
  14.     @classmethod
  15.     def load(cls, filepath):
  16.         """从二进制文件加载"""
  17.         with open(filepath, "rb") as f:
  18.             data = f.read(struct.calcsize("i i i 32s"))
  19.         level, score, lives, name_bytes = struct.unpack("i i i 32s", data)
  20.         name = name_bytes.decode("utf-8").rstrip('\0')
  21.         return cls(level, score, lives, name)
  22. save = GameSave(5, 99999, 3, "Player1")
  23. save.save("savegame.dat")
  24. loaded = GameSave.load("savegame.dat")
  25. 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中的二进制字段都依赖这套编码。
  1. import base64
  2. original = b"Hello, World! This is binary data \x00\xff\xfe"
  3. encoded = base64.b64encode(original)
  4. print(f"原始数据: {original}")
  5. print(f"Base64: {encoded}")
  6. # b'SGVsbG8sIFdvcmxkISBUaGlzIGlzIGJpbmFyeSBkYXRhIAD//g=='
  7. decoded = base64.b64decode(encoded)
  8. print(f"解码: {decoded}")
  9. print(f"匹配: {original == decoded}")
复制代码

把图片嵌入HTML或接口返回体,是Base64最常见的用途。下面两个函数分别负责图片到字符串、字符串回图片,其中decode("ascii")是为了把b64encode返回的bytes转成可放入JSON或HTML的str:
  1. import base64
  2. def image_to_base64(image_path):
  3.     """读取图片并转为Base64字符串"""
  4.     with open(image_path, "rb") as f:
  5.         image_data = f.read()
  6.     return base64.b64encode(image_data).decode("ascii")
  7. def base64_to_image(base64_str, output_path):
  8.     """从Base64字符串还原为图片"""
  9.     image_data = base64.b64decode(base64_str)
  10.     with open(output_path, "wb") as f:
  11.         f.write(image_data)
复制代码

为了不依赖外部素材,可以用struct和zlib手工拼一个1x1像素的合法PNG做测试。这段代码同时演示了PNG的分块结构:8字节签名,随后依次是IHDR、IDAT、IEND,每个块都由长度、类型、数据和CRC四部分组成,其中'>'表示大端字节序:
  1. import struct, zlib
  2. def create_minimal_png(filepath):
  3.     """创建最小的合法PNG文件"""
  4.     signature = b'\x89PNG\r\n\x1a\n'
  5.     ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
  6.     ihdr_crc = struct.pack('>I', zlib.crc32(b'IHDR' + ihdr_data) & 0xFFFFFFFF)
  7.     ihdr = struct.pack('>I', 13) + b'IHDR' + ihdr_data + ihdr_crc
  8.     raw_data = b'\x00\xff\x00\x00'  # filter + RGB
  9.     compressed = zlib.compress(raw_data)
  10.     idat_crc = struct.pack('>I', zlib.crc32(b'IDAT' + compressed) & 0xFFFFFFFF)
  11.     idat = struct.pack('>I', len(compressed)) + b'IDAT' + compressed + idat_crc
  12.     iend_crc = struct.pack('>I', zlib.crc32(b'IEND') & 0xFFFFFFFF)
  13.     iend = struct.pack('>I', 0) + b'IEND' + iend_crc
  14.     with open(filepath, "wb") as f:
  15.         f.write(signature + ihdr + idat + iend)
  16. create_minimal_png("test.png")
  17. b64 = image_to_base64("test.png")
  18. print(f"PNG的Base64: {b64[:80]}...")
  19. # 可嵌入HTML:<img src="data:image/png;base64,..." />
  20. base64_to_image(b64, "restored.png")
  21. print(f"还原文件大小: {__import__('os').path.getsize('restored.png')}字节")
复制代码

标准Base64的+和/在URL中会被转义或产生歧义,因此需要URL安全变体,把+替换为-、/替换为_,解码时改用urlsafe_b64decode。JWT的各个段、URL参数中的编码数据都使用这一变体:
  1. import base64
  2. data = b"test data for URL"
  3. std = base64.b64encode(data)
  4. print(f"标准: {std}")
  5. url_safe = base64.urlsafe_b64encode(data)
  6. print(f"URL安全: {url_safe}")
  7. decoded = base64.urlsafe_b64decode(url_safe)
  8. print(f"解码: {decoded}")
复制代码

五、实战:用魔术字节识别文件类型

很多文件格式的前几个字节是固定标识,称为魔术字节。以文件头对照表为基础,先取所有签名中最长的长度作为读取量,再用startswith逐一比对,就能在不解析文件内容的情况下判断类型:
  1. FILE_SIGNATURES = {
  2.     b'\xff\xd8\xff': "JPEG图片",
  3.     b'\x89PNG\r\n\x1a\n': "PNG图片",
  4.     b'GIF87a': "GIF图片",
  5.     b'GIF89a': "GIF图片",
  6.     b'%PDF': "PDF文档",
  7.     b'PK\x03\x04': "ZIP压缩包",
  8.     b'\x1f\x8b\x08': "GZIP压缩包",
  9. }
  10. def detect_file_type(filepath):
  11.     """通过魔术字节检测文件类型"""
  12.     max_header = max(len(sig) for sig in FILE_SIGNATURES)
  13.     try:
  14.         with open(filepath, "rb") as f:
  15.             header = f.read(max_header)
  16.         for signature, file_type in FILE_SIGNATURES.items():
  17.             if header.startswith(signature):
  18.                 return file_type
  19.         return "未知类型"
  20.     except Exception as e:
  21.         return f"读取错误: {e}"
  22. print(f"test.png 类型: {detect_file_type('test.png')}")
  23. 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负责可传输文本,魔术字节负责类型识别。掌握这四块,图片处理、存档持久化、接口二进制字段传输和上传文件校验都能落到具体代码上。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python二进制文件读写与struct打包、Base64编码实践

楼主这篇把二进制这条链路串得挺完整的,尤其是一开始强调文本模式和二进制模式的区别,这个坑太真实了,我自己也踩过用 open('rb', encoding='utf-8') 直接报错的蠢事,写死了记住二进制模式下千万别传 encoding。 逐字节读那段拿来排查文件结构很好用。小文件无所谓,如果真拿去分析大一点的文件,建议改成 f.read(4096) 分块再遍历,或者干脆上 mmap,速度差得挺明显的。 struct 部分想补充两点。一是格式串最好显式带字节序和对齐前缀,比如 '
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python二进制文件读写与struct打包、Base64编码实践

这篇挺实用的,把二进制读写里容易混淆的点讲得比较清楚。文本模式对应 str、二进制模式对应 bytes,以及 rb/wb 下不要传 encoding,这些对新手很关键。逐字节读取并按十进制、十六进制和可打印字符输出,用来排查文件结构也很直观。 struct 部分把 pack/unpack 和 calcsize 封装进存档类,思路很适合实际项目落地。提两个小建议:用 32s 存 UTF-8 名字时,按字节截断可能切到多字节字符中间,读回 decode 时最好处理异常,或者先控制字符数再编码;逐字节 read(1) 更适合调试小文件,大文件可以分块读取。 帖子写到 load 那里好像断了,base64 编码转换和魔术字节识别文件类型还没展开,期待后续补全,整体已经是一条很清晰的二进制数据处理链路了。
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python二进制文件读写与struct打包、Base64编码实践

感谢分享,这帖子把二进制读写的基础链路梳理得很清楚,尤其适合刚接触文件处理的朋友。rb/wb 那段用 hex 和逐字节打印的方式来观察内容,排查文件结构时确实很实用。 struct 部分写得很扎实,GameSave 那个例子直接能拿去改造成存档系统。有几个小地方可以顺手补一下: 一是格式字符串里用空格分隔其实 struct 会忽略空格,不过留着确实更易读,这点没问题。二是如果同一个结构要反复 pack/unpack,可以用 struct.Struct(“iii32s”) 预先编译,调用 .pack()/.unpack() 会比每次传格式字符串稍微高效些,代码也更紧凑。三是存档里的 name 字段用 ljust 补齐 32 字节没问题,但 load 时记得对解出来的 bytes 做一次 rstrip(b'\0'),否则名字后面会挂一串空字节,直接拿去比较或者显示都不太对。 另外逐字节读取那段,小文件分析够用,但如果文件大一些,f.read(1) 循环调用次数会很多,可以考虑一次读一块再切片遍历,性能会好不少。 帖子到 GameSave.load 的 level 那里好像断了,后面的 base64 编码转换和魔术字节识别文件类型还没看到,这两块也是我很感兴趣的,期待楼主补全。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-9 08:29 , Processed in 0.021586 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部