微软C/C++编译器中管理字符集的新选项

[原文发表地址] New Options for Managing Character Sets in the Microsoft C/C++ Compiler

[原文发表时间] 2016/2/22

微软C/C++编译器在伴随着DOS操作系统, 16位windows操作系统和32/64位windows操作系统发展的同时,也在支持不同着字符集、代码页和编码。这篇文章不仅解释了过去我们的编译器是怎样工作的,而且还包含Visual Studio 2015 Update 2 CTP C/C++编译器中提供的一些新的编译选项开关,明确支持BOM-less UTF-8文件和控制执行字符集,请下载并试用它。关于Update 2中的编译器的其他改变可以查看这篇文章

有一些非常好的在线资源很详细的描述了Unicode, DBCS, MSCS,代码页和其他相关知识,我不会尝试在这里重现它们,但我们会很快涵盖这些基本概念。The Unicode Consortium是一个学习Unicode编码非常好的网站。

我们主要会通过两个方面来让大家明白我们的编译器是怎样来处理不同的字符集的。第一个方面是如何解释源文件(源字符集)里的字节,第二个方面是它把什么字节写入二进制(执行字符集)。弄明白源代码在磁盘上是怎样编译和存储是非常重要的。

显式标记 Unicode编码

通过BOM(字节序标记)来标记Unicode文件是一种标准方式,BOM不仅能标记UTF-32、UTF-16和UTF-8,也可以判断它是否有高字节序和低字节序。通过BOM来表明字节的序列, 这些结果来自于从编码U+FEFF到任何正在使用的编码。UTF-8是一种字节流编码,所以实际上并没有排序字节需要被标记出来,但是UTF-8的标志通常仍然被叫做“BOM”。

隐式标记 Unicode编码

在支持Unicode之前的Windows(和DOS)操作系统, 其文本文件以没有迹象显示编码形式来存储,它是由应用程序来诠释的。在DOS中,ASCII范围外的任何字符都将被构建成视频卡来输出。在Windows中,这个被称为OEM(437)代码页,它包含一些非英文字符集,以及一些用于绘制文本周围框线的字符集。

Windows最终还是添加了对DBCS (双字节字符集) 和MBCS (多字节字符集) 的支持。至今仍然没有一个标准的方式来表示文本文件的编码是什么,无论系统代码页如何设置字节通常会被解释。当32位的Windows出现时,它为了UTF-16分割APIs或者设置另外的所谓的“ANSI”APIs,这些APIs用8个字节来表示当前文件中的Unicode编码。

注意:通常你是不能在Windows中设置系统代码页为Unicode代码页(或者UTF-16 和UTF-8), 所以在很多情况下,在一个老的应用程序中去来弄明白没有BOM的 Unicode编码,并不是一件容易的事情。

这也是现在常见的在UTF-8中不使用BOM的编码,大部分Linux环境下默认是这样的。虽然很多的Linux工具可以处理BOM, 但大部分工具却不能生成它。实际上没有BOM,很多简单的事情例如连接和追加文件也不用担心谁来写BOM。

怎样用微软C/C++编译器从一个文件中读取文本

在过去的某一时刻,微软编译器内部改用了UTF-8字符集。 所以,从磁盘上读取文件时会转换成UTF-8字符集。如果一个文件中有BOM,我们可以使用并读取指定的任意编码的文件并把它转换到UTF-8。如果一个文件中没有BOM,我们可以尝试通过看前8个字节来删除UTF-16编码中的低字节序和高字节序。如果这个文件看起来像UTF-16, 我们将把它当作一个包含UTF-16字节序标记的文件来处理。

如果没有字节序标记,而且它看起来也不像UTF-16, 那么我们可以用当前的代码页(调用GetACP的结果)来把磁盘上的字节转换成UTF-8。这个是否正确取决于文件中实际的编码是怎样的以及包含什么字符。如果文件中实际包含的是UTF-8编码,它将不能被识别为正确的系统代码页,不能被设置为CP_UTF8。

