乱码不是字坏了,是尺子拿错了

乱码这东西,从我刚接触计算机起就一直烦我。
打游戏遇到过,改配置文件遇到过,写第一个 Python 脚本传到服务器也遇到过。每次我都靠玄学糊弄过去:开个什么开关、加一行注释、换个编码再存一遍——有时候蒙对了,也说不清为什么。直到大二真坐下来把它想明白,我才发现:那些字根本没坏,是我每次都拿错了「解码的尺子」。
我跟乱码的几次交手
先说几次印象最深的,当年我一个都没真懂,只是背下了「解法」。
微软模拟飞行 2020 的汉化。 装民间汉化包,装完进游戏,中文全是乱码。查了半天,解法是去 Windows 的「区域设置 → 管理 → 更改系统区域设置」里,勾上一个叫 「Beta:使用 Unicode UTF-8 提供全球语言支持」 的选项,重启就好了。当时我只当它是个玄学开关,勾上能用就行。
改游戏配置。 拿记事本打开某些游戏的配置 / 存档文本,里面的中文直接是一堆问号和方块。
第一个上服务器的 Python 脚本。 那会儿我刚学编程,本地跑得好好的脚本,传到服务器一跑,输出全是乱码。后来不知从哪学来一招:在文件开头加一行 # -*- coding: utf-8 -*-,据说能治。我就养成了习惯,每个 .py 开头都先敲上这行——也不知道为啥,反正「据说管用」。
这些「解法」我背了好几年,直到把底下的原理拆开,才发现它们其实是同一件事。
先说结论:乱码是「编码」和「解码」对不上
字在计算机里,存的是字节。
存的时候,用某套规则把「字」变成「字节」,这叫编码(encode);读的时候,再用某套规则把「字节」变回「字」,这叫解码(decode)。
只要存和读用的不是同一套规则,字节一个没变,翻出来的字就全错了。 这就是 。
所以乱码几乎从来不是「文件坏了」「字体没装」「网络丢包」——是解码的尺子拿错了。同一串字节,用对的尺子量是「你好」,用错的尺子量就成了「浣犲ソ」。字还是那些字节,错的是量它的尺子。
Unicode 不是编码,是一张号码表
要搞清尺子有哪些,得先分清两个老被混在一起的东西:Unicode 和 UTF-8。
先说 。它干的事,是给世界上几乎每一个字符发一个唯一编号,这个编号就叫 。比如「你」的码点是 U+4F60,「好」是 U+597D。它就是一张「字 ↔ 号码」的对照表,仅此而已——它不规定这个号码该怎么存成字节。
把号码变成字节,是另一层的事,那才是「编码」:UTF-8、UTF-16、UTF-32 都是把 Unicode 码点落地成字节的不同规则。
所以「Unicode 是通用规范,UTF-8 是其中一种」这句话是对的——只要补上一句:Unicode 在上层管号码,UTF-8 在下层管字节,两层别混。
UTF-8 的「8」不是「每个字 8 位」
这是我误解最久的一点。
我一直以为 UTF-8 的意思是「每个字用 8 个 bit,不够就补 0」。错。那个 8 指的是编码的基本单位是 8 位(也就是 1 字节),不是每个字都 8 位。UTF-8 是变长的:
| 字符 | 占用 |
|---|---|
| ASCII(英文、数字、半角符号) | 1 字节 |
| 拉丁带重音、希腊、西里尔等 | 2 字节 |
| 汉字 | 3 字节 |
| emoji、生僻字 | 4 字节 |
注意那个汉字 3 字节——如果你下意识觉得「汉字是 2 字节」,那是 的规矩(GBK 里一个汉字固定 2 字节),不是 UTF-8。我以前就老把这俩的字节数记混,而记混字节数,恰恰是后面乱码的伏笔。
那它怎么知道一个字到底占几字节
既然是变长的,读的时候凭什么知道「接下来 3 个字节是一个汉字」,而不是「3 个各占 1 字节的字符」?
我当初隐约觉得「应该有个标志位告诉你一个字从哪开始」——这个直觉是对的,而且这正是 UTF-8 设计得最漂亮的地方。看每个字符头字节的高位:
| 字节数 | 头字节 | 续接字节(各 1 个) |
|---|---|---|
| 1(ASCII) | 0xxxxxxx | —— |
| 2 | 110xxxxx | 10xxxxxx |
| 3(汉字) | 1110xxxx | 10xxxxxx 10xxxxxx |
| 4 | 11110xxx | 10xxxxxx 10xxxxxx 10xxxxxx |
规则就两条:
- 头字节开头连续几个
1,就代表这个字符占几字节(0开头是 1 字节,110是 2 字节,1110是 3 字节……); - 后面的续接字节一律以
10开头。
拿一个混着各种长度的字符串走一遍最直观——"1是one😇",里面 1 字节、3 字节、4 字节的字符都凑齐了。先看每个字符的码点,和它编成的 UTF-8 字节:
| 字符 | 码点 | UTF-8 字节(二进制) | 十六进制 | 字节数 |
|---|---|---|---|---|
1 | U+0031 | 00110001 | 31 | 1 |
是 | U+662F | 11100110 10011000 10101111 | E6 98 AF | 3 |
o | U+006F | 01101111 | 6F | 1 |
n | U+006E | 01101110 | 6E | 1 |
e | U+0065 | 01100101 | 65 | 1 |
😇 | U+1F607 | 11110000 10011111 10011000 10000111 | F0 9F 98 87 | 4 |
多字节字符是怎么从「码点」装进那个模板的?拿最长的 😇 看一眼最清楚:它的码点 U+1F607 是 21 个有效位,按 3 + 6 + 6 + 6 切成四段——000 / 011111 / 011000 / 000111,分别填进 4 字节模板的那些 x:
| 段 | 模板 | 填入后 | 字节 |
|---|---|---|---|
| 头字节 | 11110xxx | 11110·000 | F0 |
| 续接 1 | 10xxxxxx | 10·011111 | 9F |
| 续接 2 | 10xxxxxx | 10·011000 | 98 |
| 续接 3 | 10xxxxxx | 10·000111 | 87 |
汉字「是」(U+662F) 同理,只是用 3 字节模板:0110 011000 101111 填进 1110xxxx 10xxxxxx 10xxxxxx,得到 E6 98 AF。
现在把整串拼成一条扁平的字节流——存进文件、发上网络的就是这一串,中间没有任何分隔符:
31 E6 98 AF 6F 6E 65 F0 9F 98 87 解码器怎么把这 11 个字节切回 6 个字符?它从左往右,只看每段头字节的高位就够了:
| 字节 | 头字节高位 | 判定 | 切出 |
|---|---|---|---|
31 | 0… | 1 字节 | 1 |
E6 98 AF | 1110… | 3 字节 | 是 |
6F | 0… | 1 字节 | o |
6E | 0… | 1 字节 | n |
65 | 0… | 1 字节 | e |
F0 9F 98 87 | 11110… | 4 字节 | 😇 |
31=00110001,0开头 → 单字节,「1」,指针前进 1;E6=11100110,开头三个 1 → 连它往后 3 个字节是一个字,「是」,前进 3;6F/6E/65都是0开头 → 各自单字节,「o」「n」「e」;F0=11110000,开头四个 1 → 往后 4 个字节一个字,「😇」,前进 4。
全程没用到任何分隔符,只靠头字节那几个标志位,就把一条变长字节流切得干干净净。这也是为什么哪怕数据从中间断开,往后找到第一个不是 10 开头的字节,就能立刻重新对齐到下一个字的开头——自同步。
回头看那几次乱码
把尺子和字节想明白,当年那几个「玄学解法」全解释得通了。
模拟飞行。 汉化包里的文本是按 UTF-8 存的(汉字 3 字节)。可游戏本身是个「非 Unicode 程序」,它读文本时用的是系统的 ANSI 代码页——而中文 Windows 的默认代码页是 GBK。于是 UTF-8 的 3 字节汉字,被 GBK 按 2 字节一刀刀切开重新解释,全错位 → 乱码。那个「Beta:使用 UTF-8」的开关,干的事就是把系统这把默认尺子从 GBK 换成 UTF-8,尺子对上了,自然就好了。代价是:别的还假设 GBK 的老程序,可能反过来开始乱——所以微软给它标了「Beta / 有风险」。
记事本。 纯 txt 文件里只有字节,不带「我是什么编码」的标签,记事本打开时只能猜。猜错了就乱。最经典的是「联通」二字:用 GBK 存「联通」,它的字节恰好长得像合法的 UTF-8,记事本再打开时猜成 UTF-8,就糊了。
Python 上服务器。 我本地的 .py 文件,被老编辑器默认存成了 GBK;传到 UTF-8 的 Linux 服务器上,按 UTF-8 一解就乱。而我加的那行 # -*- coding: utf-8 -*-,其实是声明「这个源文件是 UTF-8」给解释器听(PEP 263,主要是 Python 2 的事;Python 3 源码默认就是 UTF-8,多数时候根本不用写)。它当年「看着管用」,是因为有时候我的文件确实存成了 UTF-8、缺的只是那句声明;可要是文件本身存的是 GBK,加这行照样乱——我那是把符咒当药吃,蒙对过几次而已。真正该治的,是让编辑器存成 UTF-8 + 确认两头 locale 一致。
几条我记到现在的规矩
- 文本永远成对出现:谁编码的,就得用谁那套来解码。传文件、连数据库、读 HTTP,第一件事是确认两头编码一致。
- 一律 UTF-8:新项目、新文件、网页的
<meta charset>、HTTP 的Content-Type,全押 UTF-8,从源头上就不给「两把尺子」留机会。 - 数据库别用「不检查」的编码:我现在用 PostgreSQL 建库一律 UTF-8,绝不用
SQL_ASCII——那个等于「我不检查编码,你塞什么字节我存什么字节」,看着没事,其实是把乱码这颗雷留给未来的自己;客户端的client_encoding也要和服务器对齐。 - 看到乱码先别慌:它不是字没了,是尺子错了。只要原始字节还在,用对的编码重新解一遍,往往能救回来。
收尾
乱码烦了我那么多年,我却一直在背「解法」——开个 beta 开关、加一行注释、换个编码再存一遍。
直到把「字 → 码点 → 字节」这条链拆开,我才看清那些玄学背后是同一件事:让编码和解码用的是同一把尺子。 Unicode 给字发号码,UTF-8 把号码变字节,GBK 是另一套规矩;乱码,不过是存进去用了一把尺子、读出来又换了一把。
字从来没坏过。坏的是我每次都拿错了尺子。
// related
学习笔记
为什么 let s2 = s1 有时报错,有时不报错
从 C 转 Rust 重写 ACGHub,借用规则我自觉很快就懂了。结果真正把我看懵的,是一个看起来最不该出问题的操作——`let s2 = s1`,它一会儿编译报错,一会儿又安然无事。
学习笔记
包进事务,点赞就不会数错了吗
ACGHub 里到处是计数——点赞、收藏、回答数。我一直觉得:读出来 +1 再写回去,只要包进 BEGIN…COMMIT,事务保证原子性,就不会出错。这篇拆的就是这个「就不会出错吧」——它其实会,而搞懂它为什么会,得先把我对 MVCC 的误解也一起纠了。
学习笔记
为什么游戏设置里有五种抗锯齿
大一打游戏翻设置,抗锯齿那栏点开是 SSAA / MSAA / FXAA / SMAA / TAA 一长串全是缩写。好奇查了查才发现,它们其实都在跟同一个东西较劲——锯齿,而锯齿的根,是采样不够。