The World of TomasRan

今日Unicode字符集及其编码方式

万事俱备,再唤东风

之前有讨论过关于字符编码的问题,如果对此毫无了解,可以去看看 关于字符集、编码字符集和字符编码,这里将不再强调一些基本的概念,而是想分析一下具体的字符集及其编码方式。我们的目标锁定在Unicode,话不多说,扬帆起航!

Unicode的设计目标

Unicode的诞生,基于人们希望能够拥有一个统一字符集以容纳世界上所有字符的念想。

任何改革始于矛盾。在Unicode诞生之前,各个国家为了能够让自己的语言得到支持,大量的区域特定字符集被创造出来,即便是较为通用的ASCII码字符集也只是对拉丁字母的支持度较高,而对于大量的亚洲字符却无可奈何。这给计算机制造商带来的问题就是他们必须实现所有的字符编码方案来让他们的机器可以满足不同使用者的需求,使用多语言的时候还需要在各种编码方案之间进行来回切换着实令人懊恼。

矛盾如此突出,也就很自然地催生出尽快统一字符集的想法。在这样的环境下,Unicode应运而生。

Unicode的字符集编号

Unicode字符集尽量覆盖了全世界已知的所有字符,并为未知的字符留下扩展空间,以下将做详述。

编号空间 - 确定字符集的范围

Unicode字符集编号使用的数字码空间,从 0x0000 ~ 0x10FFFF,一共1,114,112个码点(就是具有标识意义、可与其建立映射关系的数字码)。书写形式是一个前缀“U+”,后面跟上对应的数字码,例如:U+0058 就是大写拉丁字母X的Unicode编号。

注意:超过4位十六进制数,不能添加数字 “0” 为开头。例如: U+01000 是非法的格式(U+0100 合法)。

具体可以查看 Unicode Character Table

平面划分 - 可扩展的编号空间

Unicode将它的编号空间划分成了17个平面。我们观察一下它的编号空间:0x0000 ~ 0x10FFFF,应该很容易得出结论,它就是按照高两位的不同划分了一共 0x00 ~ 0x10(一共17)个平面,每个平面包含 164 = 65536 个码点。

其中 0x0000 ~ 0xFFFF 被称为基本多语种平面(Basic Multilingual Panel),基本多语种平面是几乎所有的现代语言字符以及大量符号的数值编码。基本多语种平面的字符用4位十六进制数就可以表示,而其余的十六个平面字符则需要5~6位十六进制数表示,称之为辅助多语种平面。下图是具体的划分:

unicode编码平面划分表

我们现在所看到的Unicode字符集的编号方案已经是设计较为成熟的版本,其实Unicode诞生之初采用的是16-bit 去编号一个字符,因此提供的编码空间是 0x0000 ~ 0xFFFF,一共216(65536)个码点,也就是现在的基本多语种平面,涵盖了最为常用的字符。但是这显然是不够的,字符的数量在日益增长,需要对这个字符集进行扩充并且能够很好的兼容之前的版本,因此便就增加了16个延续在基本多语种平面之后的辅助多语种平面。

那么,为什么需要进行平面划分呢?

图书馆将书籍进行分类,当需要某种类别的书时直接去对应的书架上去找会节省不少时间。将Unicode字符集划分成为不同的平面也类似,Unicode字符集的17个平面对应了17张字符编号页,计算机可以根据字符编号的高两位的不同判断应该去查询哪个编号页,这意味着内存中只需载入这一张需要的编号页就可以,否则每次都要载入全部的字符编号无疑是一种内存浪费。如此的平面划分不仅具有良好的扩展性,也具备充分的兼容性。

通用字符类型

Unicode的每一个码点都有通用类别属性。主要的类别有:字母,标志,数字,标点,象征,分隔符和其它。这些分类可以再分割。但是并不是每一个字符都只能划分为一种通用类型。例如 U+000A 换行字符既属于控制符又属于分隔符。查看具体的 Unicode通用字符类型

码点类型

上面提到Unicode字符集对字符有不同的分类,而Unicode提供的码点,也是如此。

高代理码点和低代理码点

