关于字符集与字符编码——一个ö字符引发的案件

最近自己用C++写了一个小工具,对一些文件进行批处理,绝大多数文件都没有问题,直到遇见了一个文件名带有Erik Grönwall - Higher的文件。这个文件名通过FindNextFile读取到内存中后,字符ö会被问号'?'替换,如下图所示

 

我大致判断是字符集与字符编码的问题,但是具体问题出在了哪里,我不知道,针对这个奇怪恼人的案件,我决定进行一番彻底的调查。在此声明,以下结论全是我个人观点,请谨慎阅读。

首先对字符集与字符编码的基本概念进行梳理,细节不在此进行赘述,参考这篇文章https://www.cnblogs.com/skynet/archive/2011/05/03/2035105.html,文章对基本知识介绍得比较全了。了解基本知识后,我们作出如下几点判断:

  1. 字符集是字符的集合,字符编码是字符集的数字编码,所以字符集和字符编码是既相互关联又相互独立的东西。一般情况下,一个字符集就只有一套字符编码,一套字符编码也只对应一个字符集。如果字符集和字符编码是一一对应的,则我们将字符集称为编码倒也无不可,比如GB2312是一个字符集,我们称呼GB2312码,也能将就,知道是什么意思就行。
  2. Unicode是一个字符集,它的设计目标是将世界上所有的字符都包括进去,如果我们的开发程序目标防止未来出现什么乱码问题,最好采用Unicode字符集。
  3. 在这里,要引入一个“代码页”的概念,为什么在字符集、字符编码之外还要引入一个“代码页”的概念呢?这要归因于Unicode字符集,这个字符集可以有好多套字符编码,utf8、utf16、utf32都是对Unicode字符集的编码。一个字符在不同的编码规则下的数据是不一样的,比如“道”字符在utf8编码下是E98193,在utf16下面编码是9053,所有这些字码的组合就是一个代码页。显然,utf8的代码页和utf16的代码页是不一样的,但是这两个代码页表示的字符空间都是一样的,假设utf8代码页里面是“张三李四”这些字符,utf18里面也是“张三李四”这些字符,而我们对于“张三李四”这个组合叫做Unicode字符集。所以虽然utf8和utf16的编码规则不一样,但他们都服务于Unicode字符集。
  4. 代码页有一个代号,如GB2312的代码页号码是936。简言之,我们用936代表GB2312,所以这个936就是字符集GB2312的代码页。一套字符编码规则有一个唯一的代码页,utf8的代码页是65001。

知道以上几点的基本结论后,我们进行下一步的调查。

用VS写过C++的都知道,项目是一定要有字符集的,为什么一定要设置字符集?这涉及到一个字符的解析问题,计算机内存中是一连串的0和1,一连串的0和1组合成不同的数据,字符无法直接存储在内存中。当我们遇见一片字符内存,假设数据是E98193,因为一个同样的编码在不同的字符集中表示不同的字符,所以在不知道字符集的情况下,我们无法知道这个数据编码的是哪个字符。如果我们选择的是utf8编码的Unicode字符集,对应的代码页是65001,机器一查65001,哦,E98193在其中编码的是“道”,然后把这块内存数据解释为“道”,这个时候显示就正确了。如果程序的代码页不是65001,而是936即采用的是GB2312,那么拿着E98193查出的结果就是错误的乱码!所以字符集的设置很重要。

