2024-03-31
Unicode 标准及其 UTF 编码的构造和解释
0%
XhrUnsent
久以来对 unicode 的一些细节还是不够清晰,因此今天集中注意力深入研究并实现 Unicode 最常用的三种编码 UTF-32/16/8 来获得完全同步 (超长文警告)

# Unicode 是什么 ?

Unicode 与众所周知的 ASCII 是同一类的东西,是字符编码的一种方案,可以用来将
字符实体
编码到某个整数空间中,比如 Unicode 里的数字
0x0041
对应的是
'A'
,记作
U+0041
,称作
码点 Code Point
截止到 2024,已经有上百亿设备上跑着 Unicode 了,是这个星球上最常见的字符编码方案了,如果哪天发现了外星人需要传点什么东西跟他们做文化交流,完全可以将 Unicode 标准发给他们。

Unicode 码点 (Code Point)

所谓码点就是我前面提到的那个数字了,Unicode 规范里的每一个字符实体都有唯一的「整数」作为其码点。
在现代浏览器上都支持 Unicode 编码,这意味着你在网页上输入的任意一个字符都对应一个码点,在下面这个 Demo 中可以输入字符串并将其转为对应码点信息, 可以试试
E
U+0045
C
U+0043
Z
U+005A
N
U+004E
placeholder

Unicode 编码空间 (Encoding-Space)

从 Unicode 规范来看,字符所对应的整数「码点」应该是一个 4 字节无符号整数,其编码空间相当大,即 2^32 这么大,但是在这么大的容量目前其实只用了一百一十多万而已,而且在这 100 多万里面还有很多还没用上呢,只是预先划分出来了。
Unicode 标准组织为了管理上的方便,提出了两种划分这个整数空间的方式,一种是 Unicode 平面划分,一种是 Unicode 区块划分。

Unicode 平面划分 (Plane)

将码点的整数空间以 65536 (即 0x0 到 0xFFFF) 的长度来将这个四字节整数分割成不同的区间,在规范里这些区间的学名叫做平面
而这 4 字节证书容量为 2^32 即 4294967296。若以 65536 为区间划分的话刚好也能划分出 65536 个平面,即
0x0000xxxx ~ 0xFFFFxxxx
(xxxx 为平面内的偏移)
比如最开头的第一个区间
U+00000000
U+0000FFFF
是第一个平面,叫做 BMP,英文是 Basic Multilingual Plane, 里面包含了最常用的 65536 个字符,而在其中最前面的 128 个码点含义跟 ASCII 编码保持一致,以此来保证能向下兼容 ASCII
再比如,第二个平面是
U+010000
U+01FFFF
, 名字是 SMP 平面,英文是 Supplementary Multilingual Plane, 字面意思是多语种辅助平面
目前 Unicode 官方定义了 17 个平面, 即
U+00xxxx
U+10xxxx
,这个数量其实相当小,因此书写码点的时候通常会将前面的 0 省略掉,比如第一平面 BMP 的范围是:
U+0000
U+FFFF
;又比如蛋糕的 emoji
🍰
这个字符的码点是
U+1F370
因此它位于第二平面
SMP
或许你已经发现了,按 65536 为区间划分的好处了,就是以 16 进制表示的时候,最低 4 位就是平面内的偏移,高位则是平面的序号,比如
U+00xxxx
是第一平面 BMP;
U+01xxxx
是第二平面 SMP 以此类推,下面是官方定义的十七个平面定义,其中 4 号 到 13 号只是分配了但还没用到:
平面编号码点区间英文名中文名
0 号平面U+000000 - U+00FFFFBMP: Basic Multilingual Plane基本多文种平面
1 号平面U+010000 - U+01FFFFSMP: Supplementary Multilingual Plane多文种补充平面
2 号平面U+020000 - U+02FFFFSIP: Supplementary Ideographic Plane表意文字补充平面
3 号平面U+030000 - U+03FFFFTIP: Tertiary Ideographic Plane表意文字第三平面
4 号平面 ~ 13 号平面U+040000 - U+0DFFFF已分配,但尚未使用/
14 号平面U+0E0000 - U+0EFFFFSSP: Supplementary Special-purpose Plane特别用途补充平面
15 号平面U+0F0000 - U+0FFFFFPUA-A: Private Use Area-A保留作为私人使用区 (A区)
16 号平面U+100000 - U+10FFFFPUA-B: Private Use Area-B保留作为私人使用区 (B区)
更具体平面的完整分配规则可以查看规范官网:

Unicode 区块 (block)

Block 是对 Plane 进一步的划分,比如在 BMP 0x0000~0xFFFF 这个平面中,最开始的 128 位是 ASCII 编码, 从官网给出的 Plane 划分来看,区块就是表格上的这些格子:
0%
XhrUnsent
其中最开头的 C0-Controls 加上 Basic Latain 其实就对应了 ASCII 编码,而汉字编码,主要存到 CKJ 区块中等等 —— 总之区块是对平面的再划分,一个平面上可以有很多区块,具体区块可以看 Unicode 官网说明
前面 Unicode 平面里的 15 和 16 号平面是所谓的
A区
B区
这两个区间是保留给开发者随意定义的。而除了这两个平面外,在第一平面 BMP 内下面这个区块 0xE000 ~ 0xF8FF 也是一样的功能,用来用作 private use 的,共计 6400 个:
0%
XhrUnsent
比如苹果系统的「 U+F8FF」图标就是在上面这个 Block 内的:(注意如果你不是苹果系统看不到这个 🍎 字符)
U+F8FF
🍎
U+1F34E
placeholder

# UTF-32

介绍完前面的码点和码点编码空间的平面和区块分配后,我们来讨论一下如何将码点编码成字节流 —— 这个工作几乎是 unicode 相关开发中最核心最重要的部分,即将码点编码成字节流,或者反过来将字节流解析为码点
前面提到,一个码点就是一个 uint32,因此一个一眼丁真的 unicode 编码办法就是将所有码点转成 4 字节无符号整数 —— 而这样的编码方式就是 UTF-32,它也因此得名 32
比如说将
"Hello, 世界"
表达成对应码点并对齐到 4 字节整数
00字符    码点    对齐到 4 字节整数
01'H' => U+48 => 0x00000048
02'e' => U+65 => 0x00000065
03'l' => U+6C => 0x0000006C
04'l' => U+6C => 0x0000006C
05'o' => U+6F => 0x0000006F
06',' => U+2C => 0x0000002C
07' ' => U+20 => 0x00000020
08'世' => U+4E16 => 0x00004E16
09'界' => U+754C => 0x0000754C
再将上述对齐后的整数拼接在一起得到这样一段 Buffer 作为 UTF-32 字节流:
cursor=0x0000
length=40
0x0000
0x0010
0x0020
00
00
FE
FF
00
00
00
48
00
00
00
65
00
00
00
6C
00
00
00
6C
00
00
00
6F
00
00
00
2C
00
00
00
20
00
00
4E
16
00
00
75
4C
你可能会发现开头的四个字节
0x0000FEFF
貌似没有用到,实际这四个字节叫做 UTF BOM (Byte Order Mark) 是用来标记字节序的,此处表明是大端字节序,表示高位字节存储在左边,而如果是
0xFEFF0000
则代表是小端存储,此时高 16 位和低 16 位顺序要调转一下,即:
cursor=0x0000
length=40
0x0000
0x0010
0x0020
FE
FF
00
00
00
48
00
00
00
65
00
00
00
6C
00
00
00
6C
00
00
00
6F
00
00
00
2C
00
00
00
20
00
00
4E
16
00
00
75
4C
00
00
上述两种字节序都可以下载下来看看 (注意 VSCode 目前并不支持 UTF-32 编码, mac 的话用系统自带的编辑器就行了, vim 也可以)
看到这里可能很多人会问:FEFF 是随便写的吗?—— 涉及到后文的概念,可以先忽略,后文会解答

# UTF-16

由于 UTF-32 是一种定长编码,缺点就是比较大,并没有被广泛使用,实际场景中用的比较多的是 utf16 和 utf8,现在来看看 utf16 是怎么编码的吧
考虑到大多数人只使用第一平面,即 0x0000-0xFFFF, utf16 就是针对这种现象进行编码设计
  • 情况一:第一平面 BMP 内的字符,即 0x000000-0x00FFFF 中的字符直接用 2 字节存储,即直接存储低 16 位即可
  • 情况二:后续 16 个平面,即 0x010000-0x10FFFF 中的字符使用 4 字节存储
  • 其他情况:大于 0x10FFFF 的无法使用 UTF-16 进行编码
