Unicode和字符集

在Windows XP的Notepad中有一个经典的BUG: 新建一个文本文件,在里面输入Bush hid the facts,没有换行符。然后保存退出,再打 开这个文件,文件的内容就会变成畂桳栠摩琠敨映捡獴。这种现象叫做Mojibake (源自日语文字化け),我们一般叫它乱码。

两段文字其实是同一串字节流在不同编码下的得到的结果。

$ echo '畂桳栠摩琠敨映捡獴' | iconv -f UTF8 -t UTF16LE
Bush hid the facts

有人说任何符合单词长度是4 3 3 5的文件都能触发这个BUG,但实际上不是。 Bush hid the factsThis app now broken都能触发BUG,但Bush hid the truth 就不行。Notepad会调用IsTextUnicode()来**字符串是不是unicode编码,显然猜得 不太准确。

其实这里还有一个问题,Windows下的Unicode编码其实是UTF-16LE With BOM。

Once upon a time

理清字符编码问题的最好方法就是跟随字符编码的发展,一点一点来看

Unix系统和K&R C刚成型的时候,并不需要处理太多字符,只需要常见的英文字 母和数字等就够用了。他们采用了ASCII码表来表示这些字符。ASCII码表用7bit表示一 个字符,所有常用的字符都编码在0x20~0x7f这个范围内,0x20是空格,0x41是A等等。 0x00~0x1f留给了控制字符,包括Backspace、CR、LF等。当时绝大多数电脑都用的都是 8-bit表示一个字节,大多数情况下都是用一字节保存一个字符。

用一字节表示一个字符带来一个问题:每个字节值只用到了0x00~0x7f,还有1bit没有用到 。当时很多人都想到了要把这一位利用起来。当时,有的文字处理软件将这一位用来标记正 文的最后一个字符:这一位为1的是正文的最后一个字。这只是个例。更多人的想法是将 ASCII扩展,将0x80~0xff映射为自己需要的字符。但是很不幸,映射成哪些字符每个人的 想法都不同。

当时IBM-PC将0x80-0xff这个区间内字符称为OEM字符,包含方言字符、制表符等(áåâäà├─┼┤这些等)。这应该是最常见的扩展。不同的PC制造商也都借鉴了这个思路,实现 了自己的OEM字符。有些厂商为了将PC销往不同的地区,针对不同语言的用户使用不同的OEM 。比如在销往欧洲地区将0x82映为é,而在销往西亚地区的PC中将0x83映为ת。这样做有 一个明显的问题:同样地文本在不同的电脑上可能显示为不同的内容,résumé在一部分PC 上能正常显示,在令一部分PC上就会变成rתsumת。幸好当时互联网不发达,这样的问题比 较罕见。

之后一段时间里,这些OEM扩展逐渐汇总为ANSI标准。在标准中,对0x80~0xff不同的扩展 以(Code page)区分。就像一本书,希腊语在737页,中文在936页,查找一个字符首先 要知道用哪一个Code page,随后在对应的页上查找。这似乎解决了不同扩展的问题,只 要预先将所有的Code page预装在PC上,打开文件时只要指定好Code page就能得到正常 的显示结果。这种编码方式一般叫做ANSI。但是这个解决方案还是不能正确处理包含两种 以上语言的的文件。

The 2 Byte of Madness

以上只是在欧美、西亚地区的情况,在亚洲地区,尤其是中日韩,扩展ASCII采用的是更 “疯狂”的做法。这些地区的文字数量远远超过0x00~0xff所能编码的范围,在这些地区,通 常采用的是DBCS(Double Byte Cherecter Set),用2字节编码一个扩展字符。例如中文常 用的GBK2312编码,原ASCII用一个字节编码,用连续2个0xA1~0xfe内的字节编码一个汉 字,最多可以编码\(94 * 94 = 8836\)个汉字。Shift JIS就更复杂了,要考虑是半宽字符 还是普通字符,而且Shift JIS还有很多版本,还要考虑日语的不同书写系统...这样做使 得像“在字符串中向前移动一个字符”等操作变得非常复杂,很难保证其在不同的代码页下都 能正常工作。Unicode就是为了解决这些混乱的情况而生的。

The call of Unicode

