上一篇文章提到了字符编码的问题,你是否对字符编码的问题还是不了解,是否遇到过文件乱码的问题呢,看到 ANSI,GB2312,GBK,latin-1,cp936,euc-cn,GB18030,big5。这么多的会不会头晕呢?

先看一个很早看到的一个奇怪现象:在 Windows 的记事本里新建一个文本文件,输入「联通」两个字,保存,关闭,再次打开,会发现文本已经不是「联通」了,而是几个乱码。这原因下面就会揭开。

各种字符编码

1. ASCII

ASCII(ISO 646) 编码大家都应该熟悉,是用一个 8 位的字节来表示空格、标点符号、数字、大小写字母或者控制字符的,其中最高位为「0」,其他位可以自由组合成 128 个字符了,这就是 ASCII 的编码。

2. latin-1

latin-1 又称 ISO/IEC 8859-1,是国际标准化组织 ISO 字符编码标准 ISO/IEC 8859 的一部分,它在 ASCII 编码空置的 0xA0-0xFF 的范围内加入了其他符号字母以供西欧来使用,所以也叫「西欧语言」,另外 ISO 的编码还有其他的一些语言编码,这些都是单字节 8 位编码。

3. ANSI

ANSI 是美国的国家标准协会,ANSI 的编码也是在 ASCII 的标准上扩展而来的,但 ANSI 的编码是双字节 16 位的编码,在简体中文的操作系统中 ANSI 就指的是 GB2312,而在日文的操作系统就指的是 JIS,这些编码之间互相不兼容,但所有的 ANSI 编码都兼容 ASCII 编码。

4. GB2312(1980)

GB2312 是对 ANSI 的简体中文扩展。GB2312 的原型是一种区位码,这种编码把常见的汉字分区,每个汉字有对应的区号和位号,例如:「我」的区号是 46,位号是 50,这种区位码在上初中时还用一个小册子查过。GB2312 因要与 ASCII 相兼容,所以每个字的区号和位号都加上 0xA0 得到两个最高位都是「1」的 8 位字节(0xCED2, 11001110 11010010),这两个字节组合而成就是一个汉字的 GB2312 编码,GB2312 编码中小于 127 的字符与 ASCII 的相同。与区位码常提到的另一个词是「内码」,字面意思就是计算机内部使用的二进制编码,也就是区位码加上 0xA0 得到的。因为 GB2312 无法对繁体中文编码,所以与之对应的繁体中文编码方式为 BIG5。

5. GBK(1995) 和 GB18030(2000)

GB2312 共收录了七千个字符,由于 GB2312 支持的汉字太少而且不支持繁体中文,所以 GBK 对 GB2312 进行了扩展,对低字节不再做大于 127 的规定,以支持繁体中文和更多的字符,GBK 共支持大概 22000 个字符,GB18030 是在 GBK 的基础上又增加了藏文、蒙文、维吾尔文等主要的少数民族文字。

6. UCS 和 unicode

由于各国之间的编码不同造成的交流传输不便,ISO 打算废除所有的地区性编码方案,重新建立一个全球性的编码方案把所有字母和符号都统一编码进去,称之为「Universal Multiple-Octet Coded Character Set」,简称为 UCS(ISO10646),UCS分为 UCS-2 和 UCS-4 两个方案,UCS-2 采用 2 个字节来存储一个字符,共可以编码 216 个字符(即 65536),这大概可以覆盖完世界上所有的字符,如果不够还可以采用 UCS-4 来编码,UCS-4 采用 31 位来编码,最高位为「0」,大概有 21 亿个字符。UCS-4 高两个字节为 0 的码位被称作BMP(Basic Multilingual Plane, 基本多语言面),即将 UCS-4 的 BMP 去掉前面的两个零字节就得到了 UCS-2。在 UCS-2 的两个字节前加上两个零字节,就得到了 UCS-4 的 BMP。

在同时代又有 unicode.org 这个组织也制定了自己的全球性编码 unicode,unicode 1.0 的编码统一采用双字节编码,也可以编码 65536 个字符,unicode2.0 采用 20 位编码,编码范围为 0 到 0x10FFFF,由于这两种编码采用了不同的编码,也阻碍了交流,但自从 unicode2.0 开始,unicode 采用了与 USC 相同的字库和字码,ISO 也承诺将不会给超出 0x10FFFF 的 UCS-4 编码赋值,使得两者保持一致。现阶段主要采用的是 UCS-2/unicode 16 位的编码,这种定长编码便于计算机的处理,ASCII 在这种编码下就统一变成了高字节全是「0」,低字节来编码,这种编码在英文存储中会浪费一倍的空间,但这些浪费在在现在存储器极度便宜的时代也算不得什么。

UCS 不仅给每个字符分配一个代码,而且赋予了一个正式的名字. 表示一个 Unicode/UCS 值的十六进制数,通常在前面加上「U+」,就象 U+0041 代表字符「拉丁大写字母A」。

