Unicode的设计和原理

3,479 阅读17分钟

Unicode是计算机科学发展的基石之一,也是一直伴随每个程序员的概念,而很多地方上都存在对Unicode一些错误或者不精确的解释。这篇文章将围绕以下几点,详细地介绍一次Unicode。

  • Unicode的历史
  • Unicode平面映射
  • UTF和UCS的关系
  • UTF-8的设计和原理
  • UTF-16的设计和原理
  • 字节序BOM

Unicode的历史

一切得从ASCII码讲起,1960年,美国人依照自己的语言,发明了ASCII,全称American Standard Code for Information Interchange。这套只支持英语系字符的编码规范,使用1个字节的前7位映射128个字符,其中33个控制字符和95个可显示字符(其中包括大小写字母各26个,10个阿拉伯数字,以及其他标点符号)可以参见下表:

ASCII码ASCII码

随着计算机在全世界普及,ASCII码无法在信息的存储和传输中表示英语系以外的字符,所以各个国家地区陆续推出了他们的编码规范,出现了群雄割据的混乱局面。

ISO/IEC 8859 字符集:这是拉丁语系国家推出了字符集,和ASCII码一样,也是单字节字符集,其特点是扩充了ASCII空置最高位,也就是128-255的范围,加入了其他字符。ISO/IEC 8859有很多系列,从ISO/IEC 8859-1到ISO/IEC 8859-15(中间废弃了一个IEC 8859-12),基本每个规范对应西方国家一个小语种。

GB2312、GBK、GB18030字符集:这是针对简体中文推出的三个字符集,它们都是双字节的字符集,而且都向下兼容ASCII。依次按照GB2312、GBK、GB18030的顺序发展,排前面的是排后面的子集,而我们最常使用的是GBK,它包含的中文字符已经足够我们日常使用,所以平时见到GBK比见到GB18030多。

其他语言地区推出其他的字符集:比如日本有JIS,台湾有繁体中文编码规范BIG5.

按照这个混乱的形势发展下去,势必严重影响计算机科学的长远发展,每推出一个字符集都会增加一个历史包袱。为了解决这个问题,人们尝试寻找一套统一的字符集标准,统一这个混乱的局面。

于是同时有两个组织开始研究这件事情,

  • 鼎鼎大名的国际标准化组织ISO推出了ISO/IEC 10646标准,该标准定义了一个统一的字符集:Universal Multiple-Octet Coded Character Set,简称UCS。它涵盖两种编码方式:UCS-2和UCS-4,它们分别使用16bit和32bit存储一个字符,UCS-4等价于UTF-32,但是UCS-2和UTF-16存在一定差别,UCS-2只是UTF-16的子集,后续会介绍。

  • 美国一个软件制造商协会推出一套统一字符集:Unicode,最开始它是设计为一个字符存储于2个字节的编码方式,也就是UTF-16。(现在的UTF-16不再仅仅只支持2个字节了)

这两个协会后来发现了对方都在做同样的事,而人类无需两套字符集,于是这两家机构合并了彼此的研究成果,诞生了现在的Unicode!!Unicode有一句口号:

enabling people around the world to use computers in any language。

我们需要在这里先分清两个概念,字符集以及编码方式,字符集的概念是:“收集一系列字符,然后规定每个字符映射到某个整数上”。而编码方式,就规定这些映射字符的整数,应该如何在计算机中以二进制的形式存储或传输。而Unicode只是字符集,它的官方规范定义了三种编码方式:UTF-8,UTF-16,UTF-32。

Unicode规范规定,使用U+前缀加上一个十六进制整数表示一个字符,比如U+0041表示大写拉丁字母A。而整个Unicode的字符集,需要U+000000到U+10FFFF的存储空间,一共使用了21个bit,共有1,112,064个位置。

Unicode从1991年的1.0版本,到2017年6月发布最新版本10.0.0,目前Unicode包含的字符,只使用了U+000000到U+10FFFF这个空间12%左右位置,每年Unicode都在引入新的字符,比如每年都有复杂的汉字在申请加入Unicode规范,而笔画多得丧心病狂的biangbiang面的biang字,至今未被Unicode收录。来感受一下这个字的写法:
BiangBiang