那么,当我正在处理一段字节流的时候,比如我读取到 0xABCD,此时要怎么判断读取的这两个字节是对应情况一还是情况二的四字节的前半部分呢?
这里看似无解,但是 Unicode 标准单独给第一平面挖了一段 Block 来解决这个问题:在第一平面 0x0000 - 0xFFFF 中的 0xD800 - 0xDFFF 这段并没有编码含义,而是保留给 UTF-16 来使用:
0%
XhrUnsent
利用这个区块,具体一点来说在处理字节流两字节两字节进行读取解析的时候,如果读取的两个字节不在这个区间,就是情况一,比如前面的 0xABCD 就不在这个区间,因此应该将其当作情况一来处理;而如果遇到在这个区间内的时候,说明此时读取的不是第一平面内的码点,应该按情况二来处理,此时额外读取后两个字节组成四个字节来解析。
再进一步来看,情况二中提到用 4 个字节来编码,这 4 字节又分为前后两个部分,每个部分各 2 个字节,对于这「2 个字节」如果:
  1. 1.
    位于 high-half surrogates 中,称为高代理;范围是 0xD800 - 0xDBFF, 换成二进制为 110110/0000000000 - 110110/1111111111,即以 0b110110 开头的情况为高代理
  2. 2.
    位于 low-half surrogates 中,称为低代理;范围是 0xDC00 - 0xDFFF,换成二进制为 110111/0000000000 - 110111/1111111111,即以 0b110111 开头的情况为低代理
  3. 3.
    高低代理中的低 10 位是有效位,一共 20 位将其取出拼接在一起存储为 TMP
  4. 4.
    UTF-16 标准规定 TMP = 码点 - 0x10000, 此时将 TMP 加上 0x10000 即可得到码点 (后面会说为什么要减去 0x10000)
情况二的 4 个字节分成前后两部分,其中,前面两个字节的前 6 位二进制固定为 110110,后面两个字节的前 6 位二进制固定为 110111, 前后部分各剩余 10 位二进制是码点减去 0x10000 的结果 —— 也就是说通过放弃 BMP 的一部分空间,将两个代理的低 10 位拼接来编码其他平面的码点。
好吧,看到这里估计都被绕晕了,我上面写的规则貌似很复杂,其实实际很好理解,关键在于「放弃 BMP 的一部分空间,用两个代理的方式凑出 20 位来编码其他平面的码点」
这里手把手给一个例子:我将以下面
🍰 (U+1F370)
这个 emoji 来说明如何将其码点 0x1F370 编码为 UTF-16 字节流:
00// ⬇️ 🍰 的 unicode 码点 (0x1F370 的二进制形式)
01   1 11110011 01110000   // ⬅️ 码点需要先减去 0x10000
02 - 1 00000000 00000000   //
03 ----------------------- // ⬇️ 高代理(两字节) ⬇️ 低代理(两字节)
04     11110011 01110000    1101100000111100  1101111101110000
05     aaaaaabb bbbbbbbb    HHHHHH****aaaaaa  LLLLLLbbbbbbbbbb
06                          将高低代理对写在一起就构成四字节的 utf16
07                          0xD83C 0xDF70
08 // ⚠️ 注意:
09 // 1. HHHHHH 代表固定的高代理头, 110110 (0xD800 到 0xDBFF)
10 //    LLLLLL 代表固定的低代理头, 110111
11 // 2. 上述标 a 标 b 的部分其实就对应右边的标 a 标 b 部分,
12 //    从低位往左边填充,填完就就填 0 (也就是标星号 * 的那部分)
上述例子中标 * 的部分实际上就是平面的序号减一,这也说明了为什么要减去 0x10000:避免在高低代理对的 20 位整数空间中重复编码第一平面,而由于最多只能带 20 位,因此大于 0x100000 的码点是不能用 UTF-16 进行编码的。

实现 utf16EncodeCodePoint

