Unicode编码

1. 编码的意义

计算机里所有的数据都是字节的形式存储、处理的,需要这些字节来表示计算机里的信息。但是这些字节本身又是没有任何意义的,需要对这些字节赋予实际的意义,因此才会制定各种编码标准。

2. 两类编码模型

1.1. 简单字符集

在这种编码模型里,一个字符集定义了这个字符集里包含什么字符,同时把每个字符如何对应成计算机里的比特也进行了定义。例如ASCII,在ASCII里直接定义了A->0100 0001。也就是ASCII直接完成了现代编码模型的前三步工作。

1.2. 现代编码模型

在现代编码模型里要知道一个字符如何映射成计算机里比特,需要经过如下几个步骤:

  • 知道一个系统需要支持哪些字符,这些字符的集合被称为字符表(Character repertoire)。
  • 给字符表里的抽象字符编上一个数字,也就是字符集合到一个整数集合的映射。这种映射称为编字符集(CCS,Coded Character Set)。Unicode是属于这一层的概念,跟计算机里的什么进制啊没有任何关系,它是完全数学的抽象的。
  • 将CCS里字符对应的整数转换成有限长度的比特值,便于以后计算机使用一定长度的二进制形式表示该整数。这个对应关系被称为字符编码表(CEF,Character Encoding Form)。UTF-8, UTF-16都属于这层。

对于CEF得到的比特值具体如何在计算机中进行存储和传输,因为存在大端小端的问题,这就会跟具体的操作系统相关了。这种解决方案称为字符编码方案(CES,Character Encoding Scheme)。

下边分别介绍几类编码模型。

3. 几种编码模型

3.1. ASCII

计算机中所有的信息最终都表示为一个二进制的字符串,每一个二进制位有0和1两种状态,通过不同的排列组合,使用0和1就可以表示世界上所有的东西。

1字节对应8位二进制数,每位二进制数有0、1两种状态,因此1字节可以组合出256种状态。如果这256中状态每一个都对应一个符号,就能通过1字节的数据表示256个字符。美国人于是就制定了一套编码(其实就是个字典),描述英语中的字符和这8位二进制数的对应关系,这被称为ASCII码。

ASCII码一共定义了128个字符,包括英文字母A-Z,a-z,数字0-9,一些标点符号和控制符号等。这128个字符只使用了8位二进制数中的后面7位,最前面的一位统一规定为0。

3.2. GB2312

英语用128个字符来编码完全是足够的,但是用来表示其他语言,128个字符是远远不够的。于是,一些欧洲的国家就决定,将ASCII码中闲置的最高位利用起来,这样一来就能表示256个字符。但是,这里又有了一个问题,那就是不同的国家的字符集可能不同,就算它们都能用256个字符表示全,但是同一个码点(也就是8位二进制数)表示的字符可能可能不同。例如,144在阿拉伯人的ASCII码中是گ,而在俄罗斯的ASCII码中是ђ。

因此,ASCII码的问题在于尽管所有人都在0-127号字符上达成了一致,但对于128-255号字符上却有很多种不同的解释。与此同时,亚洲语言有更多的字符需要被存储,一个字节已经不够用了。汉语中,按照如下规定:

  • 一个小于127的字符的意义与原来相同,但两个大于127的字符连在一起时,就表示一个汉字。
  • 前面的一个字节(称之为高字节)从0xA1(10100001)用到0xF7(11110111),后面一个字节(低字节)从0xA1(10100001)到0xFE(11111110)。

这样可以组合出大约7000多简体汉字。这些编码还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在ASCII里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的全角字符。而原来在127号以下的那些就叫半角字符了。这种汉字方案叫做GB2312。GB2312是对 ASCII 的中文扩展。

3.3.GBK

但是中国的汉字太多了,很快就就发现有许多人的人名没有办法在这里打出来。于是继续把GB2312没有用到的码位用上。后来还是不够用,于是干脆不再要求低字节一定是127号之后的内码,只要第一个字节是大于127就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。扩展之后的编码方案被称为GBK标准,GBK包括了GB2312的所有内容,同时又增加了近20000个新的汉字(包括繁体字)和符号。

3.4. GB18030/DBCS

