理解 C 和 C++ 程序的编码
计算机从计算器走向通用后,编码就一直是一个绕不开的话题,经历了早期的八仙过海后,Unicode 的失败实则带我们走入了另一个泥潭。
ASCII,ASCII 兼容与非 ASCII 兼容编码
1963 年,美国标准协会(ASA,ANSI 的前身)发布了电报编码字符集标准 ASCII。这一标准与后来的 Unicode 共同奠定了现代计算机编码体系的基础。
ASCII 最初面向美国市场,因此定义了具有美国特色的符号(如 $)。同年,IBM 基于打孔机经验推出了 EBCDIC 编码 —— 在当时的混乱时期,这种并行发展并不罕见。EBCDIC 专为计算机设计,是 8 比特编码。
由于设计来源不同,EBCDIC 与 ASCII 天然不兼容。
ASCII 发布的同年,国际标准化组织(ISO)已着手对其“国际化”改造,最终于 1973 年推出 ISO646。在保留主要字符编码的前提下,将部分码位定义为“可本地化”,允许在不同语言环境中赋予不同含义。
日本标准委员会(JISC)1969 年发布的 JIS X 0201 字符集,将反斜线 \ 定义为日元符号 ¥。之所以早于 ISO646 面世,是因为 ISO646 需要等待各国标准的反馈。JIS X 0201 不仅修改了反斜线,还增加了片假名,成为 8 位字符集。
ASCII 和 ISO646 本质是为电报设计的非计算机专用标准,这埋下了长期影响计算机系统的隐患。它们在计算机中被视为 7 比特编码。
1980 年中国电子工业部标准化研究所发布的 GB/T 1980-80(1998 年修订版称 GB/T 1980-1998)采取了一种“灵活”策略:不直接修改“可本地化字符”,而是规定经“双方约定”,编号 36 的字符($)可作为 ¥ 使用。
1978 年日本推出 JIS X 0208,这是单字节 7/8 位的双字节定长编码,收录了 JIS X 0201 字符和日本汉字(后由 JIS X 0212 补充)。1992 年 Windows 3.1 推出 Shift-JIS(移位 JIS),完全兼容 JIS X 0201,采用 1/2 字节变长编码对 JIS X 0212 进行编码。
1980 年发布的 GB/T 2312 是兼容 GB/T 1980-80 的 1/2 字节变长编码,又被称为 EUC-CN(扩展 Unix 字符集 - 中国)。1995 年 GBK 进一步扩展了字符。EUC 系列编码是 UTF-8 发明之前的一类 ASCII 兼容的变长编码。
1990 年 ISO10646(尚未与 Unicode 合并)提出了两种方案:非 ASCII 兼容的 UTF-1(1/2/3/5 字节变长)和 UCS-2(16 比特 2 字节定长)。即使在 1991 年 ISO10646 与 Unicode 合并时,设计者还愚蠢的认为 16 比特足以容纳全球字符。
1996 年 Unicode 认识到错误,将字符集扩展至 32 比特,推出 UTF-16(2/4 字节变长)和 UTF-32(4 字节定长)。而 1993 年公开的 UTF-8 采用 1/2/3/4 字节变长设计,完全兼容 ASCII。2000 年发布的 GB18030 是 1/2/4 字节变长编码,兼容 GBK(1/2 字节变长)且支持全部 Unicode 字符,实际上可以叫做 UTF-GB18030。
此外,EBCDIC 也有多种变长变体,其中一个特殊的变体是 UTF-EBCDIC(1/2/3/4/5 字节变长)。
上面这些例子只是典型,不同语言都有自己的字符集和编码方案。
至此编码有三大流派:
- ASCII 兼容派:如 EUC、Shift-JIS、UTF-8、GB18030(1+N 字节变长)
- ASCII 不兼容派:如 EBCDIC、UTF-1(1+N 字节变长)
- 多字节派:如 UCS-2、UCS-4、UTF-16、UTF-32
Shift-JIS 形式上兼容 ASCII,但显示的结果不同,在 Windows 系统中,路径的反斜线被显示为 ¥ 就是该问题的遗留。
实际上,自 Unicode 1.1 (1991) 支持组合附加符号(如 é = e + ◌́ )起,即便 UTF-32 也是逻辑变长编码。在 1999 年,Unicode 才姗姗来迟的发布了规范化形式(Unicode Normalizations Forms)定义了如何比较两个字符串是否相同。Unicode 几乎在所有问题上都犯了错。
源文件处理
毫无疑问,源码作为文本文件是有自己的编码的,MSVC 使用 /source-charset 选项告诉编译器将以何种编码读取源文件,GCC 使用 -finput-charset,Clang 和 MSVC,GCC 兼容但实际上会忽略它们,只支持 UTF-8。
执行字符集
C 和 C++ 标准使用执行字符集一词来指示普通字面量具有的编码,普通字面量指的是没有任何修饰的字符串字面量或者字符字面量。同理,执行宽字符集指的是使用 “L” 前缀的字符串字面量或者字符字面量。MSVC 中,执行字符集可以使用 /execution-charset 设置,执行宽字符集一定是 UTF-16。GCC 可以用 -fexec-charset 和 -fwide-exec-charset 设置。Clang 则只支持 UTF-8,UTF-16 和 UTF-32(依宽字符宽度而定)。
GCC 有一个非常奇葩的点在于,它会把转换结果按字节的形式使用,也就是说即使 1 字节是 8 比特,GCC 仍然支持你使用 -fexec-charset=UTF-16 来让两个字节储存一个字符。这将导致 strlen
函数失效。
执行字符集可以简单认为是程序使用这些字符串字面量和字符字面量时的字符集,它也是编译后这些字面量储存在二进制文件中使用的字符集。
Visual Studio 中有一个选项叫使用 Unicode 字符集,它实际上指的是 UNICODE
宏和 _UNICODE
宏,前者会将所有无后缀的函数宏名定义为 W 版本的函数名,并且为 _T
宏和 TEXT
宏包裹的字符串字面量和字符字面量添加 L
前缀。后者会使得所有 CRT 的 _t
前缀的函数的参数和返回值变为宽字符版本。
也就是说,它实际上是提供了一种切换 A 版本函数和 W 版本函数的方案,和 UTF-8 半毛钱关系都没有。该设计是为了让 Windows95 软件能迁移到 Windows2000 以支持多语言而设计的,任何从编写时就不支持 Windows95 的软件使用它都是错误的,应该无条件使用宽字符和 W 版本的函数。
运行时本地化
在上个世纪,Unix 和 Windows 的程序都是以单线程编写的(即使有些变体的内核本身支持多 CPU),因此这些系统提供的本地化环境毫无疑问是为单线程设计的。 这些系统为每个进程提供一个 “本地化环境”,程序可以读取和设置它们。由于程序都是单线程的,因此一旦环境被修改就会立即生效。本地环境通常和编码相关。
在这些系统支持多线程库后,出现了线程独占的本地化环境(Windows CRT 的 _configthreadlocale
,Win32 的 SetThreadLocale
,POSIX 的 uselocale
)。这些函数是古怪而且危险的,因为它们会导致每个线程输出不一样编码的字符串。
由于执行字符集的存在,这种方式也是不一定正确的,因为执行字符集必须和本地环境匹配,否则本地环境可能无法转换执行字符集到环境字符集。
实际上,以这种方式支持多语言一直是不可靠的。想象一下:假设一个软件写入 Latin-1 文件名,而另一个软件使用 GBK 读取它,那么显然会变成乱码,这也是为什么上世纪九十年代的软件急于使用 UCS-2(例如 Windows,macOS,Java,JavaScript 等等),因为不用 Unicode 这种把所有字符都收录了的字符集是无法解决该问题的。这也是为什么 zip 文件会乱码,因为 zip 文件只按原样(任意 ASCII 兼容的方式)储存文件名。
Windows 程序允许使用 GetConsoleCP
、GetConsoleOutputCP
、SetConsoleCP
和 SetConsoleOutputCP
来读取和设置控制台的编码,读取函数可以用于程序主动适应控制台的编码,设置函数用于告诉控制台该如何显示输出的字符。在 Unix 上控制台的本地化环境和启动时的环境变量一致。
Windows 从 1903 起支持使用 manifest 将系统代码页设置(伪装)为 UTF-8,使 A 版本函数默认使用 UTF-8 而不是其他 ASCII 兼容编码。但注意,使用 SetThreadLocate
仍然会再次改变 A 版本函数的行为;所有 A 版本函数一定是使用 W 版本函数 + 转码实现的;有些函数不提供 A 版本;以及使用 manifest 是可执行文件作者的特权。
结论
对于 Unix 来说,默认 UTF-8 已经是常态。对于 Windows 来说,作为库开发者,使用宽字符字面量和 W 后缀函数可以保证你的代码不受任何文本编码,任何编译选项,任何程序本地化环境影响,永远为 UTF-16。