Unicode的首要目的就是把能找到的所有书写系统中的字符统一起来,用统一的编码方式 表示。对Unicode常见的误解就是Unicode就是DBCS,最多能编码65535个字符。Not True。在Unicode标准6.2中码表地址为0x000000~0x10FFFF,收录了110182个字符。 Unicode中最与众不同的地方就是它如何理解字符。一般我们会认为字符A和其编码表示 0x41是等价的,两者之间可以互换,写到文件中的也应该是0x41这个Hex值。Unicode 不这样想,每个字符对应一个的独立的Code PointA对应的是Latin Capital letter A, 同时为其分配了一个唯一的Magic Code U+0041。其中U代表Unicode0041是一 个十六进制数。这样做实际上在把字符本身和其编码方式之间解耦,字符集只关心字符本身 ,而不关心其他问题。后面会看到这样做的好处。

例如字符串"Hello World"在Unicode中的表示就是

U+0048 U+0065 U+006C U+006C U+006F U+0020 U+0057 U+006F U+0072 U+006C U+0064

这只是一列Code Point,怎么把它存在文件中,这一步才需要编码。

Encoding Unicode

最初,人们想到的是最直接的方式:直接把Code Point中的16进制Magic当成编码。之 前的"Hello World"编码为:

00 48 00 65 00 6c 00 6c 00 6f 00 20 00 57 00 6f 00 72 00 6c 00 64

慢著,这里忽略了一个非常关键的问题: 字节序 。这是在Big EndianCPU下的编码。 如果在Little EndianCPU下上述编码就会变成:

48 00 65 00 6c 00 6c 00 6f 00 20 00 57 00 6f 00 72 00 6c 00 64 00

这样还是没有解决统一编码的问题。同样地文件在不同的电脑上可能还是会解释成不同的内 容。于是有人想到了一个诡异的解决方案:BOM(Bit Orger Marking)。这个方法很简单 ,在文件开头加上2字节,0xFEFF表示BE,0xFFFE表示LE。其实这是一个Unicode字 符Byte Order Mark(BOM) U+FEFF,通过写在文件开始的这个字符就能判断文件内容的字 节序了。

到此,似乎一切问题再次全部解决了。还是有一个很严重问题:文件可以通过第一 个字节判断字节序,如果只有一个字符串怎么办?而且在Unicode标准中不强制也不推荐使 用BOM。在Visual Studio中经典的乱码锟斤拷一部分就是BOM字符造成的,原因后述。

UTF-8 out of Space

暂时先放下这个问题,看一下现在的编码方法中的问题。零太多了,对于编码纯英文的文本 ,浪费了一般空间,而且还没解决字节序的问题。为此,有人发明了非常出色的编码方式: UTF-8UTF-8根据Code point的范围使用1~6字节编码一个字符。规则如下:

Min Max Sequence
0x00000000 0x0000007f 0vvvvvvv
0x00000080 0x000007ff 110vvvvv 10vvvvvv
0x00000800 0x0000ffff 1110vvvv 10vvvvvv 10vvvvvv
0x00010000 0x001fffff 11110vvv 10vvvvvv 10vvvvvv 10vvvvvv
0x02000800 0x03ffffff 111110vv 10vvvvvv 10vvvvvv 10vvvvvv 10vvvvvv
0x04000800 0x7fffffff 1111110v 10vvvvvv 10vvvvvv 10vvvvvv 10vvvvvv 10vvvvvv

这样做的好处非常明显:

目前为止,已经介绍了两种存储Unicode的方式,最传统的2位定长存储和UTF-8。其中定长 存储又叫UCS-2(因为2字节存储)或者UTF-16(同样因为2字节存储),又可以根据字 节序细分为UTF-16LEUTF-16BE两种。还有其他编码Unicode的方式,例如UTF-7USC-4。在ISO标准中,UTF-8又叫ISO-10646。能自由选择编码方式这点也正是 Unicode将字符与编码解耦的目的。

还有一点就是没有所谓的Unicode编码方式这样的东西。Windows中的Unicode编码指的是 UTF-16LE编码,是乱写的。Unicode根本就没有指定编码的功能。每次听到有人说把文件 用Unicode编码保存我都不知道怎么吐槽好。

The returning of ANSI

Unicode还有一个非常完美的特性,能够向后兼容最早的Code page方式的ANSI编码。 Unicode不使用U+0080~U+00ff之间的Code point,这一部分用来兼容OEM。原来的每一 个Code page都能直接对应新的Code Point一段连续的空间。在读到OEM段的字符时,只 需要根据给定的Code page得到一个偏移,就能直接得到字符在Unicode中的Code Point

锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷

很多用过Visual Studio的人都应该对锟斤拷有印象。这个是Windows对字符编码支持 非常糟糕的最直接的例子。成因很简单:把ANSI编码的文件用UTF-8解码造成的。由于对 于ASCII字符任何编码方式都相同,