根据前面的讨论来实现 UTF-16 编码器的核心部分: 将给定的一个码点转为对应字节 number[] 数组, 比如说:
00utf16EncodeCodePoint(0x6C38) // 永 (码点为 U+6C38)
01// => [0x6C, 0x38]
02utf16EncodeCodePoint(0x1F370) // 🍰 (码点为 U+1F370)
03// => [0xD8, 0x3C, 0xDF, 0x70]
下面给一个我的实现:
00/**
01 * high-half surrogates 0xD800 0xDBFF
02 * low-half surrogates 0xDC00 0xDFFF
03 * surrogates 0xD800-0xDFFFF
04 * @param codePoint 给定码点
05 * @returns 返回二字节或四字节 number[]
06 */
07export function utf16EncodeCodePoint(codePoint: number): number[] {
08  if (codePoint < 0x10000) { // 第一平面 (BMP)
09    const byte0 = (codePoint & 0b1111111100000000) >> 8;
10    const byte1 = codePoint & 0b0000000011111111;
11    return [byte0, byte1];
12  }
13
14  if (codePoint >= 0x100000) {
15    throw new Error('不支持大于 0x100000 的码点')
16  }
17
18  // 需要减去 0x10000 避免对第一平面重复编码
19  const buf = (codePoint - 0x10000);
20
21  const l10 = (buf & 0b00000000001111111111);
22  const h10 = (buf & 0b11111111110000000000) >> 10;
23
24  const h16 = h10 | 0b1101100000000000;
25  const l16 = l10 | 0b1101110000000000;
26
27  return [
28    (h16 & 0b1111111100000000) >> 8,
29    (h16 & 0b0000000011111111),
30    (l16 & 0b1111111100000000) >> 8,
31    (l16 & 0b0000000011111111),
32  ];
33}

UTF-16 Demo

下面是根据我的 UTF-16 编码器写的 Demo, 可以下载下来看看能不能打开
cursor=0x0000
length=90
0x0000
0x0010
0x0020
0x0030
0x0040
0x0050
FE
FF
D8
3C
DF
70
00
20
57
28
8F
D9
8F
93
51
65
5B
57
7B
26
4E
32
5C
06
51
76
7F
16
78
01
4E
3A
00
20
00
55
00
54
00
46
00
2D
00
31
00
36
00
20
6D
41
5E
76
57
28
4E
0B
97
62
76
84
00
20
00
42
00
75
00
66
00
66
00
65
00
72
00
56
00
69
00
65
00
77
00
20
4E
2D
5C
55
79
3A
注意,与 UTF-32 类似,UTF-16 也需要指定开头的 BOM,此处开头的 0xFEFF 代表大端存储,此时的编码叫做
UTF-16 BE
(big-endian) 如果需要小端存储则需要将 BOM 设置为
0xFFFE
并把后续高低字节序调转一下 (我的实现都是大端,比较好理解)
比如
世界
对应的大端编码
UTF-16 BE
为 0xFE, 0xFF 0x4E, 0x16, 0x75, 0x4C 此时其小端编码
UTF-16 LE
(little-endian) 的结果为:
cursor=0x0000
length=6
0x0000
FF
FE
16
4E
4C
75

# 题外话: JavaScript 里的 UTF-16

JS 里 string 底层是用 UTF-16 编码的,因此可这样验证前面
🍰 U+1F370
的编码结果 0xD83C 和 0xDF70 :
00console.log('\uD83C\uDF70');
01// 将会输出 '🍰'
从这也可以看到,JS 里可以用 \uAAAA 的方式来通过 UTF-16 编码流来构造字符串。

如何直接用 unicode 码点来构造 string ?

除了直接用 UTF-16 编码流,JS 也可以直接给定码点来构造字符串:
00console.log('\u{1F370}');
01// U+1F370 是 🍰 的码点, 将会输出 '🍰'

'🍰'.length 为什么是 2 的问题

其实就是下面这种现象:
00console.log('🍰'.length);
01// => 2
原因:js string 是 UTF-16 编码的,而 🍰 的编码需要走情况 2 来编码,最终结果是 0xD83C 0xDF70,统共 4 字节,而 length 是两字节算一个字符(UTF-16)因此最后 🍰 这类位于第二平面的码点在 js string 里 length 就是 2 了

.split('') 并不能很好的识别 UTF-16 内的字符

