再探编码

前言

计算机发展本身就是多元化的,而且标准或者说字符集具有地域性,再加上计算机系统的历史遗留性(没错,那边的 Windows 说的就是你),我觉得编码一直是一个很难的问题。

在我的博客还是 WordPress 的时候我曾经写过一篇讲编码的文章,但是实际上那时候我也是一知半解,写出来的东西也是浅薄无趣的。

然而学CS,一知半解是最恐怖的,正好最近就在各种踩坑,所以不如一次弄明白算了,也算是解决长久以来的一块心病了。

所以本文会尝试去理清各种字符集以及编码之间的关系,如果有任何错误还请指出。

字符集

字符集简单来说就是一个映射,通常情况下是一个整数到某个特定的字符的双射,比如在 ASCII 字符集中,’A’ 对应的是 65, 反过来 65 也对应的是 ‘A’。

字符集概念很简单,但是有两个问题。

  • 我知道一个字符集中 65 对应 ‘A’,反之亦然,那我可以确定它是 ASCII 吗?
  • 我在 C++ 中写了 char a = 'A' 然后发现变量 a 的值真的是 65,那是不是所有字符集中字符都是由它相对应的整数表示?

对于第一个问题,答案当然是否。因为字符集一般都是向前兼容的,比如 Unicode 中 ‘A’ 也是 65。

对于第二个问题,答案也是否。因为当字符集太大的时候,如果都这样表示,那么会浪费相当大的空间,我们在后面的 UTF 会看到如何解决这个问题。

简单概括下,字符集就是字符和整数的映射,它不代表计算机内部的实际编码表示。

ASCII

ASCII 全称 American Standard Code for Information Interchange,它只有 8bit 长,但是实际上编码只用了 7bit,还有一位一般是用来奇偶校验(因为很多电子通信也用 ASCII)。

具体的 ASCII 表这里不再给出,只强调一点就是绝大部分字符集和编码方式都是兼容 ASCII 的。

Extend ASCII(扩展 ASCII)

当计算机走向世界的时候,人们首先发现的问题就是 ASCII 不够用了。

但是正如我之前说过的,编码具有很强的历史性,改变编码方式很难一次到位,所以扩展 ASCII 就出现了,它的做法很简单:把原来 ASCII 中不参与编码的最高位拿出来用于编码,这样原来 ASCII 范围 0~127 就变成了扩展 ASCII 的0~255。

但是这里有个问题是扩展 ASCII 不像 ASCII 那样只有一个标准,也就是说扩展 ASCII 实际上有很多种,不过最后被采用的是 ISO 8859,由于 Windows 中实现要比最终标准化早一些,所以 Windows 中对应的 Windows-1252 实际上是 ISO 8859 的超集。

到这里问题还不大,但是 Windows 为自己埋下了祸根。

ANSI

首先这里要纠正一个错误的认知:没有任何一种编码方式/字符集叫做 ANSI。

ANSI 已经很累了,不想再背锅了。

ANSI 全称是 American National Standards Institute,是一个标准化组织,比如上面的 ISO 8859 就是它跟 ISO 一起提出来的。

那么当我们在谈 ANSI 编码的时候,我们在谈什么?

Windows Code Page(Windows 代码页)

实际上,一般说 ANSI 编码实际上想提到的就是 Windows 代码页。

换句话说,我们在说 ANSI 编码的时候,很大可能想表达的是某个国家特定的字符集和它所代表的代码页。

比如中文系统使用的 GBK 在 Windows 中的代码页是 cp936,一般我们在中国说 ANSI 编码可能指的就是 GBK,而上面的 Windows-1252 就是代码页 cp1252,表示的就是相应的扩展 ASCII。

但是代码页可以说是 Windows 中最致命的残留之一,这个问题直到 Windows10 1803 才有了一定程度的解决,但是由于 Windows 高度的向前兼容性,这个问题恐怕在不久的将来还会一直存在,除非 NT 推倒重来。

这里引用 cppreference 的一段话来说明这个问题

wchar_t - type for wide character representation (see wide strings). Required to be large enough to represent any supported character code point (32 bits on systems that support
Unicode. A notable exception is Windows, where wchar_t is 16 bits and holds UTF-16 code units) It has the same size, signedness, and alignment as one of the integer types, but is a
distinct type.

这里 wchar_t 在 Windows 上只有 16bit 的根源就在于代码页的设计。