后来少数民族也要用电脑了,于是再一次扩展,又加了几千个新的少数民族的字,GBK扩成了GB18030。从此之后,中华民族的文化就可以在计算机时代中传承了。中国的程序员们看到这一系列汉字编码的标准是好的,于是通称他们叫做双字节字符集(DBCS,Double Byte Charecter Set)。

在DBCS系列标准里,最大的特点是两字节长的汉字字符和一字节长的英文字符并存于同一套编码方案里,因此他们写的程序为了支持中文处理,必须要注意字串里的每一个字节的值,如果这个值是大于127的,那么就认为一个双字节字符集里的字符出现了。

当时各个国家都像中国这样搞出一套自己的编码标准,结果互相之间谁也不懂谁的编码,谁也不支持别人的编码。

3.5. Unicode

最终,美国人意识到他们应该提出一种标准方案来展示世界上所有语言中的所有字符,出于这个目的,Unicode诞生了。Unicode源于一个很简单的想法:将全世界所有的字符包含在一个集合里,计算机只要支持这一个字符集,就能显示所有的字符,再也不会有乱码了。它从0开始,为每个符号指定一个编号,这叫做码点(code point)。比如,码点0的符号就是null(表示所有二进制位都是0)。

1
U+0000=null

上式中,U+表示紧跟在后面的十六进制数是Unicode的码点。

这么多符号,Unicode不是一次性定义的,而是分区定义。每个区可以存放65536(2^16)个字符,称为一个平面(plane)。目前,一共有17个平面,也就是说,整个Unicode字符集的大小现在是2^21。最前面的65536个字符位,称为基本平面(缩写BMP),它的码点范围是从0一直到2^16-1,写成16进制就是从U+0000到U+FFFF。所有最常见的字符都放在这个平面,这是Unicode最先定义和公布的一个平面。剩下的字符都放在辅助平面(缩写SMP),码点范围从U+010000一直到U+10FFFF。

Unicode 只规定了每个字符的码点,到底用什么样的字节序表示这个码点,就涉及到编码方法。

3.5.1 Unicode编码方案

之前提到,Unicode没有规定字符对应的二进制码如何存储。以汉字“汉”为例,它的Unicode码点是0x6c49,对应的二进制数是110110001001001,二进制数有15位,这也就说明了它至少需要2个字节来表示。可以想象,在Unicode字典中往后的字符可能就需要3个字节或者4个字节,甚至更多字节来表示了。

这就导致了一些问题,计算机怎么知道这2个字节表示的是一个字符,而不是分别表示两个字符呢?这里可能会想到,那就取个最大的,假如Unicode中最大的字符用4字节就可以表示了,那么就将所有的字符都用4个字节来表示,不够的就往前面补0。这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了3倍,这显然是无法接受的。

于是,为了较好的解决Unicode的编码问题,UTF-8和UTF-16两种当前比较流行的编码方式诞生了。当然还有一个UTF-32的编码方式,也就是上述那种定长编码,字符统一使用4个字节,虽然看似方便,但是却不如另外两种编码方式使用广泛。

3.5.2. UTF-8

UTF-8是一个非常惊艳的编码方式,漂亮的实现了对ASCII码的向后兼容,以保证Unicode可以被大众接受。UTF-8是目前互联网上使用最广泛的一种Unicode编码方式,它的最大特点就是可变长。它可以使用1-4个字节表示一个字符,根据字符的不同变换长度。编码规则如下:

  • 对于单个字节的字符,第一位设为0,后面的7位对应这个字符的Unicode码点。因此,对于英文中的0-127号字符,与ASCII码完全相同。这意味着ASCII码那个年代的文档用UTF-8编码打开完全没有问题。
  • 对于需要使用N个字节来表示的字符(N > 1),第一个字节的前N位都设为1,第N + 1位设为 0,剩余的N - 1个字节的前两位都设位10,剩下的二进制位则使用这个字符的Unicode码点来填充。

编码规则如下:

Unicode 十六进制码点范围 UTF-8 二进制
0000 0000 - 0000 007F 0xxxxxxx
0000 0080 - 0000 07FF 110xxxxx 10xxxxxx
0000 0800 - 0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000 - 0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

根据上面编码规则对照表,进行UTF-8编码和解码就简单多了。下面以汉字“汉”为利,具体说明如何进行UTF-8编码和解码。