关于字符集,我们在用VS开发程序到运行的过程当中,会和很多称为字符集的东西打交道(在本文的语义中,亦可称为编码),其中有:系统区域字符集,源码文本字符集,工程属性字符集,编译字符集source-charset,执行字符集execution-charset,运行环境字符集。这几种字符集将决定我们写的代码和最终呈现的效果。网上很多文章提到“字符集”时大多含糊不清,没有指出到底是哪种字符集,当字符集意味着不同的概念时,它的意义也是不一样的,接下来我们就对这几种字符集作一个简单的介绍。

  1. 系统区域字符集。windows系统本身采用的Unicode字符集的utf-8编码,但是因各个具体区域的不同,系统区域字符集会不一样,如果我们是简体中文系统,则系统语言为GBK字符集。
  2. 源码文本字符集,也可以称呼它为源码文本编码。我们既然写了代码,就必然要将源文件保存到硬盘上,保存就要有文本编码,就是所谓的“源码文本字符集”。实际开发过程当中,很多时候我们并没有关注过源码编码,这是因为默认情况下,VS自动帮我们把源码编码设置为系统区域编码了,即GBK编码。
  3. 工程属性字符集,即我们通过Project->Properties->General->character set设置的东西。就我现在的研究来说,这个东西虽然称为“字符集”,但其实它根本就不是用来设置字符集的,VS主要是通过这个设置来决定是否开启_Unicode宏,进一步决定很多Windows API的版本,如果这个设置我们选择Unicode字符集,则_Unicode宏开启,很多Windows API使用的都是带有W后缀的版本,否则使用的就是带有A后缀的版本。简单地说,这东西和“字符集”没啥关系,就是决定是否开启_Unicode宏的。平时开发,默认的设置是多字节字符集,即不开启_Unicode宏。
  4. 编译字符集source-charset。当源码被保存后,进入编译阶段,编译器将对我们的源码文件进行扫描处理,这个字符集就是指导编译器用何种字符集来解释源码的。比如源码以GBK保存,其中有一个字符'数',它的GBK编码为CAFD,编译器扫描到CAFD时,它只知道这是一个字符,但不知道它代表哪个字,只有通过编译字符集才能对编码进行解码。这个设置我们在一般开发过程中,也基本不进行设置,VS自动设置为系统区域字符集即GBK。编译器拿着GBK一查,哦,CAFD代表'数'。
  5. 执行字符集execution-charset。这个又是干什么用的呢?我们知道,源代码经过编译、链接等操作,会生成一个exe,这个exe其实也是一块数据。还是以'数'为例,经过一些编译处理后,VS将要把这个字符保存到exe中,但是应该将'数'以何种编码保存到exe中呢?可执行字符集就是干这个事的。在不设置的情况下,VS同样帮我们设置为了区域字符集即GBK。execution-charset设置为GBK后,VS将'数'转换为GBK的CAFD,保存到exe的某块区域。而这个CAFD,就是最终我们调试时,在VS中看到的字符在内存里的数据。
  6. 运行环境字符集。这个是程序实际运行时采用的字符集,它采用的就是系统区域字符集,为GBK。我们只能通过修改系统区域字符集来修改这个设置。

了解了这些不同的字符集后,我们通过一些具体例子,来模拟一下这些字符集在哪些环节起了什么作用,测试环境简体中文Window10+VS2017

1、新建一个简单的空的main函数,添加一个Data变量并用cout输出,如下图所示,所有字符集都不用设置。

int main()
{
	const char* Data = "数据";
    cout << Data << endl;
	return 0;
}

运行调试,我们来看Data在内存中的值和Data的输出

由图中可知字符串"数据"在内存中的值为CAFD BEDD,恰好是它们的GBK编码,输出也正常,没有什么问题。

2、更改源码字符集,将源文件保存为utf-8格式。选择cpp,文件->另存为->编码保存,选择Unicode(UTF-8无签名)-代码页 65001。重新编译并调试运行,查看Data在内存中的值

可以看见"数据"在内存中的值变为了E695 B0E6,输出页变成了乱码“鏁版嵁”。为什么会这样呢?

我们来模拟一下流程:

第一步,保存文件为UTF-8格式,"数据"的UTF-8编码为E695B0 E68DAE,保存到硬盘上。这一步起作用的是源码文本字符集

第二步,编译器编译,以GBK字符集去解析(因为我们没有显式设置/source-charset),扫描到E695B0 E68DAE,认为这是一个GBK字符串,于是用GBK解码,解码出来后,是3个字符:E695代表,B0E6代表,8DAE代表。于是解码字符串为"鏁版嵁"。这一步起作用的是编译字符集source-charset

第三步,将数据保存链接到exe,以GBK字符集去编码(因为我们没有设置/execution-charset),将"鏁版嵁"编码为E695 B0E6 8DAE。这一步起作用的是字符集执行字符集execution-charset

第四步,运行调试exe,从exe中保存Data数据的区域将数据读取到内存中,是E695 B0E6 8DAE,这就是我们看见的监视里面的Data字符串里面的前4个字节,我们多看几个字节,如下图

果然是完全一致。

第五步,cout输出字符串,以运行字符集GBK(和我们的系统区域字符集一致)去解析,将E695 B0E6 8DAE解码为"鏁版嵁"输出到了Console里面。这一步起作用的是运行环境字符集。

以上就是大致的处理流程。区域字符集在很多环节都提供了默认字符集。

我们这样理一理,前因后果瞬间就明白了:文件被保存为了UTF-8模式,但是后面的所有用到的字符集都是GBK,拿着UTF-8编的码,以GBK去解码,得到的结果肯定是错误的。

 

