PInvoke与字符串转码

本文部分内容的参考文献
https://www.codeproject.com/Articles/138614/Advanced-Topics-in-PInvoke-String-Marshaling

相关文章通过Patch DLL修正QQ的状态推送 (←写于Win10 1511时期)

 

Win10 1803增加了一个Beta版UTF8区域支持。

这实际上是增加了一个真正的UTF8区域(locale),代码页为65001(UTF8)。启用后,Encoding.Default返回UTF8EncodingSealed,记事本默认编码为UTF8(打开GB2312的txt,乱码!),WinRAR默认编码也为UTF8(打开GB2312环境压缩的压缩包,中文文件名乱码!)。看上去是一个添麻烦的设置,却是向着天下大同迈出的重要一步。

众所周知Windows不同区域的编码问题一直困扰着很多用户(特别是全世界的Galgame玩家)。据观测,stackoverflow上被提及最多的codepage,除了65001(UTF8)就是932(SHIFT-JIS)和936(GB2312),可见中、日文程序占据转码相关问题的主要部分。这一点虽然巨硬有锅,但最大的问题还是出在广大C++开发者写代码时不使用Unicode/UTF8编码的字符串,还停留在ANSI(请注意区分与ASCII的区别)字符串不能自拔。

在不同的系统区域中,将一串字节解码为字符串的默认编码器是不同的,同一段字节,在中文系统中代表一个字,而在日文系统中代表另一个字,这就导致了乱码。因此从AppLocale到Locale Emulator,有许多转区工具致力于克服这个问题。由国人大佬许培飞(音)开发的Locale Emulator应该是目前实际效果(特别是对Win10支持)最好的转区工具,然而它也仍然有不支持64位程序、不支持.NET程序*的缺陷。唯一通用的解决这个问题的方法是更改“非Unicode程序的语言”(见上图),但这需要重启并同样会带来上一段所述的问题。

*.NET程序本身是不需要转码的,因为.NET已实现字符串全部使用Unicode的“大一统”,问题出在.NET程序与C++库交互(PInvoke)时的字符串转码过程。Locale Emulator不支持这种情况,导致很多基于.NET的日文软件如VOICEROID2无法在别的区域运行。而这个问题并非无法解决,例如VOICEROID2完全是由于设计者缺乏相关意识和知识,在PInvoke相关的代码编码时忽视了转码,才导致问题的出现,即使软件附带英文说明书,实际上也无法在除日文之外的其他区域运行(除非更改非Unicode程序的语言)!

 

对于Native(本机)的DLL的导出函数,如

void my_function(char* data);

应该如何在.NET中调用此函数,而不发生转码错误呢?

假设这是一个SHIFT-JIS(简称JIS)编码下编译的native dll,而我们的环境是GB2312/GBK(简称GB)国区。data仅作为传入参数(非ref/out)。

通常的PInvoke定义是这样的:

[DllImport("native.dll")]
extern static unsafe void my_function(string data)

在DllImport标签中是无法设置字符串编码的,只能设置CharSet(字符集)为ANSI或Unicode。当然此时native dll使用的是ANSI(若是Unicode就很难存在编码问题了)。另外传入参数可设置MarshalAs标签,如[MarshalAs(UnmanagedType.LPStr)] string data,但是这个标签用于字符串只是设置不同的字符串类型(影响结尾格式)等,对于编码没有太多作用。

一个首先会想到的方案

“一个首先会想到的方案”(参见相关文章)就是保证native dll收到的字节是正确的。为实现这一方案,有如下代码:

            var jis = Encoding.GetEncoding("SHIFT-JIS");
            var gb = Encoding.Default; //Encoding.GetEncoding("GB2312")
            var ori = "おはよう";
            var convert = gb.GetString(jis.GetBytes(ori)); //"偍偼傛偆"
            var back = jis.GetString(gb.GetBytes(convert)); //"おはよう"

[1]获取JIS编码的"おはよう"的字节。

[2]用当前系统编码(Encoding.Default,即GB2312)把字节编码成(乱码)字符串"偍偼傛偆"。

[3]将这个乱码字符串传给pinvoke的my_function方法,此时.NET会按当前系统代码页也就是GB去获取这个字符串的字节(实际上与[1]中的字节完全相同),这正好是[2]的逆过程。

[4]native dll收到字节,并用JIS编码将字节编码为字符串"おはよう",这正好是[1]的逆过程。

总结成一句代码:

my_function(Encoding.Default.GetString(Encoding.GetEncoding("SHIFT-JIS").GetBytes(data)));

看上去挺不错,但事实上这并不通用。还记得上面的最新科技UTF8 locale吗?开启之后,这个方案会怎样……

            var jis = Encoding.GetEncoding("SHIFT-JIS");
            var utf8 = Encoding.Default; //Encoding.UTF8
            var ori = "おはよう";
            var convert = utf8.GetString(jis.GetBytes(ori)); //"���͂悤"
            var back = jis.GetString(utf8.GetBytes(convert)); //"・ス・ス・スヘよう"

可以看到,结果呈现部分乱码。为什么?原因在于UTF8的编码方式非常复杂。在UTF8中,一个字符(char)可能是占1到6个字节,对于多字节字符,“其第一个字节从最高位开始,连续的二进制位值为1的个数决定了其编码的字节数”,意味着它在编码时会将某些字节的一部分看做计数而非字符内容。因此当我们试图用UTF8的编码器强行编码JIS/GBK的字节时,由于UTF8的编码机制,最终得到的乱码字符串所代表的字节已经变了(不再与JIS字符串的字节相同了)。因此即使再转换回去,也无法得到原来的JIS字符串了。关于这个问题的探讨,可继续阅读相关文章

