一、缘起
先不要管这是个什么鬼,这个鬼是探讨Unicode字符的存储编码的副产品。各位看官若有兴趣,请慢慢往下看。
通常情况下,我们碰到的字符对应的Unicode编码在0x0000至0xFFFF之间,字符的Unicode编码跟它在Unicode Big Endian编码格式保存的文件当中的编码是一致的。
例如,汉字“一”的Unicode编码是“4E00”,我们用Windows的记事本软件新建一个文件,写入汉字“一”(不带双引号哈),选择“unicode big endian”编码格式,保存为“一.txt”。然后我们用十六进制查看工具(例如EditPlus)打开这个文件,就可以看到,除了开头的BOM“FE FF”外,我们看到了“4E 00”这个符号串,恰好跟汉字“一”的Unicode编码一样。
但有的时候,我们会碰到一些字符的Unicode编码大于0xFFFF,这时候,字符在文件的存储编码跟它的Unicode编码就不一样了。它们究竟有什么样的关系呢?在此记录一下上网查到的资料。
注:BOM是Byte Order Mark的缩写,意思是“字节顺序标记”,它出现在文本文件头部,用两个字节来表示在Unicode编码标准中是采用unicode big endian编码格式还是unicode编码格式来保存数据的。unicode big endian编码格式的BOM是“FE FF”,unicode编码格式的BOM是“FF FE”。
二、Unicode简介
我们通常说的Unicode,正式名称是“UTF-16”,它在文件中用两个字节或者四个字节来存储一个字符。先介绍几个概念:
代码点
Unicode标准的本意很简单:希望给世界上每一种文字系统的每一个字符,都分配一个唯一的整数,这些整数叫做代码点(Code Points)。
代码空间
所有的代码点构成一个代码空间(Code Space),根据Unicode定义,总共有1,114,112个代码点,编号从0x000000到0x10FFFF。换句话说,如果每个代码点都能够代表一个有效字符的话,Unicode标准最多能够对1,114,112个字符进行编码,也就是说Unicode标准大概能表示110多万个字符。据说最新的Unicode7.0已经给超过11万个字符分配了代码点。
代码平面
Unicode标准把代码点分成了17个代码平面(Code Plane),编号为#0到#16。每个代码平面包含65,536(2^16)个代码点(17*65,536=1,114,112)。其中,Plane#0叫做基本多语言平面(Basic Multilingual Plane,BMP),其余平面叫做补充平面(Supplementary Planes)。Unicode7.0只使用了17个平面中的6个,并且给这6个平面起了名字:
Plane#0 BMP(Basic Multilingual Plane)
Plane#1 SMP(Supplementary Multilingual Plane)
Plane#2 SIP(Supplementary Ideographic Plane)
Plane#14 SSP(Supplementary Special-purpose Plane)
Plane#15 SPUA-A(Supplementary Private Use Area A)
Plane#16 SPUA-B(Supplementary Private Use Area B)
其中,基本多语言平面的码位范围为0x0000至0xFFFF,包含了最常见的字符,比如ASCII字符,汉字等。补充平面的码位范围为0x010000到0x10FFFF,其中补充平面2里面包含了基本多语言平面没有收录的一些汉字。例如,汉字“𥻗”就在这个平面。
三、基本多语言平面BMP简介
BMP是Unicode最重要的一个平面,它包含了大部分常用的字符。
0x00-0x7F:ASCII字符集,总共有128个字符,占据了BMP的前128个代码点。
0x80-0xFF:扩展SCII字符集,总共有128个字符,占据了BMP的第129-256个代码点
其中ASCII字符集和扩展SCII字符集合起来叫被称为ISO-8859-1字符集,该字符集向下兼容ASCII,其编码范围是0x00-0xFF,0x00-0x7F之间完全和ASCII一致,0x80-0x9F之间是控制字符,0xA0-0xFF之间是文字符号,欧洲很多国家的语言使用ISO-8859-1字符集。
0x4E00-0x9FFF:中日韩汉字区域,定义了两万多个汉字,其中前20,902个汉字(0x4E00-0x9FA5)是基本汉字区域,0x9FA6-0x9FEF这74个汉字是基本汉字补充区域。
0xD800-0xDFFF:这2048个码位区域的代码点没有分配给任何字符,它们有特殊的用处,被称为“Surrogate Code Points”,用来辅助表示Unicode编码位于补充平面内的那些字符。其中:
0xD800到0xDBFF:High-surrogate代码点,
0xDC00到0xDFFF:Low-surrogate代码点。
一个High-surrogate代码点和一个Low-surrogate代码点组成一个代理对(Surrogate Pair),可以在UTF-16里对补充平面内的字符进行编码,一共可以表示1024*1024+65,536=1,114,112个代码。
四、UTF-16编码简介
UTF-16是Unicode的一种文本保存的编码方式,它用两个字节来编码BMP里的代码点,用四个字节编码其余平面里的代码点。UTF-16编码在保存两个字节的Unicode字符时,要考虑字节序的问题,比如Windows的记事本,它要将文本保存为UTF-16编码的时候,就必须要选择“unicode”和“unicode big endian”当中的一个。UTF-16编码在保存四个字节的Unicode字符时,不考虑字节序问题,统统用类似“unicode big endian”的格式来存储。
由于BMP里只有65535个代码点,而两个字节恰好能表示65535个字符,所以BMP里面的Unicode字符直接把代码点转换成2个字节就可以了,保存成文件就是“unicode big endian”。
BMP之外的平面内的字符存储时要用四个字节,这需要先将代码点转化为一个代理对,然后再转为4个字节。假设要编码的补充平面内的代码点为X,具体的编码过程为:
X因为在补充平面,它的Unicode编码必定在0x010000到0x10FFFF之间,将X减去0x010000,得到的数在0x00000到0xFFFFF之间,正好可以用不超过20个二进制位来表示,我们就用20个二进制位,不足20位高位补0。然后将二十个二进制位分成两组,高位组含10个二进制位,低位组也含10个二进制位,高位组对应的数和0xD800相加,将低位组对应的数和0xDC00相加,得到的正好是一个代理对,也就是四个字节,将这四个字节写入文件就可以了。
五、举例
𥻗(chá)是《通用规范汉字表》中的第5989个汉字,它是Unicode字符集扩展B区的汉字,它在GB18030字符集中的编码是9731A435,其十六进制Unicode编码为25ED7。
由于𥻗的Unicode编码“25ED7”,大于0xFFFF,所以保存在文件中采用4个字节进行编码:
首先,0x25ED7减去0x10000,结果为0x15ED7,转化为20个二进制位:
0001 0101 1110 1101 0111。
将这二十位二进制数分成两组,十位一组,得到高位组和低位组:
高位组:0001010111
低位组:1011010111
变成16进制:
0x0057 和 0x02D7
高位组加上0xD800,得到UTF-16编码的前两个字节:0xD800 + 0x0057 = 0xD857。
低位组加上0xDC00,得到UTF-16编码的后两个字节:0xDC00 + 0x02D7 = 0xDED7。
所以,“𥻗”的UTF-16编码是:D8 57 DE D7
因此,在文件中以Unicode Big Endian编码存储字符“𥻗”的时候,除了开头的BOM外,我们会看到如下的四个字符:
D8 57 DE D7
六、心得
以前,听说Unicode用两个字节表示一个字符的编码,共有65535个字符。好像就相信了两个字节的说法。直到有一天,研究《通用规范汉字表》的时候,碰到第5989个汉字“𥻗”,发现它的Unicode编码是“25ED7”,懵了,怎么不是两个字节了?保存到文件的时候,更懵了,居然是四个字节“D8 57 DE D7”。一直想搞清楚为什么。今天总算明白通常说的Unicode仅仅指的是Unicode中的基本多语言平面,另外的平面还有字符,这些字符的Unicode编码是大于两个字节的,保存到文件就是四个字节。
七、扩展
汉字和汉字偏旁部首等在Unicode中的分布比较凌乱,为我们理解带来了困难。据不完全统计,大致包含如下几个区域:
┌──────┬────┬──────┐
│字符集 │字数 │Unicode 编码│
├──────┼────┼──────┤
│基本汉字 │20902字 │4E00-9FA5 │
├──────┼────┼──────┤
│基本汉字补充│74字 │9FA6-9FEF │
├──────┼────┼──────┤
│扩展A │6582字 │3400-4DB5 │
├──────┼────┼──────┤
│扩展B │42711字 │20000-2A6D6 │
├──────┼────┼──────┤
│扩展C │4149字 │2A700-2B734 │
├──────┼────┼──────┤
│扩展D │222字 │2B740-2B81D │
├──────┼────┼──────┤
│扩展E │5762字 │2B820-2CEA1 │
├──────┼────┼──────┤
│扩展F │7473字 │2CEB0-2EBE0 │
├──────┼────┼──────┤
│康熙部首 │214字 │2F00-2FD5 │
├──────┼────┼──────┤
│部首扩展 │115字 │2E80-2EF3 │
├──────┼────┼──────┤
│兼容汉字 │477字 │F900-FAD9 │
├──────┼────┼──────┤
│兼容扩展 │542字 │2F800-2FA1D │
├──────┼────┼──────┤
│PUA(GBK)部件│81字 │E815-E86F │
├──────┼────┼──────┤
│部件扩展 │452字 │E400-E5E8 │
├──────┼────┼──────┤
│PUA增补 │207字 │E600-E6CF │
├──────┼────┼──────┤
│汉字笔画 │36字 │31C0-31E3 │
├──────┼────┼──────┤
│汉字结构 │12字 │2FF0-2FFB │
├──────┼────┼──────┤
│汉语注音 │43字 │3105-312F │
├──────┼────┼──────┤
│注音扩展 │22字 │31A0-31BA │
├──────┼────┼──────┤
│〇 │1字 │3007 │
└──────┴────┴──────┘
对我们大多数人来说,掌握住基本汉字区域的20902个汉字也差不多够了。我们从可以从网上看到处理汉字的各种正则表达式,最常用是[\u4E00-\u9FA5],恰好对应Unicode的基本汉字区域,如果要处理全部中日韩汉字和偏旁部首标点符号等,可以用[\u2E80-\u9FF]来匹配。
八、什么鬼
用正则表达式[\u2E80-\u9FF]来匹配汉字和相关符号可能会错过《通用规范汉字表》中的一些像“𥻗”那样的位于Unicode补充平面内的汉字,但也是无可奈何的。Python这么强大都搞不定“𥻗”字的显示问题,我们也没必要纠结一些汉字不能处理的问题了。
试一下Python的出错界面(提前用Windows的记事本写一个“𥻗”字,以Unicode Big Endian编码格式保存为“米查.txt”):

这下子,连出错信息中的“Non-BMP character”也焕然大悟了,以前一直不知道这是个什么鬼。
参考资料:
https://blog.csdn.net/zxhoo/article/details/38819517
http://www.qqxiuzi.cn/zh/hanzi-unicode-bianma.php
http://blog.chinaunix.net/uid-21633169-id-4396998.html