在基本平面中,U+D800 ~ U+DBFF(1024个码点)这一码值范围被称为高代理码点,U+DC00 ~ U+DFFF(1024个码点)这一码值范围被称为低代理码点。

在UTF-16编码方式(后面再做解释)中高代理码点后面紧接低代理码点便构成了一个代理对,它们代表了不在基本平面之中的1,048,576(1024 × 1024)个码点。

非字符码点

在Unicode的码点范围中,还有一部分码点是非字符码点,它们不能用来对字符进行编码,它们的范围是:U+FDD0 ~ U+FDEF以及任何以FFFE、FFFF结尾的码值(例如1FFFF,1FFFE,10FFFF…)。这个非字符码值的数量是固定的,一共是66个,并且也不会再增加。

保留码点

就像很多编程语言中的保留字一样,Unicode码值空间也有一部分是保留码点,这部分码点可以用来映射字符但是目前还未使用。

私有码点

私有码点类似于关键字,但是Unicode标准并没有对此进行明确说明,因此这些字符交换需要发送方和接收方在它们的解释上达成共识。也就是说在接收方和发送方需要事先约定好这些私有字符而不是在接收到字符时从Unicode标准中去寻找解释,现在一共有三个私有码点区段:

  • U+E000 ~ U+F8FF(6400个码点)
  • U+F0000 ~ U+FFFFD(65534个码点)
  • U+100000 ~ U+10FFFD(65534个码点)

以上关于Unicode字符集编号做了一个较为详细的介绍,概念性的东西稍微多一点,下面就来一起探究一下Unicode的字符编码方式吧。

放轻松

Unicode的编码方式

在了解Unicode的编码方式之前,我们先拿ASCII码进行预热。

我们知道,ASCII码字符集使用8个比特位进行编号,一共可以提供 28 = 256 个码点。可见编号空间实在是很狭小。因此,ASCII码的编码方式就是简单地查询它的字符编号表,利用字符编号表的一一映射关系进行编解码。例如我们需要编码一个字符 “A”,查ASCII码表可得 “A” 的字符集编号是65,则直接将65的二进制形式 “0100 0001” 输出;解码以8-bit为分隔,进行逆向的查表过程,例如 “0100 0001 0100 0010” 以8-bit为划分可得 “0100 0001” 和 “0100 0010”,查表可得是字符 “A” 和 “B”。这就是单字节编码方案。

那么在Unicode编码中,我们是否也可以采取这种方案?答案自然是否定的。

Unicode庞大的字符集决定了它并不能采取单字节编码的方案。事实上,在Unicode诞生之初,仅有对基本多语种平面BMP(即编号空间为 0x0000 ~ 0xFFFF)的定义。而表示这一编号空间需要 216个码点,对应 16 / 8 = 2 个字节,因此,早期的Unicode采用的是二字节编码方案。

如果Unicode止步于此,没有后续的扩展,似乎Unicode编码也能采用和ASCII码类似的方式,通过简单的查表法来进行编码和解码,只不过这次的分隔单位是16-bit。看上去没什么问题?其实即便如此也存在一些问题,每个字符都要写满两字节的长度就有点浪费了。例如我们只想存储字符 “A”,那么通过查表写入的二进制数据流将会是 “0000 0000 0100 0001”,有没有感觉到一点点冗长?

当然了,真正的原因还是因为Unicode需要进行扩展,我们无法简单通过查表法就可以达到目的,并且,这种以字符集编号来映射二进制流的方式也让字符集编号和其编码方式过于耦合。

Unicode扩展之后增加了16个辅助平面,编号空间从 0x0000 ~ 0x10FFFF,在编号空间范围内的二进制流的长度存在16位、20位、24位不等,这就很尴尬了,现在有一段二进制流数据 “0000 0010 0100 1001 0110 1110 1110 0001 0001”,想通过查表法找到指定的字符?先把这一段二进制数据做个分隔吧,采用多少位进行分隔?我并不知道,于是乎华丽丽地仆街了。

鉴于此,我们需要寻找其它的编码方案来解决这个问题,不能仅仅依靠字符集编号这个一一映射关系,我们可以制定字符集编号与二进制流数据之间的转换规则,通过这个规则计算出字符编码后的值(编码)或者根据编码后的值找到对应字符(解码)。

