字符集编码与 C/C++ 源文件字符编译乱弹
最近在看国际化编程 (i18n: internationalization) 的东西,也弄清楚了点字符集有关的一些问题,其实网上的一些牛人已经将字符集、Unicode 等相关的问题说的很清楚了,我在这里引用他们的总结并自己小结一下心得,并且实验一下在编译时,源代码自身的字符集与编译生成工具之间的问题。
locale与字符集
locale,中文有时翻译成“现场”,还不如叫英文的locale好,它的意思是“一套和地域有关的习惯而形成的程序运行上下文”,它由很多方面 (category) 组成,比如:某个地区的人们习惯怎样表示他们的货币金额 (LC_MONETARY) ,是用 "$100",还是用 "¥100";习惯怎么表示十进制多位数 (LC_NUMERIC) ,是每一千位进行分隔 "100,000",还是每一万位进行分隔 "10,0000";习惯怎么表示日期时间 (LC_TIME) ,是日-月-年的方式 "30-01-1999",还是年-月-日的方式 "1999-01-30",等等还有其它一些方面,不过其中我们最关心的是一个叫 LC_CTYPE 的,CTYPE 的含义大概是:Character Type(字符类型),它表示某个地区的字符用哪个字符集进行编码。还有LC_ALL,它是其它所有方面的并集。
C 标准库中设置 locale 的函数是:setlocale(),MSDN VC10 参考:Language and Country/Region Strings
字符集(Character-Set)按照发明顺序和继承关系,有以下常用的几种:
-
ASCII
ANSI 发布的字符编码标准,编码空间 0x00-0x7F,占用1个字节,上学时学的 C 语言书后面的字符表中就是它,因为使用这个字符集中的字符就已经可以编写 C 程序源代码了,所以给这个字符集起一个 locale 名叫 C,所有实现的 C 语言运行时和系统运行时,都应该有这个 C locale,因为它是所有字符集中最小的一个,设置为其它 locale 时可能由于不存在而出错,但设置 C 一定不会出错,比如:当 Linux 的 LANG 配置出错时,所有的 LC_* 变量就会被自动设置为最小的 C locale。MSDN VC10 参考:Code Pages。
-
ISO-8859-1
ISO 发布的字符编码标准,又称 Latin-1 字符集,编码空间 0x00-0xFF,占用1个字节,可以编码大多数的西欧地区语言。参考:ISO IEC 8859-1。
-
GB2312,GBK,GB18030
GB 系列是由中国国标局发布的字符编码方案(其中 GBK 不是正式标准),后期发布的版本兼容之前的,是之前的超集。
参考:中文的几个编码 by blade2001
-
GB2312
为1-2字节变长编码,汉字区中编码 6763 个字符。
-
GBK
是微软对 GB2312 的扩展,后由国标局作为指导性标准,为1-2字节变长编码,编码 21886 个字符,分为汉字区和图形符号区。汉字区编码 21003 个字符,支持CJK汉字(简体、繁体、常用日韩文),Windows 代码页为 CP936。
-
GB18030
为1-2-4字节变长编码,汉字区编码 27484 个字符,支持CJK汉字、常用藏文、蒙文、维吾尔文等,Windows 代码页为 CP54936。
-
上面的所有编码都将ASCII作为自己的子集实现,所以这些字符集又叫做本地化的(Native)ANSI 字符集。
一般在程序中为了支持国际化,在程序初始化时,将 locale 设置为系统配置的 Native ANSI 字符集,即执行:setlocale(LC_ALL, "")。
ASCII、ISO-8859-1 这种用1个字节编码的字符集,叫做单字节字符集(SBCS - Single-Byte Character Set)。
GB 系列这种用1-2、4个不等字节编码的字符集,叫做多字节字符集(MBCS - Multi-Byte Character Set)。
由 SBCS 编码的数据可以随机访问,从任意字节偏移开始解编码都能保证解析出的字符和后继字符是正确的。
而由MBCS编码的数据,只能将其作为字节流进行解析,如果从随机任意的字节偏移开始解编码,有可能定位到切断一个字符的中间位置,导致后继解析出的字符连续出错。作为字节流时,是从某个标识位置进行解析字符,比如从数据的开始位置,或从每个新行符 '\n' 之后开始解析字符。
Unicode 的理解
参考:谈谈 Unicode 编码,简要解释 UCS、UTF、BMP、BOM 等名词 by fmddlmyy
首先,Unicode 它不是一个东西,它至少涉及3个方面:Code Point,UCS,UTF。
每个地区、国家都有自己的 Native ANSI 字符集,虽然它们在 ASCII 子集部分是相同的,但其它部分都不尽相同,如果一个字符仅在一个特定的Native ANSI 字符集中编码,那么好,如果用户使用别的字符集,那么无论如何也无法解析和表现这个字符。怎样让你的文本数据同时可以包含英、法、德、中、日、阿拉伯甚至世界上所有可能的、完全的字符?办法似乎只有一个,就是在世界范围内对所有可能的字符进行穷举编码,这就是最初提出Unicode 的原因,全称为 Universal Multiple-Octet Code。
-
Code Point
在实际编码之前先给每个穷举到的字符指定一个序号,叫它 Code Point,把它当做是数学概念,和用几个字节存储无关,只要发布Unicode 的标准化组织(ISO 和 unicode.org)愿意,将新出现的字符继续向后编号就可以了,既然数学序号,就没有什么不够用的问题。编号时有一些原则,就是越常用的字符越靠前,编号到一定数量后,发现差不多了,常用字符都编完了,截止于此将之前的编号组成的子集叫做基本多文种平面(BMP - Basic Multilingual Plane),在 BMP 里的字符,只要4位16进制数就可以表示,当然在 BMP 以外的字符则需要使用5位或更多16进制数表示。比如:"汉" 字在 BMP 里,它的 Code Point 可以表示为 U+6C49。常用CJK汉字都落在 BMP 内,所以都能用U+HHHH 的形式表示其 Code Point。Windows 下有个字符映射表(charmap.exe)的工具,可以列举每个字符的Code Point、字体支持、字符集之间的关系。
-
UCS
有了 Code Point 后就可以规定它的字符集,叫做 UCS - Unicode Character Set,它和存储有关,用2个字节存储 Code Point 叫做 UCS-2,用4个字节存储的叫做 UCS-4,UCS-2 可以编码并存储 BMP 中的所有字符,而如果不够用了(要用到 BMP 外的字符),则可以使用 UCS-4。通常交流中提到 Unicode,如果不特指,就指代的是 UCS-2。
UCS 和 Native ANSI 字符集采用的 MBCS 编码是不同的,UCS 不将 ASCII 作为自己的子集,无论什么情况 UCS 总使用定长的字节来编码字符,UCS-2 使用2个字节,UCS-4 使用4个字节,而不是 Native ANSI 字符集中可能采用的变长编码。比如:"A" 在 GB2312 中编码为 0x41,而在 UCS-2 中编码为 0x0041。
当存储多字节编码的数据并且不将其作为字节流解析时,就要考虑保存数据的大小端问题(Endian),可以使用 BOM(Byte Order Mark)标识一个 UCS 字符数据块是采用 Big Endian 还是 Little Endian 进行存储:在 Unicode 概念中有一个字符,它的 Code Point 为 U+FEFF,实际上它不映射到任何地区、国家中的可能字符,即是一个不可能存在字符的 Code Point((-_-^),Unicode 标准对它的注释为:ZERO WIDTH NO-BREAK SPACE),当开始处理 UCS 数据块时,UCS 标准建议先处理这个 ZERO WIDTH NO-BREAK SPACE 字符,比如 UCS-2 数据块,如果一开始读到/写入的字节序列是 FF FE(8 进制:377 376),那么说明后续的 UCS-2 按 Little Endian 存储;如果是 FE FF(8进制:376 377),则说明后续的 UCS-2 按 Big Endian 存储。
采用定长的 UCS 有一个好处,就是可以像 SBCS 一样随机访问数据块中的任何字符,当然这里的随机偏移单位不是每字节:当用 UCS-2,是每2字节随机偏移,当用 UCS-4 时,是每4字节随机偏移。
但是 UCS 也有缺点,一是有些浪费:比如用 UCS-2,如果在一个数据块中只使用对应于 ASCII 中的字符,那么有一半存储都被浪费掉了,因为对应于ASCII 中的字符,它的 UCS-2 编码实际上是它的 ASCII 编码加上填0的高1字节组成的2字节编码,那种使用16进制编辑器打开文件后隔一列为0的字符文件就是这种情况。二是和 ASCII 不兼容,由于太多的已有系统使用 ASCII(或 Native ANSI)了,这点使 UCS 和其它系统对接时有点麻烦。
-
UTF
UTF - Unicode Transformation Format,作为 Unicode 的传输编码,是对 UCS 再次编码映射得到的字符集,能够一定程度上解决上面 UCS 的2个缺点。UTF-8 是以8位为单元对 UCS-2 进行再次编码映射,是当前网络传输、存储优选的字符集。UTF-8 使用8位单元(1字节)变长编码,并将 ASCII 作为子集,这样就可以将 UTF-8 当做一种 MBCS 的 Native ANSI 字符集的实现,因此 UTF-8 需要使用1字节流方式解析字符。处于BMP 中的CJK汉字,使用 UTF-8 编码时通常会映射到3字节序列,而 GB 系列字符集中的CJK汉字通常为2字节序列。
UTF-8 和所有的 Native ANSI 字符集一样:当数据块中只有 ASCII 子集部分的字符时,是无法区分这个数据块用哪种 Native ANSI 字符集进行编码的,因为这部分的编码映射关系对于所有的 Native ANSI 字符集是共享的,只有当未来数据块中包含像CJK汉字这种在 ASCII 子集之外的字符时,采用不同 Native ANSI 字符集的数据块才会表现出不同。
不过有一种方法可以让数据块标识自己使用的是 UTF-8 编码(即使字符内容都在 ASCII 内),这对于文本编辑器等应用很有用,它们可以使用这个标识判断文件当前使用的字符集,以便未来插入 ASCII 之外的字符时决定如何编码。这个标识方法就是使用 UCS-2 中 BOM 的 UTF-8 编码,其1字节流为:EF BB BF(8 进制:357 273 277)。当数据块的开始有这个流时就说明后续字符采用 UTF-8 编码。因为 UTF-8 使用1字节流方式处理,这时 BOM 已经失去其在 UCS-2 中作为标识字节序大小端的作用,而仅把 EF BB BF 作为 UTF-8 编码的标识功能(Magic),有时就叫它UTF-8 Signature。但并非所有能处理 UTF-8 数据的应用都假定有 Signature 这个标识功能的存在:微软的应用大多都支持 UTF-8 Signature,但在开源领域,比如 Linux 下有相当多的程序都不支持 UTF-8 Signature。
源文件字符集与编译
在 ISO C99 中有了宽字符处理的标准,例程大多在 wchar.h 中声明,并且有了 wchar_t 这么一个类型。不管哪种 C 编译器和标准/RT库实现,wchar_t 通常都可以认为是存储 UCS 字符的类型,C 语言语法中也使用前缀的 L 字符来说明一个字符常量、字符串字面量在编译时采用 UCS 编码。
VC8 cl的实现中,默认的编译选项将 wchar_t 做为内建类型(选项:/Zc:wchar_t),此时 sizeof(wchar_t) 为 2,可存储 UCS-2 编码。Linux GCC 4 的实现中,sizeof(wchar_t) 为 4,可存储 UCS-4 编码。MinGW 和 Cygwin 的 GCC 4 中,sizeof(wchar_t) 为 2。
如此有这么一个疑问:
-
源文件程序语法中的字符编码指示。
-
源文件自身的字符编码。
这2者有何种联系?于是我做了如下实验,试着搞明白编译器对上面2者的处理作用。分别实验了3个我常用的编译工具集:VC8、MinGW GCC、Linux GCC。
-
先看当程序语法中使用非 wchar_t 字符编码指示时的情况,按照教科书上的说法这种字符串字面量编译时使用 ASCII 编码,因为其中有汉字,因此我把它想象成用某种 Native ANSI 字符集进行编码,在随后的测试和调试中便可判断这种假定是否正确。
源码如下:
01
#include "common.h"
02
03
#define MAX_BUF_SIZE 256
04
typedef
unsigned
char
BYTE
;
05
06
const
char
g_szZhong[] =
"这是ABC 123汉字"
;
07
08
int
main(
int
argc,
char
* argv[])
09
{
10
BYTE
buf[MAX_BUF_SIZE] = {0};
11
FILE
* fs = NULL;
12
if
( argc < 2 )
13
{
return
1; }
14
15
if
( (fs =
fopen
(argv[1],
"wb"
)) == NULL )
16
{
return
errno
; }
17
18
memcpy
(buf, g_szZhong,
sizeof
(g_szZhong));
19
20
fwrite
(buf,
sizeof
(
char
),
sizeof
(g_szZhong), fs);
21
fclose
(fs);
22
23
return
errno
;
24
}
使用记事本、iconv 等工具将上面的源文件做出5份不同的字符集编码的出来:GBK、UCS-2 LE、UCS-2 LE(BOM)、UTF-8、UTF-8(BOM)。其中 LE 表示UCS-2 采用 Little Endian 字节序存储;带 BOM 的表示:在文件头有 BOM 标识,对于 UTF-8 来说就是 Signature,没有带 BOM 的就没有这个文件头标识。
我编译生成了上面的程序后,查看了3处字符串的编码:
-
内存中的字符串:使用 gdb、VC 等调试工具,跟踪 memcpy() 时向 buf[] 中复制的字符数据。
-
可执行文件中的字串常量:使用 WinHex、hd 等16进制查看工具,在编译生成的对象文件和可执行映像文件中查找字符串字面量的中间部分 "ABC 123"。
-
该程序写入的文件:该程序使用 fwrite() 向某个命令行参数指定的文件写入 buf[] 中的字符数据,查看这个写入文件的编码。
实验结果:
-
MinGW GCC 4.4.0
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK GBK GBK GBK UCS-2 LE (BOM) 编译出错 UCS-2 LE 编译出错 UTF-8 (BOM) UTF-8 UTF-8 UTF-8 UTF-8 UTF-8 UTF-8 UTF-8 -
Linux GCC 4.3.2
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK GBK GBK GBK UCS-2 LE (BOM) 编译出错:不识别 BOM (FF FE),且源代码字符处理出错 UCS-2 LE 编译出错:源代码字符处理出错 UTF-8 (BOM) 编译出错:不识别 BOM (EF BB BF) UTF-8 UTF-8 UTF-8 UTF-8 -
VC8 cl 14.00.50727.42
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK GBK GBK GBK UCS-2 LE (BOM) GBK GBK GBK UCS-2 LE GBK GBK GBK UTF-8 (BOM) GBK GBK GBK UTF-8 UTF-8 UTF-8 UTF-8
实验小结:
-
基本上每种编译器都有自己的源代码字符集编码处理方式。
-
基本印证了开始的观点:如果能编译成功,则编译器确实是用某种 Native ANSI 字符集来编码非 L 前缀的字符串字面量,并且如果需要会在编译过程中做一些字符集编码转换,如 VC8 cl。
-
GCC的工作方式似乎很直接了当:不管你源文件是什么编码,编译时只将两引号之间的字符串复制一份到对象文件中,参考:在 Linux C 编程中使用 Unicode 和 UTF-8。VC8 也可以有这种工作方式,那就是不带 BOM 的 UTF-8 源文件。
-
VC8 对 Unicode 类源文件有较好的支持没有什么稀奇的,因为微软也是 Unicode 不遗余力的推广者,参考:各 C/C++ 编译器对 wchar_t 字符和字符串的正确支持程度。
-
-
再看当程序语法中使用wchar_t字符编码指示时,即在程序中使用UCS字符或字符串时情况,将上面源程序改为如下:
01
#include "common.h"
02
03
#define MAX_BUF_SIZE 256
04
typedef
unsigned
char
BYTE
;
05
06
const
wchar_t
g_szZhong[] = L
"这是ABC 123汉字"
;
07
08
int
main(
int
argc,
char
* argv[])
09
{
10
BYTE
buf[MAX_BUF_SIZE] = {0};
11
FILE
* fs = NULL;
12
if
( argc < 2 )
13
{
return
1; }
14
15
if
( (fs =
fopen
(argv[1],
"wb"
)) == NULL )
16
{
return
errno
; }
17
18
memcpy
(buf, g_szZhong,
sizeof
(g_szZhong));
19
20
fwrite
(buf,
sizeof
(
char
),
sizeof
(g_szZhong), fs);
21
fclose
(fs);
22
23
return
errno
;
24
}
实验结果:
-
MinGW GCC 4.4.0
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK 编译出错:无法将源代码中字符量转换到 UCS UCS-2 LE (BOM) 编译出错:不识别 BOM (FF FE),且源代码字符处理出错 UCS-2 LE 编译出错:源代码字符处理出错 UTF-8 (BOM) UCS-2 LE UCS-2 LE UCS-2 LE UTF-8 UCS-2 LE UCS-2 LE UCS-2 LE -
Linux GCC 4.3.2
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK 编译出错:无法将源代码中字符量转换到 UCS UCS-2 LE (BOM) 编译出错:不识别 BOM (FF FE),且源代码字符处理出错 UCS-2 LE 编译出错:源代码字符处理出错 UTF-8 (BOM) 编译出错:不识别 BOM (EF BB BF) UTF-8 UCS-4 LE UCS-4 LE UCS-4 LE -
VC8 cl 14.00.50727.42
源代码 内存中的字符串 可执行文件中的字串常量 写入的文件 GBK UCS-2 LE UCS-2 LE UCS-2 LE UCS-2 LE (BOM) UCS-2 LE UCS-2 LE UCS-2 LE UCS-2 LE UCS-2 LE UCS-2 LE UCS-2 LE UTF-8 (BOM) UCS-2 LE UCS-2 LE UCS-2 LE UTF-8 UCS-2 LE (Wrong) UCS-2 LE (Wrong) UCS-2 LE (Wrong)
实验小结:
-
如果用 GCC 编译器,并且要在程序中使用 UCS 字符,目前一定要满足这个条件:源代码用不带 BOM 的 UTF-8 编码保存。
-
Linux GCC 的实现中果然将 wchar_t 作为4字节的 UCS-4 的存储类型,能处理 UCS-4 的应用似乎不多(反正 Windows 记事本不行),所以最好就不要把它作为保存文件的编码了,把它做为程序内部字符处理的编码就好(例如计算含CJK汉字的字符串中字符个数)。
-
上面所有的 UCS 字符存储都使用 Little Endian 字节序,是因为我在x86架构的PC上作的实验,没有什么特别的。
-
当用 VC8 编译不带 BOM 的 UTF-8 源文件时,虽然可以生成可执行程序,但是其中对 UCS 字符的编码出现了错误,在上表中用 Wrong 表示,其表现为:超出 ASCII 之外的字符变为另外奇怪的字符,即没有将源代码中的 UTF-8 字符串 "这是ABC 123汉字" 正确转换成 UCS-2 编码。原因是:当使用不带 BOM 的 UTF-8 文件时,是没有办法从“表象”上区分它究竟是 UTF-8 还是别的 Native ANSI 字符集编码,只能按照应用特定的优选编码来解析字符,而 VC8 编译器会优选系统配置的 Native ANSI 字符集作为解析字符集,因为是简体中文 Windows 所以这个是 GBK 字符集,编码错误就是因为 VC8 编译器把实际为 UTF-8 编码的字符当做 GBK 编码向 UCS-2 转换造成的。另外,VC8 有个特殊的 #pragma setlocale 预编译指示,用来指示源文件的编码。
-
后来又用 Cygwin GCC 4.3.4 测试了下,它和 MinGW GCC 的效果基本相同,也用 UCS-2 编码 wchar_t 型字符,只是无法识别带 BOM 的 UTF-8 源文件,所以更像原版的 Linux GCC,而 MinGW 的目标是 Windows native 的工具集,所以考虑了 Windows 的习惯。
-
源文件字符集总结:
目前 GCC 对 UCS-2、BOM 的源文件都不太感冒,对于 GCC 来说最好的源文件编码是不带 BOM 的 UTF-8。而 VC8 中相反,如果使用 Unicode 类编码来保存源文件,最好带上 BOM。
碎碎念
以前在听人谈 Unicode 时,总会说:“Unicode 用2个字节编码字符,所以可以表示所有的字符,不存在平台移植的问题,Java 中就用它”,听的很迷糊,在自己静下心来看看 i18n 的东西后,才发现说这些话的人也许根本不明白 Unicode 是怎么回事,或者是知道点皮毛、用过其中最简单的方法就以为自己会了然后妄论一番,自从上学以来遇见的这种人太多了,其中还包括为数不少的老师,所以至今对大学老师有欺人惑鬼的偏见,真不知道孔老二的那个“知之为知之,不知为不知”是怎么被这些家伙忘到九霄云外去的。