目前我们在电脑上是无法打出这个字,自然也没有一个网页能显示这个字(不信?去随便谷歌百度搜搜看,最多只能看到图片上的字)。

Unicode平面映射

接下来我们介绍一下Unicode平面映射的概念,从U+000000到U+10FFFF,Unicode的编码空间可以划分为17个平面(plane),每个平面包含2^16(65,536)个码位。17个平面的码位可表示为从U+xx0000到U+xxFFFF,其中xx表示十六进制值从0x00到0x10,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0)。其他平面称为辅助平面(Supplementary Planes)。其中,BMP平面内,从U+D800到U+DFFF之间的码位区块是永久保留不映射到Unicode字符。UTF-16就利用保留下来的0xD800到0xDFFF区段的码位来对辅助平面的字符的码位进行编码,后面介绍UTF-16编码原理时会细讲。

接下来让我们一起看几张有趣的图,通过可视化的形式看一下Unicode编码空间各个平面的大致情况,其中每个小方格表示16 * 16 = 256个字符,每个大方格表示一个平面(plane),大小等于65535,整副图表示了17个平面。

Unicode码元占用情况Unicode码元占用情况

我们可以发现,目前大部分Unicode处于前三个平面内,空白方格表示未使用的码位,蓝色代表已经使用的码位,绿色表示私有区域(用于干嘛呢?再研究),而红色区域是Surrogate区域(维基百科上翻译为代理区域),这个区域主要用于扩展UTF-16,用于UTF-16显示在16bit编码空间之外的字符,后面介绍UTF-16会详细介绍Surrogate区域。

第0平面包含了绝大部分我们日常能见到的字符,包括各种常见语言,英文、希腊语、中文、日文、韩文、希伯来语等等。而第1平面包含很多历史字符,以及emoji字符,而第2平面包含了很多汉字。而其他平面,第14平面使用了很少一部分,第15、16平面用于私有空间,

我们再来看看Unicode编码空间前三个平面中语言的分布

Unicode语种分布情况Unicode语种分布情况

其中占据最多的 蓝色代表汉字,而 棕色代表韩文,这两种语言占据了前三个平面大部分的空间。

我们再来看一个更有意思的,下图显示前三个平面内,各个Unicode字符的使用频率,黑色代表频率最低,依次按红色,黄色再到白色递增。

Unicode使用频率Unicode使用频率

我们可以发现,第0平面(BMP)包含的字符使用最多,只有少部分处在第1和第2平面,而第1平面最后一行的之所以显示使用频率那么高,是因为那个区域对应着emoji字符。

UTF、UCS的关系

上面我们介绍了Unicode的字符集,以及起大概的分布情况,而关于Unicode还有一个重要的点,就是怎么使用二进制的形式来存储和传输Unicode,这就是Unicode编码的问题,也称为Unicode Transformation Format,简称UTF。目前Unicode官方支持的编码方式有三种:UTF-8,UTF-16,UTF-32。而我们常见的Unicode编码,这种说法其实不大准确,但是可以注意一点,很多地方默认Unicode编码对应的就是UTF-16编码。

在介绍三种UTF编码方式之前,我们先讲讲另一个相关的概念,同时也是一个历史问题,就是UCS,我们前面讲到Unicode最开始是合并了ISO创建的UCS字符集,而这个UCS字符集存在两种编码方式,分别是UCS-2和UCS-4,它们分别占用2个字节,和4个字节。从Unicode 1.1开始,已经废弃了UCS-2编码方式,而被UTF-16代替,UCS-2只能描述2个字节内的字节,超过两个字节则不能描述,而作为UCS-2超集的UTF-16则可以。

UTF-8,UTF-16,UTF-32这三种编码方式,其中UTF-32是定长(4个字节)的编码方式,而UTF-8UTF-16是变长的编码方式,UTF-8最开始的定义是可以使用1至6个字节代表一个字符的,后来改为1至4个字节,而UTF-16则可以使用2个或者4个字节。