执行字符集

明白“execution character set”也是非常重要的,编译器在执行字符集的基础上会解释不同的字符串。让我们从一个简单的例子开始看起。

const char ch = ’h';

const char u8ch = u8’h';

const wchar_t wch = L’h';

const char b[] = “h”;

const char u8b[] = u8″h”;

const wchar_t wb [] = L”h”;

上面的代码实际上被解释成你指定的类型。

const char ch = 0x68;

const char u8ch = 0x68;

const wchar_t wch = 0x68;

const char b[] = {0x68, 0};

const char u8b[] = {0x68, 0};

const wchar_t wb [] = {0x68, 0};

这应该是非常合理的,而且不会考虑当前文件编码和代码页。现在,让我们来看看下面的代码。

const char ch = ‘屰';

const char u8ch = ‘屰';

const wchar_t wch = L’屰';

const char b[] = “屰”;

const char u8b[] = u8″屰”;

const wchar_t wbuffer[] = L”屰”;

注意:我们拿这个任意字符为例,它在汉语中的意思是“屰”,这似乎可以达到我的目的。它是Unicode 字符编码是U+5C70。

在这里我们有考虑这些方面。该文件是如何处理包含这段代码的编码的? 我们正在编译的当前系统代码页是什么? 在UTF-16中它的编码是0x5C70, 在 UTF-8 中,它被分隔为 0xE5, 0xB1, 0xB0。在936 代码页中, 它是 0x8C, 0xDB.  在我们当前正在运行的代码页1252 (Latin-1)中,它是不能被表示的。1252 代码页通常被用于英文的windows和许多其他的西方语言。表1中展示的是当系统使用1252代码页时这些变量编译的结果。

表1 – 今天的示例结果是当动态编译代码的结果。

File Encoding

UTF-8 w/ BOM

UTF-16LE w/ or w/o BOM

UTF-8 w/o BOM

DBCS (936)

Bytes in source file representing 屰

0xE5, 0xB1, 0xB0

0x70, 0x5C

0xE5, 0xB1, 0xB0

0x8C, 0xDB

Source conversion

UTF8 -> UTF8

UTF16-LE -> UTF-8

1252 -> UTF8

1252 -> UTF-8

Internal(UTF-8) representation

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xC3, 0xA5, 0xC2, 0xB1, 0xC2, 0xB0

0xC5,0x92, 0xC3, 0x9B

转换到执行字符集

char ch = ‘屰';

UTF-8 -> CP1252

0x3F*

0x3F*

0xB0

0xDB

char u8ch = u8’屰';

UTF-8 -> UTF-8

error C2015

error C2015

error C2015

error C2015

wchar_t wch = L’屰';

UTF-8 -> UTF-16LE

0x5C70

0x5C70

0x00E5

0x0152

char b[] = “屰”;

UTF-8 -> CP1252

0x3F, 0*

0x3F, 0*

0xE5, 0xB1, 0xB0, 0

0x8C, 0xDB, 0

char u8b[] = u8″屰”;

UTF-8-> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xC3, 0xA5, 0xC2, 0xB1, 0xC2, 0xB0, 0

0xC5, 0x92, 0xC3, 0x9B, 0

wchar_t wb[] = L”屰”;

UTF-8 -> UTF-16LE

0x5C70, 0

0x5C70, 0

0x00E5, 0x00B1, 0x00B0, 0

0x0152, 0x00DB

星号 (*)表示会生成警告C4566,在这些示例中,这个警告表示”由通用字符名称’\u5C70’表示的字符不能在当前代码页(1252)中表示”。错误C2015表示“太多的字符常数”。

这些关于字符‘h’的简单示例的结果可能没有实际的意义,但是我们将会讲解每种示例下是怎样运行的。

