字符编码:从基础到乱码解决
问题的引入
在日常开发中,当我们尝试将中文输出到控制台时,点击编译。这时,细心的读者可能会关注到 VS 的控制台会输出一段这样的警告(也有可能是团队规定不允许有警告出现🌝):
文件包含在偏移 0x9c8 处开始的字符,该字符在当前源字符集中无效(代码页 65001)。
同时你心心念念的中文,输出到控制台却成为了乱码。为什么会出现这种问题呢?
这一系列的问题,归根结底,就是一个字符在计算机中,应该怎么样来表示。也就是字符的编码问题。所以,让我们先来了解了解,现代计算机体系中的编码模型是什么样的。
这一系列问题,追根溯源,其实就是一个字符在计算机中该如何表示的问题,即字符的编码问题。那么,我们先来了解一下现代计算机体系中的编码模型是怎样的。
字符编码模型
Unicode 字符编码结构模型分为 5 层,下面我们以一个“汉”字为例,为大家介绍这 5 层。
抽象字符集 (Abstract Character Set) ACR
待编码字符集,定义字符的逻辑集合,不涉及具体的编码逻辑。这一层仅确定“汉”字属于某个字符集。(像 GB2312 就只收录了 6763 个常用的汉字和字符,一些生僻字就没有被收录进来。又比如 ASCII 中就没有中文字符。)
编码字符集 (Coding Character Set) CCS
从抽象字符集(ACR)映射到一组非负整数,也就是为每一个字符分配一个唯一的二数字(码位/码点)。例如:Unicode、ASCII、USC、GBK 等编码。
在 Unicode 中,“汉”,表示成:\u6C49,而在 GBK 中,“汉”,表示成:0xBABA。
字符编码表 (Character Encoding Form) CEF
一个从一组非负整数(来自 CCS)到一组特定宽度代码单元序列的映射。我们常说的 UTF-8、UTF-16、UTF-32 就是一个字符编码表。他规定了在抽象字符集中的“非负整数”怎么用字节表示。
例如在 UTF-8 中,“汉”字用三个字节表示:0xE6B189。
字符编码方案 (Character Encoding Scheme) CES
一个从一组代码单元序列(来自一个或多个 CEF)到序列化字节序列的映射。
定义码元序列的存储方式,解决字节序等问题:
例如:
UTF-8 无需处理字节序(单字节码元),直接存储为
0xE6 0xB1 0x89
。UTF-16 若使用大端序(Big-Endian),则存储为
FE FF 6C 49
(前两个字节为 BOM 标识)。
此层确保不同系统对同一编码单元序列的解析一致性。
传输编码语法 (Transfer Encoding Syntax) TES
针对特殊场景的二次编码,如网络传输:
通过 Base64 将二进制 0xE6B189 转换为字符串“5rGJ”
URL 编码将 UTF-8 字节转换为 %E6%B1%89

通过上面的介绍,相信你对现代编码模型的五层有了基本的了解。讲完了字符编码模型,接下来我们来了解一些常见的字符编码标准及其特点。
常见字符编码
相信大家在日常的开发中,经常听到 Unicode、GB2312、GBK、UTF-8、UTF-16、UTF-32、ANSI,却又对这些概念比较模糊。首先要明确一点的是,Unicode、GB2312、GBK 都是编码字符集,而 UTF-8、UTF-16、UTF-32 则是 Unicode 的编码字符表。ANSI 比较特殊,我们待会再具体介绍。
由于篇幅限制,对各个编码的具体编码模式感兴趣的读者可以在参考文献中自行了解。
ASCII
引用自 ASCII-Wikipedia、ASCII-simple-Wikipedia
ASCII,全称 American Standard Code for Information Interchange(美国信息交换标准代码),于 1963 年发布。标准 ASCII 采用 7 位二进制数来表示字符,因此它最多只能表示 128 个字符。
ASCII 编码虽然解决了英语的编码问题,但中文怎么办呢?汉字有那么多字。此时,就有了 GK2312 编码。
GB2312
引用自 ASCII-Wikipedia-zh、ASCII-Wikipedia-en
GB2312,又称 GB/T 2312-1980,全称《信息交换用汉字编码字符集·基本集》,与 1980 年由中国国家标准总局发布。GB2312 收录共收录 6763 个汉字,其中一级汉字 3755 个,二级汉字 3008 个;同时收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、注音符号、俄语西里尔字母在内的 682 个字符。
GB2312 使用两个字节来表示,第一个字节称为“高位字节”,对应分区的编号(把区位码的“区码”加上特定值);第二个字节称为“低位字节”,对应区段内的个别码位(把区位码的“位码”加上特定值)。

