« 并发 Hash Map 的实现 | 返回首页 | 通过对缓存测速提取信息的旁路攻击 »

Windows 下 UTF-16 的坑

最近帮多个活跃的开源项目改了同一个 bug : 完善 Windows 下的 Unicode 支持。

问题源于 Windows 和其它平台不同,它有悠久的历史包袱。和现代大多数操作系统对 Unicode 支持的共识不同,它的 API 不是基于 UTF-8 而是基于 UTF-16 的,且还遗留了一套过去对 ANSI 支持的兼容 API 。

最近我接触的几个活跃开源项目,都是西方人主导开发的,他们维护者似乎对 UTF-16 均有所误解,或是懒得做全面的支持。我认为罪魁祸首就是微软自己把 ANSI 编码称为 MBCS 多字符编码字符集,和 WideChar 宽字符集对应起来,暗示 WideChar 是单字编码。而事实上 UTF-16 方案应该成为 MWCS 才合理。

Unicode 虽然常用的只有 BMP ,也就是 U+0000 到 U+FFFF 部分的字符集。但是随着 Emoji 的普及,SMP 也变得非常常见了。其实 Unicode 目前的标准有 17 的 Plane ,需要 21bit 才能表示完全。而很多人直观的觉得,UTF-16 每一个字 ( 2 字节 )表示一个码点。这是不对的,像 Emoji ,一些罕见汉字(SIP)需要用两个字来表示。这个叫做 surrogate pair ,对于 Unicode ,U+D800 到 U+DFFF 这个区段是不存在的,专门用于实现 UTF-16 中的 surrogate pair 。

U+D800 到 U+DBFF 留出了 1024 个位置,也就是 10 bit ;U+DC00 到 U+DFFF 也是 10bit ,这 20bit 刚好能表示 BMP 外的 16 个 Plane ( 4 + 16 bits )。

现代软件通常都会统一使用 UTF-8 作为内码,为了兼容 Windows ,则在最后调用 API 的时候再和 UTF-16 做转换。虽然 UTF-8 编码并不复杂,但这里最好不要自己实现这个转换,因为容易犯错(漏掉 surrogate pair 的处理)。建议尽量使用 MultiByteToWideChar 和 WideCharToMultiByte 。btw, 因为犯错误的人/项目实在太多,甚至有人提出了一个叫做 WTF-8 的 Unicode 编码方案,可以正确编码错误的 UTF-16 串。

在 Windows 下比较坑的是,WM_CHAR 这个消息,在 Unicode 模式下,也是传递 UTF-16 编码的,如果敲出的字符不在 BMP ,那么会用两个连续的消息分两次传递。大多数开源软件都没能正确处理这个问题。我最近给 imgui 提了一个 PR ,想解决它的这个问题。我同样在 lua iup 和 bgfx 中发现了类似问题。

btw, 很多人没有细读 msdn ,被 WM_UNICHAR 这个消息名误导,认为只要处理这个消息,就可以直接拿到 Unicode 的 codepoint ,因为 msdn 上写的是,这个消息会按 UTF-32 来发送消息。但实际上,Windows 的 IME ,也就是会直接产生超出 BMP 的输入的主要途径,它并不会向窗口发送 WM_UNICHAR 消息。这个消息时专门用于 ANSI 窗口向 Unicode 窗口发送 Unicode 字符消息用的,我认为设计这个消息的动机是,如果你连续向一个窗口发送两个 WM_CHAR ,很难保证原子性,很可能中间被插入另一个 WM_CHAR 。所以必须有这么一个消息,可以原子的发送一个 Unicode 字符。至于 Windows 系统内部通过 DefWindowProc 产生出来的 WM_CHAR 消息,很可能是直接对线程的消息队列写,则能保证超过 BMP 的 Unicode 字符可以被分割为两个消息,中间不被打断。换句话说,WM_UNICHAR 只是为了解决程序自己内部传递字符用的,你不处理它,系统的 DefWindowProc 也会帮你转换为 WM_CHAR ;而只处理它是无法解决 surrogate pair 问题的。

