Windows
应用开发
Author

admin

字符编码

背景

Windows 环境下主要涉及到以下三种字符编码:

  • UTF-16:16 位字符平面的变长编码,区分大端方式(UTF-16BE)和小端方式(UTF-16LE),若无特别说明,UTF-16 指 UTF-16LE
  • ANSI:本地字符编码,可以是定长编码,也可以是变长编码。
  • UTF-8:8 位字符平面的变长编码,Linux 内核以及网络环境下最常用的字符编码方式,不存在端序问题。

其中,UTF-16 与 UTF-8 能够做到无损转换(两者都能够编码所有 Unicode 字符)。

另外,还有 UTF-32,它采用 32 位的码元序列直接对 Unicode 进行编码,即采用定长编码方式,使用时仍需区分大端方式和小端方式。由于 UTF-32 编码方式中一个字符需要占用 4 字节空间,因而较少使用。

Windows 内核中,字符统一采用 UTF-16LE 编码。由于历史包袱问题,Windows 操作系统默认情况下在对用户显示字符时会将 UTF-16LE 字符转换为恰当的本地字符编码(如 GB2312、BIG5、ISO-8859-1)对用户呈现。不过,在新式应用开发时也支持对用户显示 UTF-8 字符,但需要额外的设置。Windows 11 支持将区域字符编码格式统一设置为 UTF-8,但是在使用未采用 UTF-8 统一编码的应用程序时仍存在不少问题。

从 Windows 操作系统的角度上看,UTF-8 实际上与 ANSI 并无太大分别,ANSI 与 UTF-8 编码的字符类型在 Windows 编程环境下统一被归类为 多字节字符(MultiByte),MultiByte 所采用的字符编码可以是变长的(如 GB18030、UTF-8),也可以是定长的(如 ASCII、GB2312)。但是,鉴于 UTF-8 的特殊地位(UTF-8 能够表示所有 Unicode 字符且不存在大小端序的问题,几乎所有的网络环境下都采用 UTF-8 作为默认编码方式,且 UTF-16 与 UTF-8 之间能够无损转换),因而往往将 UTF-8 字符区别于其他 MultiByte 字符。

与 MultiByte 相对应的是 宽字符(WideChar)。在 Windows 下,WideChar 特指采用 UTF-16LE 编码的字符类型。另外,Windows 语境下,Unicode 也指 UTF-16LE。在 Windows 的环境下使用 C/C++ 编程时,wchar_tchar16_t 均指 UTF-16 字符,统一称作 WideChar,每个字符占两字节,采用小端方式编码(头文件 BaseTsd.h 中又将 wchar_t 定义为 WCHAR 类型)。

typedef wchar_t WCHAR;

Windows API 中的 WideChar 和 MultiByte

一般而言,涉及到字符串参数的 Windows API,都会有 ANSI 版本和 WideChar 版本,对应的 API 分别带有 AW 后缀。如:CreaterocessACreateProcessW

因此,从避免字符编码方式混乱的角度出发,应该尽量统一使用带 W 后缀的版本,传递字符串参数时统一使用 UTF-16LE 进行字符串的编码。在定义宽字符类型的字符或字符串时,需要添加 L 作为前缀。

wchar_t wstr = L"宽字符类型";

Windows API 还在头文件中使用名为 _UNICODE 的宏(下划线不可省略,原因见下),当该宏被定义后,其后声明的所有未进行展开的 Windows API 都将使用宽字符作为默认字符串的字符类型。例如,CreateProcess 会在 _UNICODE 被定义的情况下展开为 CreateProcessW,反之则展开为 CreateProcessA

Note that both the Tchar.h and Wchar.h files are required, and that the leading underscore on the _UNICODE variable is also required. This nomenclature is specific to the standard C library. “UNICODE” rendered without the underscore is for the Microsoft Windows runtimes.

由上述内容可知,_UNICODE 是编译时使用的宏,UNICODE 是为 Windows 运行时保留的宏。因此编写程序时应当定义前者。

#define _UNICODE
#include <Windows.h>

这里有一点需要注意,#define _UNICODE 必须定义在 #include <Windows.h> 之前,这是因为 Windows API 头文件采用如下的预处理方式判断应该采用哪一种展开形式。

#ifdef _UNICODE
#define RegDeleteKeyValue  RegDeleteKeyValueW
#else
#define RegDeleteKeyValue  RegDeleteKeyValueA
#endif // !UNICODE

此外,Windows API 还提供了 TCHAR 类型和 TEXT() 宏,以方便程序员对 WideChar 和 MultiByte 的处理。

#ifdef UNICODE
 typedef WCHAR TCHAR;
#else
 typedef char TCHAR;
#endif

TCHAR 会根据是否定义 _UNICODE 来确定使用宽字符还是多字节字符。

void TEXT(
   quote
);
/*quote: Pointer to the string to interpret as UTF-16 or ANSI.*/

