编码是最繁琐的问题之一了,尤其对于 C++这种 string 还只是char *简易封装的底层语言来说,对于非 ASCII 的字符串需要考虑各个环节的编码,只要错了一项最终就是乱码。对于一些更高级的语言比如 Python3,全部使用 utf8 就少了很多乱七八糟的乱码问题。

ASCII 码

首先从 ASCII 码开始,在早期的计算机中,使用一个字节(8 比特,实际上是 7 比特)来表示所有英文字母和常见的标点符号,以及回车,制表符等控制字符,如下表。

这里 4 比特用一个十六进制数来表示,实际上没有用完\(2^8=256\)个,只用了一半 128 个,具体而言就是只用了 7 比特,最高位始终为 0。 一些控制字符比如著名的换行符:CR/LF,制表符 HT(tab),剩下的 128 个字符,其实对于绝大多数欧美的拼音文字都是足够的,因此基于 ASCII 码也产生了很多表示西欧文字的编码方案。但是对于成千上万的汉字编码,一个字节显然是不可能完成的任务,典型的中日韩三国的编码问题,通常这被称为 CJK 编码,针对这些语言必须设计多字节编码方案。

多字节编码

仅仅使用 ASCII 字符显然是不够的,必然需要使用多个字节来表示字符,例如成千上万的汉字字符等,这会面临很多问题:

  • 字节序问题:所有的文字处理程序以及存储设备都会将文字信息抽象为字节流或者字节块,对于上面的单个字节表示一个字符,没有问题,但是如果我们使用多个字节代表一个字符,例如两个字节00100100-101111001表示某一个字符,101111001-00100100表示另一个字符,那么问题来了:
    1. 如果程序获取的是字节流,第一个获取的字节是 A,第二个获取的字节是 B,怎么解析?应该是 AB 还是 BA?
    2. 如果程序需要从内存(字节块)中读取连续两个字节的信息,地址 p 对应的字节是 A,地址 p+1 对应的字节是 B,怎么解析?应该是 AB 还是 BA?
  • 字符定位的问题:如何将连续的字节流精确地按照每个字符的字节数进行拆分解析?假设读取的字节流为
    1
    ...ABCDEFGH...

并且已经明确两个字节表示一个字符,那么在未知起止点的情况下,就有两种可能

1
2
...AB|CD|EF|GH|...
...A|BC|DE|FG|H...

  • 兼容 ASCII 码的问题:多字节方案能不能和最普遍使用的 ASCII 码兼容?如果读取字节流时遇到一个 0 开头的字节,能否自动识别为 ASCII 字符,这个做法很容易破坏第二个问题中对字节流进行的分组,但是我们很希望编码保持对 ASCII 的兼容性。

这些问题是任何多字节编码方案都必须要考虑的,关于字节序的问题: 整数浮点数等的字节序由机器架构决定,而编码的字节序选择则根据具体编码有不同的处理,对于某些编码是直接固定了顺序,另一些则仍然需要大小端标记。

GB 编码方案

由于历史原因,最初各国内部分别制定了国内文字在计算机中的编码方案,相互之间是完全冲突的,例如中国大陆对汉字的编码方案先后设计了几个国家标准:GB2321 < GBK < GB18030,这三个标准是向后兼容的,表示的汉字范围逐渐扩大,因此这里不做细分,习惯称为 GBK 编码。(GBK 编码严格来说,不仅仅包含简体和繁体汉字,还有常见的日文和少数民族文字,并且兼容 ASCII)(与之类似的还有中国台湾和香港使用的 BIG5 编码,与 GB 系列编码不兼容)

GBK 编码

GBK 编码将每一个汉字用两个字节的数据进行编码,第一个是高位字节,第二个是低位字节(两者顺序固定,不存在字节序问题)

具体范围如下表,为了兼容性等,这里的编码空间被切分的很零碎。

