字符集编码(四):UTF

在前面文章《字符集编码(中):Unicode》中我们聊了 Unicode 标准并提到其有三种实现形式:UTF-16、UTF-8 和 UTF-32,本篇我们就具体聊聊这三种 UTF 是怎么实现的。

UTF 是 Unicode Translation Format 的缩写,翻译过来是 Unicode 转换格式,对应字符编码模型中的第三、四层(字符编码形式和字符编码方案),负责将 Unicode 码点以特定的码元存储在计算机中

UTF-X 中的 X 表示码元的宽度(比特数),如 UTF-16 表示使用 16 位码元存储数据。

UTF-16

Unicode 最初是打算使用 16 位定长编码形式的,在这种情况下 Unicode 标量值(也就是码点)和其在计算机中的码元表示是一致的。

比如汉字“啊”的 Unicode 标量值(码点)是 554A,其码元表示也是 55 4A(二进制是 01010101 01001010)。

这种表示方式的优点是简单快速,不需要任何标志位,也不需要做任何转换,所以在 Unicode 设计之初选用双字节定长编码。

不过这种方式只能表示 2^16 也就是 65536 个字符,但 Unicode 联盟很快发现两个字节无法容纳世界上所有的字符,于是很快将编码空间扩展到 0~10FFFF,显然原先的表示方式不再可行了。

于是他们对原先的 UTF-16 做了改进。

改进后的 UTF-16 采用变长编码形式,使用一个或两个码元来表示字符编码。具体来说是基本平面(BMP,范围是 0000~FFFF)的字符使用一个码元表示,补充平面的使用两个码元。

然而这里有个问题:程序在解析一段文本时,如何知道某个码元(16 bit)是表示一个单独的字符呢,还是和另一个码元一起表示一个字符呢?

比如当程序遇到字节序列01001110 00101101 01010110 11111101时,应该将其解析为两个字符(每个字符占一个码元)呢还是解析为一个字符呢?

这种问题我们在以前讲 GB 2312 时也遇到过。GB 2312 中为了兼容 ASCII,使用一个字节编码 ASCII 字符,而用两个字节编码其它字符。为了识别某个字节到底是单独表示字符还是和另一个字节一起表示字符,GB 2312 将字节最高位作为标识位:如果一个字节的最高位是 0,则表示该字节是单独表示 ASCII 字符,否则就是和另一个相邻字节一起表示一个字符。

UTF-16(以及后面要讲的 UTF-8)也是采用类似标识位的方式解决问题的:每个码元的高 n 位作为标识位,说明该码元是单独表示一个字符还是和另一个码元一起表示字符,因而改进后的 UTF-16 码元表示类似xxxxyyyyyyyyyyyy,其中 x 表示标识位,y 表示实际值,程序根据一系列的 x 的值决定如何处理该码元。

Unicode 最大编码值(标量值)是 10FFFF,对应二进制是100001111111111111111。该范围的值中,FFFF 以内的值(二进制:000001111111111111111,注意其中高 5 为全部是 0,只有低 16 位是变化的)可以只用一个码元表示,大于 FFFF 的值要用两个码元表示,形如:xxxxyyyyyyyyyyyy xxxxyyyyyyyyyyyy

那么,当用两个码元表示的时候,需要做到:

  1. 标志位(x 表示的)需要具有某种固定特性(如固定位数的固定值),让程序能够据此做出正确处理;
  2. 数据位(y 表示的)能够放得下 10FFFF - FFFF = 100000(十进制是 1114111 - 65535 = 1048576)个值;
  3. 两个码元的标识位需要有所区别,这样程序才能知道谁在前谁在后;

1048576 = 1024 * 1024,所以要求码元中 y 位要有 10 位(2^10=1024),这样两个码元在一起能表示的数值的个数就是 1024*1024 了,于是确定可以用高 6 位(16 - 10)作为标识位。

UTF-16 两个码元的表示大致就是这样:

// 1. 每个码元的高 6 位是标识位,剩下的是数据位;
// 2. 两个码元的标识位需要有所区别,让程序能够识别是是高位码元,谁是低位码元;
xxxxxxyyyyyyyyyy xxxxxxyyyyyyyyyy

