在 Windows 上将 R 切换到 UTF-8 和 UCRT 时遇到的问题



从 2022 年 4 月发布的 4.2.0 版本开始,Windows 上的 R 使用 UTF-8 作为本机编码,并通过 UCRT 作为新的 C Windows 运行时。R 及其软件包的过渡是一项历时数年的非平凡工作。本文总结了在过程中发现的一些技术障碍,重点关注可能对其他项目有用的方面。

R 具体信息

R 使用 C 和 Fortran(以及 R)实现。它需要一个 Fortran 90 编译器。R 代码尽可能地实现平台无关,使用标准 C 库函数,而不是特定于操作系统的 API。大量代码最初是为 POSIX 系统开发的。

对于扩展包也是如此。目前,CRAN 上有近 19,000 个软件包,其中近 4,500 个包含 C、C++ 或 Fortran 代码。

Windows 上的 R 对外部库使用静态链接。它们以静态方式链接到 R 本身和 R 软件包,特别是 R 的动态库和各个软件包的动态库。

R 软件包主要以源代码形式分发。对于 Windows,CRAN 提供 R 软件包的二进制构建以及编译器工具链和预编译静态库的分布,以构建 R 和 R 软件包。在过渡到 UCRT/UTF-8 之前,R 使用针对 MSVCRT(作为 C 运行时)的 GCC/MinGW-w64 工具链。

CRAN 检查已发布的软件包,并要求软件包维护人员修复问题,并使软件包适应 R 中的更改。使用 CRAN 软件包检查对 R 的开发版本进行测试,以预见任何问题。因此,仅在 CRAN(和 Biococonductor)软件包准备就绪后,才发布了以 UTF-8 作为本机编码且以 UCRT 作为 C 运行时的 R。帮助软件包作者对软件包进行必要的更改是这项工作的重要组成部分。

需要新的工具链

仅针对同一 C 运行时编译的对象文件才能在 Windows 上链接在一起。这意味着从 MSVCRT 过渡到 UCRT 要求使用 UCRT 工具链从头开始重新编译所有静态库。

构建新的工具链、静态库和重新构建 R 软件包需要在过渡中付出最大的努力,但对于其他项目而言可能有所不同,最好在单独的文章中进行描述。

关键在于项目允许在多大程度上使用新的编译器工具链从头开始自动从源代码重新构建所需的完整软件堆栈,而不重复使用/下载来自不同来源的预编译代码。R 并非如此。

工具链和软件分发的决策是在 2 年前做出的,当时决定继续使用 GCC/MinGW-W64,当时使用的是 GCC 10。在 R 4.2.0 发布时,它是 GCC 10.3 和 MinGW 9。由于需要 Fortran 90 编译器,因此 LLVM/Clang 不是一个选项。

选择 MXE 交叉编译环境是因为很容易确保工具链和所有库都从源代码重新编译为 UCRT,同时它支持构建静态库。今天会有许多不同的选项,特别是对于不需要 Fortran 90 或静态库的项目。

新工具链的编译问题

UCRT 与 MSVCRT 不同,这需要对源代码进行一些修改才能重新编译。下面提到的两个常见问题肯定与过渡到 UCRT 有关。与 MinGW、GCC 版本或其他涉及软件的更新可能相关的编译问题已排除在外。

打印 64 位整数

一个令人惊讶的障碍是,在 C 中使用 e.g. printf 打印 64 位整数时,无法在不收到 GCC 警告的情况下进行打印:%lld(C99,受 UCRT 支持)和 %I64d(Microsoft)格式都会导致警告。

这给构建外部库带来了麻烦,因为有时警告会自动变成错误,并且需要调整编译器选项(-Wno-format 或不变成错误)。

CRAN 要求在包中解决格式警告,因此根本无法在那里禁用它们。

这是一个 GCC bug,已报告,我提供了一个补丁,该补丁用于 R 的新工具链和 Msys2 中。到目前为止,它尚未被 GCC 采用,但问题的主要部分已在 GCC 11 中以不同的方式解决。

补丁解决的其余部分是,在 C99 和 Microsoft 格式中提供错误的格式说明符将发出两个警告,而不是一个警告。有关更多详细信息,请参阅 GCC PR#95130

指定 Windows 运行时版本

一些软件明确设置了 __MSVCRT_VERSION__ C 预处理器宏,并且意外使用的值暗示使用了 MSVCRT,这会破坏构建,通常是链接。删除设置通常可以解决问题。此宏可能根本不应该在 C 运行时之外手动设置。