那么接下来,就让我们接触一下现行Unicode的几种编码方案。

UTF-32

UTF-32是固定编码长度的方案。它采用4个字节(这里一个字节视为8位)去映射一个Unicode字符集的码点。4个字节可以建立的映射关系已经远远超过Unicode字符集编号空间提供的码点数量,因此,UTF-32可以简单建立4个字节码和Unicode字符集码点的对应关系。

相信很容易看出来,这和查表法的道理一样。时间复杂度o(1),优点是很快。缺点也很明显,对于处于BMP平面的常用字符,全部采用32bit存储无疑是增加了大量的存储空间,造成浪费。

UTF-16

UTF-32的编码方式简单粗暴。对于我等使用MacBook,而其硬盘价格已上天的屌丝男士来说,牺牲存储自然是不能忍受的方案。那么我们就来接触一下Unicode编码的第二种实现方式吧:UTF-16。

我们的Unicode码点空间至少采用3个字节才能映射完全,2个字节不够用,4个字节又浪费,何其尴尬。UTF-16在这样的现实情况下,便萌生了揉合的想法:2个字节和4个字节我都采用,但是用它们表示的平面不一样。UTF-16将Unicode码点空间进行了划分,制定了具体的编码规则如下:

U+0000 ~ U+D7FF 和 U+E000 ~ U+FFFF(包含首尾)

对于这段码点空间,UTF-16采用16位二进制数对其进行编码,这16位二进制数和码点编号在数值上相等。举例来说明,会将 “U+B100” 编码为 “1011000100000000”,编码的值是十六进制0xB100的二进制形式。

U+D800 ~ U+DFFF(包含首尾)

那么对于BMP平面,被遗忘的那一部分编码空间做什么用呢?

Unicode标准将这段区间保留下来,为UTF-16编码提供高代理码点和低代理码点(这个概念前面有提及),并且处于这一区间的码点不会和具体的字符对应(在Unicode字符编号中 0x0401 对应了字符 “A”,而 0xD800 ~ 0xDFFF 之间的任何编号不会对应任何字符)。

编码方式实际上是建立字符和二进制流的映射关系。

U+10000 ~ U+10FFFF(包含首尾)

码点空间还剩下哪些呢?自然是其余16个辅助平面。

UTF-16 代理对的计算方式

这16个辅助平面被两个16-bit的编码单元组成的代理对所表示。先贴出编码的计算方法,然后以一个具体的实例去解读:

  1. 用辅助平面的码位减去 0x10000(结果是在 0 ~ 0xFFFFF 之内的20-bit长的值);
  2. 将上面的计算结果以10位为分隔划分为两部分(易于计算,每一部分值的范围在 0 ~ 0x3FF 之间);
  3. 高位的10比特的值加上 0xD800 得到第一个码元或称作高位代理(high surrogate),值的范围是 0xD800 ~ 0xDBFF;
  4. 低位的10比特的值加上 0xDC00 得到第二个码元或称作低位代理(low surrogate),现在值的范围是 0xDC00 ~ 0xDFFF。
  5. 组合高低位,构成代理对
来看一个栗子

看到以上计算方法是否能够豁然开朗呢?至少知道了高代理码点和低代理码点的由来和这样称呼的原因吧。现在就通过一个例子来清晰这个计算过程:

假设现在我们需要将Unicode字符集中的 “U+1CCCC” 进行编码,计算过程:

步骤一:减去0x10000

0x1CCCC - 0x10000 = 0xCCCC

步骤二:划分高低位

0xCCCC的二进制形式:0000 1100 1100 1100 1100 
10位:00 0011 0011 => 0x33
10位:00 1100 1100 => 0xCC

步骤三:计算高位

0x33 + 0xD800 = 0xD833

步骤四:计算低位

0xCC + 0xDC00 = 0xDCCC

步骤五:组合

该字符的UTF-16编码即 0xD833 0xDCCC
转化为二进制:1101 1000 0011 0011 1101 1100 1100 1100