在微软最初的设计里,代码页只有两种 Single-Byte Character Set(SBCS) 和 Double-Byte Character Set(DBCS),比如上面的 cp1252 就是一个 SBCS,中文系统使用的 cp936 就是一个 DBCS。

当然现在微软也有 UTF-8 编码的代码页,不过那都是后话了。

GB(国标)

GB2312

扩展 ASCII 还是太少了,比如中文,255 个字怎么可能够用嘛,隔壁同源的日文也是同理。

所以国家这时候就开始着手设计自己的字符集了,第一个国标就是 GB2312。

GB2312 采用两个字节编码,收录了绝大多数常用的汉字。

GBK

但是 GB2312 只收录了约 7000 个汉字,甚至连朱镕基的“镕”都没有,而且台湾港澳台使用的字体也没有收录,所以就有了 GBK,即 “GB2312扩”。

它仍然采用两个字节编码,兼容 GB2312 的同时收录了日本、台湾和韩国等通用字符集的汉字。

Windows 中的 936 号代码页实际上几乎就是 GBK。

但是随着计算机技术的发展,GBK 仍然无法满足需要,而且 GBK 实际上并不是国家标准,所以后来接盘的是 GB18030,这里我们先停下来,看一看隔壁的 Unicode。

Unicode

随着 Internet 的发展,编码的问题愈发突出,如何让文本能正确的显示在显示器上成了一个难题,Unicode 就是在这种背景下出现的。

最新的 Unicode 标注一共收录了约 14 万个字符,基本涵盖了世界上绝大数语言。

从 Unicode 起,另外一个问题提了出来:有了字符集,如何编码让它更适合计算机使用或者网络传输呢?

之前的单字节和双字节编码因为占用空间少,所以直接不加任何编码就可以使用,但是 Unicode 可是有 14 万个,如果还采用定长编码的话至少需要 18bit 才能保证一一对应,这样的话对于之前 ASCII 中就有的字符来说有 11bit 就被浪费了,显然是不合理的。

所以这里字符集和编码方式就必须分开考虑了,这也是为什么会有 Unicode Transformation Format(UTF) 一说了。

简单来说,Unicode 还是那个 Unicode,但是编码方式会有很多种。

UTF-7

UTF-7 已经不属于 Unicode 标准了,但是它还属于 RFC 标准。

简单来说,UTF-7 对于 ASCII 不做改变,但是对于非 ASCII 字符采用 base64 编码后在首位加上 + 和 - 用于区分,这样编码出的字符串中所有字符就是标准 ASCII 了。

如果做过 XSS 或者 SQL 注入的应该对 UTF-7 有印象。

UTF-8

UTF-8 是最常用的编码,也是 Web 中几乎统治性的编码方式。

UTF-8 是一种变长编码方式,它兼容 ASCII 最小可以用 1byte 表示,同时最多可以用 4byte 表示所有的 Unicode 字符。

UTF-8 编码方式其实很好理解,对于某个字符首先取出它的二进制表示:

  • 如果小于等于 7 位(ASCII),那么直接表示为 0xxxxxxx
  • 如果小于等于 11 位但是大于 7 位,那么表示为 110 xxxxx 10 xxxxxx
  • 如果小于等于 16 位但是大于 11 位,那么表示为 1110 xxxx 10 xxxxxx 10 xxxxxx
  • 如果小于等于 21 位但是大于 16 位,那么表示为 11110 xxx 10 xxxxxx 10 xxxxxx 10 xxxxxx

刚才提到过,最新的 Unicode 标准只要 18bit 就可以全部表示,所以 UTF-8 目前是可以表示 Unicode 全部字符的。

这里举个例子,中文的“中”字,它的 Unicode 为 \u4e2d,也就是 0b100 111000 101101,长度为 15 位,需要三个字节。

所以编码为 UTF-8 后为 0b11100100 10111000 10101101,也就是 0xe4 0xb8 0xad 即 \xe4\xb8\xad。

下面我们可以用 Python 验证下

1
2
3
4
>>> '\u4e2d' # '中'
'中'
>>> '\u4e2d'.encode('utf-8') # 编码为 UTF-8
b'\xe4\xb8\xad'

那么为什么 UTF-8 能得到这么广泛的应用呢?

  • 体积小:相对定长编码来说节省了很多空间。
  • 兼容性:完全兼容 ASCII 的编码方式。
  • 易检测:我们可以看到 UTF-8 四个字节的开头各不相同,而且无论怎么编码都不会冲突,只要根据头部第一个字节就可以知道后面一共有多少字节。
  • 计算快:从 UTF-8 到 Unicode 只用移位就可以完成,这对于计算机来说是相当高效的。