需要注意的是,VS2015之后,微软官方才支持通过命令行的方式进行/source-charset /execution-charset的设置,并且目前为止,只支持设置为utf-8。我们可以通过Project->Property->C/C++->命令行,输入/source-charset:utf-8设置编译字符集为utf-8,设置 /execution-charset:utf-8为执行字符集为utf-8。如果都设置为utf-8的话,直接输入/utf-8即可。接下来我们再进行一个测试,编写如下代码,保存为格式Unicode(UTF-8无签名)-代码页 65001

int main()
{
	const char* Data = "数据";
	const char* u8Data = u8"数据";
	cout << "Data:" << Data << endl;
	cout << "u8Data:" << u8Data << endl;
	return 0;
}

字符串加前缀u8,是向编译器说:这是个utf-8字符串,在写入exe的时候,请以utf-8进行编码。如果我们在命令行中添加/execution-charset:utf-8,相当于对所有字符串都加了u8前缀,我们这里为了测试两种字符串,就只加u8前缀。

运行调试,查看监视数据和输出分别为:

Data的分析我们已经做过了,u8Data的内存数据更奇怪,并且输出的乱码也不一样,我们按照分析Data的方法来分析u8Data。

我们来模拟一下流程:

第一步,保存文件为UTF-8格式,"数据"的UTF-8编码为E695B0 E68DAE,保存到硬盘上。

第二步,编译器编译,以GBK字符集去解析(因为我们没有设置/source-charset),扫描到E695B0 E68DAE,认为这是一个GBK字符串,于是用GBK解码,解码出来后,是3个字符:E695代表,B0E6代表,8DAE代表。于是解码字符串为"鏁版嵁"

第三步,将数据保存链接到exe,以utf-8字符集去编码(因为我们添加了u8前缀,和/execution-charset:utf-8效果类似),将"鏁版嵁"进行utf-8编码为E98F81 E78988 E5B581。

第四步,运行调试exe,从exe中保存Data数据的区域将数据读取到内存中,是E98F81 E78988 E5B581,我们看见的监视里面的u8Data字符串里面的9个字节,一模一样。

第五步,cout输出字符串,以运行字符集GBK(和我们的系统区域字符集一致)去解析,将E98F 81E7 8988 E5B5 81解码为"閺佺増宓"输出到了Console里面。

这样一分析,乱码看起来已经不像之前那么让人头大了。


以上就是我们对字符集、字符编码的原理调查。回到我们开始的问题,ö字符为什么会被替换为问号'?',就是运行字符集不支持ö这个字符。查看我的工程设置,是多字节字符集,我电脑的区域是简体中文,那么程序采用的字符集就是GBK,去网上查字符编码,在GBK中果然查不到ö的编码!原来如此!

FindNextFile在将Erik Grönwall - Higher.mp3读取进来并且转为GBK的时候,发现字符GBK中找不到'ö'的编码,于是替换为GBK的默认字符'?',就造成了我们看见的现象。

前因后果搞明白之后,接下来我们要做的是:怎么才能让我们的程序认识ö并且正确地显示?解决方法很简单,就是让我们程序采用的运行字符集包含'ö'这个字符就行,而936代码页是肯定不行的了,65001代码页的utf8就挺好,于是问题就转化为怎么样才能让我们的程序采用utf8代码页?目前证明的有效方法是修改操作系统的区域语言设置,win10->控制面板->更改日期时间或数字格式->管理->更改系统区域设置,最后出现如下图中的设置,勾选Beta版,这样我们的系统区域采用的字符集就是utf8编码的字符集了,重启电脑后重新生成工程,运行一下看看,我们的程序已经能识别'ö'了!案件到此成功解决。

需要注意的:

1、关于编译字符集,VS会检测cpp文件的BOM开头,如果监测到了BOM,则会自动采用utf-16 字符集、utf-8字符集当作编译字符集。我们知道utf-8编码是可以不带BOM的,VS对不带BOM的文件无法识别,会当作ANSI编码,然后采用系统区域字符集作为编译字符集。可以通过命令行/source-charset:utf-8显示通知编译器我们的源代码是utf-8编码。

2、在源码文本字符集,编译字符集source-charset,执行字符集execution-charset,各自的步骤中,如果出现对应的字符集无法表达字符的情况,VS都会报Warning。如我们源码字符串中有个'ö'字符,保存时会弹框警告。

     在未设置编译字符集为utf-8的情况,如果我们的源码包含'ö',则同样会报Warning,因为编译器觉得用GBK去解释一个GBK中不存在的'ö'是不正常的。

关于编译字符集、执行字符集的详细解释

查看汉字的UTF-8码、GBK码、BIG5码

UTF-8、GB2312的汉字编码互转

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值