这样就完成了字符 “U+1CCCC”的UTF-16编码过程。

寻找理论依据

被莫名其妙地灌输了一套计算体系,UTF-16这种编码方式的理论依据是什么呢?我们再明确一下,编码方案实际上是建立字符集中的所有字符和二进制流的一一映射关系(需要明确一下一一映射的概念? 一一映射也称作 双射)。

那么接下来我们就分析一下UTF-16是否满足了这样的条件。

使用UTF-16编码方案对BMP基本多语种平面的字符进行编码显然是一一映射,它直接将字符编号的值作为编码结果输出,字符编号是唯一的,所以它们肯定是一一映射。而对于 U+D800 ~ U+DFFF 这一区段,它不对应任何的字符,因此不影响关系成立。

对于其余16个辅助平面,是否是一一映射需要考虑两点:

  1. 两个不同的字符编号是否能计算出相同的编码值?
  2. 一个编码值解码是否能得到不同的字符编号?

我们希望答案皆非,这样UTF-16编码方案才能成立。而事实上呢?可以开动脑筋想想了。

首先,既然是一一映射,编码字符集和编码值在数量上至少应该是一致的。16个辅助平面总共可以提供 216 ×
16 = 220 个码点。而UTF-8采用代理对去编码一个辅助平面的字符,高代理对范围是 0xD800 ~ 0xDBFF (一共1024个),低代理对范围是 0xDC00 ~ 0xDFFF(一共1024个),那么他们的组合一共有 1024 × 1024 = 220种可能。果然如此,他们相等。

然后我们来分析它的计算过程:16个辅助平面的字符编号在 0x10000 ~ 0x100000之间,减去0x10000后的范围在 0x0 ~ 0xFFFFF 之间,这一步过程生成的值和16个辅助平面的字符编号显然是一一映射。

那么,在下面的计算中,我们来验证以上提出的两点:

(1)两个不同的字符编号是否能计算出相同的编码值?

将上一步的计算结果拆分为高低各10位,记作(x1,y1)。

我们假设存在另一个字符编号经过这一系列计算之后会生成相同的编码值,那么将另一个字符编号记作(x2,y2)。也就是说(x1,y1)和(x2,y2)经过计算之后会得到相同的编码值。我们假设这是成立的。

那么按照UTF-16编码的计算方式,也就是下列等式会成立:

x1 + 0xD800 = x2 + 0xD800

y1 + 0xDC00 = y2 + 0xDC00

这显然得到 x1 = x2,y1 = y2,假设不成立,则结论:两个不同的字符编号经过UTF-16编码计算的结果是唯一的。

(2)一个编码值解码是否能得到不同的字符编号?

如果只能实现编码值的唯一而不能保证解码值的唯一显然是不合适的,接收方将无法明确你想表达的意思。

我们知道UTF-16采用16-bit编码BMP平面字符,32-bit编码其余16个辅助平面字符。假设现在有一串经过UTF-16编码的二进制流,例如 “100A2000 000F00AE 10001000 00008888 11111111 00001F13 000001AE 0900 0AAAA”(随意杜撰的数据)。我们能实现解码的唯一吗?

首先,如果在这一串二进制数据流中我们明确知道这16-bit是某个字符编号的结果,那32-bit是另一个字符编号的结果,换句话说,我们能将二进制数据按照字符编号结果为单位进行明确划分,这样我们就只需要按照UTF-16编码的逆向过程去解码就可以了,这个解码值的唯一性应该毋庸置疑(按照验证编码唯一性逆向推导回去很容易得出结论)。这个计算过程应该算得上是 a piece of cake吧。

所以关键问题就落到在这样一串连续的二进制数据中,我们如何去按照字符编号结果为单位去划分,毕竟每个字符编号结果位数可能不一样,如果编码值的位数都是16-bit的话那以16-bit为单位划分即可,而事实并非如此,这样,我们进一步明确目标:解码的时候我们究竟是截取16位还是32位?

