Windows 乱码问题研究
乱码问题
乱码问题一直是 Windows 系统的老大难问题,究其原因,主要有如下几点:
- 程序源代码的编码格式:以 Microsoft Visual Studio 为例,若开发者在开发时未规范地使用 UTF-8 等 1 Unicode 文件编码格式作为源代码文件编码,而是采用默认的 ANSI 编码(如 GB2312),则在编译链接后的可执行文件中,多字节字符串(例如使用
const char*类型声明的字符串)就会以 ANSI 编码保存,这进一步导致程序与 Windows 代码页深度绑定,一旦在其他代码页环境下运行,就会导致乱码。一个典型的例子就是使用printf打印字符串到终端:printf函数的行为是将多字节字符串的字节流原封不动地“搬”到终端,假定源文件以 GB2312 编码,那么终端进程就必须以 GB2312 (对应于 936 代码页)进行解码才能正确显示字符串。这种情况下,如果同时有 A、B、C 三个程序,其源代码分别以 GB2312、SHIFT-JIS、ISO-8859-1 进行编码,就会不可避免造成冲突。对于这样的情形,只能为每个进程进行适配,即 A 进程运行时用 GB2312,B 进程运行时用 SHIFT-JIS,以此类推。这种思路推而广之就是为每个进程模拟出其所需要的区域格式和代码页 2 。 - 字符串相关的 Windows API:在现代 Windows 程序开发中,应该完全摒弃
A后缀的 ANSI Windows API,转而使用W后缀的宽字符 UTF-16 Windows API(除非开发者知道自己在做什么)。然而,C / C++ 中像fopen这样的语言标准库接口,由于其输入参数本身是const char*这类指示多字节字符串的类型,因此其实现不可避免地要调用WriteFileA,因而极易导致多字节字符串被错误编码的问题 3 。这种问题在从 Linux 等平台移植而来的程序上体现的尤为明显,这些程序中的多字节字符串虽然普遍采用 UTF-8 编码,但却调用了 ANSI Windows API,进而使得 UTF-8 字符串以 ANSI 格式进行解码导致乱码。要处理这样的问题,只能让程序在代码页和区域格式设定为 UTF-8(65001)的环境下运行。
解决方案
要想比较完美地解决乱码问题,一个非常自然的思路就是为每个进程单独模拟一个区域格式和代码页环境。 微软最开始提供了 AppLocale 来实现这种功能,但是很快就烂尾了,网络上虽然有 AppLocale 可供下载,但在新系统上由于兼容性问题已经无法正常使用。 后来,还出现了 Locale Emulator 这样的解决方案 4 。Locale Emulator 通过 DLL 注入的方式,使用钩子技术 hook 与区域格式相关的 Windows API 来实现运行环境的模拟。Locale Emulator 在涉及程序 UI 显示时存在一些瑕疵,不过总体上还是非常不错的。
到这里,对于从 Linux 移植而来的程序,其乱码问题仍没有得到很好的解决。前面已经提到过,这些程序需要一个代码页为 UTF-8 的运行环境。 微软也意识到了这个问题,因此从 Windows 10 1903 开始,微软提供了全局 UTF-8 的选项,对于没有指定区域格式的应用程序,默认使用 UTF-8 作为代码页(参阅 Use UTF-8 code pages in Windows apps)。该方案可较好地解决 Linux 平台移植而来的程序乱码问题。 然而,一旦开启此选项,依赖 ANSI 代码页的程序又会出现乱码问题。
于是,连同此选项一起出现的还有 应用程序清单(App Manifest) 中的 activeCodePage 属性(参阅 Use UTF-8 code pages in Windows apps、Application manifests),可以强制某个程序使用 UTF-8 代码页。
要使用该功能,需要 Windows SDK 中的 mt.exe 程序 5 ,并编写一份像下面这样的清单:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity type="win32" name="..." version="6.0.0.0"/>
<application>
<windowsSettings>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
</assembly>
然后,在 shell 中执行下面的命令:
mt.exe -manifest <MANIFEST> -outputresource:"<EXE>;#1"
其中,<MANIFEST> 为清单文件,<EXE> 为需要强制使用 UTF-8 代码页的程序,#1 表示清单文件序号 6 。
执行后,会将 activeCodePage 信息注入指定的可执行文件中,之后再运行指定程序时就会使用 UTF-8 代码页。
从 Windows 11 开始,activeCodePage 还可以设置为像 zh-CN、ja-JP、en-US 这样的区域格式,注入后,会使用该区域格式对应的默认代码页(如 en-US 对应的代码页就是 1252)。
有了这样的功能,就可以可以先将系统代码页全局设定为 UTF-8,并对不支持 UTF-8 的程序单独注入清单。
清单注入有两种方法:
- 内嵌清单:将清单内容嵌入到可执行文件中,这样在运行时不需要额外的清单文件。 7
- 外部清单:将清单文件存放在可执行文件所在目录下,命名为
<EXE>.1.manifest(可执行文件的.1可省略,即<EXE>.manifest),装载器在运行时会自动加载该清单。
需要指出,外部清单方式并不总是有效,具体取决于程序链接时的选项。
内嵌清单拥有比外部清单更高的优先级,因此如果可执行文件自身已经内嵌了清单,则外部清单无效。
要了解更多细节,请参阅:/MANIFEST (Create side-by-side assembly manifest)。
一般而言,先尝试注入外部清单,如果外部清单无效,再尝试通过内嵌方式进行注入。
一个需要关注的细节是 mt.exe 在注入内嵌清单时,会直接覆盖已有的清单内容,因此需要先用 mt.exe 先将可执行文件的内嵌清单提取出来(如果有的话)与新的清单内容合并,最后再注入可执行文件。
为了更方便地实现清单注入,可编写如下 PowerShell 脚本,实现工具函数 InjectLocale:
function EmbedLocaleInManifest {
param([XML]$Manifest, [String]$Locale = "UTF-8")
$asm_Namespace = "urn:schemas-microsoft-com:asm.v1"
$WindowsSettings_Namespace = "http://schemas.microsoft.com/SMI/2019/WindowsSettings"
# <assembly>
$assemblyNode = $Manifest.assembly
if (-not $assemblyNode) {
$assemblyNode = $Manifest.CreateElement("assembly", $asm_Namespace)
$Manifest.DocumentElement.AppendChild($assemblyNode) | Out-Null
}
# <application>
$applicationNode = $assemblyNode.application
if (-not $applicationNode) {
$ns = $Manifest.DocumentElement.NamespaceURI
$applicationNode = $Manifest.CreateElement("application", $ns)
$assemblyNode.AppendChild($applicationNode) | Out-Null
}
# <windowsSettings>
$windowsSettingsNode = $applicationNode.windowsSettings
if (-not $windowsSettingsNode) {
$ns = $Manifest.DocumentElement.NamespaceURI
$windowsSettingsNode = $Manifest.CreateElement("windowsSettings", $ns)
$applicationNode.AppendChild($windowsSettingsNode) | Out-Null
}
# <activeCodePage>
$activeCodePageNode = $windowsSettingsNode.activeCodePage
if (-not $activeCodePageNode) {
$activeCodePageNode = $Manifest.CreateElement("activeCodePage", $WindowsSettings_Namespace)
$windowsSettingsNode.AppendChild($activeCodePageNode) | Out-Null
}
$activeCodePageNode.InnerText = $Locale
return $Manifest
}
function InjectLocale {
param([String[]]$FilePath, [String]$Locale = "UTF-8", [Switch]$Embed)
$activeCodePage_Manifest = (@"
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity type="win32" name="..." version="6.0.0.0"/>
<application>
<windowsSettings>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">$Locale</activeCodePage>
</windowsSettings>
</application>
</assembly>
"@).Trim()
try {
if ($Embed) { # 清单内嵌到 exe 中
$mt = Get-Command -Name mt -ErrorAction Stop
ForEach-Object -InputObject $FilePath {
if (-not (Test-Path $_)) {
Write-Error "File not found: $_"
continue
}
$ManifestFile = $null
try {
$ManifestFile = New-TemporaryFile # 创建临时清单,供 mt 使用
$Manifest = $null # 清单对象
&$mt -inputresource:"$FilePath;#1" -out:"$($ManifestFile.FullName)" # 导出内嵌清单
if ($ManifestFile.Length -gt 0) {
# 内嵌清单存在
$Manifest = [XML](Get-Content $ManifestFile.FullName)
EmbedLocaleInManifest -Manifest $Manifest -Locale $Locale | Out-Null # 嵌入 locale 信息
$Manifest.Save($ManifestFile.FullName) # 保存修改后的清单
}
else {
Out-File -InputObject $activeCodePage_Manifest -FilePath $ManifestFile.FullName -Encoding UTF8
}
&$mt -manifest "$($ManifestFile.FullName)" -outputresource:"$_;#1"
}
catch {
Write-Output $_
}
finally {
if ($ManifestFile) { Remove-Item $ManifestFile }
}
}
}
else {
ForEach-Object -InputObject $FilePath {
if (-not (Test-Path $_)) {
Write-Error "File not found: $_"
continue
}
# 添加 .manifest 后缀后保存为副本
$ManifestFileName = "$_.manifest"
Out-File -InputObject $activeCodePage_Manifest -FilePath $ManifestFileName -Encoding UTF8
}
}
}
catch {
Write-Error $_
}
}
复制上述代码到剪切板,在 PowerShell 中执行:Get-Clipboard | Out-String | Invoke-Expression,即可将函数定义导入到当前 PowerShell 会话中。
运行时,可以使用下面的命令将指定了区域格式的清单注入到指定的一个或多个可执行文件中:
InjectLocale -FilePath "C:\Path\To\foo.exe","C:\Path\To\bar.exe" -Locale "UTF-8" -Embed # 内嵌清单(需要预装 `mt.exe`)
InjectLocale -FilePath "C:\Path\To\foo.exe","C:\Path\To\bar.exe" -Locale "UTF-8" # 外部清单
上述支持同时处理一个或多个可执行文件。默认情况下创建外部清单,创建内嵌清单需要手动指定 -Embed 选项。
创建内嵌清单时,需要预装 Windows SDK,并确保 mt.exe 包含在 $Env:PATH 列出的路径中。
另外,如上文所述,注入除 UTF-8 以外的区域格式需要 Windows 11 及以上版本,否则只能注入 UTF-8 或 Legacy 代码页。详见 activeCodePage。
几个例子
我们可以编写几个例子来观察清单注入功能带来的影响,测试所用的系统区域格式为 zh-CN,即简体中文(中国),对应代码页 936。
setlocale 获取默认区域格式
// locale.c
#include <locale.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
const char* locale = setlocale(LC_ALL, "");
printf("Current locale: %s", locale);
return 0;
}
注入前后对比效果如下(ja-JP 对应代码页为 932):
从这个例子我们也可以看出,区域格式(示例中的 Chinese (Simplified)_China)与代码页(示例中的 936 和 932)之间并不是非得绑定的。
适配 Linux 移植而来的程序
前面提到,在 Windows 平台下像 fopen 这样的标准库接口在创建文件时上会调用 CreateFileA,创建时传入的文件名字符串 lpFileName 在并不是 ANSI 编码的字符串,而是 UTF-8 编码的字符串(除非开启全局 UTF-8,这时 ANSI 代码页就是 UTF-8 了),因此,对于从 Linux 移植而来的程序,需要一个代码页为 UTF-8 的运行环境。
我们通过下面的例子来验证这一点:
#include <Windows.h>
#include <stdio.h>
int main (int argc, char *argv[])
{
HANDLE hFile = CreateFileA("测试", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile) CloseHandle(hFile);
FILE* pf = NULL;
fopen_s(&pf, "验证", "w");
if (pf) fclose(pf);
return 0;
}
在注入清单前,运行后创建的文件名称出现乱码:
注入后,运行后创建的文件名称正确显示:
适配使用其他本地代码页的程序
在这个例子里,我们编写一个创建日语文件名称的程序,比较清单注入前后的效果。
由于测试环境是区域格式是简体中文,因此没有直接的办法编译一个使用日语 SHIFT-JIS 编码的源代码。
所以,下面的代码实现取了个巧,将日语字符串“真夏の夜の淫梦”在 SHIFT-JIS 下的编码直接存入一个字节数组中,然后使用 CreateFileA 创建文件。
#include <Windows.h>
int main(int argc, char *argv[])
{
const char filename[] = {
0x90, 0x5E, 0x89, 0xC4, 0x82,
0xCC, 0x96, 0xE9, 0x82, 0xCC,
0x88, 0xFA, 0x9A, 0xEB, 0x00,
0x00, 0x00, 0x00, 0x00
};
HANDLE h = CreateFileA(
filename,
GENERIC_WRITE,
0,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL
);
CloseHandle(h);
return 0;
}
直接运行编译链接后的可执行文件,创建的文件名现乱码:
这是很显然的,CreateFileA 函数使用了当前代码页(936)对文件名进行解码。
可以看到,注入了 activeCodePage 为 ja-JP 的清单后,文件名正确显示。
一些思考
清单注入是一个非常有用的功能,可以解决 Windows 平台下的乱码问题。 清单注入存在的一个局限性是需要修改可执行文件,所以如果可执行文件启动的时候执行校验的话就会出问题。
目前没遇到过,主要是像这种启动的时候还知道要校验的程序开发的时候都比较规范,对 Unicode 的支持基本没啥问题。
另请参阅
Windows 平台下,区域格式(locale)和代码页(code page)深度绑定,比如当我们提及简体中文(中国)时,对应的默认代码页就是 936。因此,文中在不引起混淆的情况下,有时会交换使用这两个概念。但是,严格来说,这二者是不可混淆的。这二者之间的这种深度绑定的关系主要还是由于一系列的历史遗留问题,绝不意味着某种区域格式就必须使用特定的代码页。 区域格式主要影响日期、时间、货币等与区域相关的数据的表示方式,而代码页则影响程序中的字符串如何编码和解码。
微软为这些涉及字符串的库函数提供了宽字符版本,这些宽字符版本大都不是标准库的一部分;现代 C++ 中也有像
<filesystem>这样的解决方案,可以编写出移植性良好的 C++ 程序。然而,以上这些并不能完全解决 Linux 程序移植到 Windows 平台所要面临的问题——Linux 开发中几乎没有人会使用宽字符相关的函数,更不用说现存的大量老旧代码。Microsoft Visual Studio 中随附 Windows SDK,也可从 此处 单独下载 Windows SDK。