似乎目前唯一的方法是,当你在 WM_CHAR 中发现了 surrogate (U+D800 到 U+DBFF) 时,把它记在和窗口相关的数据结构中,等下一个 surrogate (U+DC00 到 U+DFFF)到来后,再和之前记录的值拼接起来,算出 code point ,再转发出去。


对于历史悠久的开源项目修改,需要注意,很多项目仍旧遗留了 ANSI 版本的 Windows API 封装,而并非完全是 Unicode 版本的。混用 A 版本和 W 版本(或是 T 版本)的 API 要非常小心,尤其是设计基于 DLL 的插件处理。Windows 官方的说法是,建议严格使用 T 版本的宏,不要直接调用 A 版或 W 版 API 。但大多数现在的开源项目,为了减少不确定因素,都回避了这类 API 的宏替换。

虽然 Windows 内核中一律使用 Unicode ,A 版本的 API 只是做一个转换。但不要认为只有涉及字符串的 API 才分 A 和 W 版本,设计消息参数的也会区分。例如 PeekMessage DispatchMessage DefWindowProc 都有两个版本(但 TranslateMessage 却没有)。一个窗口的消息输入,其实主要就是最终把支付输入翻译成 WM_CHAR 的过程,到底是翻译成 ANSI 还是 UTF-16 ,只取决于 Windows 的 Class ,也就是你是用 W 版的 RegisterWindowClass 还是 A 版的。如果一个窗口的 Class 是 Unicode 的,那么你必须使用 DefWindowProcW 函数过滤消息。你的 WindowProc 不可以直接调用,而需要使用 CallWindowProcW 调用,它负责对 MSG 做正确的转换。

记住,Windows 的消息循环是基于线程,而不是基于窗口的。一个线程只能有一个消息循环分发点。如果你不在意 BMP 之外的 Unicode 支持的话,其实使用 PeekMessage/GetMessage 的 A 版或 W 版都无所谓,Windows 最终都会做正确转换的。

但是,ANSI 字符集仅仅是 Unicode 的一个子集,如果你的线程中混用了 ANSI 类窗口和 Unicode 类窗口,那么你用 PeekMessageA 获取消息,当 DispatchMessageA 去触发 Unicode 窗口的 WinProc 时,有些字符就会丢失。所以,你必须用 PeekMessageW 和 DispatchMessageW 才能保证信息不会丢失。

不要觉得不可能出现这种混合的情况,我用 google 就找到了这么一个古老的案例 。它解答了我起初的一个疑惑:为什么微软在设计 API 的时候,不干脆去掉 ANSI 版的 PeekMessage/GetMessage/DispatchMessage ,反正 Unicode 是过去所有字符集的超集了。这是因为,有不少历史遗留软件,因为 MFC 的缘故,还会绕过 WindowProc 直接去访问 GetMessage 获取的 MSG 中的消息。当然,我还是认为这是 Windows API 的设计失误。


最后再补充一些我在帮 imgui 完善 Unicode 支持,做测试时了解到的小知识。

一般中文字体文件都不会包含 BMP 以外的字符,而扩展的罕见汉字,SIP 那些你在常规的 ttf 文件中是找不到的。但是你在 Windows 下用字体 API 却取的到。这是因为,在 Windows 下,如果你的字体使用了 SIP 中的字但找不到,Windows 会尝试注册表 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\LanguagePack\SurrogateFallback 中对应的替代字体文件。比如宋体,在我的系统上,最终会从 C:\Windows\fonts\simsunb.ttf 中查找那些罕见字。

Comments

这个,还真没特别注意过。隐患啊。。

厉害了风哥,希望风哥能开源个ECS框架的游戏Demo出来让我们借鉴借鉴。

Post a comment

非这个主题相关的留言请到:留言本