在第一和第二列中,我们知道了文件的编码是什么,所以转换到UTF-8在内部表现为0xE5,0xB1是正确的。执行的字符集是Windows代码页1252, 然而,但我们试图转换Unicode 字符U+5C70到这个代码页时,会运行失败并且用默认的字符0x3F来代替 (即问号) ,得到警告C4566,但被转换的字符是0x3F。对于U8字符,已经在UTF-8下就不需要转换了,但是我们不能在一个字节中存储3个字节,所以提示错误C2015。对于宽字符,“wide execution character set”总是以UTF-16来存储,所以宽字符和宽字符串的转换是正确的。对于U8字符串,已经在UTF-8下也不需要转换了。

在第三列(无标记的UTF-8),磁盘上的字符是0xe5, 0xb1和0xb0。每个字符用当前的代码页1252转换为UTF-8来做解释,结果是三个双UTF-8的内部序列字符: (0xC3, 0xA5) , (0xC2, 0xB1) 和 (0xC2, 0xB0) 。对于一个简单的字符会转换为代码页1252, 得到0xE5, 0xB1,0xB0。在多字符中这个结果和编译“abcd”的结果是相同的。多字节文本值在VC中实现了定义,每一个字符都是一个整数。注意: 多个窄字符和宽字符在编译器中的处理是不同的。对于宽字符,我们只是取多个字符的第一个字符,这个例子中是0x00E5。对于窄字符串,用当前的代码页来转换,结果是4个字节个字节0xe5, 0xb1, 0xb0, 0。对于U8字符串,用和内部设置相同的字符集,结果是为0xC3, 0xA5, 0xC2, 0xB1, 0xC2, 0xB0, 0。对于宽字符串,我们使用UTF-16字符集来执行,结果为0x00E5, 0x00B1, 0x00B2, 0。

最终,第四列的文件被用代码页936来保存, 这个字符在磁盘上被存储为0x8c,0xDB。我们用当前的代码页1252和两个双字节UTF-8: (0xC5, 0x92), (0xC3, 0x9B) 来转换它。对于窄字符串,字符会被转换为0x8C,0xDB,我们得到的字符值是0xDB。对于U8字符,字符不会被转换,但这是错的。对于宽字符,字符被转换为UTF-16的结果0x0152, 0x00DB,第一个值会被用,所以结果是0x0152,对于字符串,会有相似的转换。

改变系统代码页

如果我们使用的是一个不同于1252的系统代码页,第二列和第三列的结果也是不同的。从上面的描述中,你应该可以预知这些情况下会发生什么。因为这些不同,许多开发者都只会设置代码页为1252来构建工程。对于其他的代码页,你可以得到不同的结果,并且没有警告和错误。

编译指令

有两个编译器指令可以影响这个过程,它们是“#pragma setlocale” 和“#pragma execution_character_set”。

相关setlocale杂注的知识你可以从这里https://msdn.microsoft.com/en-us/library/3e22ty2t.aspx找到一些文档。这个杂注尝试允许用户修改正被解析的文件的源字符集,它似乎添加了允许宽文本指定使用非Unicode文件。然而,只允许使用单字节字符集在效率方面存在缺陷。如果你尝试在上面的例子中添加杂注来设置区域。

#pragma setlocale(“.936″)

const char buffer[] = “屰”;

const wchar_t wbuffer[] = L”屰”;

const char ch = ‘屰';

const wchar_t wch = L’屰';

表2就是新的运行结果,其中红色高亮部分是运行结果不同的部分,它的使用将会导致很多实例转换失败,转换结果是0x3F (?)字符。杂标实际上不改变读取源文件的方式,它只是在宽字符和宽字符串的字面值中被使用。当见到一个宽字面的字符,编译器会转换每个内部的UTF-8字符到1252,当文件是只读时会尝试撤销这个转换。它会从原始模块转换到通过“地域设置” 杂注设置的代码页,然而在这种情况下,第三列和第四列的初始相应的转换为3个UTF-8 或者2 个UTF-8 字符集。在示例的第四列中,内部的UTF-8字符中的(0xC5, 0x92)转换后返回到CP1252, 结果是0x8C。 编译器则尝试把它转换成CP936,然而0x8C只是一个头字节,并不是一个完整的字符。所以,转换成0x3F是失败的,会被用默认的字符来代替,第二个字符的转换也是失败的,结果是 0x3F。所以,从字面上来看,第三列有三个宽字符串以0x3F字符结束,第四列有两个是0x3F字符。

