TimothyQiu's Blog

keep it simple stupid

字符编码

分类:技术,闲扯

我们平时所说的「文本」基本上都是在说「电脑屏幕上的字符」,但是小学生都知道「计算机只懂 0101」,那么电脑究竟是怎么处理三千世界中如此纷繁复杂的文字的呢?

开天辟地阿斯克

电脑是美国人发明的,所以最初的人们也只需要考虑「如何让电脑明白英文的字母和符号」的问题。

于是 ASCII(美国信息交换标准代码)诞生了。它参考了电报报文的设计,将 0 到 127 都赋予了对应的字母和符号。于是可以被电脑处理的数字序列 72 101 108 108 111 32 87 111 114 108 100 33 就可以通过查 ASCII 码表翻译成 Hello World!。当然,用更「计算机」一些的十六进制表示每一个数,就是:

48 65 6c 6c 6f 20 57 6f 72 6c 64 21
H  e  l  l  o     W  o  r  l  d  !

那么,输入并显示一个字符的过程就变成了:

  1. 你按下键盘的 H 键
  2. 电脑在内存中保存 0x48
  3. 电脑在屏幕上显示 0x48 号图形

也就是说,电脑不需要能够「明白」字符,只需要能够用数字「表示」字符。

百家争鸣,被榨干的单字节

随着时代发展,电脑开始在各地使用。以法语、希腊语等为母语的人发现 ASCII 真心坑爹,没有 è、é 没有 α、β、θ、让我们怎么活?

好消息是:计算机终于迎来了以 8 位为 1 字节的时代,而 ASCII 只为 0x00 到 0x7F 规定了对应字符。也就是说,ASCII 只占了低 7 位 0XXX XXXX,还有 0x80 到 0xFF 这余下的 128 个码位可以让人糟蹋。

这一利好消息的发现让人们大为振奋。

法国人开心地用 0xE8 表示 è,用 0xE9 表示 é;希腊人欢乐地用 0xE1 表示 α,用 0xE2 表示 β,用 0xE8 表示 θ……

E    9    7    1    7    5    6    9    7    6    6    1    7    5
1110 1001 0111 0001 0111 0101 0110 1001 0111 0110 0110 0001 0111 0101
é         q         u         i         v         a         u

所有 0 开头的字节在 ASCII 里找对应字符,所有 1 开头的字节在各自定义的字符集里找对应字符,问题解决了。

传说的巨龙,汉字的秘密

于是电脑来到了中国,但是中文不同于字母文字,如何给数万汉字编码就成了大问题。

一位伟人一拍脑袋:「一个字节只有 0 到 255,但两个字节就有 0 到 65535 啦!《新华字典》也就一万多个字,用两个字节表示一个汉字不就行了嘛~讨厌!」

没错,这就是一种后世所谓的多字节字符集(MBCS)。我们熟悉的 GB2312 和 GBK 使用的都是如下形式的二进制编码:

0XXX XXXX
1XXX XXXX XXXX XXXX

如果一个字节为最高位为 0,那么后续的 7 位表示一个字符(128 个码位)。如果最高位为 1,那么后续 15 位表示一个字符(32768 个码位)。

GB2312 前辈利用了这 32768 个码位中的 7445 个,而后辈 GBK 则利用了 21886 个。

例如二进制数据 48 69 20 CA C0 BD E7 的解析:

1| 4    8    6    9    2    0    C    A    C    0    B    D    E    7
2| 0100 1000 0110 1001 0010 0000 1100 1010 1100 0000 1011 1101 1110 0111
3| /100 1000 /110 1001 /010 0000 /100 1010 1100 0000 /011 1101 1110 0111
4| /4   8    /6   9     2   0    /4   A    C    0    /3   D    E    7
5|  H         i                   世                  界
  1. 字节序列(十六进制)
  2. 字节序列(二进制)
  3. 字节序列 --[GBK 解码]--> 码位(二进制)
  4. 字节序列 --[GBK 解码]--> 码位(十六进制)
  5. 码位 --[GBK 字符集]--> 字符

那么,几个之前没有讲的概念就比较明白了。

危机!乱码的陷阱

于是,全世界人民都一本满足了,但是这番和谐景象的背后却隐藏着天大的危机。之前,大家都用着自己的字符编码相安无事,但全球化却导致乱码横行。

聪明的你也许已经发现了:之前我们说过,法国人的 è 用 0xE8 表示,而希腊人的 θ 也用 0xE8 表示。有一天,法国人写了封 Email 给希腊人:

Jeux de caractères codés

希腊人收到一看:

Jeux de caractθres codιs

这是毛啊?于是转发给了中国人,中国人打开一看:

Jeux de caract鑢es cod閟

擦,顿时感觉自己没文化了……于是回复:

我看不懂……

希腊人无辜地打开一看:

Ξ?Ώ΄²»Ά?‘­‘­

这这这……于是转发给法国人,法国人也一头雾水:

ÎÒ¿´²»¶®¡­¡­

「我还是删了吧,妈妈说不要跟外国人发邮件……」

1| J  e  u  x     d  e     c  a  r  a  c  t  è  r  e  s     c  o  d  é  s
2| 4a 65 75 78 20 64 65 20 63 61 72 61 63 74 e8 72 65 73 20 63 6f 64 e9 73
3| J  e  u  x     d  e     c  a  r  a  c  t  θ  r  e  s     c  o  d  ι  s
4| 4a/65/75/78/20/64/65/20/63/61/72/61/63/74/68 72/65/73/20/63/6f/64/68 73
5| J  e  u  x     d  e     c  a  r  a  c  t  鑢    e  s     c  o  d  閟
  1. 法国人写的文字
  2. 法国人根据 latin-1 将文字转换为码位、并将码位编码得到实际保存的字节序列
  3. 希腊人根据 latin/greek 将序列解码得到码位、并将码位转换为字符,得到的文字
  4. 中国人根据 GBK 解码后得到的码位
  5. 中国人根据 GBK 字符集将码位转换到的字符