此时有人(包括我)可能会忘记大前提,问为什么要用Encoding.Default?我用Encoding.GetEncoding("GB2312")来代替Encoding.Default编码不行吗?当然是不行的,请看上面的步骤[3]——在PInvoke机制中,总会按当前系统代码页去获取字符串的字节来传给native dll,这个过程是无法控制的(除非像LE那样用hook手段)。而此时我们的系统代码页正是UTF8,所以我们在步骤[2]中只能用Encoding.Default(或Encoding.UTF8)来保持对称。也就是说,虽然这个方案适用于932(SHIFT-JIS)/936(GB2312)区域,但这是因为JIS和GB的编码方式接近(用1个字节表示字母、数字与常用符号,用2个字节表示汉字等),所以在一般情况下用乱码GB字符串作为真实JIS字符串的载体不会有问题。但是换成UTF8与GBK/JIS互转就没这么简单了。

 

科学的方案

真正科学的方案是要在PInvoke的方法定义上下功夫。对于上述native方法,我们在PInvoke定义时可写成这样:

[DllImport("native.dll")]
extern static unsafe void my_function(byte[] data)

这里我们把原native函数中的char* data定义成了byte[] data。再次强调,在此场景中,data仅作为传入参数(非ref/out)。否则这样定义是无法接收到native端返回的修改后的data值的。后面会再详述。

这样我们就可以跳过上面产生问题的步骤[3]了,代码如下:

my_function(Encoding.GetEncoding("SHIFT-JIS").GetBytes(data));

在此方案中,我们实际上跳过了步骤[2]和[3],只进行了步骤[1]和[4],而这两个步骤是不会出问题的。且这样就不再依赖当前代码页了,无论你是932、936还是65001,都一样适用!至此问题得到解决。

 

如果native函数中的data是一个传出参数,即native方法会设置data的值然后由.NET端使用,这种情况该如何处理呢?

通常在这种情况下,native函数需要告知char*所代表的字符串长度(如果不告知,则要约定最大长度)。native函数定义如下:

void my_function(char *data, int len);

采用如下PInvoke定义:

[DllImport("native.dll")]
extern static unsafe void my_function(IntPtr data, out int len)

这次我们把原native函数中的char* data定义成了IntPtr data。

此后一次完整的调用过程如下:

            //申请一块非托管内存,maxLength指定一个当前场景中可能的最大大小
            IntPtr ptr = Marshal.AllocHGlobal(maxLength);
            my_function(ptr, out int len);
            byte[] dataBytes = new byte[len];
            Marshal.Copy(ptr, dataBytes, 0, len);
            var data = Encoding.GetEncoding("SHIFT-JIS").GetString(dataBytes);
            if (ptr != IntPtr.Zero)
            {
                Marshal.FreeHGlobal(ptr); //释放内存
            }

首先我们申请一块非托管内存并得到对应的指针,然后将指针传给native方法,native方法会将字符串填充在这块内存中。随后我们将填充了的部分(JIS字符串的字节)复制出来并编码为字符串。最后把申请的内存释放以避免内存泄露。至此问题得到解决。

这里需要注意,Marshal.AllocHGlobal / Marshal.FreeHGlobal 虽然大多数时候都是没问题的,但是在某些native dll有特殊操作的场合中是无法使用的,详情参阅参考文献。

如果native函数较多,要在每个PInvoke调用上糊一段上述代码还是比较繁琐的,但是上面提到的MarshalAs标签支持CustomMarshaler,通过使用这种机制可以将本文最初的native方法的PInvoke定义表示如下:

[DllImport("native.dll")]
extern static unsafe void my_function(
[MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef=typeof(ShiftJISMarshaler))] string data)

在ShiftJISMarshaler(继承自ICustomMarshaler)中我们可参照上文的描述定义托管到本机(object -> IntPtr)和本机到托管(IntPtr -> object)的双向转换。使用CustomMarshaler的好处是我们不再需要对应每个函数糊一段Encoding.GetBytes()/Encoding.GetString(),不过缺点是由于是object和IntPtr的相互转换,写起来还是会有点麻烦的(相对于只需一行的Encoding而言)。

关于CustomMarshaler的写法可参照参考文献。

评论 (2) -

  • Win10 这个支持很棒,不过似乎还要一段很长的时间.
    在试用期间,相当多的C++库.Net ,GAL game , 甚至 Telegram 都无法很好地运行,
    而就我所看到的,真的很多的 .Net应用 是用 Encoding.Default 转换,因而会产生问题.
    希望有生之年Net 库和C++库都有相应的更新支持吧,不然一些不再维护的应用(特指GAL game)还是无法直接运行正常啊.
    谢谢U大的详尽的讲解,受益不少
    • 其实我现在自用的电脑还在坚持使用这个UTF8区域,并且将所有不兼容的程序全部淘汰了(包括WinRAR)。这也有助于强迫自己使用UTF8(包括文本文档、代码源文件等),并发现自己程序潜在的兼容问题。
      至于Galgame,大部分还是可以用LE去转码的。而.NET应用,一般我可以自己patch,如我制作的VOICEROID2转区补丁,可以让大家在各种区域(包括国区和UTF8区域)的系统上使用此软件(原本仅日区能正常运行,开发者似乎没想解决此问题,在最近的更新中只是针对此问题添加了一个提示框)。

添加评论

Loading