Unicode
随着计算机技术在全世界的广泛应用,越来越多来自不同地区,拥有不同文字的人们也加入了计算机世界,同时也带来了越来越多的种类。在 1991 年,由一个非盈利机构 Unicode 联盟首次发布了 The Unicode Standard,旨在统一整个计算机世界的编码。
Unicode 的编码空间从 U+0000
到 U+10FFFF
,划分为 17 个平面(plane),每个平面包含 216 个码位(0x0000~0xFFFF),其中第一个平面称为基本多语言平面(Basic Multilingual Plane,BMP),其他平面称为辅助平面(Supplementary Planes)。
GBK
由于 GB2312 只收录了 6763 个汉字,有一些 GB2312 推出之后才简化的汉字,部分人用名字、繁体字等未被收录进标准,由中华人民共和国全国信息技术标准化技术委员会 1995 年 12 月 1 日制订了 GBK 编码。GBK 共收录 21886 个汉字和图形符号。
UTF-8、UTF-16、UTF-32
Unicode 转换格式(Unicode Transformation Format,简称 UTF),一个字符的 Unicode 编码虽然是确定的,但是由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对 Unicode 编码的实现方式有所不同。所以就有着不同的 Unicode 转换格式:UTF-8、UTF-16、UTF-32。
UTF-8
UTF-8(8-bit Unicode Transformation Format)是一种用于实现 Unicode 的编码方式,它使用一到四个字节来表示一个字符。UTF-8 具有良好的兼容性和效率,能够与 ASCII 字符集完全兼容,对于其他语言字符也能够以较高效的方式进行编码。
UTF-8 采用下面的规则来编码
在 ASCII 码的范围,用一个字节表示,超出 ASCII 码的范围就用字节表示,这就形成了我们上面看到的 UTF-8 的表示方法,这样的好处是当 UNICODE 文件中只有 ASCII 码时,存储的文件都为一个字节,所以就是普通的 ASCII 文件无异,读取的时候也是如此,所以能与以前的 ASCII 文件兼容。
大于 ASCII 码的,就会由上面的第一字节的前几位表示该 unicode 字符的长度,比如 110xxxxx 前三位的二进制表示告诉我们这是个 2BYTE 的 UNICODE 字符;1110xxxx 是个三位的 UNICODE 字符,依此类推;xxx 的位置由字符编码数的二进制表示的位填入。越靠右的 x 具有越少的特殊意义。只用最短的那个足够表达一个字符编码数的多字节串。注意在多字节串中,第一个字节的开头"1"的数目就是整个串中字节的数目。
UTF-8 BOM
BOM,全称字节序标志(byte-order mark)。目的是为了表示 Unicode 编码的字节顺序。使用 BOM 模式会在文件头处添加 U+FEFF
,对应到 UTF-8 格式的文件,则会在文件起始处添加三个字节:0xEF、0xBB、0xBF
。 还记得我们之前在说字符编码方案时,说过 UTF-8 无需处理大端小端。那为什么不需要呢?
字节序(Endianness)是指多字节数据(如一个整数或一个字符的多字节表示)在内存中的存储顺序。而对于 UTF-8 中,每个使用 UTF-8 存储的字符,除了第一个字节外,其余字节的头两个比特都是以"10"开始,除了第一个字符以外,其他都是唯一的。
但是 Unicode 标准并不要求也不推荐使用 BOM 来表示 UTF-8,但是某些软件如果第一个字符不是 BOM (或者文件里只包含 ASCII),则拒绝正确解释 UTF-8。
UTF-16
UTF-16 把 Unicode 字符集的抽象码位映射为 16 位长的整数(即码元)的序列,也就是说在 UTF-16 编码方式下,一个 Unicode 字符,需要一个或者两个 16 位长的码元来表示。因此 UTF-16 也是一种具体编码。
Unicode 的基本多语言平面(BMP)内,从 U+D800 到 U+DFFF 之间的码位区段是永久保留不映射到 Unicode 字符。UTF-16 就利用保留下来的 0xD800-0xDFFF 区块的码位来对辅助平面的字符的码位进行编码。
UTF-16 采用下面的方法用来编码:
基本平面的码点,直接用 16 比特长的单个码元表示,数值等价于对应的码位。
辅助平面的码点,先将码位减去
0x10000
,得到的值范围为 20 比特长的0x00000 ~ 0xFFFFF
。其次高位的 10bit(值范围为0x000 ~ 0x3FF
),加上0xD800
,得到第一个码元,又称高位代理(现代 Unicode 标准称之为前导代理),值范围为0xD800 ~ 0xDBFF
。再将低位的 10bit(值范围也为0x000 ~ 0x3FF
),加上0xDC00
,得到第二个码元,又称低位代理(现代 Unicode 标准称之为后尾代理),值范围为0xDC00 ~ 0xDFFF
。

