From:
http://msdn.microsoft.com/zh-cn/magazine/cc164193.aspx
http://msdn.microsoft.com/en-us/magazine/cc164193.aspx
Code download available at: CLRInsideOut2008_01.exe (1269 KB)
CLR 完全介绍
托管代码与非托管代码之间的封送处理
Yi Zhang and Xiaoying Guo
让我们面对现实吧。这个世界并不完美。几乎很少有公司在完全用托管代码开发程序,除此之外仍存在很多需要您处理的旧式非托管代码。您怎样将托管和非托管项目集成起来呢?在形式上是采用从托管应用程序调用非托管代码,还是从非托管代码应用程序调用托管代码?
幸运的是,Microsoft® .NET Framework 互操作在托管和非托管代码之间开辟了一条通道,而封送处理则在该连接中扮演着非常重要的角色,因为它允许在两者之间进行数据交换(请参见图 1)。有很多因素会影响 CLR 在非托管和托管领域之间封送数据的方式,包括诸如 [MarshalAs]、[StructLayout]、[InAttribute] 和 [OutAttribute] 等属性,以及 C# 中 out 和 ref 之类的语言关键字。
Figure 1 Bridging the Gap between Managed and Unmanaged Code (单击该图像获得较大视图)
因为这些因素很多,所以它可能是进行正确封送的一大难题,因为这项工作要求了解很多有关非托管和托管代码的详细情况。在本专栏中,我们会介绍您在日常工作中尝试进行封送处理时将遇到的一些基本却又容易混淆的主题。我们不会介绍自定义封送处理、封送处理复杂的结构或其他高级主题,但是如果真正理解了这些基本的概念,您就为处理这些问题做好准备了。
[InAttribute] 和 [OutAttribute]
我们要讨论的第一个封送处理主题是关于 InAttribute 和 OutAttribute 的使用,这是位于 System.Runtime.InteropServices 命名空间中的两种属性类型。(在将这些属性应用到您的代码中时,C# 和 Visual Basic® 允许使用缩写形式 [In] 和 [Out],但是为了避免混淆我们坚持使用全名。)
当应用于方法参数和返回值时,这些属性会控制封送处理的方向,因此它们又被称为方向属性。[InAttribute] 告知 CLR 在调用开始的时候将数据从调用方封送到被调用方,[OutAttribute] 则告知 CLR 在返回的时候将数据从被调用方封送回调用方。调用方和被调用方都可以是非托管或托管代码。例如,在 P/Invoke 调用中,是托管代码在调用非托管代码。但是在反向 P/Invoke 调用中,就可能是非托管代码通过函数指针调用托管代码。
[InAttribute] 和 [OutAttribute] 有四种可能的使用组合:只用 [InAttribute]、只用 [OutAttribute]、同时使用 [InAttribute, OutAttribute] 以及两者都不用。如果没有指定任何一个属性,那就是要 CLR 自己确定方向属性,默认情况下通常是使用 [InAttribute]。但是,如果是 StringBuilder 类,则在没有指定任何一个属性的情况下,会同时使用 [InAttribute] 和 [OutAttribute]。(有关详细信息,请参阅后面有关 StringBuilder 的部分。)另外,使用 C# 中的 out 和 ref 关键字可能会更改已应用的属性,如图 2 所示。请注意,如果没有为参数指定关键字,就意味着它是默认的输入参数。
Figure 2 Out and Ref and Their Associated Attributes
请看一下图 3 中的代码。其中有三个本机 C++ 函数,并且它们都对 arg 进行相同的更改。此外,请注意对字符串操作使用 strcpy 仅仅是为了便于说明——生产代码应改用这些函数的安全版本,它们可在 msdn.microsoft.com/msdnmag/issues/05/05/SafeCandC 中找到。
Figure 3 Trying Out Directional Attributes
唯一的不同是我们在 P/Invoke 签名中使用方向属性调用它们的方式,如下面的 C# 代码所示:
[DllImport(@"MarshalLib.dll")] |
如果您通过 P/Invoke 从托管代码调用这些函数,并将“Old”作为字符数组传递给这些函数,就会获得以下输出(出于演示目的而有所缩减):
Before Func_In_Attribute: arg = Old |
让我们进一步看一下结果。在 Func_In_Attribute 中,传入了原始值,但是在 Func_In_Attribute 内部发生的更改并没有传播回来。在 Func_Out_Attribute 中,没有传入原始值,而 Func_Out_Attribute 内部发生的更改已传播回来了。在 Func_InOut_Attribute 中,传入了原始值,并且 Func_Out_Attribute 内部发生的更改也已传播回来。然而,只要您稍做修改,情况就完全不同了。这一次让我们修改一下本机函数以使用 Unicode,如下所示:
MARSHALLIB_API void __stdcall Func_Out_Attribute_Unicode(wchar_t *arg) |
在此我们声明了 C# 函数,仅应用 [OutAttribute],并将 CharSet 更改为 CharSet.Unicode:
[DllImport(@"MarshalLib.dll", CharSet=CharSet.Unicode)] |
以下是输出:
Before Func_Out_Attribute_Unicode: arg = Old |
有趣的是,尽管没有 [InAttribute],也还是传递了原始值。[DllImportAttribute] 会告知 CLR 封送 Unicode,而且由于 CLR 中的字符类型也是 Unicode,所以 CLR 发现了一个优化封送处理的机会,即固定字符数组然后直接传递该字符的地址。(稍后您将看到有关复制和固定的详细介绍。)然而,这并不意味着您应依赖这种行为。相反,在不依赖 CLR 默认封送行为的时候,应始终使用正确的封送方向属性。这种默认行为的典型例子是使用 int 参数的情况;不必指定 [InAttribute] int arg。
某些情况下,[OutAttribute] 将被忽略。例如,由于 [OutAttribute]int 没有任何意义,所以 CLR 便忽略这个 [OutAttribute]。同样,[OutAttribute] 字符串也是如此,因为字符串是固定不变的。
接口定义 (IDL) 文件也具有 [in] 和 [out] 属性,它们可视为与 CLR 中的 [InAttribute] 和 [OutAttribute] 相同。
关键字 Out 和 Ref 以及通过引用传递
之前,我们已经介绍了 C# 的 out 和 ref 关键字可以被直接映射到 [InAttribute] 和 [OutAttribute]。事实上,out 和 ref 还可以改变作为 CLR 封送对象或封送目标的数据类型。将数据作为 out 或 ref 传递与通过引用传递相同。如果使用 ILDASM 来检查中间语言 (IL) 中对应的函数签名,您会看到该类型旁边有一个 & 字符,它表示该参数应通过引用传递。在通过引用传递时,CLR 会增加额外的中间环节。图 4 列举了几个示例。
Figure 4 Marshaling Results
让我们总结一下针对图 5 所示表格中的 out 和 ref 所讨论的内容。
Figure 5 Default Attributes
请注意,在通过引用传递时,如果没有指定方向属性,CLR 就会自动应用 [InAttribute] 和 [OutAttribute],这就是图 4 中的 Microsoft 中间语言 (MSIL) 签名中只有“string &”的原因。如果指定了任何这些属性,CLR 将遵循它们,而不是采用默认行为,如下例所示:
public static extern void |
以上签名会替代 ref 的默认方向行为,将它变成仅使用 [InAttribute]。在此特定的情况下,如果您执行 P/Invoke,那么指向 ComplexStructure(它是一个值类型)的指针会从 CLR 端传递到本机端,但是被调用方无法使任何改动对 pStructure 指针所指向的 ComplexStructure 可见。图 6 列举了一些方向属性和关键字组合的其他示例。
Figure 6 More Attributes and Keywords
返回值
至此为止我们仅讨论了参数。从函数返回的值又如何呢?CLR 会自动将返回值视为使用 [OutAttribute] 的普通参数。同时,CLR 还可以转换函数签名,这是一个由 PreserveSigAttribute 控制的过程。如果在应用于 P/Invoke 签名时 [PreserveSigAttribute] 被设为 false,CLR 就会将 HRESULT 返回值映射到托管异常,并且它会将 [out, retval] 参数映射到该函数的返回值。因此下面的托管函数签名
public static string extern GetString(int id); |
会变成非托管签名:
HRESULT GetString([in]int id, [out, retval] char **pszString); |
如果 [PreserveSigAttribute] 被设为 true(P/Invoke 的默认值),此转换就不会发生。请注意,对于 COM 函数而言,[PreserveSigAttribute] 通常默认设为 false,不过有很多方法可以改变此设置。有关详细信息,请查看有关 TlbExp.exe 和 TlbImp.exe 的 MSDN® 文档。
StringBuilder 和封送处理
CLR 封送拆收器具有内置的 StringBuilder 类型知识,并且处理它的方式与处理其他类型不同。默认情况下,StringBuilder 作为 [InAttribute, OutAttribute] 传递。StringBuilder 很特别,因为具有 Capacity 属性(该属性可以在运行时确定必需缓冲区的大小),并且它可被动态地更改。因此,在封送过程中,CLR 可以固定 StringBuilder,直接传递在 StringBuilder 中使用的内部缓冲区的地址,并允许适当的本机代码更改该缓冲区的内容。
为了充分利用 StringBuilder,您将需要遵循下列所有规则:
- 不要通过引用传递 StringBuilder(使用 out 或 ref)。否则,CLR 会认为该参数的签名是 wchar_t **,而不是 wchar_t *,并且它将无法固定 StringBuilder 的内部缓冲区。性能会大大降低。
- 当非托管代码使用 Unicode 时使用 StringBuilder。否则,CLR 将不得不复制该字符串,并将它在 Unicode 和 ANSI 之间转换,这样会降低性能。通常情况下,您应将 StringBuilder 作为 Unicode 字符的 LPARRAY 或作为 LPWSTR 封送。
- 始终提前指定 StringBuilder 的容量,并确保该容量对存放缓冲区而言足够大。在非托管代码端的最佳做法是接受字符串缓冲区的大小作为参数,以避免缓冲区溢出。在 COM 中,您还可以使用 IDL 中的 size_is 来指定大小。
复制和固定
当 CLR 执行数据封送时,它有两个选择:复制和固定(请参阅 msdn2.microsoft.com/23acw07k)。
默认情况下,CLR 会创建一个将在封送过程中使用的副本。例如,如果托管代码要将某个字符串作为 ANSI C-String 传递到非托管代码,CLR 会复制该字符串,将其转换成 ANSI,然后将该临时对象的指针传递到非托管代码。该复制过程可能会相当慢,并可能造成性能问题。
在某些情况下,CLR 可通过将托管对象直接固定到垃圾收集器 (GC) 堆来优化封送处理,这样在调用过程中就无法重定位它。指向托管对象(或指向托管对象内部某个位置)的指针将被直接传递到非托管代码。
当满足下列所有条件之后就可以执行固定:第一,托管代码必须调用本机代码,而不是本机代码调用托管代码。第二,该类型必须可直接复制或者必须可以在某些情况下变得可直接复制。第三,您不是通过引用传递(使用 out 或 ref)。第四,调用方和被调用方位于同一线程上下文或单元中。
第二条规则需要进一步说明一下。可直接复制类型是指在托管和非托管内存中具有共同表示方法的类型。因此,在进行封送处理时可直接复制类型不需要进行转换。不可直接复制但能够变成可直接复制的类型的典型例子是字符类型。默认情况下,它不可直接复制,因为它可被映射到 Unicode 或 ANSI。然而,由于字符在 CLR 中始终是 Unicode,所以当指定了 [DllImportAttribute(CharSet= Unicode)] 或 [MarshalAsAttribute(UnmanagedType.LPWSTR)] 时,它会变成可直接复制。在下面的示例中,arg 可被固定在 PassUnicodeString 中,但是无法固定在 PassAnsiString 中:
[DllImport(@"MarshalLib.dll", CharSet = CharSet.Unicode)] |
内存所有权
在函数调用期间,函数可对它的参数进行两种类型的更改:引用更改或就地更改。引用更改涉及更改指针指向的位置;如果指针已指向一块已分配的内存,那么就需要首先释放内存,否则指向它的指针会丢失。就地更改涉及更改引用所指向位置的内存。
进行哪一种更改取决于参数的类型以及(最重要的是)被调用方和调用方之间的约定。但是,由于 CLR 无法自动了解合约,所以它不得不依赖有关类型的常识,如图 7 所示。
Figure 7 CLR Type Knowledge
如前所述,在通过引用传递时只有引用类型有两层中间环节(但是也有一些例外,例如“[MarshalAs(UnmanagedType.LPStruct)]ref Guid”),所以只有指向引用类型的指针或引用可以更改,如图 8 所示。
Figure 8 Type Change Rules
您不必担心就地更改所需的内存所有权,因为调用方已为被调用方分配了内存,而且调用方拥有这些内存。在此我们以“[OutAttribute] StringBuilder”为例。相应的本机类型为 char *(假设是 ANSI),因为我们不是通过引用传递。数据被封送出去,而不是封送进来。内存由调用方(在本例中是 CLR)分配。内存的大小由 StringBuilder 对象的容量确定。被调用方不需要关注内存。
为了更改字符串,被调用方会自己直接更改该内存。但是,在进行引用更改时,分清谁拥有哪个内存非常重要,否则可能会发生很多意外的后果。关于所有权问题,CLR 遵循 COM 风格的约定:
- 作为 [in] 传递的内存归调用方所有,应由调用方分配,由调用方释放。被调用方不应尝试释放或修改该内存。
- 由被调用方分配并作为 [out] 传递或返回的内存归调用方所有,应由调用方释放。
- 被调用方可释放作为 [in, out] 传递自调用方的内存,为其分配新的内存,并覆盖原有的指针值,从而将其传递出去。新内存归调用方所有。这需要两层中间环节,例如 char **。
在互操作领域中,调用方/被调用方变成了 CLR/本机代码。上述规则意味着,在解除固定的情况下,如果在本机代码中接收到作为 [out] 传递自 CLR 的一个内存块的指针,您就需要释放它。另一方面,如果 CLR 接收到作为 [out] 传递自本机代码的指针,CLR 就需要释放它。显然,在第一种情况下,本机代码需要解除分配,而在第二种情况下,托管代码需要解除分配。
由于这涉及到内存分配和解除分配,所以最大的问题是要使用什么函数。有很多选择:HeapAlloc/HeapFree、malloc/free、new/delete 等等。但是,由于 CLR 在非 BSTR 情况下使用 CoTaskMemAlloc/CoTaskMemFree,而在 BSTR 情况下使用 SysStringAlloc/SysStringAllocByteLen/SysStringFree,所以您就必须使用这些函数。否则就很可能会在某个版本的 Windows® 下发生内存泄漏或故障。我们已经看到过这样的情况,即在 Windows XP 中将经过 malloc 的内存传递给 CLR 之后程序没有发生故障,但在 Windows Vista® 中却发生了故障。
除了这些函数以外,从 CoGetMalloc 返回的系统实现的 IMalloc 接口也很好用,因为在内部它们使用的是同一个堆。但是,最好始终坚持使用 CoTaskMemAlloc/CoTaskMemFree 和 SysStringAlloc/ SysStringAllocByteLen/SysStringFree,因为 CoGetMalloc 将来可能会发生变化。
让我们看一个示例。GetAnsiStringFromNativeCode 采用 char ** 参数作为 [in, out],并返回 char * 作为 [out, retval]。对于 char ** 参数,它可以选择调用 CoTaskMemFree 来释放由 CLR 分配的内存,然后通过使用 CoTaskMemAlloc 来分配新内存,并用新内存的指针覆盖该指针。随后,CLR 会释放该内存,并为托管字符串创建一个副本。对于返回值,它只需要通过使用 CoTaskMemAlloc 来分配新的内存块,然后将其返回给调用方。返回后,新分配的内存即归 CLR 所有。CLR 会先使用它创建新的托管字符串,然后再调用 CoTaskMemFree 释放它。
让我们看一下第一个选择(请参见图 9)。相应的 C# 函数声明如下所示:
Figure 9 Using Pointers
class Lib |
当下面的 C# 代码调用 GetAnsiStringFromNativeCode
string argStr = "Before"; |
输出是:
Before GetAnsiStringFromNativeCode : argStr = "Before" |
如果您准备调用的本机函数没有遵循这个约定,您就必须亲自封送,以避免内存损坏。这种情况很容易发生,因为非托管函数的功能可能会返回它需要的任何内容;它可以每次返回同一块内存,也可以返回由 malloc/new 分配的新内存块,等等,这些还是取决于约定。
除了内存分配以外,传进或传出的内存大小也非常重要。正如在 StringBuilder 情形中所讨论的那样,修改 Capacity 属性也很重要,这样 CLR 便可以分配足够大的内存来存放结果。此外,将字符串作为 [InAttribute, OutAttribute](不使用 out 或 ref 以及任何其他属性)封送并不是明智的办法,因为您不知道该字符串是否够大。您可以使用 MarshalAsAttribute 中的 SizeParamIndex 和 SizeConst 字段指定缓冲区的大小。但是,在通过引用传递时,这些属性将无法使用。
反向 P/Invoke 和委托生存期
CLR 允许将委托传递到非托管领域,这样便可将该委托作为非托管函数指针调用。实际上,结果就是 CLR 创建了一个 thunk,后者将来自本机代码的调用转发给实际委托,然后再转发给真正的函数(请参见图 10)。
Figure 10 Using a Thunk (单击该图像获得较大视图)
通常情况下,您不必担心委托的生存期。将委托传递到非托管代码时,CLR 就会确保委托在调用期间处于活动状态。
但是,如果本机代码在超过调用时间跨度的情况下保留某个指针副本,而且准备在以后通过该指针回调,您可能需要使用 GCHandle 显式阻止垃圾收集器收集该委托。必须提醒您的是,固定的 GCHandle 可能对程序性能有很大的负面影响。幸运的是,在本示例中,您不必分配固定的 GC 句柄,因为该 thunk 已在非托管堆中分配,并且通过 GC 已知的引用间接引用该委托。因此,thunk 无法四处移动,并且当委托自身处于活动状态时本机代码应始终能够通过非托管指针调用该委托。
Marshal.GetFunctionPointerForDelegate 可以将委托转换成函数指针,但是它对保证委托的生存期没有任何作用。请看一下下面的函数声明:
public delegate void PrintInteger(int n); |
如果您为其调用 Marshal.GetFunctionPointerForDelegate 并存储返回的 IntPtr,则将 IntPtr 传递到您准备调用的函数,如下所示:
IntPtr printIntegerCallback = Marshal.GetFunctionPointerForDelegate( |
该委托可能会在您调用 CallDelegateDirectly 之前被收集,您会收到一个 MDA 错误,指出检测到 CallbackOnCollectedDelegate。要修复这个错误,可以将对委托的引用存储在内存中或分配 GC 句柄。
如果本机代码将非托管函数的指针返回给 CLR,本机代码将负责保留实际的函数代码。这通常不是问题,除非该代码位于动态加载的 DLL 中或者是动态生成的。
P/Invoke Interop Assistant
了解并记住到目前为止已介绍的所有属性和规则可能会有一点困难。毕竟,大多数托管代码的开发人员只需要能够快速找到用于 Win32® API 函数的 P/Invoke 签名,将其粘贴到他们的代码中就完成任务了。这正是 P/Invoke Interop Assistant(可从《MSDN 杂志》网站获得)可以发挥作用的地方。此工具可以有效地帮助从 C++ 转换成托管的 P/Invoke 签名,以及它们之间的反向转换。它甚至带有一个包含 Win32 函数、数据类型和常量的数据库,所以将 Win32 P/Invoke 添加到您的 C# 或 Visual Basic 源文件之类常见任务会变得非常简单。此工具软件包中有两个命令行工具 SigImp 和 SigExp,它们可用于文件批处理。该软件包还包含一个 GUI 工具,其中包括上述两个工具的功能。
该 GUI 工具对于进行简单的转换来说非常方便。它包含三个选项卡:SigExp、SigImp Search 和 SigImp Translate Snippet。
SigExp 可将托管签名转换成非托管签名。它反射托管程序集以找到所有 P/Invoke 声明和 COM 导入的类型。根据此输入,它会生成相应的本机 C 签名(请参见图 11)。
Figure 11 P/Invoke Interop Assistant GUI Tool—SigExp (单击该图像获得较大视图)
SigImp Search 和 SigImp Translate Snippet 可将非托管签名转换成托管签名。它们会根据手动输入的本机函数签名的本机类型、函数、常量和代码段,使用 C# 或 Visual Basic 生成托管签名和定义。
SigImp Search 允许用户选择他们希望用于生成代码的托管代码语言,然后选择代码生成所基于的本机类型、过程或常量。该工具会显示从 Windows SDK 头文件收集的受支持类型、方法和常量的列表(请参见图 12)。
Figure 12 P/Invoke Interop Assistant GUI Tool—SigImp Search (单击该图像获得较大视图)
SigImp Translate Snippet 允许用户将他们自己的本机代码段写入工具中。然后工具会在主窗口中生成并显示托管代码等效项体,如图 13 所示。
Figure 13 P/Invoke Interop Assistant GUI Tool—SigImp Translate Snippet (单击该图像获得较大视图)
有关 P/Invoke Interop Assistant 中 GUI 工具或命令行工具的详细信息,请参阅该工具随附的文档。