字符集、字符编码和编码转换

概述

字符集(Character Set)是字符的集合。字符在字符集中的位置称为码位(Code Point)。不同的字符集通常包含不同数量的字符,且同一个字符在不同的字符集中的码位也往往不同。常用的字符集有 GB2312、GB18030 和 Unicode 等。

字符编码(Character Encoding)是将字符的码位转换为计算机中实际存储和传输的字节序列(编码表示)的规则和过程。只有字符集和字符编码都确定后,字符的编码表示才能确定。很多字符集都只支持一种编码,因此它们的名称既表示一个字符集,也表示一种字符编码,比如 ASCII 和 GB2312。常用的字符编码还有 UTF-8、UTF-16 和 UTF-32 等。

代码页

一个字符集中的所有字符以及它们的一种编码表示构成一个代码页(Code Page)。Windows 代码页又称 ANSI 代码页。在 Windows 系统中,代码页的索引称为 代码页标识符(Code Page Identifier)。例如,包含字符集 GB2312 中的所有字符及其编码表示的代码页的标识符为 936。

历史上,不同的国家和地区制定了不同的字符集和字符编码。因此,不同国家和地区的 Windows 用户通常使用不同的代码页。例如,新加坡和中国大陆地区用户使用 936 代码页,对应的字符集为 GB2312;港澳台地区用户使用 950 代码页,对应的字符集为 Big5。在 Windows 系统中,计算机当前所处的地理位置称为系统区域(System locale),对应的代码页称为系统默认代码页。很多程序在读写文本文档时都按系统默认代码页处理,因此系统默认代码页会影响程序解读文本文档的内容。有两种方式可以更改系统区域:

  • 控制面板 - 更改日期、时间或数字格式 - 管理 - 更改系统区域设置
  • 设置 - 时间和语言 - 语言 - 管理语言设置 - 更改系统区域设置

常用字符集

ASCII

ASCII(American Standard Code for Information Interchange,美国信息交换标准码)由 ANSI(American National Standards Institute,美国国家标准学会)制定,用 7 比特表示一个字符,共包含 128 个字符。

Latin1

即 ISO-8859-1,用 8 比特表示一个字符,共包含 256 个字符。Latin1 兼容 ASCII,即前 128 个字符(0x00-0x7F)与 ASCII 完全一致。兼容 ASCII 的字符集都称为 ASCII 的扩展字符集。

GB2312

即信息交换用汉字编码字符集,用 1 到 2 个字节表示一个字符,兼容 ASCII。

GB2312 将字符集分成 94 个区,每个区包含 94 个字符。每个字符的分区编号(01-94)和位置编号(01-94)组成它的区位码。例如,汉字「好」位于 0x1A 区 0x23 位,故它的区位码就是 0x1A23。

区位码加上 0x20 就得到国标码。例如,汉字「好」的国标码就是 0x3A43。国标码是每个字符的唯一编号。加上 0x20 是为了重用 ASCII 的前 32 个控制字符。只重用前 32 个控制字符,是因为剩下的 ASCII 字符都已经包含在 GB2312 中了。

国标码加上 0x80(最高位置 1)就得到机内码。例如,汉字「好」的机内码就是 0xBAC3。可以认为,机内码 = 区位码 + A0。汉字是以机内码的形式在计算机中存储和传播的。

GB18030 和 GBK

GB18030 兼容 GBK,GBK 又兼容 GB2312。因此,GB18030、GBK、GB2312 都是 ASCII 的扩展字符集。

UCS

UCS(Universal Multiple-Octet Coded Character Set,通用字符集)是能够容纳世界上所有字符的字符集。字符在 UCS 中的码位称为通用字符名(Universal Character Name,UCN),记作 U+hhhh,其中 hhhh 是 16 进制数。例如,汉字「好」的通用字符名是 U+597d

在 HTML 中,通用字符名记作 &#ddddd;,其中 ddddd 是 10 进制数,例如:

1
2
<!-- 你好 -->
<p>&#20320;&#22909;</p>

组合用字符

顾名思义,组合用字符(Combining Character)专门用来和其他字符结合成一个新的字符。例如,带抑音符的小写字母 à 也可由小写字母 a 与组合用抑音符 U+0300 组合而成。

Unicode HTML Charactor
U+0061 &#97; &#97;
U+0061 + U+0300 &#97;&#768; a&#768;
U+00E0 &#224; &#224;

UTF

UCS 支持多种编码,这些编码统称为 UTF(Unicode Transformation Format,UCS 转换格式),共有 UTF-8、UTF-16 和 UTF-32 三种。UTF-8 使用 1 到 4 个字节表示一个字符;UTF-16 使用 2 或 4 个字节表示一个字符。使用 UTF-8 编码时,对于一个 n 字节的字符:

  • n 等于 1 时,字节的最高位为 0
  • n 大于等于 2 时,最高字节的高 n 位为 1,其余字节的最高 2 位固定为 10
Unicode Length Bytes
0x00 - 0x7F 1 0*** ****
0x0080 - 0x07FF 2 11** **** 10** ****
0x0800 - 0xFFFF 3 111* **** 10** **** 10** ****
0x00010000 - 0x0010FFFF 4 1111 **** 10** **** 10** **** 10** ****

例:汉字「好」的通用字符名是 U+597d,介于 0x08000xFFFF 之间,要用 3 个字节表示:

1
2
3
4
5
6
              5    9     7    d
0101 1001 0111 1101
5 9 7 d
0101 10 0101 11 1101
1110 0101 1010 0101 1011 1101
e 5 a 5 b d

BOM

字节序由字节流开头的 BOM(Byte Order Mark,字节顺序标记)指示。

Encoding Encoded BOM
UTF-8 EF BB BF
UTF-16 big-endian FE FF
UTF-16 little-endian FF FE
UTF-32 big-endian 00 00 FE FF
UTF-32 little-endian FF FE 00 00

数据结构

根据每个字符占用的字节数,可以把字符集分为以下 3 类:

  • 单字节字符集(Single-Byte Character Set,SBCS),每个字符占用 1 字节,如 ASCII 和 Latin1。
  • 双字节字符集(Double-Byte Chactacter Set,DBCS),每个字符占用 1 到 2 字节,如 GB2312 和 GBK。
  • 多字节字符集(Multi-Byte Character Set,MBCS),每个字符占用 1 到 4 个字节,如 GB18030 和 UTF-8。

把字符分为以下 2 类:

  • 以 1 个字节为存储单位,占用 1 到 4 个字节的字符称为多字节字符(Multi-byte Character)。由多字节字符组成的字符串称为多字节字符串(Multi-byte string)。
  • 以 2 个字节为存储单位,占用 2 或 4 个字节的字符称为宽字符(Wide character)。由宽字符组成的字符串称为宽字符串(Wide string)。

C/C++ 提供多种数据类型用于存储字符。根据单位数据占用的字节数,可以把这些数据类型划分成两类:

  • 窄字符型:单位数据占用 1 字节,只有 char 一种。
  • 宽字符型:单位数据占用 2 或 4 个字节,有 wchar_tchar16_tchar32_t 三种。

在 Windows 中,数据类型为 char、使用系统默认代码页的多字节字符串又称 ANSI 字符串;数据类型为 wchar_t、使用 UTF-16 编码的宽字符串又称 Unicode 字符串。

用 MSVC 编译 C/C++ 代码时,可以用 /source-charset 选项指示源字符集,用 /execution-charset 选项指示执行字符集。源字符集就是源文件使用的字符集。只有源字符集符合实际,预处理器才能正确加载源文件的内容。执行字符集就是程序使用的字符集。源文件中的字符串字面量在编译前会被转换成使用执行字符集的版本。

字符串字面量的编码可以通过添加前缀单独指定:

1
2
3
4
5
const char     s[]   =   "A好"; // 41 ba c3            (GB2312)
const char s8[] = u8"A好"; // 41 e5 a5 bd (UTF-8)
const wchar_t ws[] = L"A好"; // 0041 597d (UTF-16)
const char16_t s16[] = u"A好"; // 0041 597d (UTF-16)
const char32_t s32[] = U"A好"; // 0000 0041 0000 597d (UTF-32)

字符可以用通用字符名表示,记作 \uhhhh\Uhhhhhhhh,例如:

1
const wchar_t ws[] = L"\u597d\U0000597d"; // L"好好"

通用字符名还可以记作 3 位的 8 进制数 \ooo 或 16 进制数 \xhh...,例如:

1
const wchar_t ws[] = L"\041\x597d"; // L"A好"

编码转换

在不同的操作系统上进行字符编码转换的方式不同;C/C++ 标准库也提供了一组用来支持 UTF 的库函数。

Windows

在 Windows 上进行编码转换可以借助 MultiByteToWideChar()WideCharToMultiByte() 这两个函数。

MultiByteToWideChar() 函数可以把多字节字符串转换成 UTF-16 编码的宽字符串,原型如下:

1
2
3
4
5
6
7
8
int MultiByteToWideChar(
UINT CodePage,
DWORD dwFlags,
const char *lpMultiByteStr,
int cbMultiByte,
wchar_t * lpWideCharStr,
int cchWideChar
);

CodePage 是在转换过程中使用的代码页或编码,它决定了目标字符串的编码,可以是本机支持的任何代码页,也可以是下列宏定义:

  • CP_ACP 系统默认代码页,在中国大陆地区为 GB2312 编码。
  • CP_UTF8 UTF-8 编码。

dwFlags 可以改变函数的行为,可以是 0。常用的标志如下:

  • MB_ERR_INVALID_CHARS 若遇到非法字符,则转换失败。

多字节字符串的开头由 lpMultiByteStr 指示、长度由 cbMultiByte 指示。当多字节字符串以字符串结束符 \0 结尾时,cbMultiByte 可以是 -1

用于保存宽字符串的内存空间的起始地址由 lpWideCharStr 指示、大小由 cchWideChar 指示。当 cchWideChar0 时,函数返回以字符为单位的字符串长度,不执行转换工作。

下面的例子将 GB2312 编码的多字节字符串 "好" 转换成 UTF-16 编码的宽字符串:

1
2
3
4
5
6
7
8
9
const char lpMultiByteStr[] = "好"; // ba c3
int cchWideChar = MultiByteToWideChar(CP_ACP, 0,
lpMultiByteStr, -1,
NULL, 0);

wchar_t *lpWideCharStr = new wchar_t[cchWideChar]; // 597d
MultiByteToWideChar(CP_ACP, 0,
lpMultiByteStr, -1,
lpWideCharStr, cchWideChar);

WideCharToMultiByte() 函数可以把 UTF-16 编码的宽字符串转换成多字节字符串,原型如下:

1
2
3
4
5
6
7
8
9
10
int WideCharToMultiByte(
UINT CodePage,
DWORD dwFlags,
const wchar_t *lpWideCharStr,
int cchWideChar,
char * lpMultiByteStr,
int cbMultiByte,
const char * lpDefaultChar,
int * lpUsedDefaultChar
);

如果源字符串包含代码页不包含的字符,函数就会用 lpDefaultChar 指示的字符替换这些字符,同时把 lpUsedDefaultChar 设为 TRUE

下面的例子将 UTF-16 编码的宽字符串 "好" 转换成 UTF-8 编码的多字节字符串:

1
2
3
4
5
6
7
8
9
const wchar_t lpWideCharStr[] = L"好"; // 597d
int cbMultiByte = WideCharToMultiByte(CP_UTF8, 0,
lpWideCharStr, -1,
NULL, 0, NULL, NULL);

