页面上突然出现几个「锟斤拷」,或者一段本该是中文的字段变成了问号和方块。遇到乱码,很多人的第一反应是「换个编码试试」——把 UTF-8 改成 GBK,或者反过来,试几次碰运气。这种方式有时能成功,但更多时候只是把问题从一个形态换成了另一个形态。更可靠的做法是:把可疑文本转成十六进制,从字节层面看清到底发生了什么。
乱码的本质是字节与解读方式不匹配
一个字符在存储和传输时是一串字节,显示时则需要按某种编码规则解读。乱码的产生,几乎总是因为写入时用的编码和读取时用的编码不是同一个。
举个最典型的例子。汉字「中」用 UTF-8 编码是三个字节 E4 B8 AD,用 GBK 编码是两个字节 D6 D0。如果写入端按 UTF-8 存了 E4 B8 AD,读取端却按 GBK 去解读,它会把这几个字节按 GBK 的规则两两组合,得到两个完全不相关的字符。反过来,如果写入端用 GBK,读取端按 UTF-8 解读,D6 D0 不是合法的 UTF-8 序列,解码器就会报错或输出替换字符。
这就是为什么乱码往往表现为「一串莫名其妙但看起来还挺像汉字的字」——字节是真实存在的,只是被按错误的规则拼装了一遍。
三种典型的乱码形态
不同的错误组合会产生不同形态的乱码,识别形态能大幅缩短排查时间。
第一种是「锟斤拷」。这三个字看起来像汉字,其实是由 U+FFFD(替换字符)经过二次编码产生的。当解码器遇到无法识别的字节序列时,会用替换字符 U+FFFD 占位;如果这串替换字符再被按 GBK 编码一次,就变成了「锟斤拷」。所以看到这三个字,基本可以断定中间某个环节发生过「先解码失败、再重新编码」的过程。
第二种是成片的「烫烫烫」或「屯屯屯」。这是调试环境下的产物:某些编译器在调试模式下会用 0xCC 填充未初始化的内存,0xCC 在 GBK 里对应的汉字读起来就是「烫」。看到这类乱码,说明读到的是未经初始化的内存,而不是真正的编码错误。
第三种是问号、方块或者「口口口」。这类字符表示「当前编码无法表示这个字符」,通常发生在把宽字符转成窄字符的时候,转换过程直接把无法表示的字替换成了问号。这种转换是不可逆的,原始信息已经丢失。
用十六进制看清真实字节
要确定问题出在哪一步,需要绕开「显示」这一层,直接看字节。
具体做法是把可疑文本转成十六进制表示。如果页面上显示的是乱码,可以先把它复制出来,用进制转换工具转成 hex,再对照编码规则判断它原本是什么。例如看到一段乱码转成 hex 后是 E4 B8 AD,那就说明字节本身是正确的 UTF-8,问题出在读取端没有按 UTF-8 解码。
另一个常用的判断依据是字节数的奇偶与范围。GBK 用双字节表示汉字,字节值通常落在 81 到 FE 之间;UTF-8 的汉字是三个字节,首字节在 E0 到 EF 之间,后两个字节都是 80 到 BF。看到 E4 开头的三字节序列,几乎可以确定是 UTF-8;看到 D6 D0 这种两字节组合,就要考虑 GBK。
如果手上是一段怀疑编码有问题的文本,也可以反向操作:按 UTF-8 编码一次,再按 GBK 编码一次,比较两次得到的字节序列,就能确认它是从哪一种编码误读过来的。
顺着链路逐个环节检查
知道字节是什么之后,接下来要定位是哪个环节出了问题。编码问题通常出现在这条链路的某一环上,按顺序检查效率最高:
- 页面声明:
Content-Type响应头里的charset,以及 HTML 里的<meta charset>。两者不一致时,浏览器可能按错误的编码渲染。 - 数据库连接:连接字符集是否设置为
utf8mb4。连接字符集错了,读写都会发生转换,问题会出现在数据进出数据库的那一刻。 - 表和列的字符集:即使连接没问题,如果表或列定义成了旧的字符集,写入的内容仍会被截断或转换。
- 文件本身:源码文件、模板文件的保存编码是否与声明一致。
- 中间层:反向代理、网关、消息队列在处理文本时是否做了意外的编码转换。
每一环都确认一遍,比反复尝试切换编码要可靠得多。
检查清单
- 乱码的本质是写入编码与读取编码不一致,先确定两个编码分别是什么
- 看到「锟斤拷」想到替换字符 U+FFFD 的二次编码,看到「烫」想到调试填充
0xCC - 把可疑文本转成十六进制,用字节范围判断真实编码:
E0—EF开头多为 UTF-8 汉字 - 按顺序检查页面声明、数据库连接、表与列、文件本身、中间层五个环节
- 问号和方块表示转换时信息已丢失,无法还原,只能从源头修正
- 修完源头后,历史数据需要用脚本按正确编码重新转换,不能只改配置