.split 并不会识别 UTF-16 高低代理对,因此会出现这个问题:
00'🍰 Hello'.split('');
01// => ['\uD83C', '\uDF70', ' ', 'H', 'e', 'l', 'l', 'o']
可以看到,🍰 的高/低代理对被完全拆开了,无法正常显示了,很多场景下不符合预期,此时可以通过下面这两种方式都将 string 按 UTF-16 序进行切割,避免这类问题:
00Array.from('🍰 Hello');
01// => ['🍰', ' ', 'H', 'e', 'l', 'l', 'o']
02
03[...'🍰 Hello'];
04// => ['🍰', ' ', 'H', 'e', 'l', 'l', 'o']
当然,也可以手动 for 循环遍历字符串来自己拆(判断在不在高低代理区块即可)

如何将一段 string 转为码点数组 ?

用前面提到的 Array.from 将字符拆开后再通过 codePointAt 方法取到码点:
00Array.from('🍰 🍉').forEach(ch => {
01  console.log(`'${ch}'=>U+${ch.codePointAt(0).toString(16)}`);
02});
03// 将会输出
04// '🍰'=>U+1f370
05// ' '=>U+20
06// '🍉'=>U+1f349
下面是根据上面这段写的一个小 demo (跟开头那个是同一个 Demo 组件)
🍰
U+1F370
U+0020
🍉
U+1F349

# UCS-2 与 UTF-16 的关系

UCS-2 是在 UTF-16 出现之前常用的 Unicode 编码方式:
  • UCS-2 是成立于 1984 年的 UCS 组织于 1990 年提出的
  • 而 UTF-16 是成立于 1991 年的 Unicode 组织于 1996 年提出
UCS-2 早于 UTF-16 提出,其核心是用两字节编码第一平面 BMP 0x0000 到 0xFFFF 共计 65536 个字符,后来设计的 UTF-16 也考虑到这一点进行设计,对应的就是前面提到过的 UTF-16 情况一

# UTF-8

终于来到 UTF-8 了,它是一种变长编码,特点是最小可以一字节使用,在一字节的时候编码规则跟 ASCII 一样,实现了兼容,具体的规则如下
  1. 1.
    对于长度为 1 字节的字符,将最高位设置为 0, 其余 7 位设置为 Unicode 码点。值得注意的是, ASCII 字符在 Unicode 字符集中占据了前 128 个码点。也就是说, UTF-8 编码可以向下兼容 ASCII 码, 这意味着我们可以直接使用 UTF-8 来打开年代久远的 ASCII 文本文件。
  2. 2.
    对于长度为 n 字节的字符 (n > 1), 将首个字节的高 n 位都设置为 1,第 n+1 位设置为 0;从第二个字节开始,将每个字节的高 2 位都设置为 10;其余所有位用于填充字符的 Unicode 码点。
下面以「永」字为例:
00//      unicode 码点               UTF-8
0111011000 0111000    11100110 10110000 10111000
02                         ----     --       --
03                                  10 开头   10 开头   
04         第一个字节开头的 1110 代表总共 3 个字节
05         其后每一个字节的开头都是 10,
06         然后将码点按位填充到剩下的位得到最终结果
07         (16 进制后为: 0xE6 0xB0 0xB8)
核心是确定 utf 头总共多少字节,然后将码点位填充上去即可,下面给一个我自己的编码实现:
00/** 将单个码点转为 utf-8 编码 */
01export function utf8EncodeCodePoint(codePoint: number): number[] {
02  // 0b01111111 及以下的情况跟 ASCII 保持一致,直接返回即可
03  if (codePoint <= 0b01111111) {
04    return [codePoint]
05  }
06
07  // 先将 codePoint 按 0b10 + 6 位的方式隔开成 number[]
08  const result: number[] = [];
09  for (;;) {
10    const _6bit = codePoint & 0b111111;
11    const _8bit = 0b10000000 | _6bit;
12    result.unshift(_8bit);
13    codePoint = codePoint >> 6;
14    if (codePoint === 0) break;
15  }
16
17  // 处理 `utf 头`
18  const header = ((1 << result.length) - 1) << 1;
19  const restBits = 8 - 1 - result.length;
20
21  // 判断第一位放不得下 header
22  if ((1 << restBits) > (result[0] & 0b111111)) {
23    const headerByte = (header << restBits);
24    result[0] = (result[0] & 0b111111) | headerByte;
25  } else {
26    // 放不下则单独开一个字节塞到最前面
27    result.unshift(((header + 1) << 1) << (restBits - 1));
28  }
29
30  return result;
31}
下面是解码的实现:
00/**
01 * 将 arr 当作 utf-8 buffer 进行解码,将其解码位码点数组
02 */
03export function utf8Decode(arr: ArrayLike<number>) {
04  let result: number[] = [];
05  let i = 0;
06  while (i < arr.length) {
07    const firstByte = arr[i];
08
09    // 先扫描 utf 头搞到总长度
10    let scanner = 0b10000000;
11    let utfLength = 0;
12    while (firstByte & scanner) {
13      utfLength ++;
14      scanner = scanner >> 1;
15    }
16
17    // 第一位通过前面的 scanner 就能快速取出其有效的数据位
18    let buf = firstByte & (scanner - 1);
19    for (let offset = 1; offset < utfLength; offset ++) {
20      buf = buf << 6;
21      buf = buf | (arr[i + offset] & 0b00111111);
22    }
23
24    result.push(buf);
25    i = i + utfLength;
26  }
27
28  return result;
29}