Encoding Word size Unicode support
UCS-2 16 bits BMP only
UCS-4 32 bits Full
UTF-8 8 bits Full
UTF-16 16 bits Full
UTF-32 32 bits Full

接下来介绍两种变长的的编码UTF-8和UTF-16的实现原理。

UTF-8的实现原理

UTF-8的编码规则可以概括为二条:

  • 单字节的字符,字节的第一位设为0,后面7位为这个符号的Unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的。
  • 对于n字节(n = 2,3,4)的字符,第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的unicode码。

所以,UTF-8的字符编码区间如下表格,x位是有效的二进制位,它们拼接起来就是对应的Unicode,表中的1和0,只是用于描述当前字符占用几个字节。

字节数 最小值 最大值 Byte 1 Byte 2 Byte 3 Byte 4
1 U+0000 U+007F 0xxxxxxx
2 U+0080 U+07FF 110xxxxx 10xxxxxx
3 U+0800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 U+10000 U+1FFFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

接下来举两个例子:

比如,希伯来语字母aleph(א)的Unicode代码是U+05D0,我们按照上面的表格将其转化成UTF-8,U+05D0属于U+0080到U+07FF区域,根据以上表格,说明它使用双字节的格式:110xxxxx 10xxxxxx.
十六进制的0x05D0换算成二进制就是101-1101-0000.这11位数按顺序放入x部分:11010111 10010000.
最后结果用十六进制写起来就是0xD7 0x90,这就是这个字符aleph(א)的UTF-8编码。

再举个例子,汉字“啊”的unicode是U+554A(0101 0101 0100 1010),根据上表,可以发现U+554A处在第三行的范围内(0000 0800-0000 FFFF),因此“啊”的UTF-8编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从“啊”的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位在高位补0。这样就得到了,“啊”的UTF-8编码是”11100101 10010101 10001010”,转换成十六进制就是0xE5958A。

UTF-16的实现原理

Unicode的编码空间从U+000000到U+10FFFF,其中跨越了第0平面(BMP平面)和第1平面,我们可以将其分为U+0000到U+FFFF和U+10000到U+10FFFF两个区间,而U+0000到U+FFFF区间内有一个部分U+D800到U+DFFF是用于代理区域的(Surrogate area),这个区域的码位是永久保留不映射到Unicode字符。UTF-16就利用保留下来的0xD800-0xDFFF区段的码位来对第1平面的字符(U+10000到U+10FFFF)的码位进行编码。

从U+0000至U+D7FF以及从U+E000至U+FFFF的码位

这个是BMP平面去掉代理区域U+D800到U+DFFF剩下的区域,UTF-16使用两个字节编码这个范围内的码位,而UCS-2也可以编码这个区域的Unicode字符,这个区域是UTF-16和UCS-2唯一的交集。

从U+10000到U+10FFFF的码位

辅助平面中的码位,在UTF-16中被编码为32bit,4个字节。先看一下其编码算法,然后再看一个例子。

辅助平面的码位(U+10000到U+10FFFF)减去0x10000,得到的值的范围U+0到U+FFFFF,最多占20bit长,我们将20bit长分为两部分,

高位的10bit的值(值的范围为0到0x3FF)被加上0xD800得到第一个码元,称作高位代理(high surrogate),值的范围是0xD800到0xDBFF。

低位的10bit的值(值的范围是0到0x3FF)被加上0xDC00得到第二个码元,称作低位代理(low surrogate),值的范围是0xDC00到0xDFFF。

将上面获得的高位代理和低位代理结合起来得到的四个字节,就是最终的Unicode编码,我们来举一个例子。

例如U+10440编码(𐑀):

0x10440减去0x10000,结果为0x00440,二进制为0000 0000 0100 0100 0000。
分区它的上10位值和下10位值(使用二进制):0000000001 and 000100000。
添加0xD800到上值,以形成高位:0xD800 + 0x0001 = 0xD801。
添加0xDC00到下值,以形成低位:0xDC00 + 0x0040 = 0xDC40。