对一个有BOM的Unicode文件来说,运行结果和之前是相同的,因此通过BOM来强制指定一个文件的编码是有意义的。

表2 – 当前示例是用可变的编码来编译时的结果,红色部分是和表1不同的部分。

File Encoding

UTF-8 w/ BOM

UTF-16LE w/ or w/o BOM

UTF-8 w/o BOM

DBCS (936)

Bytes in source file representing 屰

0xE5, 0xB1, 0xB0

0x70, 0x5C

0xE5, 0xB1, 0xB0

0x8C, 0xDB

Source conversion

UTF8 -> UTF8

UTF16-LE -> UTF-8

1252 -> UTF8

1252 -> UTF-8

Internal (UTF-8) representation

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xC3, 0xA5, 0xC2, 0xB1, 0xC2, 0xB0

0xC5, 0x92, 0xC3, 0x9B

转换到执行字符集

char ch = ‘屰';

UTF-8 -> CP1252

0x3F*

0x3F*

0xB0

0xDB

char u8ch = u8’屰';

UTF-8 -> UTF-8

error C2015

error C2015

error C2015

error C2015

wchar_t wch = L’屰';

UTF-8 -> UTF-16LE

0x5C70

0x5C70

0x003F

0x003F

char b[] = “屰”;

UTF-8 -> CP1252

0x3F, 0*

0x3F, 0*

0xE5, 0xB1, 0xB0, 0

0x8C, 0xDB, 0

char u8b[] = u8″屰”;

UTF-8-> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xC3, 0xA5, 0xC2, 0xB1, 0xC2, 0xB0, 0

0xC5, 0x92, 0xC3, 0x9B, 0

wchar_t wb[] = L”屰”;

UTF-8 -> UTF-16LE

0x5C70, 0

0x5C70, 0

0x003F, 0x003F, 0x003F, 0

0x003F, 0x003F, 0

其他的杂注会影响到#pragma execution_character_set,它承担的目标是执行字符集,但是却只支持“utf-8”。它介绍了在VS2008和VS2010后实现了允许用指定utf-8为执行字符集。支持u8前缀之前,我们已经不需要。针对这一点,我们真的应该鼓励用户用新的前缀来代替#pragma execution_character_set。

当前的问题汇总

#Progma selocale当前还有很多问题。

  1. 它不能设置到UTF-8,这是一个重要的限制。
  2. 它只影响字符串和字符。
  3. 它实际上不能在DBCS字符集下正确的工作。

execution_character_set 杂注把窄字符串作为UTF-8来编码,但不支持任何其他字符集。另外,全局设置的唯一方式是在一个程序中包含使用了/FI(强制包含)的标头。

尝试跨平台编译包含非ASCII的字符串很难得到正确的结果。

VS2015 Update 2 中的新编译选项

为了解决上述问题,我们有一些新的命令行编译选项允许你来指定源文件字符集和执行字符集。 /source-charset: 选项可以是IANA字符集名称或者一个Windows代码页标识符 (前缀带点) 。

/source-charset:<iana-name>|.NNNN

如果一个IANA名称是正确的,那么它会通过IMultiLanguage2::GetCharsetInfo映射到一个windows代码页上。这个代码页会被用来转换所有的 BOM-less文件到它的内部UTF-8格式。如果UTF-8是指定的源字符集,那么就不需要执行转换操作,因为编译器内部用的就是UTF-8。如果指定的名字是未知的或者代码页上发生了其他的检索错误会抛出一个错误。还有一个限制是不能使用UTF-7,UTF-6或者其他任何DBCS 字符集将两个或多于两个的字节编译成一个字符。而且,代码页不允许编译器来处理ASCLL集合,因为会引起很多看起来像字符异常的错误。