UTF-8 Demo

我用上面的实现的 utf-8 编解码做了一个 Demo:
cursor=0x0000
length=87
0x0000
0x0010
0x0020
0x0030
0x0040
0x0050
F0
9F
8D
B0
20
E5
9C
A8
E8
BF
99
E8
BE
93
E5
85
A5
E5
AD
97
E7
AC
A6
E4
B8
B2
E5
B0
86
E5
85
B6
E7
BC
96
E7
A0
81
E4
B8
BA
20
55
54
46
2D
38
20
E6
B5
81
E5
B9
B6
E5
9C
A8
E4
B8
8B
E9
9D
A2
E7
9A
84
20
42
75
66
66
65
72
56
69
65
77
20
E4
B8
AD
E5
B1
95
E7
A4
BA

# 回头看 Unicode 标准

前面主要讨论了 Unicode 的编码,但是编码部分只是 Unicode 标准的一部分,此处更具体的记录一下 Unicode 的诞生细节:
  1. 1.
    Unicode 1987 年提出,目的是提供一种统一的字符编码
  2. 2.
    标准由 Unicode Consortium(Unicode 组织/联盟)维护,这个联盟的宗旨是让 Unicode 取代其他的编码,成为唯一标准
根据 Unicode 组织的网站上的
Unicode 技术报告 #17
显示,前面我写的那些编码方式只是整个 Unicode 标准一部分而已:
根据这份报告,Unicode 标准主要有五个部分构成,或者称为
五层模型
:

ACR: Abstract Character Repertoire (抽象字符表)

the set of characters to be encoded, e.g., some alphabet or symbol setUTR#17 原文
就是字符集收录,相当于字典,比如 🍰 就在 ACR 中, 比如我们的汉字也都在 Unicode 字符集中,称为 ACR

CCS: Coded Character Set (编码字符集)

a mapping from an abstract character repertoire to a set of non-negative integersUTR#17 原文
ACR 里每一个字符到码点的映射关系,将这些映射关系放置在 CCS 中,这其实就是 🍰 映射到其码点 U+1F370,定义了所有字符到码点的映射关系

CEF: Character Encoding Form (字符编码方式)

a mapping from a set of non-negative integers (from a CCS) to a set of sequences of particular code units of some specified width, such as bytesUTR#17 原文
定义了码点如何编码为特定的 bit 位宽的「码元」,比如:
  • UTF-32 直接用 4 字节将码点当成 uint32 无符号整数进行编码,其一个码元是 4 字节
  • UTF-16, 一个码元是 2 字节,根据读取到的第一个码元的范围,分两种情况:
    1. 1.
      码点位于 BMP 平面的 0x0000 ~ 0xFFFF 这个区间的时候直接用一个码元即两个字节进行编码
    2. 2.
      码元在后续平面,此时通过高低代理的方式将 20 位码点信息编码到 4 字节 2 个码元中
  • UTF-8 用 1 字节的变长编码形式进行编码, 其码元位宽就是 1 字节, 而从其编码细节来看首字节最大可指定变长编码的长度为 7,因此最大可以有 8 个码元
    1. 1.
      0b0xxxxxxx: 能编码的位数是 7,跟 ASCII 刚好对应
    2. 2.
      0b10xxxxxx 0b10xxxxxx: 能编码的位数是 12 位
    3. 3.
      0b110xxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 17 位
    4. 4.
      0b1110xxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 22 位
    5. 5.
      0b11110xxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 27 位
    6. 6.
      0b111110xx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 32 位
    7. 7.
      0b1111110x 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 37 位, 已经超过 32 位了,实际不可能出现
    8. 8.
      0b11111110 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx 0b10xxxxxx: 能编码的位数是 42 位,已经超过 32 位了,实际不可能出现