非编码运行时问题

在运行时向 UCRT 过渡时,仅检测到几个与编码无关的问题。

无效参数

UCRT 在检查运行时函数的参数时更加严格。新出现的问题包括设置 Windows 上不可用的区域类别、重复关闭文件描述符以及将无效描述符传递给 dup2

默认情况下,MinGW 无效参数处理程序不执行任何操作,但例如当链接到由 MSVC 构建的应用程序时,这会导致程序终止。使用 MSVCRT 构建时,这些问题被隐藏/消除。

要检测这些问题,可以通过 _set_invalid_parameter_handler 设置自定义处理程序并运行测试。只要测试覆盖率允许,一旦设置处理程序,调试这些问题通常很容易。

C 运行时的共存

我们尚未遇到这种情况,但在将应用程序切换到 UCRT 时,而链接到它的某些 DLL 仍为 MSVCRT 构建,可能会出现互操作性问题。例如,它可能是由一个运行时中的 malloc 意外动态分配,而由另一个运行时中的 free 释放。

然而,无论如何,跨 DLL 混合运行时对编码支持没有好处(更多内容见下文)。

本文的其余部分涵盖了在向 UCRT/UTF-8 过渡期间发现的与编码相关的问题。

为何通过 UCRT 使用 UTF-8

MSVCRT 不允许 UTF-8 成为 C 运行时的编码(由 setlocale() 函数报告并由标准 C 函数使用)。为了支持 Unicode,链接到 MSVCRT 的应用程序因此必须对涉及字符串的任何内容使用 Windows 特定的 UTF-16LE API,或使用某些第三方库,例如 ICU。

UCRT 支持 UTF-8 作为 C 运行时的编码,以便人们可以使用标准 C 库函数,这对于编写可移植代码更好,并且它似乎是 Microsoft 现在也推荐的方式。

UCRT 是新的 Microsoft C 运行时,并且预计应用程序最终将不得不切换到它。

活动代码页及后果

虽然首选标准 C API,但 R 本身也使用 Windows 特定的函数,包括 *A*W 形式(在必要时)。*A 调用使用活动代码页(有时称为系统编码)定义的编码,它可能与 C 库编码不同,但通常是相同的。通常,活动代码页在系统范围内指定,更改它需要重新启动。

R 和软件包的代码并未设计为始终仔细区分两种编码,如果仅在基本 R 中完成此操作,它将变得更加复杂,更不用说 R 软件包和外部库了。此外,目标是始终支持 Unicode 字符串,因此我们希望活动代码页也是 UTF-8。

现在可以通过融合清单将活动代码页设置为整个进程的 UTF-8,因此它是在构建时决定的,无需进行系统范围的更改或重新启动。

R 因此指定在清单中,然后将 C 编码设置为活动代码页,因此编码始终相同。仅在较新的 Windows(在台式机上,为 Windows 10 2019 年 11 月或更新版本)上,可以通过清单将活动代码页设置为 UTF-8。在较旧的系统上,清单的此部分将被忽略,活动代码页将变为系统范围内使用的任何代码页,然后也变为 C 编码。

另一个后果是“嵌入”。当 R 用作链接到不同应用程序的动态库时,它将使用应用程序的活动代码页(然后是 C 编码)。如果此类应用程序以不允许将 UTF-8 设置为活动代码页的方式设计,则需要将其拆分:可以创建一个使用 UTF-8 的新小型嵌入式应用程序,并且该应用程序可以与原始嵌入式应用程序进行通信。

虽然理论上应用程序可以使用不同的 C 运行时链接到动态库,但 MSVCRT 无法将 UTF-8 用作本机编码。因此,字符串操作将无法与混合运行时一起使用。鉴于 R 将 UTF-8 用作活动代码页,即使在单独执行字符串操作时,基于 MSVCRT 的 DLL 也无法正常工作。

外部应用程序

即使在同一系统上的 Windows 上的不同应用程序可以使用不同的编码(C 运行时的编码),但通常不会这样做,并且经常默认认为所有数据都采用默认系统编码。

我们遇到了 aspell 工具的问题,幸运的是,该工具允许指定 UTF-8,以及 R 包中附带并使用的小型测试应用程序的问题。

显然,随着 Windows 上使用不同“ANSI”编码(至少为 UTF-8 或系统区域设置的默认编码)的应用程序的出现,现在即使在“ANSI”代码中也需要了解编码,包括处理命令行参数。