在 UTF-16 中标识位的值是固定不变的,所以两个码元中每个码元能表达 1024(2^10)个数值,这些值必然会跟基本平面 BMP 中某些值冲突,所以必须将 BMP 中某 2048 个值(两个码元,每个占用 1024 个)保留起来不做码点分配。

Unicode 在基本平面(BMP)中将 D800~DFFF 共 2048 个值(包括 D800 和 DFFF 自身)划出来给 UTF-16 专用,这段空间的值不能作为字符码点分配。

这 2048 个值中,前 1024 个(D800~DBFF)叫做高位代理(high surrogates),对应两个码元中左边的那个码元;后 1024 个(DC00~DFFF)叫做低位代理(low surrogates),对应右边的码元。高低位代理一起称为代理对(surrogate pair),UTF-16 就是用代理对来表示扩展平面的字符编码。

D800~DBFF 的数值(二进制)都是以 110110 开头(高 6 位),因此高位码元(我们称两个码元中的左边那个为高位码元)用 110110 作为标识位;DC00~DFFF 的数值都是以 110111 开头,因此低位码元用 110111 作为标识位。

举个例子,当程序遇到下面这串码元序列该如何解析呢:

image-20220311113923321

作为 UTF-16 编码形式,上面一共有三个码元。程序发现第一个码元(01010101 01001010)不是 11011 开头,则将其作为单码元字符解析(汉字“啊”);第二个码元(11011000 01000000)是 110110 开头,说明它是双码元字符的高位码元(高代理),于是继续获取下一个码元(11011100 00000000)检查其标识位 110111,无误,于是将这两个码元一起解析成一个字符(是一个不常用的汉字,很多编辑器显示不出来)。

于是上面的码元序列解析出来就是:

image-20220311115128181

接下来的问题是,上面 Unicode 码点 U+20000(二进制:10 00000000 00000000)是如何映射到两个码元中的数据位的呢?

简单的理解是下面的映射关系:

image-20220311115808636

对于 小于 FFFF 的值,由于一个码元能直接放得下,就直接放进去,没什么说的(注意 D800~DFFF 保留出来了)。

大于 FFFF 的值,我们将其分为图中 u、x、y 三部分,然后将 u - 1、x、y 直接填入码元的数据位中即可。

注意 Unicode 编码空间最大值是 10FFFF,其二进制有 21 位(100001111111111111111),而上面两个码元的数据位一共只有 20 位,少了一位。

但我们发现,最大值 高 5 位 10000 减去 1 就变成 4 位了,加上剩下的 16 位正好是 20 位。

当然,这是我们从直觉上这么理解的,其实 UTF-16 的这个映射关系是有数学公式的:

image-20220311121421312

其中 CH 和 CL 分别表示高低代理码元的值,U 表示 Unicode 码点值(又叫标量值);下标 16 表示 16 进制;/ 是整除,mod 是取模。

是不是看得一脸懵逼?

翻译过来其实很简单:假设码点值是 X,则将 X - 10000 取 10000 以上部分,该部分除以 400(十进制 1024),得到的整数加上高代理偏移量 D800 作为高位码元的值,得到的余数加上低代理偏移量 DC00 作为低位码元的值。

反过来,通过码元值推导码点值:

image-20220311122128225

因为前面我们是减掉了 10000,所以这里要加回去。


UTF-8

Unicode 最初决定采用双字节定长编码方案,后来发现没法彻底兼容现有的 ASCII 标准的文件和软件,导致新标准无法快速广泛推广使用,于是 Unicode 联盟很快推出 8 位编码方案以兼容 ASCII,这就是 UTF-8。

(由于 UTF-8 的码元宽度是一个字节,下面会混合使用字节与码元的概念。)