GB18030

好了,到这里我们就要说回 GB 了。

之前提到过接替 GBK 并且真正成为国标的是 GB18030,但是不同的是 GB18030 也采用的变长编码,并且跟 Unicode 完全兼容,也就是说实际上 GB18030 也是一种 UTF。

GB18030 在 2byte 编码的时候跟 GBK 完全一致,但是在 4byte 编码的时候比较复杂,这里偷懒不展开了。(翻译一下:都用 UTF-8 不就完了)

UTF-16

在说 UTF-16 之前,不得不提的是 UCS-2。

UCS-2 是一个定长编码,它用 2byte 直接表示 Unicode —— 这当然是不够的,所以 UTF-16 就是为了补充 UCS-2 出现的。

UTF-16 同样是变长编码,但是它以 2byte 为一个单位。

  • 对于小于 2byte 就可以表示的 Unicode 它与 UCS-2 相同。
  • 对于大于 2byte 才能表示的 Unicode,它的编码稍微有些复杂。

对于某个要编码的字符,假设它的 Unicode 编码为 U。

首先把 U 减去 0x10000 得到 U’,因为 Unicode 目前最长为 18bit,所以这时候 U’ 一定小于 20bit,分别对高 10bit 和低 10bit 做如下处理:

  • 对于高 10bit,把它加上 0xD800 得到 UTF-16 编码的高 2byte,同时注意到一个 Unicode 目前最长为 18bit,所以减去 0x10000 后高 10bit 中最多只有低 8bit 不为 0,这样高 2byte 一定以 0b110110 开头。
  • 对于低 10bit,把它加上 0xDC00 得到 UTF-16 编码的低 2byte,由于 0xC 对应的二进制为 0b1100,所以低 2byte 一定以 0b110111 开头。

因此一个 4byte 编码的 UTF-16 中 2byte 的最小值为 0xDC800 而最大值为 0xDFFF,为了避免一个 4byte 编码的 UTF-16 被误认为两个 2byte 的 UTF-16,Unicode 规定 0xD800 - 0xDFFF 不对应任何单个的 Unicode 字符,被称为“代理区”。

比如某个字符的 Unicode 为 U = 0x12345 (哎呀,反正打出来也是个’口’),然后我们可以得到 U’ = U - 0x10000 = 0x02345 = 0b1000 1101000101,低 10bit 为 0x345,高 10bit 为 0x8。

所以可以得到高 2byte 为 0xD800 + 0x8 = 0xD808,低 2byte 为 0xDC00 + 0x2345 = 0xff45,拼在一起就是 0xd808df45,下面就用 Python 验证下

1
2
3
4
>>> '\U00012345'.encode('utf-16be').hex()
'd808df45'
>>> '\U00012345'.encode('utf-16').hex()
'fffe08d845df'

当然这里还有另一个问题就是大小端问题,虽然 RFC 建议在没有 BOM 的时候默认为大端序,但实际上很多应用(包括系统)默认都是小端序。对于 UTF-16 来说,UTF-16BE 代表大端序,UTF-16LE 代表小端序。

很多操作系统比如 Windows 采用 UTF-16 作为内码,但是 UTF-16 实际上由于 corner case 过多,很多库并没有做很好的测试,因此很可能出现安全性问题。(实际上已经有相应的 CVE 了)

UTF-32

最后出场的是 UTF-32,从它的名字上我觉得就可以明白它是怎么编码了——没错,4 字节定长编码,简单暴力。

不过正如之前所说,这样会浪费大量的空间,因此 UTF-32 的应用非常少。

做个小验证吧,仍然是之前的“中”

1
2
3
4
>>> '\u4e2d'
'中'
>>> '\u4e2d'.encode('utf-32be').hex()
'00004e2d'

大小端序的问题也不再赘述。

小结

编码是真的很复杂,不过当你决定把它研究透的时候,一切都清晰明了了起来。

个人觉得最重要还是要理解字符集和编码方式的关系,也就是 Unicode 跟 UTF 的关系,因为这部分可能是最让人迷惑的。

编码问题作为一个困扰我很久的“恶龙”,如今能“斩掉”还是很爽的。

参考资料

ASCII
what is ansi format
Windows Code Page
code pages
American National Standards Institute
International Organization for Standardization
Unicode
Fundamental types
GB2312
GB18030
UTF8
UTF16