“汉”的Unicode码点是0x6c49(110 1100 0100 1001),通过上面的对照表可以发现,0x0000 6c49位于第三行的范围,那么得出其格式为1110xxxx 10xxxxxx 10xxxxxx。接着,从“汉”的二进制数最后一位开始,从后向前依次填充对应格式中的x,多出的x用0补上。这样,就得到了“汉”的UTF-8编码为11100110 10110001 10001001,转换成十六进制就是0xE6 0xB7 0x89。

解码的过程也十分简单:如果一个字节的第一位是0 ,则说明这个字节对应一个字符;如果一个字节的第一位1,那么连续有多少个1,就表示该字符占用多少个字节。

3.5.3. UTF-16

Windows内核、Java、Objective-C (Foundation)、JavaScript中都会将字符的基本单元定为两个字节的数据类型,也就是我们在C/C++中遇到的wchar_t类型或Java中的char类型等等,这些类型占内存两个字节,因为Unicode中常用的字符都处于0x0 - 0xFFFF的范围之内,因此两个字节几乎可以覆盖大部分的常用字符。

UTF-16编码介于UTF-32与UTF-8之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用2个字节,辅助平面的字符占用4个字节。也就是说,UTF-16的编码长度要么是2个字节(U+0000到U+FFFF),要么是4个字节(U+010000到U+10FFFF)。那么问题来了,当遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?

这里有一个很巧妙的地方,在基本平面内,从U+D800到U+DFFF是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。

辅助平面的字符位共有2^20个,因此表示这些字符至少需要20个二进制位。UTF-16将这20个二进制位分成两半,前10位映射在U+D800到U+DBFF(空间大小2^10),称为高位(H),后10位映射在U+DC00到U+DFFF(空间大小2^10),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。

因此,当遇到两个字节,发现它的码点在U+D800到U+DBFF之间,就可以断定,紧跟在后面的两个字节的码点,应该在U+DC00到U+DFFF之间,这四个字节必须放在一起解读。

接下来,以汉字”𠮷”为例,说明UTF-16编码方式是如何工作的。汉字”𠮷”的Unicode码点为0x20BB7,该码点显然超出了基本平面的范围(0x0000 - 0xFFFF),因此需要使用四个字节表示。首先用0x20BB7 - 0x10000计算出超出的部分,然后将其用20个二进制位表示(不足前面补0),结果为0001000010 1110110111。接着,将前10位映射到U+D800到U+DBFF之间,后10位映射到U+DC00到U+DFFF即可。U+D800对应的二进制数为1101100000000000,直接填充后面的10个二进制位即可,得到1101100001000010,转成16进制数则为0xD842。同理可得,低位为0xDFB7。因此得出汉字”𠮷”的UTF-16编码为0xD842 0xDFB7。

Unicode3.0中给出了辅助平面字符的转换公式:

1
2
H = Math.floor((c-0x10000) / 0x400)+0xD800
L = (c - 0x10000) % 0x400 + 0xDC00

根据编码公式,可以很方便的计算出字符的UTF-16编码。以𝌆字符为例,它是一个辅助平面字符,码点为U+1D306,将其转为UTF-16的计算过程如下。

1
2
H = Math.floor((0x1D306-0x10000)/0x400)+0xD800 = 0xD834
L = (0x1D306-0x10000) % 0x400+0xDC00 = 0xDF06

所以,𝌆字符的UTF-16编码就是 0xD834 0xDF06,长度为四个字节。

3.5.4. UTF-32

UTF-32是最直观的编码方法,每个码点使用四个字节表示,字节内容一一对应码点。比如,码点0就用四个字节的0表示,码点597D就在前面加两个字节的0。

1
2
U+0000 = 0x0000 0000
U+597D = 0x0000 597D

UTF-32的优点在于,转换规则简单直观,查找效率高。缺点在于浪费空间,同样内容的英语文本,它会比ASCII编码大四倍。这个缺点很致命,导致实际上没有人使用这种编码方法,HTML 5标准就明文规定,网页不得编码为UTF-32。

3.6. UCS-2编码

JavaScript语言采用Unicode字符集,但是只支持一种编码方法。这种编码既不是UTF-16,也不是UTF-8,更不是UTF-32。上面那些编码方法,JavaScript都不用。JavaScript用的是UCS-2。关于UCS-2的历史,可以查看原文链接,还是比较有意思的。

4. 参考文章

彻底弄懂Unicode编码