7. UTF

UTF(Unicode/UCS Transfer Format),UCS 变长存储的编码方式,主要用来解决 UCS 编码的传输问题的。分为 UTF-7,UTF-8,UTF-16,UTF-32 等。

8. UTF-8

UTF-8 是一次传输 8 位 (一个字节) 的 UTF 编码方式,一个字符可能会经过 1-6 次传输,具体的跟 unicode/UCS 之间的转换关系如下:

unicodeUTF-8
U+00000000 - U+0000007F0xxxxxxx
U+00000080 - U+000007FF110xxxxx 10xxxxxx
U+00000800 - U+0000FFFF1110xxxx 10xxxxxx 10xxxxxx
U+00010000 - U+001FFFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
U+00200000 - U+03FFFFFF111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
U+04000000 - U+7FFFFFFF1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

比如:「我」的 unicode/UCS 编码为「U+6211」(01100010 00010001),在 U+00000800 - U+0000FFFF 之间,所以采用三字节编码,按规则分段为:0110 001000 010001,再分别替换上表中的x,得到11100110 10001000 10010001,即为「E6 88 91」,这就是「我」的 UTF-8 编码。

UTF-8 的传输与字节顺序无关,可以在不同平台之间交流,并且容错能力高,任何一个字节损坏后,最多只会导致一个编码码位损失,不会链锁错误 (如 GB 码少一个字节就会整行乱码),所以建议在保存文件时尽量采用 UTF-8 的编码来保存文件。

再来看开头提到的那个奇怪现象,就不难解释了,当使用记事本新建文件时,默认的编码是 ANSI,输入中文就是 GB 系列的编码,「联通」两字的编码为:

c1 1100 0001  
aa 1010 1010  
cd 1100 1101  
a8 1010 1000

注意到了吗?第一二个字节、第三四个字节的起始部分的都是「110」和「10」,正好与 UTF-8 规则里的两字节模板是一致的,于是再次打开记事本时,记事本就误认为这是一个 UTF-8 编码的文件,让我们把第一个字节的 110 和第二个字节的 10 去掉,我们就得到了「00001 101010」,再把各位对齐,补上前导的 0,就得到了「0000 0000 0110 1010」,这是 UNICODE 的 006A,也就是小写的字母「j」,而之后的两字节用 UTF-8 解码之后是 0368,这个字符什么也不是。这就是只有「联通」两个字的文件没有办法在记事本里正常显示的原因。

而如果你在「联通」之后多输入几个其他字,其他的字的编码不见得又恰好是 110 和 10 开始的字节,这样再次打开时,记事本就不会坚持这是一个 UTF-8 编码的文件,而会用 ANSI 的方式解读之,这时乱码又不出现了。

9. UTF-16

UTF-16 是一次传输两个字节的 UTF 编码方式,现如今 Unicode/UCS 也主要采用 16 位编码,所以 UTF-16 的存储方式和 Unicode/UCS 的编码方式也相同。确切的说是和 UCS-2/unicode 16 的编码方式相同。

其他的一些问题

1. big endian 和 little endian

在 UTF-16 或者 UCS 的编码中经常遇到这两个选项,big endian 和little endian 是 CPU 处理多字节数的不同方式。例如“汉”字的 Unicode/UCS 编码是 6C49。那么写到文件里时,究竟是将 6C 写在前面,还是将 49 写在前面?如果将 6C 写在前面,就是 big endian。还是将 49 写在前面,就是 little endian。

这两个词语出自《格列佛游记》。小人国的内战就源于吃鸡蛋时是究竟从大头 (Big-Endian) 敲开还是从小头 (Little-Endian) 敲开,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位。

我们一般将 endian 翻译成「字节序」,将 big endian 和 little endian 称作「大尾」和「小尾」。

2. UTF 的字节序和 BOM

BOM 称为「Byte Order Mark」。UTF-8 以字节为编码单元,没有字节序的问题。而 UTF-16 以两个字节为编码单元,在解释一个 UTF-16 文本前,首先要弄清楚每个编码单元的字节序。例如收到一个「奎」的 Unicode/UCS 编码是 594E,「乙」的 Unicode/UCS 编码是 4E59。如果我们收到 UTF-16 字节流「594E」,那么这是「奎」还是「乙」?

在 Unicode/UCS 编码中有一个叫做「ZERO WIDTH NO-BREAK SPACE」的字符,它的编码是 FEFF。而 FFFE 在 Unicode/UCS 中是不存在的字符,所以不应该出现在实际传输中。UCS 规范建议我们在传输字节流前,先传输字符「ZERO WIDTH NO-BREAK SPACE」。这样如果接收者收到 FEFF,就表明这个字节流是 Big-Endian 的;如果收到 FFFE,就表明这个字节流是 Little-Endian 的。因此字符「ZERO WIDTH NO-BREAK SPACE」又被称作 BOM。