同样我们也以“汉”字为例,它在 Unicode 中为:U+6C49,处于 BMP 中,所以直接用 0x6C49 表示。而另外一个以 U+10437 编码(𐐷)为例:
0x10437
减去0x10000
,结果为 0x00437,二进制为 0000 0000 0100 0011 0111分割它的上 10 位值和下 10 位值(使用二进制):0000 0000 01 和 00 0011 0111
添加 0xD800 到上值,以形成高位:0xD800 + 0x0001 = 0xD801
添加 0xDC00 到下值,以形成低位:0xDC00 + 0x0037 = 0xDC37
UTF-32
Unicode-32 直接采用 4 个字节来存储 Unicode 码位。这种编码格式的优点是能够直接用 Unicode 码位来索引,但同时,相比于其他编码(UTF-8、UTF-16),浪费空间,所以应用并不广泛。
ANSI
当我们创建一个文本文件,并用 Notepad++查看其默认编码时,会看到一个 ANSI

那么 ANSI 是什么编码呢?简而言之,ANSI 不是某一种特定的字符编码,而是在不同系统中,表示不同的编码。
输入字符集与执行字符集
+ **输入字符集**:决定了编译器如何读取和解析源代码中的字符。 + **执行字符集**:决定了编译器如何将字符和字符串常量编码并存储到可执行文件中。
例如:输入字符集为 GB2312 时,"中文"两个字,对应的二进制是:

而输入字符集为 UTF-8 时则为下面:

而执行字符集,可以通过显示设置字符集来修改:
在编译器中显式设置输入字符集和执行字符集。对于 GCC 编译器,可以使用 -finput-charset=UTF-8 -fexec-charset=UTF-8
选项;对于 MSVC 编译器,可以使用 /source-charset:utf-8 /execution-charset:utf-8
选项,你也可以使用 /utf-8
来指定输入字符集和执行字符集都为 UTF-8。
如果输入字符集和执行字符集不一致,编译器需要在编译过程中进行字符编码的转换。当两者不一致时,编译器需进行编码转换,可能引发:
字符映射丢失(如 GBK→ASCII)
字节序列错误(如 UTF-8→UTF-16LE)
所以,尽量将两个字符集设置成一样的。
代码页
在计算机发展的早期阶段,ASCII 编码(美国信息交换标准代码)是主流的字符编码方式,它使用 7 位二进制数表示 128 个字符,包括英文字母、数字和一些标点符号。然而,ASCII 编码无法满足多语言环境的需求,因为世界上有成千上万种语言和符号。
为了解决这个问题,操作系统和软件开发商引入了代码页的概念。代码页允许系统支持多种字符集,尤其是那些超出 ASCII 范围的语言字符。在 Windows 操作系统中,代码页是系统用来处理文本数据的机制。例如,当用户在系统中输入或显示文本时,系统会根据当前的代码页设置来解释这些字符。
假设你有一个文本文件,内容是中文字符“你好”。如果这个文件是用 GBK 编码保存的,那么它的字节序列可能是 C4 E3 BA C3
。操作系统会根据代码页 936(GBK)来解释这些字节,并正确显示为“你好”。但如果系统错误地使用了代码页 1252(西欧字符集),这些字节会被解释为乱码,因为代码页 1252 中没有对应的字符。
再探乱码
看到这里,相信各位读者对字符编码已经有些一些基础的了解。所以,下面让我们尝试解答刚开始提出的问题:
为什么
std::cout << "中文" << std::endl;
输出到控制台会乱码?该字符在当前源字符集中无效(代码页 65001)
为什么控制台会输出乱码?
假设有这样一段代码:
运行起来后,会发现输出到控制台是这种情况:

这个问题的影响因素有两个:
控制台字符编码
文件源字符集
首先,在 Windows 下,控制台的默认编码是当前系统的代码页(通常是 GB2312),所以如果你输出到控制台的字符不是当前代码页编码对应的字符,那么就会发生乱码。当前系统的代码页通过 cmd 执行命令 chcp
来查看。 假如文件的源格式是 UTF-8
,那么"中文"这两个字的字节序列为:

当我们输出到控制台时,按照 GB2312 编码去解析这 6 个字节时,我们会得到:
涓(E4B8)(ADE6)枃(9687),其中 ADE6 在 GB2312 中为错误编码,所以会显示一个问号。
根据这个思路,我们有两种方法解决这个问题:
修改控制台字符编码
修改源文件字符集
第一种我们通过执行 chcp
来修改当前代码页:
第二种,就是修改文件的字符编码格式,改成 GB2312。怎么改我就不赘述了,网上一大把。
该字符在当前源字符集中无效?
这一个问题与输入字符集有关,当文件编码与编译器预期不一致,例如你的文件是 GB2312 编码,但编译器(如 MSVC)默认使用 UTF-8(代码页 65001)来解析源文件。GB2312 和 UTF-8 是不兼容的编码格式,导致编译器无法正确解析文件中的字符。
笔者的 Visual Studio 工程命令行有一个 /utf-8
,也就代表输入、执行编码集都为 utf-8。所以,当你文件的编码为 GB2312 时,
“创”字的 GB2312 编码在 GB2312 编码中,“创”字的编码是
0xD4 0xB4
。“创”字的 UTF-8 编码在 UTF-8 编码中,“创”字的编码是
0xE5 0x8D 0x94
。当编译器以 UTF-8 编码解析文件时,会将 GB2312 编码的字节序列
0xD4 0xB4
视为一个潜在的 UTF-8 字符。然而,根据 UTF-8 的编码规则:0xD4
是一个以 1101 开头的字节,表示这是一个两字节字符,第一个字节的格式应为 110xxxxx ,第二个字节的格式应为 10xxxxxx 。但是,0xD4
的二进制是 11010100 ,而 0xB4 的二进制是 10110100 。
虽然第二个字节符合 10xxxxxx 的格式,但第一个字节的值 0xD4
超出了 UTF-8 两字节字符的合法范围( 0xC0 到 0xDF ),因此整个字节序列 0xD4 0xB4
是无效的 UTF-8 字符。
QString 一些字符相关的函数
在 QString 中有许多的转换函数:
QString::fromLatin1
QString::fromLocal8Bit
QString::fromUtf8
QString::fromWCharArray
QString 是以 UTF-16 的格式存储的字符:
QString stores a string of 16-bit QChars, where each QChar corresponds to one UTF-16 code unit.
所以,调用上面这些函数就是用指定的格式读取字符,并将这些字符转换成 UTF-16 格式。参看下面的例子:
输入字符集为 GB2312 时:

输入字符集为 UTF-8 时:

最后的最后
感谢各位读者阅读本博客,本博客内容在创作过程中,参考了大量百科知识以及其他优秀博客,并结合笔者自身在实际工作中遇到的相关问题。笔者希望通过这篇博客,能为各位读者在字符编码这一块提供一些有价值的见解和帮助。
文章转载自:师从名剑山
评论