UTF-8 使用一到四个字节的字节序列来表示整个 Unicode 编码空间。和 UTF-16 一样,UTF-8 也是变长编码方案,所以它的每个码元(字节)同样需要包含标识位和数据位两部分,形如xxxyyyyy。这里的标识位需要做到:

  1. 存在某种固定规则,让程序能够判断出它是标识位;
  2. 和 UTF-16 要么一个码元要么两个码元的设计不同,UTF-8 涉及到1~4 个码元(理论上可以不止 4 个),所以标识位还应包含码元数量信息;
  3. 码元序列中第一个码元的标识位和其他码元的应该有所不同,这样程序才能知道应该从哪个码元开始解析;
  4. 需完全兼容 ASCII 码,即 ASCII 码字符只需要一个码元(一个字节);

UTF-8 编码逻辑是这样的:

  1. 先看字节最高位,如果是 0,则说明是用一个字节表示字符,也就是 ASCII 字符(这里再次见识到 ASCII 编码标准中最高位恒 0 的重要性);
  2. 反之,如果最高位是 1,说明是用多字节编码(至少两个字节)。此时首先要区分首字节和后续字节,让程序知道从哪个字节开始解析。UTF-8 规定,此时首字节最高两位一定是 11,而后续字节最高两位一定是 10,程序据此区分;
  3. 首字节高位有几个 1 就表示用多少个字节表示字符,比如 1110XXXX 表示用三个字节表示一个字符(如常用汉字);

UTF-8 的规则看起来还是挺简单的,总结起来就是:通过最高位判断是否单字节字符;如果是多字节,通过 11、10 分别识别首字节和后续字节;通过首字节高位连续有多少个 1 识别该字符是由多少个字节表示。

比如程序遇到字节序列01100001 11100101 10010101 10001010该如何解析呢?

第一个字节最高位是 0,说明是单字节字符,直接按字面意思解析得到拉丁字母 a。

第二个字节是 1 开头,说明是多字节字符;最高两位是 11,说明该字节是多字节字符的首字节,于是从该字节高位解析字符字节数:高位有连续的三个 1(标志位是 1110),说明该字节和它后面的两个字节一起表示一个字符,然后检查后续两个字节,确实以 10 开头,符合规则,于是将这三个字节一起解析得到汉字“啊”。

接下来的问题是,汉字“啊”的 Unicode 码点是如何存入多字节码元中呢?

UTF-8 规则直观理解如下:

image-20220311173929297

这个规则是很直观的,直接将二进制标量值中的位拷贝到码元相应位置即可。

比如汉字“啊”的码点是 U+554A,二进制标量值是 00000 01010101 01001010,从表中可知需要用三个字节存放其低 16 位(16 位以上都是 0)。三个字节一共有 24 位,减去 8 个标识位,刚好还剩 16 个位可用:

image-20220311175109239

当然除了以上这种直观理解,UTF-8 的规则也是可以用数学公式表达的,需要对四个编码范围分别表述,此处不再贴出公式。


UTF-32

Unicode 还有一种最直观但最占用空间(也最不常用)的编码表示:UTF-32,它采用 4 字节(32 位)码元,任何 Unicode 码点都是用 4 个字节表示。由于 4 字节足以容纳任何 Unicode 标量值(我们称 Unicode 码点的二进制表示为标量值),所以它是最直观的表示方式,无需做任何标识和转换。

比如汉字“啊”的 Unicode 码点是 U+554A,其二进制标量值是1010101 01001010,其 UTF-32 表示就是00000000 00000000 01010101 01001010(此处没有考虑大小端)。

和 UTF-16 一样,UTF-32 也不能兼容 ASCII 标准。


大小端与 BOM

我们在《字符集编码(补):字符编码模型》的第四层字符编码方案 CES中提到字符编码在计算机中存储时存在大小端问题(那里也详细讲解了大小端的概念,不熟悉的同学可以先看下那边文章)。在那篇文章中我们说过只有多字节码元(UTF-16、UTF-32)才存在大小端问题,单字节码元(UTF-8)不存在大小端问题。

我们还是以汉字“啊”为例,其 UTF-8、UTF-16 和 UTF-32 的编码形式在编码模型第三层(字符编码形式 CEF)分别表示如下:

// “啊”的码点是 U+554A
UTF-8: 11100101 10010101 10001010 // 十六进制:E5 95 8A
UTF-16:01010101 01001010 // 十六进制:55 4A
UTF-32:00000000 00000000 01010101 01001010 // 十六进制:00 00 55 4A