检测当前编码

虽然 R 默认通过 setlocale(LC_CTYPE,"") 将 C 库编码设置为活动代码页,但用户可以覆盖此设置,或者 R 可以在旧版 Windows 上运行,该 Windows 不允许将 UTF-8 作为活动代码页,或者嵌入在具有不同活动代码页的应用程序中。因此,有必要能够检测 C 库编码。

R 通过解析调用 setlocale(LC_CTYPE, NULL) 的结果来执行此操作。编码通常以后缀 .<codepage_num> 给出,例如 Czech_Czechia.1250 代表 CP1250(类似于 Latin 2)。

对于 UTF-8,Windows 中的代码页为 65001,但后缀给定为 .utf8,因此必须特殊处理。根据 Microsoft 文档,输入时允许使用 .UTF8.UTF-8.utf8utf-8,因此 R 现在检测其中任何一个。遗憾的是,setlocale(LC_CTYPE, NULL) 的输出未明确指定。

区域设置名称并不总是包含代码页,例如当它们采用 cs-CZcs_CZ 形式时。在这种情况下,根据文档,可以将其作为默认区域设置 ANSI 代码页(GetLocaleInfoExLOCALE_IDEFAULTANSICODEPAGE)找到,该代码页现在受 R 支持。这最近已添加到 R 中,并且在过渡到 UTF-8 之前不起作用,但我现在没有找到一种简单的方法来查找 MSVCRT 文档,以检查运行时是否支持这些代码页。

无论哪种情况下,在切换到 UCRT 时,都值得查看“UCRT 地区名称、语言和国家/地区字符串”的文档,并将其与应用程序所做的假设进行比较。

剪贴板

Windows 剪贴板中的文本可以采用 UTF-16LE 格式,在这种情况下不需要特殊处理,也可以采用“文本”编码。后者会导致如下所述的问题。

在 R 中,已修复此问题,始终在 UTF-16LE 中使用“Unicode 文本”,因为这似乎是最简单的解决方案。具有讽刺意味的是,需要切换到组件的 UTF-16LE 接口才能过渡到 UTF-8。

即使可以为剪贴板中的“文本”内容指定区域设置,并且该区域设置定义了“文本”所采用的编码,但仍存在两个问题。首先,据我所知,没有一个区域设置将 UTF-8 作为编码,因此无法真正使用任意 Unicode 文本,而我们希望允许使用 UTF-8(此外,使用此类区域设置通常也会对其他应用程序产生影响)。因此,虽然 Windows 应该允许在以前使用“ANSI”编码的任何地方使用 UTF-8,但它实际上并未对剪贴板执行此操作。

此外,某些应用程序不会填写“文本”的区域设置,然后 Windows 会自动使用当前输入语言,即当用户将数据粘贴到剪贴板时选择的“键盘”。R 也是如此。

对于剪贴板的编程访问,此默认行为没有意义,因为所使用的字符串通常是在调用写入操作时从不同时间编码的。然而,在写入操作发生时隐式设置所用区域设置的问题甚至在切换到 UTF-8 之前就已经存在:用户可以在创建和发送字符串之间切换输入语言。

对话框/窗口中的字体

Windows API 的某些 *A 函数不会从活动代码页获取要使用的编码,而是从设备上下文中获取字体字符集。这包括用于向对话框写入文本的函数 TextOutA。当通过 CreateFontIndirect 创建字体时,可以指定一个字符集,其中 DEFAULT_CHARSET 是根据当前系统区域设置设置的值,例如对于英语,它是 ANSI_CHARSET,一个非 UTF-8 编码。

事实证明,可以通过 TranslateCharsetInfo 显式地获取 UTF-8 字符集,方法是将 65001 作为源代码页传递。这是编码通过区域设置指定但区域设置没有我们正在使用的 UTF-8 信息的另一个问题实例。

RichEdit(以及噩梦的来源)

R 的图形前端 Rgui 提供了一个脚本编辑器。它是一个编辑器窗口,可以在其中编辑一些 R 代码,将其保存到文件,从文件中读取它,并在 R 解释器中执行。该编辑器使用 RichEdit 2.0 控件实现。

R 源代码文件中没有保存编码信息。之前,文件被假定为默认系统编码,这在不同的系统上是不同的。切换到 UTF-8 是有意义的,以支持所有 Unicode 字符并始终具有相同的编码,代价是较旧的脚本文件将不得不由用户转换。