所以U+10440的UTF-16编码是0xD801 0xDC40。

到这里,UTF-16的编码原理讲完了,但是有一个疑问,为什么4个字节的UTF-16编码需要这么复杂,需要去使用一个代理区域?为什么不直接用普通的方式映射到4个字节上(就像UTF-32那样)???

答案是,如果直接用普通的方式将辅助平面的码位(U+10000到U+10FFFF)映射到4个字节,而BMP平面(从U+0000至U+FFFF)继续使用2个字节,那么,最终在解析UTF-16的文件时,我们就无法知道字符之间的边界了,不知道哪里是使用2个字节表示一个字符,哪里是使用4个字节表示一个字符,但是,借助了代理区域之后,我们会发现,上面的低位代理和高位代理,这两个值的范围分别是0xD800到0xDBFF和0xDC00到0xDFFF,而它们总的范围就是0xD800到0xDFFF,刚好就是代理区域的区间,所以,4个字节的UTF-16,高位两个字节范围处于代理区域,而低位两个字节范围是不占用代理区域的。至此,我们就可以很容易识别出UTF-16的2个字节的码位和4个字节的码位。

这也就是为什么UTF-16中设计0xD800到0xDFFF这个代理区域的初衷,

BOM

编码方式使用的最小字节组合称为码元(Code Unit),UTF-8的码元是1个字节,UTF-16的码元是2个字节,而UTF-32的码元是4个字节。码元大于一个字节则在存储和传输时就要考虑字节序的问题,字节序分两种,一种是大端方式(Big Endian),一种是小端方式(Little Endian),大端方式规定,一个码元内的字节按正常方式存储,而小端方式规定,一个码元内的字节序按反过来的顺序存储,为了表示字节序,Unicode规范中规定UTF-16和UTF-32需要使用BOM(byte order mark)描述字节序。BOM是一段添加在数据流开头的字符串。同时,如果BOM不出现文件开头,它则是一个零宽度非换行空格:ZERO WIDTH NON-BREAKING SPACE,用户看到的仅仅是一个空格,但是从Unicode3.2开始,规定U+2060作为零宽度非换行空格,而BOM字符仅仅用于文件开头表示字节序,以免混淆。

下表总结BOM在各种编码方式中的使用。

BOM Encoding Endian
0xEF 0xBB 0xBF UTF-8 endianless
0xFF 0xFE UTF-16-LE little endian
0xFE 0xFF UTF-16-BE big endian
0xFF 0xFE 0x00 0x00 UTF-32-LE little endian
0x00 0x00 0xFE 0xFF UTF-32-BE big endian

而UTF-8的码元是以一个字节为单位,所以可以不BOM前缀,而且Unicode规范也推荐不要在UTF-8编码的内容添加BOM前缀,因为有些软件在解码时,会忘记处理UTF-8的BOM。

下表的例子说明UTF-16的大端方式和小端方式是如何存储数据的,其中一个例子是2字节的UTF-16字符,另一个是4个字节的UTF-16字符。需要注意一点,小端方式只是对同个码元内的字节倒序存储,而UTF-16的码元是2个字节,所以4个字节长的UTF-16是分为两半,每一半将内部的字节调到顺序。如果是UTF-32,由于它的码元是四个字符,则小端排序直接将4个字节颠倒顺序。

字符 UTF-16 UTF-16小端 UTF-16大端
€:U+20AC 20AC 20 AC AC 20
𐐷:U+10437 D801 DC37 D8 01 DC 37 01 D8 37 DC

另外,对于UTF-16和UTF-32,如果以BOM开头,则可以知道对应的字节序,但是如果没有BOM,则默认是big endian。而且,对于编码方式UTF-16BE,UTF-16LE,UTF-32BE,UTF-32LE,可以不需添加BOM,因为BE和LE已经分别声明是大端方式和小端方式了。

参考/推荐阅读

zh.wikipedia.org/wiki/UTF-16
www.unicode.org/faq/utf_bom…
reedbeta.com/blog/progra…