基于这里的图表,程序对 GBK 编码的解析处理可以是:

  • 先判断当前字节是否处于 ASCII 的范围(二进制意义下,第一个比特是否为 0),如果是,则视作 ASCII 码的单字节处理进行处理,如果不是,进入下一步
  • 将当前字节和后一个字节分别检查:要求首位字节范围在 0x81 到 0xFE 之间,低位字节范围在 0x40 到 0xFE 之间(并且低位字节剔除了 0x7F)
  • 如果不在合法范围,可能显示乱码或报错,或者选择偏移一个字节再次进行检测,增加健壮性;如果在合法范围内,查表获取相应中文字符。

一段简单的示意代码如下

1
2
3
4
5
6
 bool IsGBKCharacter(unsigned char byte1, unsigned char byte2) {
bool firstByteInRange = (byte1 >= 0x81 && byte1 <= 0xFE);
bool secondByteInRange = ((byte2 >= 0x40 && byte2 <= 0x7E) || (byte2 >= 0x80 && byte2 <= 0xFE));

return firstByteInRange && secondByteInRange;
}

具体的中文在 GBK 编码下如何存储?由于一个字符对应两个字节,因此还是涉及到哪个字节在低地址,哪个在高地址的问题, 不过 GB 系列编码在一开始就固定了两个字节的相对顺序,相当于强制大端序:高位字节始终存储在低地址,低位字节始终存储在高地址,避免了系统差异的麻烦。(与之不同的是后面的 unicode 系列编码,需要面临大小端的问题)

查表易得中文 “万” 的 GBK 编码为 CDF2(高位字节 CD,低位字节 F2),按照 GBK 编码保存到文件中,再通过 16 进制阅读器打开,确实依次为 CDF20A,最后的 0A 为 LF 换行符,验证确实相当于固定大端序。

GB18030 编码

对 GBK 编码进一步扩展,添加更多的中文语境下可能用到的字符,就得到了 GB18030,但是两字节的范围已经不够了,GB18030 选择了变长方案,即大部分常用字符和 GBK 编码一样采用两个字节,少部分的冷门字符则需要使用四个字节的编码, 由于仍然兼容 ASCII 码,因此 GB18030 实际上是 1/2/4 变长的编码方案:在 GBK 的基础上,如果前两个字节落入指定范围,则变成 4 字节模式。

半角与全角

值得注意的是,一个汉字(以及汉字标点符号)的显示宽度大约是一个 ASCII 可见字符(包括英文字母,数字和标点符号)的两倍,如果混杂在一起不利于排版的美观,因此 GBK 在兼容原始的 ASCII 字符中的英文字符和数字标点的同时,额外提供了一套英文字符和数字标点的编码,它的显示宽度和一个汉字是相当的,这就是全角字符,与之相对的 ASCII 英文字符称为半角字符。

半角和全角字符例如:

  • ASCII 的数字 2,编码为 0x32,这是半角字符;
  • GBK 的数字2,编码为 0xA3 0xB2,这是全角字符。

ANSI 编码

与 GB 系列编码一样,各国各种语言针对自身特点,在兼容 ASCII 的基础上,设计了不同的扩展编码方案,这些方案除了兼容 ASCII,相互之间是绝对冲突的,那么如何让计算机在不同语言环境下通用?

微软早期的解决办法是将这些编码统称为 ANSI 编码,这里 ANSI 具体指什么,需要借助一个指标(代码页),这个指标指示当前应该针对非 ASCII 字符按照哪一种编码处理,比如在中文操作系统中通常是 GBK 编码,在日文操作系统中则是日文对应的 Shift JIS 编码等。

就像把繁杂的 ANSI 编码方案整理为一本词典,设置代码页为哪一页,在处理非 ASCII 字符时就会根据那一页的编码方案进行处理。对于 windows 系统,在不同的国家地区,使用不同的语言和区域设置,会相应开启不同的代码页,例如中文系统对应的代码页编号为 936,可以在 cmd 中输入 chcp 查看。(注,由于 ANSI 在 windows 系统中只支持单字节和双字节模式,但是 GB18030 包含了扩展的四字节模式,无法支持,因此代码页称为 GBK 936 更合适)

