乱码与编码:世间本无“锟斤拷”
从六个字节亲手制造“锟斤拷”,再沿着编码与解码的路径,分清哪些乱码能恢复、哪些信息已经丢失。
本文目录
“手持两把锟斤拷,口中疾呼烫烫烫。”
这些字看起来像程序员的暗号,实际上常常是某段数据处理过程留下的痕迹。它们不需要靠猜,也可以一步步算出来。
先打开 Python,亲手制造一次“锟斤拷”:
raw = bytes.fromhex('ef bf bd ef bf bd')
print(raw.decode('utf-8'))
print(raw.decode('gbk'))
输出:
��
锟斤拷
同样的六个字节,使用不同规则解释,就成了两种文字。 文件里保存的是字节,屏幕上显示的是解释结果。
一、先分清字符、码点和字节
拿“中”举例:
text = '中'
print(f'U+{ord(text):04X}')
print(text.encode('utf-8').hex(' '))
print(text.encode('gbk').hex(' '))
输出:
U+4E2D
e4 b8 ad
d6 d0
“中”是我们想表达的字符;U+4E2D 是它的 Unicode 码点;e4 b8 ad 则是 UTF-8 保存这个字符时使用的字节序列。
编码与解码是两个相反方向:
字符 ──encode──→ 字节
字符 ←──decode── 字节
因此,“这份文件是什么编码”问的是:应该按哪套规则把它的字节还原成文字。不能只看某个字节就决定它是什么字符。
ASCII、GBK、Unicode 和 UTF-8
ASCII 定义了 0x00~0x7F 共 128 个码位,包含英文字母、数字、标点和控制字符。它没有为汉字分配位置。
GB2312、GBK 等编码扩展了中文表示能力。GBK 兼容 ASCII,常见汉字用两个字节表示,但并不是任意汉字都能用 GBK 编码。
Unicode 给字符分配码点,UTF-8 则规定这些码点如何变成字节。UTF-8 使用 1~4 字节:ASCII 字符占 1 字节,常用汉字通常占 3 字节,扩展区汉字可能占 4 字节。比如:
for text in ['A', '中', '\U00020000']:
raw = text.encode('utf-8')
print(f'U+{ord(text):04X}', len(raw), raw.hex(' '))
不要把“一个字符占几个字节”当作固定常识,它需要先指定编码。有些屏幕上的单个图形还由多个 Unicode 码点组合而成。Unicode 编码 FAQ
二、乱码是怎样一步步发生的?
“你好”的 UTF-8 字节是:
e4 bd a0 e5 a5 bd
假设接收方误用了 GBK:
original = '你好'
raw = original.encode('utf-8')
garbled = raw.decode('gbk')
print(garbled)
输出会变成 浣犲ソ。这次没有发生解码报错,因为这组字节碰巧也能按 GBK 解释,只是解释出的字符不对。
如果之后又把 浣犲ソ 正常保存成 UTF-8,错误就被固化成了新的文件。此时只把编辑器切回 UTF-8,当然救不回来——新文件本来就是一个合法的 UTF-8 文件,只是保存了错误的文字。
需要撤销的是那次错误的解释过程:
recovered = garbled.encode('gbk').decode('utf-8')
print(recovered)
assert recovered == original
先用错误的编码把乱码编回原来的字节,再按正确编码解码。这里之所以能恢复,是因为这个具体例子的转换过程保留了所需信息。
三、“锟斤拷”为什么经常意味着信息丢失?
并不是所有字节都能被 UTF-8 接受。遇到非法序列时,程序可以报错,也可以丢弃或替换它们。
bad = bytes.fromhex('ff ff')
replacement = bad.decode('utf-8', errors='replace')
print(replacement)
print(replacement.encode('utf-8').hex(' '))
结果是两个替换字符 �,以及开头见过的字节:
ef bf bd ef bf bd
再按 GBK 两字节一组解释:
| 字节 | GBK 解码结果 |
|---|---|
EF BF |
锟 |
BD EF |
斤 |
BF BD |
拷 |
于是“锟斤拷”出现了。
真正丢信息的是前面的替换操作:不同的非法输入可能都变成同一个 �,仅靠替换后的文字,无法知道原字节是什么。后面的 GBK 解码只是让损坏变得更显眼。
反过来,没有“锟斤拷”也不保证可恢复。errors='ignore' 可能已经删掉字节,无法表示的字符也可能被替换成问号。问号本身还可能就是原文,所以不能只凭长相诊断。
如果还能拿到原始文件、网络响应或备份,就应从原始字节重新开始,而不是反复对损坏后的字符串转码。
四、“烫烫烫”留下的是另一种线索
先算一下:
print(bytes.fromhex('cc cc cc cc').decode('gbk'))
print(bytes.fromhex('cd cd cd cd').decode('gbk'))
输出分别是 烫烫 和 屯屯。
在一些 MSVC 调试环境中,未初始化的栈区域可能被填充为 0xCC,调试堆中新分配的内存可能使用 0xCD。当这些字节被当作字符串读取,再按 GBK 显示,就可能出现熟悉的“烫”和“屯”。其中调试堆的填充值可查阅 Microsoft CRT 调试堆说明。
这是特定工具链与调试配置下的线索,不是 C/C++ 对未初始化内存的保证,也不能据此认定所有“烫”都来自同一种错误。
排查方向通常是:字符串是否正确终止,长度是否越界,变量是否初始化,对象是否已经释放。仅仅换编码不会修复错误的内存访问。
五、那些容易拿错的编码标签
Windows 的“ANSI”
这里的 ANSI 通常指系统使用的某个传统代码页,并不是一个全球统一的字符编码。例如简体中文环境常见 CP936,日文环境常见 CP932,西欧环境常见 Windows-1252;实际选择与系统区域和配置有关。
老日文软件在错误的代码页环境下运行,可能连文件名都解释错。区域设置或兼容工具有时能解决这一类问题,但不会自动修复已经被覆盖保存的数据。
浏览器中的 Latin-1
在 Web 编码规范中,iso-8859-1 等标签按 Windows-1252 处理;Python 的 latin-1 则不会因此变成 Windows-1252。同一个标签在不同处理环境里的规则,需要查对应规范。WHATWG Encoding
MySQL 的 utf8
在 MySQL 8.4 中,utf8 是 utf8mb3 的已弃用别名,不能表示需要四字节 UTF-8 的字符。新建完整 Unicode 文本列应明确使用 utf8mb4,并同时检查连接、数据库、表和列的配置,不能只改网页的声明。MySQL 8.4 文档
六、BOM 和百分号编码:别把字节提示当成正文
UTF-8 文件开头有时会出现 EF BB BF,也就是 BOM。UTF-8 不需要用它区分字节序,但有些工具用它识别编码;另一些程序则要求输入不带 BOM。
UTF-16 可以借助 BOM 区分字节序,也可以由协议或 UTF-16LE、UTF-16BE 这样的明确约定指定,不能一概说 BOM 必须存在。
如果已知文件是可能带 BOM 的 UTF-8,在 Python 中可以这样读:
with open('example.txt', encoding='utf-8-sig') as file:
text = file.read()
utf-8-sig 会处理开头的 UTF-8 BOM。它不是通用编码检测器。Unicode BOM FAQ
URL 中的 %E4%B9%B1%E7%A0%81 则是在用百分号表示字节:
from urllib.parse import unquote_to_bytes
raw = unquote_to_bytes('%E4%B9%B1%E7%A0%81')
print(raw.hex(' '))
print(raw.decode('utf-8'))
输出 e4 b9 b1 e7 a0 81 和“乱码”。先还原字节,再确定字符编码,这仍然是同一条处理路径。百分号编码本身并不保证底层一定是 UTF-8。
七、真正实用的乱码排查顺序
- 保留原始字节。 先复制文件或保存原始响应,避免编辑器再次覆盖。
- 找编码约定。 查看生成程序、协议声明和历史格式;自动检测只能提供候选。
- 用严格模式解码。 报错的位置是线索,不要一上来就
ignore或replace。 - 追踪每次转换。 哪一步从 bytes 变成 str,又在哪一步写回 bytes?
- 确认是否丢失信息。 如果发生替换、丢弃或不可逆映射,优先寻找原始数据。
ZIP 文件名、MP3 标签和旧系统导出的文本,也可以沿着这个顺序查。先弄清格式如何记录编码,再决定是否需要修复。
再看“锟斤拷”,它已经不是一个莫名其妙的词,而是六个字节被另一套规则重新分组的结果。
字节不会自带读音。把它们变成文字之前,先确认手里拿的是哪本规则。
交流与讨论