困难的部分是让 RichEdit 使用 UTF-8。我无法找到此行为的文档,也找不到任何其他来源,因此此处所写内容基于实验、猜测和反复试验。

R 使用 EM_LINEFROMCHAR 消息来获取当前行的索引,然后使用 EM_GETLINE 消息从脚本行中获取文本以执行它。R 使用 RichEdit20A 控件(即“ANSI”版本),但是,当 UTF-8 是活动代码页时,返回的文本仍然是默认系统(即当前区域设置)编码,而不是 UTF-8。

R 未使用 _UNICODE 标志编译,并且不能使用,无论如何现在都不需要,因为我们希望通过 *A 调用而不是 UTF-16LE 使用 UTF-8。

不过,事实证明,使用 RichEdit20W 控件(即“Unicode”版本),当活动代码页为 UTF-8 时,返回的文本实际上是 UTF-8(而不是 UTF-16LE),所以这就是我们想要的。因此,R 显式地将 RichEdit20W 用作类名。

不过,RichEdit20W 控件似乎不接受 EM_FINDTEXTEX 消息中的 UTF-8(用于“搜索操作”),因此文档中的“ANSI”字符串在这种情况下并没有真正涵盖 UTF-8。切换到 UTF-16LE 和 EM_FINDTEXTEXW 起作用了。

消息 EM_EXSETSELEM_EXGETSEL 似乎可以正确地使用字符索引,可能控件在任何情况下都内部使用 Unicode,因此传递字符索引的消息都可以工作。

但是,EM_GETSELTEXT 消息生成 UTF-16LE 字符串,而不是 UTF-8(而 EM_GETLINE 生成 UTF-8,而不是 UTF-16LE)。我没有找到对此的解释。

这些消息用于 Rgui 脚本编辑器中搜索/替换的 R 实现。提示问题可能是由于预期 UTF-8 但收到了 UTF-16LE,即仅对单个(ASCII)字符有效,其中 UTF-16LE 表示的一部分在 UTF-8 中看起来像字符串终止符。

切换已经使用较新版本的控件的应用程序可能会更容易,但我没有经验可以评论。在 Rgui 中更新到较新控件的投资可能不值得。

控制台输入

切换到 UTF-8 的一项重要功能应该是用户可以在控制台中打印和输入任何 Unicode 字符,而不仅仅是那些可以在内部处理的字符。

至少某些实现的 Windows 控制台需要被告知切换到 UTF-8。可以通过在例如 `cmd.exe` 中运行 `chcp 65001` 来执行此操作,但可以通过应用程序通过 `SetConsoleOutputCP` 调用以编程方式执行此操作。Rterm 是 Windows 上 R 的控制台前端,现在使用 `SetConsoleOutputCP` 和 `SetConsoleCP` 将输出和输入代码页设置为 UTF-8 (65001),只要使用 UTF-8 即可。

控制台中的字体需要具有要使用的字符的字形,如果默认值不够,这是用户仍然负责的事情。可能必须在 `cmd.exe` 中切换 `NSimFun` 字体以显示一些亚洲字符。

Rterm 使用 Windows 控制台 API,特别是 `ReadConsoleInputW` 函数从控制台中读取输入。收到的每个事件都包括有关键代码、扫描代码、按键是否按下或释放以及 Unicode 字符的信息。

输入到控制台中的特定字符串如何接收取决于控制台应用程序/终端:`cmd.exe`、PowerShell、mintty/bash、Windows 终端应用程序。当 Windows 终端、mintty/bash 和 `cmd.exe` 特别不同时,这并不罕见。我不知道此行为的文档/规范。

差异的一个来源,到目前为止与 UTF-8 支持无关但很好地说明了挑战,是 `Alt+xxx` 序列是否已由控制台解释,或者应用程序(Rterm)是否接收原始按键。例如,`Alt+65` 产生 `A` 键。Mintty 解释序列并仅发送字符。Windows 终端发送 Alt、解释的字符和 Alt 的释放。`cmd.exe` 发送所有按键事件,但也解释它们并发送字符。当数字锁定关闭时,Windows 终端会发送未解释的键,但不会发送结果字符。需要从此推断出一种算法,该算法在所有情况下都读取一次 `A`,因此知道如何解释序列,但也不会意外地两次获取字符。令人沮丧的是,当用户遇到在测试时未发现的特殊情况差异时。