UTF-8 不需要 BOM 来表明字节顺序,但可以用 BOM 来表明编码方式。字符「ZERO WIDTH NO-BREAK SPACE」的 UTF-8 编码是 EF BB BF。所以如果接收者收到以 EF BB BF 开头的字节流,就知道这是 UTF-8 编码了。Windows 就是使用 BOM 来标记文本文件的编码方式的。

3. UCS 的组合字符和实现级别

UCS 的组合字符指的是一些单个字符不是一个完整的字符,它是一个类似于重音符或其他指示标记,加在前一个字符后面。比如说重音符号,汉语拼音的音调,还比如「菊花文」(我҉是҉菊҉花҉文҉) 中的菊花 (U+0489),组合字符机制允许在任何字符后加上重音符或其他指示标记, 这在科学符号中特别有用,比如数学方程式和国际音标字母,可能会需要在一个基本字符后组合上一个或多个指示标记。

不是所有的系统都需要支持象组合字符这样的 UCS 里所有的先进机制. 因此 ISO 10646 指定了下列三种实现级别:

级别 1

不支持组合字符和 Hangul Jamo 字符 (一种特别的,更加复杂的韩国文的编码,使用两个或三个子字符来编码一个韩文音节);

级别 2

类似于级别 1,但在某些文字中,允许一列固定的组合字符 (例如,希伯来文,阿拉伯文,Devangari,孟加拉语,果鲁穆奇语,Gujarati,Oriya,泰米尔语,Telugo,印.埃纳德语,Malayalam,泰国语和老挝语)。如果没有这最起码的几个组合字符,UCS 就不能完整地表达这些语言。

级别3

支持所有的 UCS 字符,例如数学家可以在任意一个字符上加上一个 tilde (颚化符号,西班牙语字母上面的~) 或一个箭头 (或两者都加)。

4. Unicode 和 UCS 的区别

Unicode 标准额外定义了许多与字符有关的语义符号学,一般而言是对于实现高质量的印刷出版系统的更好的参考。Unicode 详细说明了绘制某些语言 (比如阿拉伯语) 表达形式的算法,处理双向文字 (比如拉丁与希伯来文混合文字) 的算法和排序与字符串比较所需的算法,以及其他许多东西。

另一方面,UCS(ISO-10646) 标准,只不过是一个简单的字符集表。它指定了一些与标准有关的术语。定义了一些编码的别名。并包括了规范说明。指定了怎样使用 UCS 连接其他 ISO 标准的实现,比如 ISO-6429 和 ISO-2022。还有一些与 ISO 紧密相关的,比如 ISO-14651 是关于 UCS 字符串排序的。

考虑到 Unicode 标准有一个易记的名字,且在任何好的书店里的 Addison-Wesley 里有,只花费 ISO 版本的一小部分,且包括更多的辅助信息,因而它成为使用广泛得多的参考也就不足为奇了。然而,一般认为,用于打印 ISO-10646-1 标准的字体在某些方面的质量要高于用于打印 Unicode 2.0 的。专业字体设计者总是被建议说要两个标准都实现,但一些提供的样例字形有显著的区别。ISO-10646-1 标准同样使用四种不同的风格变体来显示表意文字如中文,日文和韩文 (CJK),而 Unicode 2.0 的表里只有中文的变体。这导致了普遍的认为 Unicode 对日本用户来说是不可接收的传说,尽管是错误的。

5. 代码页 (codepage)

所谓代码页 (codepage) 就是各国的文字编码和 Unicode 之间的映射表。例如 GBK 和 Unicode 的映射表就是 CP936,所以也常用 cp936 来指代 GBK。

6. 实例

写了这么多了,举一个例子吧

打开「记事本」程序 Notepad.exe,新建一个文本文件,内容就是一个「我」字,依次采用 ANSI,Unicode,Unicode big endian 和 UTF-8 编码方式保存。

然后,用文本编辑软件 UltraEdit 中的「十六进制功能」,观察该文件的内部编码方式。

1)ANSI:文件的编码就是两个字节「CE D2」,这正是「我」的 GB2312 编码,这也暗示 GB2312 是采用大尾方式存储的。

2)Unicode:编码是四个字节「FF FE 11 62」,其中「FF FE」表明是小头方式存储,真正的编码是 U+6211。

3)Unicode big endian:编码是四个字节「FE FF 62 11」,其中「FE FF」表明是大头方式存储。

4)UTF-8:编码是六个字节「EF BB BF E6 88 91」,前三个字节「EF BB BF」表示这是 UTF-8 编码,后三个「E6 88 91」就是「我」的具体编码,它的存储顺序与编码顺序是一致的。

致谢

本文参考了大量的网络资源,包括维基百科百度百科浪迹天涯luqingfei@C++ 等网友博客,在此表示感谢。