/source-charset编译选项会影响转换单元的所有文件,这些文件不能被自动识别(它会自动识别有标记的文件和无标记的 UTF-16文件) 。因此,把一个UTF-8编码的文件和一个DBCS编码的文件放在相同的编译单元里转换是不可能的。

/execution-charset:<iana-name>|.NNNN 选项用作使用相同的查找机制通过/source-charset来得到代码页,它控制了如何生成窄字符和字符串。

这儿的/utf-8选项是一个同义词,其同样可以用以设置“/source-charset:utf-8”和 “/execution-charset:utf-8”。

请注意上述的任何新编译选项遇到#pragma setlocale 或者#pragma execution-character-set是将报错。在新编译选项和使用u8文本之间,没有必要使用这些旧的标注,特别是对于已报过bugs的问题。但是, 在新编译选项使用之前,这些已存在的杂注将继续工作。

最后,新选项/validate-charset可以自动转向上面的任何选项。 可以通过/validate-charset-关闭这一编译选项,尽管不建议这么做。之前在转换到内部UTF-8时,我们会对字符集做一些验证。然而,我们不会检查UTF-8源文件,只是直接读取它,这会造成一些不易察觉的问题,切换UTF-8的验证开关可以很好的检查它是否是有BOM文件。

示例回顾

指定正确的源字符集,无论源文件的编码如何都会得到完全相同的结果。而且,我们可以指定一个特定的独立于源文件的执行字符集,指定的执行字符集的结果应该是完全相同的。在表3中,你可以看到无论源文件是什么编码,我们现在都可以得到相同的结果。绿色的数据代表的是对表1中原始数据的更改。

表4展示了执行UTF-8字符集的结果,表5则是执行GB2312字符集的结果。

表3 – 这些示例是让每个源文件(当前的系统配置页为1252)使用正确的source-charset的结果,绿色的部分展示的是不同于表1的结果。

File Encoding

UTF-8 w/ BOM

UTF-16LE w/ or w/o BOM

UTF-8 w/o BOM

DBCS (936)

Bytes in source file representing 屰

0xE5, 0xB1, 0xB0

0x70, 0x5C

0xE5, 0xB1, 0xB0

0x8C, 0xDB

Source conversion

UTF8 -> UTF8

UTF16-LE -> UTF-8

UTF8 -> UTF8

CP936 -> UTF-8

Internal (UTF-8) representation

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

转换到执行字符集

char ch = ‘屰';

UTF-8 -> CP1252

0x3F*

0x3F*

0x3F*

0x3F*

char u8ch = u8’屰';

UTF-8 -> UTF-8

error C2015

error C2015

error C2015

error C2015

wchar_t wch = L’屰';

UTF-8 -> UTF-16LE

0x5C70

0x5C70

0x5C70

0x5C70

char b[] = “屰”;

UTF-8 -> CP1252

0x3F, 0*

0x3F, 0*

0x3F, 0*

0x3F, 0*

char u8b[] = u8″屰”;

UTF-8-> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

wchar_t wb[] = L”屰”;

UTF-8 -> UTF-16LE

0x5C70, 0

0x5C70, 0

0x5C70, 0

0x5C70, 0

表4- 在文件代码页中使用执行字符集utf-8 (代码页65001) 纠正/源字符集。

File Encoding

UTF-8 w/ BOM

UTF-16LE w/ or w/o BOM

UTF-8 w/o BOM

DBCS (936)

Bytes in source file representing 屰

0xE5, 0xB1, 0xB0

0x70, 0x5C

0xE5, 0xB1, 0xB0

0x8C, 0xDB

