Non-Profit, International

Spirit unsterblich.

理解C和C++程序的编码

字数统计:2392

计算机从计算器走向通用后,编码就一直是一个绕不开的话题,经历了早期的八仙过海后,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 1988-80(1998年修订版称GB/T 1988-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程序允许使用 GetConsoleCPGetConsoleOutputCPSetConsoleCPSetConsoleOutputCP 来读取和设置控制台的编码,读取函数可以用于程序主动适应控制台的编码,设置函数用于告诉控制台该如何显示输出的字符。在Unix上控制台的本地化环境和启动时的环境变量一致。

Windows从1903起支持使用manifest将系统代码页设置(伪装)为UTF-8,使A版本函数默认使用UTF-8而不是其他ASCII兼容编码。但注意,使用 SetThreadLocale 仍然会再次改变A版本函数的行为;所有A版本函数一定是使用W版本函数 + 转码实现的;有些函数不提供A版本;以及使用manifest是可执行文件作者的特权。

结论

对于Unix来说,默认UTF-8已经是常态。对于Windows来说,作为库开发者,使用宽字符字面量和W后缀函数可以保证你的代码不受任何文本编码,任何编译选项,任何程序本地化环境影响,永远为UTF-16。


若无特殊声明,本人原创文章以 CC BY-SA 4.0许可协议 提供。