这个时候,我们看到了代理对的作用。32位的编码值都是采用高-低位代理对的形式。高位代理的值的范围是 0xD800 ~ 0xDBFF(转化为二进制就是 1101100000000000 ~ 1101101111111111),而这一区段不在BMP平面的编码值的范围内,因此,当我们遇到这个范围内的16为二进制数据,就知道它和接下来的16位一定是组成一个代理对。这样,我们就能区分开32位和16位的编码值了。以上问题得解。

唠叨了这么多,总算是验证了UTF-16编码的合理性。然而还并没有结束,接下来,就来看看现在广为流传使用的UTF-8编码格式。

继续

UTF-8

和UTF-16一样,UTF-8也是变长的编码方式。不过,起初它采用 1 ~ 6 个字节去编码一个字符(RFC 3629重新规定其只能编码Unicode的原有字符集 0x0 ~ 0x100000,也就是最多采用4个字节足矣)。

既然是对Unicode字符集的编码,那么我们就先来看看它的编码值和Unicode编号空间的对应关系吧,别说话,接镖:

unicode编码空间范围
十六进制
UTF-8二进制编码值 注释
0x0000 ~ 0x007F
(128个码点)
0yyyyyyy 编码值占一个字节,对应ASCII码的128个字符编码,二级制编码值首部是从0开始
0x0080 ~ 0x07FF
(1920个码点)
110yyyyy 10yyyyyy 编码值占两个字节,高位字节首部从110开始,低位字节首部从10开始
0x0800 ~ 0xD7FF
0xE000 ~ 0xFFFF
(61440个码点)
1110yyyy 10yyyyyy 10yyyyyy 编码值占三个字节,第一个字节以1110开头,后两个字节以10开头
0x010000 ~ 0x10FFFF
(1048576个码点)
11110yyy 10yyyyyy 10yyyyyy 10yyyyyy 编码值占四个字节,第一个字节以11110开头,后面的字节以10开头

从上表中,我们可以总结出UTF-8编码值的几个特性,对于一个UTF-8编码值,假设为x:

  1. 如果x的第一位是0,则x表示的是一个ASCII码
  2. 如果x的前两位是10,则x表示的是多字节编码中的除首字节之外的某个字节
  3. 如果x的前三位是110,则x表示的两字节编码的首字节
  4. 如果x的前四位是1110,则x表示的是三字节编码的首字节
  5. 如果x的前五位是11110,则x表示的是四字节编码的首字节

这一规律,对于给定一段二进制,去判断该字符编码是否采用的是UTF-8编码方案是有帮助的,并且也是UTF-8解码的依据。

上面讲述了Unicode编码空间和UTF-8编码结果的区间对应关系,这自然是不够的,我们需要知道详细的编码规则。

UTF-8编码过程

以下是UTF-8的编码值计算方式:

  1. 根据给定字符的Unicode编号,找到对应的UTF-8编码值区间,确定未知数y的个数N;
  2. 将给定的Unicode编号转为二进制形式,从最低位开始,向上截取N位(不足N位高位补0);
  3. 将上一步得到的二进制结果按顺序填充到对应区间的y上,即得该字符的UTF-8编码值。

吃栗子比较容易消化

例如带上圆圈和锐音符的拉丁文大写字母 Ǻ 的Unicode字符编号为U+01FA,根据上述方法的计算过程如下:

步骤一:确定区间

U+01FA 落在区间 0x0080 ~ 0x07FF,因此编码值为 110yyyyy 10yyyyyy,共11个未知数

步骤二:Unicode字符编码二进制化

U+01FA 的二进制形式为 0000 0001 1111 1010,从低位开始向上截取11位得到: 001 1111 1010

步骤三:按顺序依次填充

将上步的二进制结果按顺序替换y,则得到编码结果: 11000111 10111010

UTF-8编码的优势

技术领域的流行都有着看上去足够理智的原因:

  1. 完美兼容ASCII码编码方案,这意味着以前的ASCII码文本不需要做任何转换就可以被UTF-8理解以及编码。
  2. UTF-8编码没有字节序的问题(了解 字节序编码方案?)。

小结

为了不在编码的问题上陷入恐慌,以上对Unicode编码进行了一些总结,表达了一些个人理解。前行的路上,点滴积累,弥足珍贵。