趁着双十一买了好几本想买的书,其中就有这本《Windows 核心编程》。首先需要吐槽的就是好好的《Windows via C/C++》这么高端大气上档次的名字怎么就被翻译成了这么个蛋疼样,而且中文版的封面也是扑面而来的一股浓郁的上个世纪气息,以至于在正文里看到 Vista 的字样都感觉各种穿越……
本文是关于这本书的第一篇读书笔记。天知道会不会有第二篇。
虽说之前已经写过好几篇关于字符编码的文章,都快写吐了,不过读了这本书的开篇还是感觉相见恨晚。这里对于前面这几篇里关于字符串的说明进行一下查漏补缺。
Unicode 和 UTF-16
前文书说到,Unicode 定义的是码位(Code Point),即为每一个字符赋一个唯一编号,记作 U+XXXX。Unicode 目前实际占用了 016 到 10FFFF16 的码位,有些已经分配了字符,有些还没有。
Unicode 中最初推出的 U+0000 到 U+FFFF 这 216 个码位称为基本多文种平面(Basic Multilingual Plane,BMP)。早年只有这一个平面的时候,Unicode 与 UTF-16 是等价的,因为可以做到一一对应。然而时过境迁,区区 16 位已经满足不了 U+10000 到 U+10FFFF 的码位了。
与 UTF-8 编码规则的爽快不同,UTF-16 在沦为实实在在的「编码」时,还需要考虑到向后兼容性问题。如何在维持 U+0000 到 U+FFFF 一一对应的同时,把剩余的 U+10000 到 U+10FFFF 编进来?
答案是:把 U+D800 到 U+DFFF 命名为 UTF-16 编码专用字符。即如果 UTF-16 数据流中出现了这一范围内的字符,意味着它本身不是一个字符,紧接着它的 16 位数据需要加上一定的偏移值才是真正的数据。
这一土豪做法让我目瞪口呆。至于 UTF-32,目前来说与它与 Unicode 码位一一对应起来绰绰有余。当然,如果哪天发现了外星文明,需要把他们的字符也编码进来,导致 Unicode 占用的码位暴增,说不定这一一对应的任务就只能交给未来的 UTF-64 了。这就是把问题留给子孙后代去解决的大智慧啊!
CHAR
和 LPSTR
前文书里吐槽过 Windows API 的丑陋不堪,也说过这也是历史遗留、不得已而为之。
没错的,CHAR
和 char
在语义上并不等价,前者专指 8 位字符,后者则只是字符而已,说不定在哪个古董机器上就是 7 位了。(尽管我不认为你会在那上面跑 Windows……)
而 LPSTR
尽管被定义为 CHAR *
,但它实际上还借助了编译器扩展实现了「以 NUL
结尾」的语义:
typedef __nullterminated CHAR *LPSTR;
Unicode 和 ANSI 版本
说实话我依旧不喜欢官方的这两个名字,我更喜欢「宽字符版本」和「多字节字符版本」这两个更拗口但更准确的名字。
由于现代 Windows 内部都是以 UTF-16 存储(承袭自 UCS-2),实际提供的 API 本身也都是 Unicode 版本的,即以 W
结尾的版本。而相应的 A
结尾的 ANSI 版本则是在 W
版本基础上的封装。
于是可以想象,全局使用 ANSI 相比全局使用 Unicode 而言要占更多的内存,理论上也要更慢一些。
以上。
C 语言中的字符串
抽象数据类型
C 语言贴近机器模型,所以并不提供字符串数据类型。但是,字符串却在 C 语言中作为 ADT 存在着:使用字符指针代表字符串,同时提供了 string.h
作为字符串的操作库,而在实际存储时则使用字符数组。
虽说单引号引起的字符是该字符对应的一个整型常量,但是 'A'
和 65 没有任何关系。因为 C 语言并没有要求每个实现都用 ASCII 字符集作为执行字符集。
事实上,C 语言标准要求每个 C 语言实现都要定义两个字符集:代码字符集、执行字符集。每个字符集又都由基础字符和扩展字符构成。扩展字符可有可无,完全由该实现所支持的区域设置决定;基础字符只划定了字符,字符对应的码位留由实现决定。
字符与 char
类型
字符集顾名思义就是字符的集合,字符集除了划定所包含的字符外,还将其所包含的字符按照一定顺序编号,这样的编号称作码位。著名的 ASCII 参照已有电报报文设计,分别用数字 0 到 127 作为英文大小写字母、阿拉伯数字、标点符号等的码位。由于出现早、使用广,后辈字符集、字符编码几乎都与之兼容为荣。
然而 ASCII 本身并不完美,尽管名字叫「美国信息交换标准代码」,但是却连英语中的一些诸如 naïve、café、élite 的外来词都无法表示,欧洲各国那些稀奇古怪的字母就更别提了。所幸,我们很快来到了一个字节 8 位的年代,char
能够容纳 0 到 255 一共 256 个值,于是各国纷纷物尽其用,将一个字节中 ASCII 并没有用到的 128 到 255 对应为自己国家所需要用到字符。这些五花八门的对应规则便是后来的代码页(code page),同样的值可以在不同的代码页中表示不同的字符。
到目前为止,char
类型还算是名副其实的字符类型。然而,东亚文化圈广泛使用的汉字成千上万,少说也需要上千个码位,一个字节所能提供的这区区 256 个码位实在是杯水车薪。
计算机科学里的任何问题都可以通过增加一个中间层来解决。
既然码位不得不超过 255,一个字节放不下,那么将以前的「码位→字节」变成「码位→编码→字节序列」如何?
举个栗子:Unicode 字符集中,字符「汉」的码位是 27721(十六进制 6C49),一个字节显然容不下,我们可以通过 UTF-8 编码将其变成字节序列。
- 通过字符集找到对应码位:汉→
U+6C49
;
- 根据 UTF-8 编码规则,介于
U+0800
和 U+FFFF
之间的码位需要按照 1110xxxx 10xxxxxx 10xxxxxx
的形式编码为三个字节;
6C49
的二进制形式是 0110 1100 0100 1001
,代入上面的模板得到 11100110 10110001 10001001
;
- 得到最终的字节序列
E6 B1 89
。
看上去不错,但这样一来,char
这个「字符」类型就有点名不副实了:一个 char
可能只是一个字符的一部分,只有两个或者更多的 char
拼起来才能够表示一个字符。更为麻烦的是,相关的标准库也变得不再可靠了:类似 strlen()
这种 too simple, sometimes naive 的方法顿时从返回字符串的字符数降级成了返回字符串的字节数。
printf("%zu\n", strlen("\xE6\xB1\x89")); // 输出 3;但实际上只有一个字:汉
多字节字符串
为了解决 strlen()
的这种尴尬,C 语言中引入了「多字节字符串」(multibyte string)的概念,一个字符可以对应多个字节;而原先的字符串则对应称为「字节字符串」(byte string),一个字符只能对应一个字节。
char const *multibyteString = "Hello 世界"; // 和普通字符串没什么区别
多字节字符串依旧是 char *
,所以这两者的区别其实只是抽象概念上的,何况严格来说,字节字符串也是一种特殊的多字节字符串。
不同的编码每个字符占用的字节数可能不同,即便是同一种编码,不同的字符也可能占用不同的字节数。所以多字节字符串面临的主要问题便是「下一个字符到底占几个字节」。C 语言引入了 mblen()
函数来回答这个问题:
int mblen(char const *s, size_t n);
其中 s
是一个多字节字符串,而 n
表示至多检查多少个字节;这个函数会根据当前区域设置(Locale)的 LC_CTYPE
分类中所指定的编码来解析这个多字节字符串,返回该字符串的字一个字符占几个字节。
char const *multibyte= "\xE6\xB1\x89"; // 「汉」的 UTF-8 编码
printf("%d\n", mblen(multibyte , 5)); // 输出 -1;因为默认区域设置为 C,无法识别这样的序列。
setlocale(LC_CTYPE, "en_US.UTF-8"); // 非 Windows 平台用,Windows 平台不支持 UTF-8 的区域设置
printf("%d\n", mblen(multibyte, 5)); // 输出 3;因为当前区域设置为 UTF-8 编码。
宽字符 wchar_t
多字节字符串的引入终于算是「一定程度上解决了问题」,但这种做法实在繁琐。于是,C 语言同时引入了宽字符类型 wchar_t
和配套支持库 wchar.h
,试图一劳永逸地解决这个问题。
wchar_t const *wideString = L"Hello World"; // 使用前缀 L
与 char
的一个字节不同,wchar_t
类型本身可以占多个字节,保证永远与字符一一对应,省下了编码的步骤。如此就可以通过 wchar.h
中提供的 wcslen()
函数正确计算 wchar_t *
字符串中的字符数了。
多字节字符串和宽字符串的转换
与 mblen()
类似,C 语言提供了两组共四个函数,根据当前的区域设置进行多字节字符(串)和宽字符(串)的转换。
wctomb()
将宽字符转换为多字节字符
mbtowc()
将多字节字符转换为宽字符
wcstombs()
将宽字符串转换为多字节字符串
mbstowcs()
将多字节字符串转换为宽字符串
误区
看到这里,你是否想过这个能够保证与字符一一对应的 wchar_t
类型到底需要占几个字节呢?没错,不一定占多少,但总之至少一个字节就对了!
事实上,关于 wchar_t
的准确要求是:能够容纳下所支持的区域设置中,最大的扩展字符集中的每一个码位。
an integer type whose range of values can represent distinct codes for all
members of the largest extended character set specified among the supported locales.
也就是说,如果有一个只支持 ASCII 字符集的奇葩的 C 语言实现存在,那么在这个实现下,wchar_t
完全可以只占 8 位。
如此,我们经常听到的「宽字符和 Unicode 有关」的说法也就不攻自破了。
C11 中引入的定长字符类型
时过境迁,转眼到了 2011 年。正如 C99 中在 stdint.h
中加入了 uint32_t
之类的定长整数类型一样,C11 也引入了两种定长字符类型以满足 Unicode 的需要:
char16_t
占 16 位,2 字节,可以保存 UTF-16 字符。
char32_t
占 32 位,4 字节,可以保存 UTF-32 字符。
同时,继 string.h
、wchar.h
后,又进入了 uchar.h
作为这两种字符类型的支持库。
char16_t *utf16String = u"Hello UTF-16"; // 使用前缀 u
char32_t *utf32String = U"Hello UTF-32"; // 使用前缀 U
C++ 中的字符串
C++ 极尽所能兼容 C 语言,陆续支持了和 C 语言相同的那些 char
、wchar_t
、char16_t
和 char32_t
类型,这里就不再重复了。
至于 std::string
,用 C++ 不可能没听说过,它封装了常用的字符串操作。但你是否知道 std::wstring
的存在呢?
如果有心,你会发现 std::string
实际上是对 std::basic_string<T>
模板的特化,形式类似于:
typedef std::basic_string<char> std::string;
typedef std::basic_string<wchar_t> std::wstring;
至于 C++11 中,由于 char16_t
和 char32_t
的引入,也对应增加了 std::u16string
和 std::u32string
的特化版本。
std::basic_string::data()
在 C++11 以来的改变
std::basic_string<T>
其实只是对 T
字符数组的封装,如果需要使用 C 风格的字符串 T *
,可以通过 c_str()
方法获取。
而如果说 c_str()
方法表示「获取该类对应的 C 字符串表示」的语义,那么 data()
方法则表示「获取该类实际保存的数据」的语义。
在 C++11 之前,通过 data()
方法获取的 T *
并不保证 NUL
结尾,即仅保证 [data(), data() + size())
有效。而自 C++11 起,data()
变为保证 NUL
结尾,与 c_str()
的行为完全相同。
从 C++17 开始,我们还可以 data()
方法获取可修改的缓冲。
Windows API 中的字符串类型
不得不说,Windows 的各种大写的自定义类型实在丑陋得令人发指。经常不由得让人发出「好好起名字会死么?」的感叹。
#define CHAR char
#define LPSTR char *
#define LPCSTR char const *
#define WCHAR wchar_t
#define LPWSTR wchar_t *
#define LPCWSTR wchar_t const *
当然,微软如此做法自然有其历史原因,我对所谓的「向前兼容」表示非常无奈。不过话说回来,除了 Windows API 如此大张旗鼓地使用 wchar_t
,其余地方真不怎么能见到宽字符的身影。现代 Windows 内部使用 UTF-16,所以 wchar_t
能够保证是 16 位的。
Windows API 中所有与字符串相关的函数、数据结构等,都存在 ANSI 和 Unicode 两个版本,可以通过宏 UNICODE
来切换。比如一个普通的 MessageBox()
函数,实际定义是这样的:
int MessageBoxW(HWND hWnd, LPCWSTR text, LPCWSTR caption, UINT type); // 宽字符串版本
int MessageBoxA(HWND hWnd, LPCSTR text, LPCSTR caption, UINT type); // 多字节字符串版本
#ifdef UNICODE
#define MessageBox MessageBoxW
#else
#define MessageBox MessageBoxA
#endif
字符类型也是类似,使用 TCHAR
作为一个通用的字符名字。字符常量则使用 TEXT()
宏来定义。
#ifdef UNICODE
#define TCHAR WCHAR
#define TEXT(s) L ## s
#else
#define TCHAR CHAR
#define TEXT(s) s
#endif
如果你用的是 Visual C++,这个 UNICODE
宏通常并不需要手动设置,而是作为工程属性的一员出现的。
p.s. 不要被这个宏的名字骗了,还记得之前我们说过的 wchar_t
和 Unicode 一点关系都没有吗?可以这么理解:wchar_t
只在 Windows API 中表示 UTF-16 数据。
宽窄字符串的转换
Windows 平台也可以用之前提到的四个函数转换 char
字符串和 wchar_t
字符串。但 Windows 本身提供的 API 似乎更有效率:
MultiByteToWideChar()
多字节字符串转换为宽字符串
WideCharToMultiByte()
宽字符串转换为多字节字符串
再次吐槽 M$ 这蛋疼的命名,命名是转换字符串,起名字起得跟转换单个字符一样。
BSTR
BSTR
可以解释为 Basic String 或者 Binary String,是 Windows 下用于 COM、自动化的字符串类型。定义为:
typedef OLECHAR *BSTR;
OLECHAR
是 Windows 的历史遗留,类似于根据宏 OLE2ANSI
转换的 TCHAR
,但 OLE2ANSI
是 16 位时代的产物,于是 Win32 环境下,OLECHAR
永远是 WCHAR
,也就是 wchar_t
。
尽管如此,BSTR
并不能等同于 OLECHAR *
,因为它们的内存布局不同:BSTR
在它自身前拥有一个四个字节的前缀、以双 NUL
结尾。其中,前缀用于描述字符串内容所占的字节数,它本身所指向的是字符串内容而非长度。
-4 0 2 4 6 8 10
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| 6 | H | i | ! | NUL | NUL |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
↑
bString
这一点,从用于创建 BSTR
的接口 SysAllocString()
便可以看出:
BSTR SysAllocString(OLECHAR const *psz);
倘若 BSTR
与 OLECHAR *
相同,那么岂不变成了鸡生蛋、蛋生鸡的问题?
虎头蛇尾
呃……前面讲 C 语言字符串的时候似乎还是觉得挺有说头,足足规划了一个星期之久,但后面似乎越来越不知道该讲什么,留待日后积累到新的东西再完善吧~
以上。
还记得大明湖畔的 mbstowcs
和 wcstombs
吗?个人觉得这哥俩的存在感真的是比 setjmp
和 longjmp
还要低啊。
size_t mbstowcs (wchar_t *dest, char const *src, size_t max);
size_t wcstombs (char *dest, wchar_t const *src, size_t max);
解释一下:「mbs」对应「Multibyte String」而「wcs」对应「Wide-character String」。于是顾名思义,这两位的功能就是把多字节字符串和宽字符字符串互相转换。
如果把 Wide-character 看作是 Unicode 的 code point,那么 Multibyte 就是对该 code point 的具体编码。至于这两个函数是如何得知 Multibyte 究竟使用什么编码,答案是他们根据当前 locale 中所指定的字符编码决定。下面的代码是在 Windows 上将 Big5 编码的字符串转换为 GBK 编码:
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#define BUFFER_SIZE (128)
int main()
{
char const *source = "Hello 世界!"; // 将文件保存为 Big5 编码
wchar_t wc_out[BUFFER_SIZE];
char mb_out[BUFFER_SIZE];
printf("%s\n", source); // 输出不正常
setlocale(LC_CTYPE, "chinese-traditional"); // 认为输入的 MBS是 Big5 编码
mbstowcs(wc_out, source, BUFFER_SIZE);
setlocale(LC_CTYPE, "chinese-simplified"); // 设置输出的 MBS 为 GBK 编码
wcstombs(mb_out, wc_out, BUFFER_SIZE);
printf("%s\n", mb_out); // 正常输出
}
也可以试试日文 Shift-JIS 编码和 GBK 的转换:将文件存为 Shift-JIS 编码,然后把第一个 setlocale
的目标修改为 "japanese"
(反正「世界」在简繁日里写法都一样)。甚至还可以跑到 Linux 上在「zh_CN.gbk」和 「zh_CN.utf-8」互转。
于是第一次知道这对函数时,我的第一想法是「哇,原来标准库里也有这样的函数啊!那我岂不是可以用很 portable 的方法来转换编码了?」无奈正常人都会说:你太甜了,这两个函数完全不可靠,还是用 Windows API 吧~还是用 libiconv 吧~
为什么呢?因为这两个函数所能进行的转换取决于 locale 的支持。例如 Windows 的中文 locale 就只能设置为 GBK 和 Big5 编码两种,Unix 的可用 locale 也和系统本身有关(?)。所以,想要通用还是老老实实用 libiconv 吧少年们~
参考:Code Pages Supported by Windows
说来,写 C/C++ 的程序,由于指针的存在,程序崩溃什么的也就没什么大惊小怪的了。人非圣贤,孰能无过嘛,而且个人觉得程序崩溃比出现错误的结果好调试多了:在 Visual Studio 里 Debug 版本 F5 调试运行直接可以断在崩溃的地方,方便调试。但 Release 版本就没这么幸运了 :(
如果说单纯是是调试 Release 版本,我只用过《游戏之旅》中介绍的勾选 Linker 选项中的 Generate Map File,然后通过崩溃提示信息中提供的 EIP
查这个 Map File 找到崩在哪个函数里,兴致高一点的根据反汇编一步步走下去兴许还能知道是崩在哪句上 :)
不过说到最终交付出去的程序,面对可能存在的各种未知问题,还是生成 Dump 文件,把崩溃那一刻的信息写进文件以供日后分析比较靠谱。
捕捉未捕获的异常
好吧,Windows API 提供了 SetUnhandledExceptionFilter
函数来设置在发生未捕获的异常时调用的回调函数(仅在程序不处于调试运行时调用)。例如设置 CrashCallback
函数:
#include <windows.h>
LONG WINAPI CrashCallback(EXCEPTION_POINTERS *exceptionInfo)
{
// 崩溃处理
return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
SetUnhandledExceptionFilter(CrashCallback);
// 程序段
}
回调时传入的参数 exceptionInfo
保存了关于该异常的详细信息,不过 Dump 的输出可以不需要亲自干预太多。
生成 Dump 文件
利用上面回调中给出的 EXCEPTION_POINTERS
结构指针提供的信息,MiniDumpWriteDump
函数即可按要求输出一个 Dump 文件内容,其原型:
BOOL WINAPI MiniDumpWriteDump(
HANDLE hProcess, // Dump 目标进程句柄
DWORD ProcessId, // Dump 目标进行 ID
HANDLE hFile, // 输出文件句柄
MINIDUMP_TYPE DumpType, // 输出类型,决定输出哪些内容
MINIDUMP_EXCEPTION_INFORMATION *ExceptionParam,
MINIDUMP_USER_STREAM_INFORMATION *UserStreamParam,
MINIDUMP_CALLBACK_INFORMATION *CallbackParam
);
输出 Dump 文件的内容根据 DumpType
参数变化,详见 MSDN 关于 MINIDUMP_TYPE
的条目。这里举例输出一个最小的 Dump 文件:
HANDLE hFile = CreateFile(TEXT("filename.dmp"), GENERIC_READ | GENERIC_WRITE,
0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if ((hFile != NULL) && (hFile != INVALID_HANDLE_VALUE)) {
MINIDUMP_EXCEPTION_INFORMATION mdei;
mdei.ThreadId = GetCurrentThreadId();
mdei.ExceptionPointers = exceptionInfo;
mdei.ClientPointers = FALSE;
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
hFile, MiniDumpNormal, &mdei, NULL, NULL);
CloseHandle(hFile);
}
需要注意的是,MiniDumpWriteDump
等声明在 DbgHelp.h
中,需要链接 DbgHelp.lib
。当然也可以自行从 DbgHelp.dll
中 LoadLibrary
之。
Dump 文件的使用
Dump 文件是可以用 WinDbg 打开的,不过因为手头没有这东西所以没有试过 =3=
Dump 文件也可以用 Visual Studio 打开,而且(似乎)方便一些:把 dmp 文件、exe 文件、pdb 文件放在同一目录中,然后用 Visual C++ 打开 dmp 文件即出现 Minidump File Summary 页面。可以查看异常信息,或者使用右侧的调试按钮开始调试运行并直接断在崩溃处。
以上,就是好久以来的流水帐……
于是又是一篇木有技术含量的笔记。
对于 Unicode 窗口,WM_IME_CHAR
和 WM_CHAR
没有区别,wParam
都是一个 WCHAR
,即输入的字符。
对于非 Unicode (DBCS) 窗口,WM_IME_CHAR
的 wParam
即由输入法生成的一个字符。这个字符既有可能是单字节字符也有可能是双字节字符。如果是单字节字符,那么和 WM_CHAR
没什么区别;如果是一个双字节字符,那么 wParam
高 8 位为 Leading byte,低 8 位为 Continuation Byte。
所有经由输入法产生的字符都会产生 WM_IME_CHAR
消息而不是 WM_CHAR
,但 DefWindowProc
会把 WM_IME_CHAR
转换为相应的一个或两个 WM_CHAR
消息。
例如:
- 不开输入法输入「9」 → 收到
WM_CHAR
(0x0039)
- 打开输入法输入「9」 → 收到
WM_IME_CHAR
(0x0039) → 收到 WM_CHAR
(0x0039)
- 打开输入法输入「笨」 → 收到
WM_IME_CHAR
(0xB1BF) → 收到 WM_CHAR
(0x00B1) → 收到 WM_CHAR
(0x00BF)
- 1
- 2
- »