具体到 windows 系统中,包含两个 locale 设置:系统 locale(决定程序输入输出编码所需要的代码页)和用户 locale(决定地区时间日期的显示格式等),系统 locale 对应的代码页称为默认代码页。 windows 现在已经不建议新的应用程序依赖系统代码页进行编程,只是出于兼容性仍然会支持。

Unicode 字符集和编码

前面的 ANSI 编码作为国际化的解决方案其实非常丑陋,因为不同的编码方案之间是完全冲突的,很可能在代码页设置不正常时导致乱码,windows 系统在某个时刻,只能很好地处理一种语言编码,因为默认代码页只有一个;将文本在采用不同代码页的电脑间传递,出现乱码几乎是必然。

于是就出现了统一编码计划——Unicode 字符集是一个宏大的计划,它希望统一所有的编码中出现的字符,将它们来者不拒地统一编号,然后基于这个字符集设计相应的编码方案,它包括了各种语言各种文字中出现的尽可能多的字符,这个字符集并不是固定的,并且不存在上限,仍然可以不断扩容,目前的容量至少为 \(17 \times 2^{16}\)。有时上下文中也会出现 USC,这个名词表示 Unicode 统一字符集(Unicode Standard Character)

字符集和字符编码是略有区别的,字符集是直接以自然数索引的形式,将所有的字符排列编号;字符编码则是根据这个字符集,建立一个字符到二进制内码的对应关系,这个二进制内码并不一定是字符排列编号的二进制值,也可以是其他按照指定规则变换后的映射关系得到的,主要是让内码避免和 ASCII 码冲突。

Unicode 字符集仅仅提供了一组编号,至于如何将这些编号对应到二进制内码,仍然保留了非常多的自由度,可以设计出很多不同的具体编码方案,分成两类:UCS-x 编码,UTF-x 编码。

下面主要介绍三类编码方案:UTF-32,UTF-16,UTF-8,以及它们的具体变体(有无 BOM,大小端等),从整体而言:

  1. UTF-32,对所有字符的编码都是四个字节,这导致了很大的空间浪费,因此实践中几乎不采用,但是原理最简单直接
  2. UTF-16,对所有字符的编码为两个字节或少数扩展的四个字节,这是除了 UTF-8 之外应用最多的,也是现代 windows 内核所采用的
  3. UTF-8,对所有字符的编码可能是 1,2,3,4 个字节,其中 1 个字节是用于兼容 ASCII 码(其他的 UTF-x 都不兼容 ASCII),这是最广泛使用的一种基于 Unicode 的编码方案

注:

  • Unicode 严格来说是一个字符集,UTF-x 才是具体的编码方案,但是目前这两者经常被混用:在 windows 的语境下,如果提到 Unicode 编码,通常指代的是 UTF-16 编码;在其它语境下,如果提到 Unicode 编码,指代的主要是 UTF-8 编码。
  • UCS-2 是固定 2 字节的编码方案,UCS-4 是固定 4 字节的编码方案,它们的应用没有 UTF-x 那么广泛,并且非常类似:UCS-2 约等于 UTF-16,UCS-4 约等于 UTF-32,因此略过。

UTF-32 编码

UTF-32 是一种最简单粗暴的,针对 Unicode 字符集的编码方案,因为 4 个字节的二进制总数\(2^{32}\)是远远超过当前 Unicode 字符总数的,对所有的字符都使用固定的 4 个字节来表示,直接将 Unicode 字符的编号转换为二进制即可。

4 个字节的第一个字节的第一位作为前导位,被设计为必须为 0(实际上自然地填充之后,在左侧不足补零,第一位必然为 0)

Unicode 字符的完整编号范围 000000-10FFFF,将编号直接转换为二进制存储即可,例如“严”的 Unicode 编号为\u4e25(U+4e25),转换成二进制数为(100 111000 100101),那么按照 UTF-32 编码应该为00 00 4E 25,在内存中实际为(大端序)

1
00000000 00000000 01001110 00100101