Alt+ 序列的使用看起来可能相当小众,但即使在使用当前输入法粘贴键盘上不存在的字符时也会用到,例如意大利键盘上的波浪号。它作为 Alt+126 发送(波浪号在 R 语言中使用)。

现在举一个 UTF-8 支持特有的问题示例。补充 Unicode 字符(即那些在 UTF-16LE 中使用代理对表示的字符)接收方式不同。例如,“熊脸”字符 (U+1F43B)。

当按下某个键然后释放时,应用程序通常会收到两个事件,一个用于按下(字符为零),一个用于释放(字符代码非零)。在 cmd.exe 和 mintty 中,“熊脸”表情符号也会发生这种情况,但对于 Windows 终端,对于这个补充字符不会发生这种情况。在那里,字符代码在按下键和释放键事件中都会收到。

事实还证明,Unicode 序列(例如带抑扬符的“c”的 <U+63><U+30>)在终端中的工作方式令人惊讶。它尚未在 R 中解决,而且我不清楚 Windows 中的控制台支持是否已准备好。

切换到 UTF-8 揭示了以前在 Rterm/getline 中存在的问题,这些问题与支持多宽度和多字节字符以及支持使用 Alt+ 序列的输入有关。R 4.1 已经对这段代码进行了重写,而这段代码的目标已经是 UTF-8 支持。更多详细信息请参阅 RTerm 中改进的多字节支持

在过渡到 UTF-8 时似乎有用的内容:修复对各种 Alt+ 输入序列的支持(带数字锁定和不带数字锁定,在数字键盘和小键盘上),打印接收到的键盘事件的诊断模式(Rterm 中的 Alt+I),使用不同的终端进行测试(cmd.exe、PowerShell、Windows 终端、mintty、Linux 终端和 ssh)。要使代理对可靠地工作,然后可能使 Unicode 序列可靠地工作,还需要做更多工作。

已经使用 conPTY 的应用程序切换到 UTF-8 可能更容易,但我没有这方面的经验可以评论。将来更新 Rterm 以在输入时使用 conPTY 和 ANSI 转义序列 API 可能有用。

大小写转换

事实证明,UCRT 大小写转换函数 towlowertowupper 不适用于某些非英语字符,例如德语 U+F6U+D6,它们在 UTF-8 中是多字节的。这适用于 MSVCRT。

R 有自己的大小写转换替换函数,在 Windows 上也必须选择这些函数。否则,可能需要使用 ICU。

GraphApp

在 GraphApp 库中发现了几个其他问题。一个自定义版本是 R 的一部分,用于 Windows 上的图形用户界面。它大量使用 Windows API 和 UTF-16LE 界面,因此出现影响有点令人惊讶。

但是,在多字节区域设置中运行时使用特殊操作模式,该模式缺少一些功能,而且过去似乎没有被大量使用。当以前在单字节区域设置中运行的用户最终使用其他代码路径时,这种情况随着切换到 UTF-8 而改变。由于非常特定于 R,因此最好在另一篇文章中更详细地介绍这些问题。

std::regex

已知 std::regex(一种用于正则表达式的 C++ 接口)对于多字节编码不可靠,在其他平台上也是如此。随着切换到 UTF-8,一些使用 C++ 的 R 包在 Windows 上也遇到了此问题。

摘要

使用 R 的经验似乎表明,将大型项目过渡到 Windows 上的 UCRT/UTF-8 是可能的。必须对代码进行的更改并不大。需要一些时间来调试问题,希望此列表能帮助其他人节省一些时间。

令人惊讶的是,让特定于 Windows 的代码工作比使用标准 C 库(但知道当前编码可能是多字节的代码)的普通 C 代码更难。

很高兴知道有“两个当前编码”,即 C 运行时,还有活动代码页,并且需要决定如何处理这些编码。R 要求两者相同(并且是 UTF-8),代价是旧 Windows 系统将不受支持。

某些 Windows 功能使用通过当前区域设置间接指定的编码,该区域设置不能是 UTF-8。这需要特殊处理和解决方法。我们在字体、剪贴板和 RichEdit 中遇到了此类问题。

通过 UTF-8 对 Unicode 的控制台支持可能需要一些工作,使用旧版 Windows API 的代码可能必须重写。

显而易见的部分:这可能会引发以前未见的问题。在运行拉丁语的系统上以前是单字节的字符有时将是多字节的。

并且,所有代码都应重新编译为 UCRT。