CES: Character Encoding Scheme (字符编码方案)

a mapping from a set of sequences of codes units (from one or more CEFs) to a serialized sequence of bytesUTR#17 原文
这层其实比较薄,就是将码点填充到前面 CEF 所允许那些位里面去,最后输出为 byte 流

TES: Transfer Encoding Syntax (传输编码句法)

a reversible transform of encoded data. This data may or may not contain textual dataUTR#17 原文
我理解是在传输的过程中检测 UTF 编码的语法机制,比如两个 case:
  • HTML 里指定编码:
    <meta charset="utf-8" />
  • Unicode UTF-32 和 UTF-16 里面出现的 BOM: FEFF0000 来表明大小端的情况也算是

# 注意力大集中

这里写一下我在实现过程中注意到的几个问题,也许日后有机会参与 unicode 的完全重构的时候再认真考虑一下这几个问题(虽然这个 connecting the dots 绝不可能发生 ....)

中文字符码点连续性问题

网上一个关于码点的设计问题:(算是五层模型里 CCS 的问题)
U+4E00
U+4E8C
U+4E09
U+56DB
U+4E94
U+7532
U+4E59
U+4E19
U+4E01
U+620A
A
U+0041
B
U+0042
C
U+0043
D
U+0044
E
U+0045
发现了吗?汉字的
一二三四甲乙丙丁
在码点上并不是连续的, 而 ABCD 这些就是连续的,不过还好汉语拼音声母 ㄅBo ㄆPo ㄇMo ㄈFo ㄉDe 或者日语假名 あa いi うu えe おo 是符合正序排列的
U+3105
U+3106
U+3107
U+3108
U+3109
U+3042
U+3044
U+3046
U+3048
U+304A

UTF-16 高/低代理打洞的问题

前面的具体讨论中已经清楚,现在的 Unicode BMP 平面定义里单独给 UTF-16 做了高低代理区间的打洞来实现 20 位码点的编码
这个方式太 hack 了,我其实不太喜欢,但是这又是 trade-off 的艺术,UTF-16 其实非常好的兼顾了编码的时间复杂度和空间复杂度

UTF-24 可行性?

UTF-16 通过高低代理对的方式编排了 20 位 bits,而这 20 位中高 4 位代表所在的 unicode 平面,低 16 位代表其平面的偏移 —— 总之 20 位足够编排 16 个平面了,如果期望包含全部的 17 个平面,用 21 位就行了,那么在这种情况下为什么没有 UTF-20 或 21 或 UTF-24 的编码方式呢?
理论上应该可行,不过估计会被狂喷,因为我在 zhihu 上问过

如何在第一个字节区分是 UTF-8 还是 ASCII

由于 UTF-8 是单字节编码,因此它没有 BOM,在这种情况下如果是纯英文文档 UTF-8 将跟 ASCII 输出一模一样的结果,而如果其中混入一两个中文 —— 这种情况下编辑器就不好判断该用哪个编码了, 可能就会出现你在浏览纯英文本的时候碰到一两个乱码这种情况
当然现代编辑器谁不支持 UTF-8 ?(我这里其实点草了微软系统里的记事本)
这个问题其实见仁见智,不过 UTF-8 确实可以带一个 BOM,这个 BOM 其实就只是标记了这是一个 UTF-8 文档,比如 VSCode 就支持这种带 BOM 的 utf8 编码:
0%
XhrUnsent
下面我构造了一个带 BOM 的 UTF-8 例子,为
0xEF 0xBB 0xBF
可以下载看看:
cursor=0x0000
length=7
0x0000
EF
BB
BF
F0
9F
8D
B0
为什么是
0xEF 0xBB 0xBF
呢? 我们不妨将其当成正常 UTF-8 流进行解析:
000xEF => 11101111 (开头 1110 代表一共 3 字节,4 位是有效位: 1111)
010xBB => 10111011 (6 位是有效位: 111011)
020xBF => 10111111 (6 位是有效位: 111111)
03有效位拼接后得到 0b1111111011111111
04转成十六进制恰好就是 0xFEFF 这恰好就是 UTF-16BOM
换个角度来说,0xEF 0xBB 0xBF 最后会被当成码点 0xFEFF 被编辑器识别,它在编码层面是有意义的比如你可以这样构造它:
00// Node REPL
01> str = Buffer.from([0xEF, 0xBB, 0xBF, 0x41]).toString()
02// => 显示为 'A'
03> console.log(str.length)
04// => 2 实际为 '\uFEFFA', 只是显示不出 0xFEFF 了
实际上 0xFEFF 除了用作 BOM 外,实际它还可以是一个合法的码点,即所谓的零宽度空格 ZWNBSP:
0%
XhrUnsent
这下懂了吧,在首字节插入 U+FEFF 零宽度空格就能区分 ASCII 和 UTF-8 编码啦,这其实也是一种 trade-off 的艺术,单独定义了一个特殊码点用来实现特殊功能
此外这个零宽度空格在 Ctrl+A 全选的时候也会选中复制到剪贴板,在粘贴到 SQL 命令行写入 UTF-8 的时候有可能会遇到问题(如果 DB 不支持带 BOM 的 UTF-8 就 GG 了)