char *lpMultiByteStr = new char[cbMultiByte]; // e5 a5 bd
WideCharToMultiByte(CP_UTF8, 0,
lpWideCharStr, -1,
lpMultiByteStr, cbMultiByte, NULL, NULL);

Linux

在 Linux 上进行编码转换可以使用库 iconv(Internationalization Conversion)提供的 iconv() 函数。

使用库 iconv 需要包含头文件 iconv.h

1
#include <iconv.h>

要使用 iconv() 函数,必须先调用 iconv_open() 函数创建一个转换器。iconv_open() 函数的两个参数分别指示目标字符集和源字符集,返回值是转换器的描述符。转换器在使用完毕后,必须用 iconv_close() 函数释放其占用的资源。下面的例子用于创建从 UTF-8 到 UTF-16 的转换器:

1
2
3
4
5
6
7
8
9
iconv_t cd = iconv_open("utf16", "utf8");
if (cd == (iconv_t)-1) {
std::cerr << "iconv_open() failed\n";
return -1;
}

// ...

iconv_close(cd);

iconv() 函数的原型如下:

1
2
3
size_t iconv(iconv_t cd,
char **inbuf, size_t *inbytesleft,
char **outbuf, size_t *outbytesleft);

cd 是转换器的描述符。inbufoutbuf 是两个指针的地址:作为入参,它们分别指向一个源字符串和一段内存空间的开头;作为出参,它们分别指向源字符串和内存空间的剩余部分。inbytesleftoutbytesleft 是两个整型变量的地址:作为入参,它们分别指示源字符串和内存空间的总长度;作为出参,它们分别指示源字符串和内存空间的剩余长度。

下面的例子用于获取 "A好" 的 UTF-16 编码:

1
2
3
4
5
6
7
8
9
10
11
12
char in[] = u8"A好";
char out[255];
char *inbuf = in;
char *outbuf = out;
size_t inbytesleft = sizeof(in); // 5
size_t outbytesleft = sizeof(out); // 255

if (iconv(cd, &inbuf, &inbytesleft, &outbuf, &outbytesleft) == -1) {
iconv_close(cd);
std::cerr << "iconv() failed\n";
return -1;
}

"A好" 的 UTF-8 编码是 41 e5 a5 bd 00,共 5 字节,转换成小端字节序的 UTF-16 编码是 41 00 7d 59 00 00,共 6 字节。由于 iconv() 还会在开头插入 BOM,因此,最终的结果是 ff fe 41 00 7d 59 00 00,共 8 字节。outbytesleft 最终的值是 247 = 255 - 8

标准库

mbrtoc32() 能够将一个 UTF-8 编码的多字节字符转换成 UTF-32 编码的宽字符。

1
size_t mbrtoc32(char32_t *pc32, const char *s, size_t n, mbstate_t *ps);

如果从 s 开始的 n 字节包含一个 UTF-8 编码的多字节字符,mbrtoc32() 就会向缓冲区 pc32 写入一个 UTF-32 编码的宽字符,并返回已处理的字节数。其他返回值的含义如下:

  • 0 遇到字符串结束符,并且已经将其写入 pc32
  • -1 遇到非法的多字节字符。
  • -2 遇到不完整的多字节字符,状态已经记录在 ps 中,下一次调用应该提供余下字节。
  • -3 上一次调用遇到需要用代理对(Surrogate Pair)表示的多字节字符,本次调用向缓冲区写入代理对的第二部分,不执行其他操作。使用 UTF-32 编码时,这种情况不可能发生,因此 mbrtoc32() 不可能返回 -3
1
2
3
4
5
6
7
8
9
10
#include <string.h> // strlen()
#include <uchar.h> // mbrtoc32()

char32_t wc = 0;
const char mbs[] = u8"你好";
mbstate_t mbstate = {0};
int ret = mbrtoc32(&wc, mbs, strlen(mbs), &mbstate);
if (ret < 0) {
perror("mbrtoc32()");
}

参考文献