cd ../articles

学习笔记

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

7 min read
屏幕上一行本该是中文的文字,变成了一串问号方块和「锟斤拷」。
屏幕上一行本该是中文的文字,变成了一串问号方块和「锟斤拷」。

乱码这东西,从我刚接触计算机起就一直烦我。

打游戏遇到过,改配置文件遇到过,写第一个 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——
2110xxxxx10xxxxxx
3(汉字)1110xxxx10xxxxxx 10xxxxxx
411110xxx10xxxxxx 10xxxxxx 10xxxxxx

规则就两条:

  • 头字节开头连续几个 1,就代表这个字符占几字节(0 开头是 1 字节,110 是 2 字节,1110 是 3 字节……);
  • 后面的续接字节一律以 10 开头。

拿一个混着各种长度的字符串走一遍最直观——"1是one😇",里面 1 字节、3 字节、4 字节的字符都凑齐了。先看每个字符的码点,和它编成的 UTF-8 字节:

字符码点UTF-8 字节(二进制)十六进制字节数
1U+003100110001311
U+662F11100110 10011000 10101111E6 98 AF3
oU+006F011011116F1
nU+006E011011106E1
eU+006501100101651
😇U+1F60711110000 10011111 10011000 10000111F0 9F 98 874

多字节字符是怎么从「码点」装进那个模板的?拿最长的 😇 看一眼最清楚:它的码点 U+1F607 是 21 个有效位,按 3 + 6 + 6 + 6 切成四段——000 / 011111 / 011000 / 000111,分别填进 4 字节模板的那些 x

模板填入后字节
头字节11110xxx11110·000F0
续接 110xxxxxx10·0111119F
续接 210xxxxxx10·01100098
续接 310xxxxxx10·00011187

汉字「是」(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 个字符?它从左往右,只看每段头字节的高位就够了:

字节头字节高位判定切出
310…1 字节1
E6 98 AF1110…3 字节
6F0…1 字节o
6E0…1 字节n
650…1 字节e
F0 9F 98 8711110…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

作者头像
yen@harvey:~$ exit 0