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 facts
,This 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 Point
,A
对应的是Latin Capital letter A
,
同时为其分配了一个唯一的Magic Code
U+0041
。其中U
代表Unicode
,0041
是一
个十六进制数。这样做实际上在把字符本身和其编码方式之间解耦,字符集只关心字符本身
,而不关心其他问题。后面会看到这样做的好处。
例如字符串"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 Endian
CPU下的编码。
如果在Little Endian
CPU下上述编码就会变成:
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-8
。UTF-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 |
这样做的好处非常明显:
-
原来
Ascii
编码集字符长度不变,对英文字符仍然是1字节编码 - 非常容易确定上一个字符和下一个字符的位置,根据每个字节的前两位即可
- 字节序无关
- 即使收到不完整的字节流,也能很轻松地跳过不完整的字符
目前为止,已经介绍了两种存储Unicode的方式,最传统的2位定长存储和UTF-8。其中定长
存储又叫UCS-2
(因为2字节存储)或者UTF-16
(同样因为2字节存储),又可以根据字
节序细分为UTF-16LE
,UTF-16BE
两种。还有其他编码Unicode的方式,例如UTF-7
和
USC-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字符任何编码方式都相同,