UTF-8 字节流传输截断问题

还是以零宽度空格的 0xEF 0xBB 0xBF 来说:
000xEF => 11101111 (开头 1110 代表一共 3 字节,4 位是有效位: 1111)
010xBB => 10111011 (6 位是有效位: 111011)
020cBF => 10111111 (6 位是有效位: 111111)
03有效位拼接后得到 0b1111111011111111
04转成十六进制恰好就是 0xFEFF 这恰好就是 UTF-16BOM
试想如果只传输了 0xEF 就 TCP 断开或者服务端掉线了, 我这里就构造了这样的情况,服务返回了一个错误的 utf-8 字节流导致浏览器乱码的情况:
00const http = require('http');
01http.createServer((req, res) => {
02  res.setHeader('Content-Type', 'text/html; charset=utf-8');
03  res.write(Buffer.from([0xEF]));
04  res.end();
05}).listen(3000);
0%
XhrUnsent
这种情况可能到处都有,只是日常都不怎么考虑到。

进程 stdout 拼接问题

stdout 也是一段一段的字节流,可能两段输出就断在 UTF-8 的首字节了,具体来说,'永' 这个字可以用 UTF-8 编码为:
cursor=0x0000
length=3
0x0000
E6
B0
B8
当我们监听 stdout 并收集 ondata 输出的时候,有可能不会一步到位,而是在中间截断得到两段 buffer (短文本很难遇到,长文本就经常遇到了):
00<Buffer E6>
01<Buffer B0 B8>
如果此时这样处理就会乱码了:
00<Buffer E6>.toString()
01<Buffer B0 B8>.toString()
我们需要将其 concat 成单个 buffer 后再 toString 这样才没问题。
00Buffer.concat([
01  <Buffer E6>,
02  <Buffer B0 B8>,
03]).toString()
这个 case 我是遇到过的,当时是调用的是 buffers.join('') 来拼接,实际这样就是给每个子元素跑一次 toString 在将其加起来,导致最后出现乱码的问题。

# EOF

好了,这篇长文终于写完了,这里写一下总结:
  • Unicode 编码空间、码点、平面、区块
  • UTF-32: 将码点当成 4 字节 uint32 进行存储
  • UTF-16: 第一平面内的直接用 2 字节存储;后续平面的则通过高低代理对的方式填充 20 位 bits 进行码点的编码,这 20 位里高 4 位是平面编号,低 16 位是在平面内的偏移 —— 也因此要减去 0x10000 来避免对第一平面的重复编码
  • UTF-8: 通过 UTF-8 头的方式控制的变长编码,将编码位提出后提取并拼接有效载荷即可将码点编码进去
  • UCS-2: 即第一平面,UTF-16 可以完全兼容它
  • ASCII: 低 128 位,UTF-8 能完全兼容它
  • JavaScript 里的 UTF-16: 由于编码的原因可能会造成一些有趣的 case
  • Unicode 五层模型: ACR、CCS、CEF、CES、TES
  • 对 Unicode 的个人思考
不好,前面太集中了,现在开始注意力涣散了 ...