← 所有文章

乱码与编码:世间本无“锟斤拷”

从六个字节亲手制造“锟斤拷”,再沿着编码与解码的路径,分清哪些乱码能恢复、哪些信息已经丢失。

本文目录

“手持两把锟斤拷,口中疾呼烫烫烫。”

这些字看起来像程序员的暗号,实际上常常是某段数据处理过程留下的痕迹。它们不需要靠猜,也可以一步步算出来。

先打开 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。

七、真正实用的乱码排查顺序

  1. 保留原始字节。 先复制文件或保存原始响应,避免编辑器再次覆盖。
  2. 找编码约定。 查看生成程序、协议声明和历史格式;自动检测只能提供候选。
  3. 用严格模式解码。 报错的位置是线索,不要一上来就 ignore 或 replace。
  4. 追踪每次转换。 哪一步从 bytes 变成 str,又在哪一步写回 bytes?
  5. 确认是否丢失信息。 如果发生替换、丢弃或不可逆映射,优先寻找原始数据。

ZIP 文件名、MP3 标签和旧系统导出的文本,也可以沿着这个顺序查。先弄清格式如何记录编码,再决定是否需要修复。

再看“锟斤拷”,它已经不是一个莫名其妙的词,而是六个字节被另一套规则重新分组的结果。

字节不会自带读音。把它们变成文字之前,先确认手里拿的是哪本规则。

— 本文完 —

有不同的想法?欢迎在下方交流,或写封邮件。

继续翻阅其他笔记 ↗

交流与讨论