Source conversion

UTF8 -> UTF8

UTF16-LE -> UTF-8

UTF8 -> UTF8

CP936 -> UTF-8

Internal (UTF-8) representation

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1

转换到执行字符集

char ch = ‘屰';

UTF-8 -> UTF-8

0xB0

0xB0

0xB0

0xB0

char u8ch = u8’屰';

UTF-8 -> UTF-8

error C2015

error C2015

error C2015

error C2015

wchar_t wch = L’屰';

UTF-8 -> UTF-16LE

0x5C70

0x5C70

0x5C70

0x5C70

char b[] = “屰”;

UTF-8 -> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

char u8b[] = u8″屰”;

UTF-8-> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

wchar_t wb[] = L”屰”;

UTF-8 -> UTF-16LE

0x5C70, 0

0x5C70, 0

0x5C70, 0

0x5C70,

表5- 使用执行字符集 GB2312 (代码页 936)

File Encoding

UTF-8 w/ BOM

UTF-16LE w/ or w/o BOM

UTF-8 w/o BOM

DBCS (936)

Bytes in source file representing 屰

0xE5, 0xB1, 0xB0

0x70, 0x5C

0xE5, 0xB1, 0xB0

0x8C, 0xDB

Source conversion

UTF8 -> UTF8

UTF16-LE -> UTF-8

UTF8 -> UTF8

CP936 -> UTF-8

Internal (UTF-8) representation

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

0xE5, 0xB1, 0xB0

转换到执行字符集

char ch = ‘屰';

UTF-8 -> CP936

0xDB

0xDB

0xDB

0xDB

char u8ch = u8’屰';

UTF-8 -> UTF-8

error C2015

error C2015

error C2015

error C2015

wchar_t wch = L’屰';

UTF-8 -> UTF-16LE

0x5C70

0x5C70

0x5C70

0x5C70

char b[] = “屰”;

UTF-8 -> CP936

0x8C, 0xDB, 0

0x8C, 0xDB, 0

0x8C, 0xDB, 0

0x8C, 0xDB, 0

char u8b[] = u8″屰”;

UTF-8-> UTF-8

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

0xE5, 0xB1, 0xB0, 0

wchar_t wb[] = L”屰”;

UTF-8 -> UTF-16LE

0x5C70, 0

0x5C70, 0

0x5C70, 0

0x5C70, 0

可以做的,不可以做的,以及未要做的

在windows上,尽可能把文件保存成有BOM的Unicode格式,这样可以避免许多问题,而且许多工具都支持读取带BOM的文件。

已经存在的不带BOM的UTF-8文件和正在更改到带BOM的文件的情况下出现问题,可以用/source-charset:utf-8选项来正确的读取这些文件。

不要用除了utf-8以外的任何选项来使用 /source-charset,除非没有别的选择,保存文件为Unicode (甚至是无BOM的UTF8)好于DBCS编码。

使用/execution-charset 或者 /utf-8可以帮助现实目标代码在windows和Linux之间的转换,因为Linux经常使用BOM-less的UTF-8文件和UTF-8执行字符集。

不要用#pragma execution_character_set,在需要的地方用u8来代替。

不要用#pragma setlocale, 保存文件为Unicode使用显示的字节序列或通用的字符名称来代替,而不是在同一个文件中用多字节集。

注意:许多Windows和CRT API当前都不支持UTF-8字符串编码,Windows代码页和本地CRT都不能设置到UTF-8。当前我们正在研究如何提高对UTF-8在运行时的支持,然而尽管有这个限制,必要时在Windows上,许多Windows平台的应用程序还是用内部UTF-8编码转换成UTF-16。

在未来的重要的编译器的发布中,我们想改变默认处理BOM-less的文件为UTF-8, 但是这个改变的更新可能会引起潜在的重大改变,验证UTF-8文件应该抓住几乎所有情况下的不正确的假设,所以我们希望将来可以实现。