所以,二进制文本数据就相当于密文,而编码和解码如同加密和解密,只有用正确的密钥才能得到明文,也只有用正确的字符编码才能得到码位。然后通过码位在字符集里取得最终的字符。

(历史上,常常将字符集与字符编码等同起来。因为大部分字符集都是 8 位的,编码/解码形同虚设,N 编码后还是 N,可以直接 1:1 映射。例如上面例子中的 latin-1 和 lantin/greek,编码都是相同的 1:1 编码,只是字符集中相同码位对应着不同字符而已。)

大逆转,万国码的光荣

打开浏览器菜单,肯定存在一个叫做「编码」的选项,点开就能看到这世界上至少存在着多少种流行的字符编码。如果浏览器「自动检测」检测得不对,网页就乱码了。索性懒人是社会进步的阶梯。为了免去百家争鸣带来的麻烦,试图让全世界「书同文」的 Unicode 诞生了。

Unicode 标准化了一个字符集,包含了世界上所有的字符,每一个字符都拥有唯一的码位 U+XXXX。(起初的码位是 16 位的,可以容纳 65536 个字符。其后不断扩展,现今已经扩展到了 U+10FFFF。)

Unicode 还提供了几套编码方案,来将 U+XXXX 的码位编码为字节序列,例如:UTF-8 和 UTF-16。

UTF-8

UTF-8 顾名思义,是一套以 8 位为一个编码单位的可变长编码。会将一个码位编码为 1 到 4 个字节。

U+ 0000 ~ U+ 007F: 0XXXXXXX
U+ 0080 ~ U+ 07FF: 110XXXXX 10XXXXXX
U+ 0800 ~ U+ FFFF: 1110XXXX 10XXXXXX 10XXXXXX
U+10000 ~ U+1FFFF: 11110XXX 10XXXXXX 10XXXXXX 10XXXXXX

例如「萌」在 Unicode 中的码位为 U+840C,对应的 UTF-8 编码为 E8 90 8C

1|     8       4     0     C
2|     1000    0100  0000  1100
3|     1000    010000    001100
4| 11101000  10010000  10001100
5| E   8     9   0     8   C
  1. 码位(十六进制)
  2. 码位(二进制)
  3. 根据 UTF-8 编码规则将码位分为三段
  4. 为每一段加上前缀,编码完成(二进制)
  5. 编码完成(十六进制)

UTF-16

UTF-16 则是一套以 16 位为一个编码单位的可变长编码。会将一个码位编码为 1 到 2 个双字节。编码算法大同小异并不重要,但有一个问题却亟待解决:字节序。

不同的系统,字节序可能不同。小端序系统的 0x1234 实际上是 34 12,把这个字节序列发给大端序系统,34 12 就会被理解成 0x3412。

于是,Unicode 中引入了一个特殊字符:字节序标(BOM),码位 U+FEFF。用于加在被编码的数据之前,表示编码时的字节序。于是,解码时,首先读出第一个双字节:

当然,UTF-16 还有派生的 UTF-16LE 和 UTF-16BE,实际上就是按字节序特化的版本。

各式乱码

分类:闲扯

烫烫烫与屯屯屯

这两个乱码应该是不少 C/C++ 程序员的必经之路吧。

微软 Visual C++ 的 Debug 模式下,会为未初始化的内存填充一些初始值,以便调试。其中,栈上的内存都用 0xCC 初始化、堆上的内存都用 0xCD 初始化。

而如果把 0xCCCC 作为字符输出,在简体中文的 Windows 系统下,就会根据其使用的 GBK 编码将其解释为「烫」字;0xCDCD 则为「屯」。

变巨

如果你用过南极星,说明你已经老了。

在那个万码奔腾的年代,由于早年的《曹操传》和《三国志》等游戏使用的都是 Big5 编码的文本数据,而简体中文 Windows 系统使用的编码不同于 Big5。用「前朝的剑斩本朝的官」就产生了乱码。

「变巨」的 GBK 编码为 B1E4 BEDE(GB2312 亦然),而在 Big5 编码中这四个字节对应的汉字为「曹操」。

俸俸伲购美病

这是《英雄传说VI 空之轨迹SC》简体中文版中的一句对白,也是最初被玩家讽刺得最惨的地方。

打上官方修正补丁后可以发现原文为「嘿嘿嘿,还好啦。」之所以会产生这样的乱码是因为 GBK 编码:

BA D9 BA D9 BA D9 A3 AC BB B9 BA C3 C0 B2 A1 A3 // 嘿嘿嘿,还好啦。
   D9 BA D9 BA D9 A3 AC BB B9 BA C3 C0 B2 A1    // 俸俸伲购美病

缺了第一个字节……

锟斤拷

相对前面几个乱码的直白,这个乱码是很纠结的存在。

Unicode 中定义了一个特殊字符「�」即 U+FFFD,称作 Replacement Character。用来表示无法显示的字符或是无法解析的数据。

如果一段数据本身是使用 GBK 编码的,那么其中可能有很多部分不符合 UTF-8 编码规则。一个处理 UTF-8 数据的程序得到这段数据后,可以选择将数据中检测到不合 UTF-8 编码规则的部分替换为 UTF-8 编码的 U+FFFDEFBFBD,这样,就在自动消除编码问题的同时对用户给出了数据编码错误的提示。

经过上面这步处理后,数据中就产生了很多 EFBFBD 的序列,此时如果试图以 GBK 将其解码,那么两个这样的序列就成了「锟斤拷」,即 EFBF BDEF BFBD