其中 UTF-8 用了三个码元,但由于其码元宽度是 1 个字节,不存在大小端问题,不用讨论。

UTF-16 和 UTF-32 都只用了一个码元,但由于两者的码元宽度大于 1 个字节,需要考虑字节序问题。

大端序存储规则是先存高位(也就是将高位放在低地址。我们将一个数左边的叫高位,右边叫低位);小端序存储规则是先存低位。“啊”字的编码方案考虑大小端后是这样的:

UTF-16BE:01010101 01001010 // 大端序。十六进制:55 4A
UTF-16LE:01001010 01010101 // 小端序。十六进制:4A 55
UTF-32BE:00000000 00000000 01010101 01001010 // 大端序。十六进制:00 00 55 4A
UTF-32LE:01001010 01010101 00000000 00000000 // 小端序。十六进制:4A 55 00 00

在前面的文章中我们还提到,之所以需要考虑大小端问题,是因为文本需要存储到磁盘文件系统并在多个异构系统之间分享。那么,当一个程序拿到一个文件后,它怎么知道该文件是按大端序存储的还是按小端序存储的呢?

为了解决这个问题,Unicode 中定义了一个特殊的字符叫 ZERO WIDTH NOBREAK SPACE(零宽度无中断空白符,就是说这个字符既没有宽度,也不能造成文本换行),其码点是 U+FEFF。Unicode 的多字节码元编码方案(UTF-16、UTF-32)就是在文件开头用这个字符

的相应编码值来表示该文件是怎么编码的。

我们先看看码点 U+FEFF 用 UTF-8、UTF-16BE、UTF-16LE、UTF-32BE、UTF-32LE 分别如何表示(BE 是 Big Endian 大端序的意思,LE 是小端序:

// 码点:U+FEFF。下面的编码表示仅用十六进制
UTF-8: EF BB BF
UTF-16BE: FE FF
UTF-16LE: FF FE
UTF-32BE: 00 00 FE FF
UTF-32LE: FF FE 00 00

那么怎么用这个字符来表示文件的编码方式呢?很简单,就是将这个字符的相应编码方案的编码值放在文件开头就行了。

比如当程序发现开头两个字节是 FE FF,就知道该文件是 UTF-16 大端编码方式,如果遇到 FF FE 就知道是 UTF-16 小端编码方式——等等!凭什么说遇到 FF FE 开头就是 UTF-16 小端?难道它不能是字符 U+FFFE 的 UTF-16 大端编码值吗?Unicode 设计时考虑到了这个问题,所以规定 U+FFFE 不能表示任何字符,直接将该值废弃掉了,于是就不会出现上面说的冲突了。

这些字节值是用来标识文件的大小端存储方式的,所以它们有个专门的名字叫 BOM(Byte Order Mark,字节序标记)。UTF-8 是不需要标记字节序的,但有些 UTF-8 文件也有 BOM 头(EF BB BF),这主要是用来标记该文件是 UTF-8 编码的(不是必须的)。

注意,对于 UTF-8 的 BOM 头,有些软件是不支持的。比如 PHP 解释器是无法识别 BOM 头的,所以如果将 PHP 代码文件保存为 UTF-8 BOM 文件格式,PHP 解释器会将 BOM 头(前三个字节 EF BB BF)视作普通字符解析(注意 UTF-8 BOM 头是合法的 UTF-8 编码字符,不会导致 UTF-8 解析错误),在 PHP-FPM 模式下会将该字符返回给浏览器,有些浏览器无法正确处理该字符,可能会在页面出现一小块空白;更严重的是由于该字符在 Cookie 设置之前就发送给浏览器了,会导致 Cookie 设置失败。所以 PHP 文件一定要保存为 UTF-8 不带 BOM 的格式。

至此,字符集编码系列就写完了,大家可以通过下面的链接查看前面的系列文章:

《字符集编码(上):Unicode 之前》

《字符集编码(补):字符编码模型》

《字符集编码(中):Unicode》



posted @ 2022-03-12 09:51  林子er  阅读(918)  评论(1编辑  收藏  举报