UTF-32 存在明显的缺点:

  • 不兼容 ASCII 码,即使一个 ASCII 字符,在 UTF-32 中对应也需要 4 个字节存储
  • 巨大的存储空间浪费,对于常见的文本,在 UTF-32 编码后会有很多字节中存储 0,造成空间浪费

因为这些缺点,实践中 UTF-32 编码应用不广。

UTF-16 编码

UTF-16 会将字符采用两个或四个字节进行编码,对应将 Unicode 字符分成两类:

  • 两个字节:Unicode 编号000000-00FFFF,直接将编号转换为二进制进行存储
  • 四个字节:Unicode 编号010000-10FFFF,这部分需要在 Unicode 编号的基础上进行转换,最终存储在四个字节中,转换规则比较繁琐,这里以例子呈现

以两个例子来具体说明:

  • 首先考虑\u20,这个字符的编号落在第一个分类中,只需要两个字节存储00 20(大端序),小端序为20 10
  • 然后考虑\u12345(显示为 𒍅),这个字符的编号落在第二个分类中,因此需要很多处理:
    1. 整体减去0x10000,得到0x02345,拆分高 10 位(0000001000)和低 10 位(1101000101
    2. 高 10 位加上0xd800,得到0xd808
    3. 低 10 位加上0xdc00,得到0xdf45
    4. 最终存储d8 08 df 45(大端序),小端序为08 d8 45 df

字节序与 BOM

由于 UTF-32 需要四个字节,我们需要讨论一下字节序问题,假设四个字节为ABCD,那么

  • 大端序:ABCD
  • 小端序:DCBA

例如“ABC”这三个字符的 UTF-32 编码为:00 00 00 41 00 00 00 42 00 00 00 43,在内存中可能有两种结果

1
2
3
4
// 大端序 BE
00 00 00 41 00 00 00 42 00 00 00 43
// 小端序 LE
41 00 00 00 42 00 00 00 43 00 00 00

例如"严"的 Unicode 编号为\u4e25,也有

1
2
3
4
// 大端序 BE
00 00 4E 25
// 小端序 LE
25 4E 00 00

对于 UTF-16 需要二或四个字节,也有类似的字节序问题:

  • 如果使用两个字节:AB为大端序,则BA为小端序;
  • 如果使用四个字节:ABCD为大端序,则BADC为小端序。

注意到 UTF-16 非常特殊:以两个字节为整体,大小端的影响仅仅体现在前两个字节的内部相对顺序和后两个字节的内部相对顺序。例如"好"的 UTF-16 编码为59 7d 00 0a,在内存中分别为

1
2
3
4
// 大端序 BE
59 7d 00 0a
// 小端序 LE
7d 59 0a 00

Unicode 提供一种特殊标记用来区分大小端问题,在文本的开头可以加上一段特殊编码,习惯称为 BOM(byte order mark):

  • UTF-32 大端序的 BOM:00 00 FE FF
  • UTF-32 小端序的 BOM:FF FE 00 00
  • UTF-16 大端序的 BOM:FE FF
  • UTF-16 小端序的 BOM:FF FE

程序可以通过开头的 BOM 来判断当前文本是大端序还是小端序。

但是这个 BOM 有时也可以省略:程序可以根据文本内容推测,或者根据机器本身来确定使用大端序还是小端序,因此可以细分为下面这些略有不同的编码方案:

  • UTF-32
    • UTF-32 BE
    • UTF-32 BE with BOM
    • UTF-32 LE
    • UTF-32 LE with BOM
  • UTF-16
    • UTF-16 BE
    • UTF-16 BE with BOM
    • UTF-16 LE
    • UTF-16 LE with BOM

UTF-8 编码

UTF-8 编码是最灵活,也是应用最广泛的 UTF 编码方案,它可能使用 1,2,3,4 个字节来表示单个字符,其中单个字节的情形完全兼容 ASCII 码,多个字节时每一个字节的二进制首位均为 1。

四种情形的分类如下:

  • 单个字节:Unicode 编号000000-00007F,UTF-8 编码为 0xxxxxx
  • 两个字节:Unicode 编号000080-0007FF,UTF-8 编码为110xxxxx 10xxxxxx
  • 三个字节:Unicode 编号 000800-00FFFF,UTF-8 编码为1110xxxx 10xxxxxx 10xxxxxx
  • 四个字节:Unicode 编号010000-10FFFF,UTF-8 编码为11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

可以发现,UTF-8 的多字节情形每个字节全部以 1 开头,以此来兼容 ASCII 码部分,并且第一个字节头部的 1 的个数,代表当前使用几个字节表示一个 Unicode 字符。后续的字节头部均为10

常见汉字都需要三个字节来存储(相比于 GB 编码,如果处理纯汉字文本,UTF-8 编码所需要的存储字节数是 1.5 倍),例如常见的汉字“严”,它的 Unicode 编号为\u4e25(U+4e25),位于三个字节表示范围,转换成二进制数为(0100 111000 100101),填入对应的x位置,得到实际在内存中的二进制形式

1
11100100 10111000 10100101

或者写成十六进制的形式:

1
E4 B8 A5

和 UTF-16/32 不同,UTF-8 编码实际上不需要 BOM,它的第一个字节是明确的。

微软有强迫症,为了和其他编码的处理保持一致,仍然给 UTF-8 设计并使用了相应的 BOM,也有的编辑器称之为带签名的 UTF-8,即文本开头的特殊字符EF BB BF,例如使用 UTF-8 BOM 保存含有一个“严”字的文本,实际的文件按照十六进制打开为

1
EF BB BF E4 B8 A5 0A

其中前三个字节为 BOM,最后一个字节0A为 LF 回车。 微软的强迫症还体现在,它将 UTF-8 在某种程度上也塞进了 ANSI 方案之中,给了它一个特殊的 65001 代码页。

建议不要对 UTF-8 使用额外的 BOM,因为在很多处理 UTF-8 的程序中可能都有问题:它们默认是按照不含有 BOM 的 UTF-8 编码进行处理的,可能有很多隐患。(VS 可以开启额外选项,来探测不含 BOM 的 UTF-8 编码)

windows 的编码问题

在 windows 中,除了仍然兼容可能被淘汰的 ANSI 方案(基于代码页),现代 windows 在系统内部(包含内核层面)选择的是 UTF-16 编码,具体来说主要是 UTF-16 LE(当时 UTF-8 尚未出现),严格来说系统内部使用的是 UCS-2 LE(固定 2 字节),因为并不是所有系统组件都支持 UTF-16 的四字节扩展部分。在 windows 语境下,Unicode 编码的含义默认就是 UTF-16 编码。与之相对的,其他系统例如 Linux 更倾向于使用不含 BOM 的 UTF-8。

事实上 windows 系统为了更好地处理宽字符(以两个字节为一个基本单元的wchar_t,而非单个字节的char),在处理字符串时提供了两套 API:

  1. 历史传承下来的 API(或者在名称上加了A),接受 char* 参数,处理字符时按照 ANSI 方案,即基于代码页选择具体的非 ASCII 编码
  2. 在 API 名称上加了 W ,接受 wchar_t*参数,处理字符时按照 UTF-16 编码

windows 建议新的程序调用系统 API 时都应该基于 UTF-16,而非基于 ANSI 方案并依赖于活动代码页,如果为了兼容性,可以这样做

1
2
3
4
5
#ifdef UNICODE
#define SetWindowText SetWindowTextW
#else
#define SetWindowText SetWindowTextA
#endif // !UNICODE

在 windows 上正在逐渐地向 UTF-16 和 UTF-8 进行靠拢,对于 ANSI 编码和代码页在逐渐淘汰,但是在 windows 上目前仍然有很多地方(尤其是控制台的非 ASCII 输入),以及一些旧的应用程序不能很好地处理编码问题。可以开启实验性选项:将默认代码页修改为 65001 也就是 UTF-8,但是这对于依赖代码页的某些旧程序可能会有乱码问题,并且对于非 ASCII 的控制台输入存在错误。