TEXT() 宏根据 UNICODE 是否定义来确定是否将字符串转为宽字符类型或是多字节字符类型。

宽字符和多字节字符之间的转换

Windows API 提供了 WideCharToMultiByteMultiByteToWideChar 来完成宽字符串和多字节字符串之间的转换。具体使用参考官方文档即可,需要注意的是,转换的桥梁始终都是 UTF-16,即:要么从宽字符转成多字节,亦或是从多字节转成宽字符。不存在将 ANSI 直接转换为 UTF-8 的操作。

字符打印

如前所述,Windows 环境下,涉及到字符串的 API 往往都有两个版本,打印函数也不例外。比如在控制台打印字符串,就通过 WriteConsole 完成,该函数有 AW 两个版本。

但是,如果要通过 C/C++ 标准库函数直接打印这些采用不同编码方式的字符串往往会导致很多问题(如:以 UTF-8 编码打印 ANSI 字符)或不可打印(如:调用 int status = wprintf(L"你好!") 打印字符,函数返回值为 -1 表示出错)的问题。前者很好理解,后者则与操作系统地区格式有关。

地区格式(locale)与代码页(codepage)

地区格式(locale,有时也称为本地格式,如 zh-CN)与代码页(codepage,如 GB2312,Windows 下对应代码页 936)之间的关系是一一对应的。在调用像 printf 这样的标准库函数时,打印字符串的操作实际上是通过操作系统的系统调用完成的。在 Windows 操作系统内核中,待打印的字符串首先会以 UTF-16 编码表示,然后在将 UTF-16 编码转换为 ANSI 编码对用户显示。为什么要这样做?原因如下:可执行程序可能在 A 主机上编译链接好,字符串按照某种编码格式存放在可执行文件中,当可执行文件在目标主机 B 上执行时,要打印该字符串,首先要转换成 Windows 操作系统默认使用的 UTF-16LE 编码格式。这个过程可以在程序预处理或编译时完成,如使用 Windows API 中提供的 TEXT 宏可以完成宽字符和多字节字符定义的匹配,也可以在程序运行时告知操作系统字符串源格式,然后交由操作系统转换。但不论如何,字符串最终的打印需要依赖于 C 语言运行时库,将 UTF-16 字符串转换成 B 主机用户的地区格式,B 主机用户可能使用与 A 主机用户完全不同的 ANSI 编码,但是通过 C 语言运行时库的转换,字符串(也许不是全部字符串)便能够在 B 主机上正确显示。

在 Linux 操作系统下也有类似的流程,内核中的待打印字符串以 UTF-8 编码,然后在用户层将 UTF-8 编码转换为特定地区格式对应的编码。不过,目前的 Linux 操作系统在用户层基本都是采用 UTF-8 编码,对应的地区格式一般具有这样的形式:zh_CN.UTF-8、en_US.UTF-8 等。但是也可以将地区格式强行设置成诸如 GB2312 这样的 ANSI 编码,但是这种做法肯定不值得提倡。

Windows 背上了沉重的历史包袱,导致目前的 Windows 操作系统对用户仍然采用 ANSI 作为缺省的编码格式。但是 Windows 同时也允许使用 zh-CN.UTF-8 这样的地区格式,Windows 对应代码页 65001。

注意:Windows locale 与 Linux locale 在格式上的区别:
Windows:zh-CN.UTF-8
Linux:zh_CN.UTF-8

回到 wprintf 打印字符串的问题上来。之前提到过,任何 C 标准库中的字符串打印总是要转换成对应的操作系统提供的调用(运行时库)。不论是 POSIX C Runtime 还是 Windows UCRT, C 语言运行时默认情况下使用 ASCII 作为字符集编码格式(称为 minimal ANSI conforming environment for C translation)。

考虑以下例子:汉字“啊”使用 UTF-16LE 编码后的码值为 0x4A550x4A 在 ASCII 中对应字母 J0x55 在 ASCII 中对应字母 U。很明显,UTF-16 与 ASCII 是无法兼容的。

相比之下,UTF-8 采用前缀编码避免了与 ASCII 码点的冲突,进而保证了与 ASCII 的兼容。因此,如果一个字符串是 UTF-8 的,那么 ASCII 码点与非 ASCII 码点可以很清楚地区分开。这就是为什么在不指定地区格式的情况下,C 语言运行时可以正确处理使用 printf 打印的 UTF-8 编码的字符串。而对于非 UTF-8 编码,就无法保证正确处理。

Windows 碰到待打印字符无法处理的情况默认将无法处理的字符以空白填充,Linux 则会打印出一些乱七八糟的 ASCII 字符。因此,如果要打印宽字符,在 C 语言中就必须先调用 setlocale (Windows、Linux 均支持) 或者 _wsetlocale(Windows 支持)设置区域格式,也就是 Windows 下的代码页。在 C++ 中,使用 std::wcout 打印字符前, std::setlocale 设定区域格式后,方可正确打印宽字符串。