在做Unity开发时,经常会需要调用 C/C++ 编译的非托管库,包括目前常见的 Lua 库,比如 ToLua、XLua 都是通过 C 封装完接口,然后编译成各个平台的动态库,才能在Unity中进行使用,P/Invoke 是.Net 平台提供的非托管与托管通信的一种服务,通过它能够非常方便的完成非托管库的接入,当然这个过程中有很多需要注意的地方,希望通过阅读本文,能够少走一些弯路。
PInvoke介绍
PInvoke是.Net Framework提供的一项服务,它使得托管代码与非托管之间的交互变得非常简单,一般来说,只需要声名一个方法并指定System.Runtime.InteropServicesDllImportAttribute
属性,就可以调用非托管方法。先看一个最简单的例子:
1 | using System.Runtime.InteropServices; |
然后我们分析以下上面的例子:
- 首先引入
System.Runtime.InteropServices
,涉及本机交互的API多数都在此命名空间。 - 为方法指定
DllImport
特性。 此特性至关重要,因为它告诉CLR要加载非托管 DLL,以及如何加载DLL。 - 定义了一个托管方法 MessageBox ,该方法的签名与非托管方法相匹配。 可以看到,声明中包含一个新关键字
extern
,告诉运行时这是一个外部方法。调用该方法时,CLR在 DllImport 指定的 DLL 内查找该方法。
CLR通过PInvoke服务调用非托管函数:
PInvoke调用非托管函数流程:
- 定位包含非托管函数的DLL,并将DLL加载到内存当中。
- 定位非托管方法的地址。
- 参数传递以及函数返回值。
只有第一次调用非托管方法时,会定位和加载DLL
定位DLL存放地址
在指定库名称的时候,我们比较关心CLI内部如何去查找该库,这个过程是通过LoadLibrary
来实现,不同的平台有不同的加载策略,对于Windows平台来说按照以下顺序进行查找:
1 | [ ] |
- 当前进程Exe文件所在目录。
- 当前目录,即
Environment.CurrentDirectory
。 - Windows系统目录,一般是
C/Windows/System32
,可以通过GetSystemDirectory(kernel32.dll)
或者Environment.GetFolderPath(Environment.SpecialFolder.System)
函数获取此目录。 - Windows 16位系统目录,一般是
C/Windows/System
。 - Windows目录,可以通过
GetWindowsDirectory(kernel32.dll)
或者Environment.GetEnvironmentVariable("windir")
函数获取此目录。 - PATH系统变量列出的目录中。
知道要查找的目录之后,还需要知道动态库的名称,不同的平台对名称的处理策略不同。
- Windows平台会对库名称附加
.dll
后缀。 - Linux会使用
lib
前缀和.so
后缀。 - Mac OSX使用
lib
前缀和dylib
后缀。
根据这些规则,在使用DllImport时,我们不应该指定前缀或者后缀,而是只是用Dll名称,例如:
1 | [ ] |
这样我们只需要为Windows平台提供MyLib.dll,Linux平台提供libMyLib.so,Mac OSX提供libMyLib.dylib文件,然后在不同的平台,CLI就能找到对应平台的库。
需要注意的是:如果提供的库名称中包含点号”.”,就不会附加后缀,例如”mylib-2.0.dll”如果指定”mylib-2.0”就不会附加后缀,从而引发DllNotFoundException。
定位非托管函数地址
在找到DLL之后,还需要找到函数在DLL的地址,该过程通过GetProcAddress
来实现,该方法需要传递函数的名称,DllImport有两种方式可以获取函数名称,第一种通过EntryPoint指定;第二种是如果没有指定EntryPoint参数,则采用声名的托管函数名称。
1 | //GetProcAddress包含在Kernel32.dll中,导入方式如下: |
DllImportAttribute其他参数介绍
在使用DllImport导入非托管函数时,除了上面说介绍过的Dll名称,EntryPoint
之外,还有一些比较重要的参数,其中包括CallingConvention
和CharSet
。
- CallingConvention :指定了传递方法参数时的约定,必须和非托管保持一致,在Windows平台下该值默认是WinAPI,对应非托管是
__stdcall
。 - CharSet :改参数指定了向非托管封送方法名(入口名称)和字符串参数的编码格式,默认值是Ansi,即按照ASCII进行编码,CharSet.Unicode按照UTF-16进行编码。
- BestFitMapping : 如果CharSet设置为CharSet.Ansi,封送处理器会采用近似匹配,将传递Unicode字符串映射到ASII编码,例如对于版权符号©会转换成字符’C’,对于没有映射的Unicode字符则转换成问号(?),默认为True。
- ThrowOnUnmappableChar : 如果为True,当Unicode字符没有成功映射为ANSI字符时,会抛出异常,而非转换成问号“?”。
数据封送
托管内存和非托管内存是完全分开的,所以两者如果需要进行数据传输,就需要一个“信使”-互操作封送拆装器(Interop Mashaler)来传递,它负责将托管参数传递给非托管,并将函数返回值传递给托管,在调用一个含参的非托管方法时,封送的过程大致如下:
- CLR分配一块非托管内存。
- 将托管数据拷贝到刚分配的内存。
- 执行非托管方法,并将刚分配的非托管内存传递给函数。
- 将非托管内存拷贝回托管内存。
数据封送按照数据的流动方向可以分为两类:
- 参数传递时:托管内存—>非托管内存
- 函数返回值:非托管内存—>托管内存
无论数据如何传输,发送方的发送类型和接收方的接受类型,都必须具备相同的表示,即内存表示应该相同,例如:非托管函数形参为int类型,那么托管向该函数传递参数必须是32bit System.Int类型。
按照封送方式划分:
- 按值封送
- 按引用封送
Blittable类型
上面提到,封送的数据必须在发送方和接受方具备相同的表示,CLR大多数数据类型在托管和非托管内存中都有一个通用的表示,封送时不需要的特殊处理,这些类型被称为blittable类型,因为它们在托管和非托管代码之间传递时不需要转换。而与Blittable类型相对的是Non-Blittable类型,封送拆装器必须做一些特殊处理才能封送Non-Blittable类型。
P/Invoke的返回值必须是Blittable类型,不支持Non-Blittable类型的返回值。
Blittable类型包括以下几种:
- byte
- sbyte
- short
- ushort
- int
- uint
- long
- ulong
- IntPtr
- UIntPtr
- float
- double
- 上述类型的一维数组
- 只包含上述类型的结构体
内存复制与内存固定
封送数据时,互操作封送拆收器(interop marshaler)可以复制或固定正在封送的数据。复制将数据副本从非托管/托管移动到托管/非托管;锁定是保持对象内存在垃圾回收时不会重定位,封送拆收器通过固定减小复制的开销来提高性能。要封送数据的类型决定了是采用复制还是固定
下图显示从托管内存向非托管内存复制值类型和复制按引用传递的类型之间的差异。
如果函数参数时按值传递的,直接将数据内存拷贝到非托管栈上;如果函数参数是按引用传递的,则将引用对象的地址指针拷贝到非托管栈上。引用传递参数(ref标记)根据ref的类型选择按传值还是按引用传递。
封送字符串
什么时候回封送字符串:
- 调用GetProcAddress时,封送方法名。
- 调用非托管参数时,传递字符串类型参数。
- 封送的结构体/类中包含字符串成员。
如何封送字符串? 有两种方式可以想非托管封送字符串:
- System.String
- System.Text.StringBuilder
两者的封送行为基本相同,封送拆装器会复制字符串,并将字符串转换为指定的编码格式,然后传输给非托管函数。两者非常重要区别在于string类型是不可变的,所以当调用结束时,不能将更改后的值复制回托管内存。
在封送时,可以使用MarshalAS()属性指定封送方式,见下表,其中StringBuilder只能允许LPStr
、LPWStr
、LPTStr
。
封送方式 | 内容 |
---|---|
UnmanagedType.BStr | 固定长度Unicode字符 |
UnmanagedType.AnsiBStr | 固定长度的Ansi字符串 |
UnmanagedType.LPStr | 指向以 null 终止的Ansi字符数组指针 |
UnmanagedType.LPWStr | 指向以 null 终止的Unicode字符数组指针 |
UnmanagedType.LPTStr | 指向以 null 终止的平台相关字符数组的指针 |
UnmanagedType.LPUTF8Str | 指向UTF8编码的字符串的指针 |
UnmanagedType.ByValTStr | 用于结构体内定长字符数组;数组的类型(Ansi、Unicode)由结构体的CharSet确定 |
非托管定义:
1 | struct StringInfoA |
托管Wrapper定义:
1 | [ ] |
在某些情况下,必须将定长字符缓冲区传递到要操作的非托管代码中。在这种情况下,只传递字符串将不起作用,因为被调用方不能修改所传递缓冲区的内容。 即使通过引用传递字符串,仍无法将缓冲区初始化为给定大小。
解决方法是将 StringBuilder 作为参数而非字符串进行传递。被调用方可以修改 StringBuilder,前提是不超过 StringBuilder 的容量。还可以初始化为固定长度。例如,如果将 StringBuilder 缓冲区初始化为 N 容量,则封送处理程序提供大小为 (N+ 1) 个字符的缓冲区。 +1 是因为非托管字符串以 null 结尾,而 StringBuilder 却不不是,所以需要 +1 字节存放null终结符。
1 | //c函数声名: |
封送System.Boolean
System.Boolean是比较特殊的一个类型,当它作为类或者结构体的成员时,默认按UnmanagedType.Bool(int,0-false,非0-true)封送;当它作为函数的参数时,默认按UnmanagedType.VariantBool(short,0-false,1-true)封送。可以通过MarshaalAs显示指定封送行为,来替换默认的封送行为,可选参数如下:
参数 | 封送行为 |
---|---|
UnmanagedType.Bool | int,0-false,非0-true |
UnmanagedType.VariantBool | short,0-false,1-true |
UnmanagedType.U1 | byte,0-false,1-true |
封送类和结构体
类对象的内存是在托管堆上分配并由垃圾回收器进行管理,因此不能将类对象进行按值封送,只能按引用进行封送。此外在使用类封送时,还有以下几点需要注意:
- 类默认的字段排序是按照 LayoutKind.Auto,这是一种不确定的排序方式,需要指定LayoutKind.Sequential 或者 LayoutKind.Exlict.
- 对于类类型,默认只能从托管内存拷贝到非托管内存,如果需要从非托管内存拷贝到托管内存,需要对参数指定 [Out] 特性,如果是需要双向进行拷贝,需要同时指定 [In] [Out] 特性
与类不同,结构内存是分配在线程栈上,并且默认字段排序是LayoutKind.Sequential,因此结构体不用添加任何特性,就可以封送给非托管。另外如果结构体分配在线程栈上,并且结构成员只包含blittable类型,那么在封送时是固定的,可以直接封送给非托管,而不需要进行内存副本拷贝。一旦包含了Non-Blittable类型,该优化将不起作用。
P/Invoke函数参数声明准则
非托管的签名 | 托管声明:非间接-struct MyType | 托管声明:一级间接-class MyType |
---|---|---|
DoWork(MyType x) 要求零级间接 | DoWork(MyType x) | 不可能实现,因为clas已经包含一级间接 |
DoWork(MyType* x) 要求一级间接 | DoWork(ref MyType x) | DoWork(MyType x) |
DoWork(MyType** x) 要求二级间接 | 不可能,因为不能出现ref ref | DoWork(ref MyType x) |
类和结构体作为返回值
前面说过,P/Invoke的返回值只能是Blittable类型,所以类不能作为传值返回值,如果想要类作为返回值,非托管的返回值必须是指向结构体的指针。
结构体可以作为非托管的传值返回值,不能以传引用的方式返回结构体,如果需要返回结构体的引用,需要返回结构的指针(IntPtr),然后调用Marshal.PtrToStructure将IntPtr转换到结构体。
封送数组
1 |
|
托管导出方法:
1 | using System; |
输出结果:
1 | Integer array passed ByVal before call: |
封送委托
当非托管参数时函数指针或者回调指针时,托管端需要向非托管封送委托数据。如果传递过去的回调函数,是在非托管调用内完成调用,CLR会避免对委托进行回收。
但是如果非托管函数将委托保存起来,等调用结束之后才调用,那么就需要手动防止对委托的垃圾回收,直到调用结束之后才能够回收。
非托管定义:
1 | typedef bool (CALLBACK *FPTR)(int i); |
托管声名:
1 | public delegate bool FPtr(int value); |
输出结果:
1 | Received value: 99 |
非托管与GC
CLR的垃圾回收器只是负责托管堆上的垃圾回收,对于非托管内存垃圾回收并不能进行正确回收,这就可能引起意想不到的问题,考虑下面的代码:
1 | class C |
当在Main中调用c.m()方法,方法调用完成之后,c不再存在引用,此时垃圾回收器可以对其进行回收,但是m()中执行了非托管方法,非托管仍然引用着c的成员对象,这是GC不可能知道的。如果正在执行OperateOnHandle非托管函数的时候,c对象被垃圾回收器回收(虽然不太可能),同时DeleteHandle被垃圾回收线程调用,这样就会引发进程错误,可能导致程序崩溃。
如何解决这个问题,可以采用HandleRef.aspx)类来替换IntPtr,来避免非托管函数完成前对托管对象进行垃圾回收。修改后的代码如下:
1 | class C |
释放非托管资源
GC只能回收托管堆的资源,不能对非托管资源进行回收,因此对于非托管资源,需要进行手动回收,我们可以通过释放模式(即IDisposable接口)进行非托管内存的回收,下面是一种利用SafeHandle实现的释放模式,也是比较推荐的一种模式。
CLR对SafeHandle做了一些特殊的处理,在进行封送的时候会自动处理SafeHandle,处理方式跟它出现的位置有关系:
- 作为传出参数时,SafeHandle.handle会被封送。
- 作为返回值时,会自动生成SafeHandle实例,并将返回的IntPtr赋值给SafeHandle.handle。
- 使用ref SafeHandle时,传出的值会被忽略(必须设置为IntPtr.Zero),返回值会被赋值过来。
- 作为结构体的成员函数,SafeHandle.handle会被封送。
1 | using System; |
非托管数据类型与托管类型对应表
非托管类型与托管类型对应关系:
Wtypes.h 中的非托管类型 | 非托管 C 语言类型 | 托管类名称 | 描述 |
---|---|---|---|
HANDLE | *void | System.IntPtr 在 32 位 Windows 操作系统上为 32 位、在 64 位 Windows 操作系统上为 64 位。 | |
BYTE | unsigned char | System.Byte | 8 位 |
short | short | System.Int16 | 16 位 |
WORD | unsigned short | System.UInt16 | 16 位 |
INT | int | System.Int32 | 32 位 |
UINT | unsigned int | System.UInt32 | 32 位 |
LONG | long | System.Int32 | 32 位 |
BOOL | long | Byte | 32 位 |
DWORD | unsigned long | System.UInt32 | 32 位 |
ULONG | unsigned long | System.UInt32 | 32 位 |
CHAR | char | System.Char | 使用 ANSI 修饰。 |
WCHAR | wchar_t | System.Char | 使用 Unicode 修饰。 |
LPSTR | char\ | System.String 或 System.Text.StringBuilder | 使用 ANSI 修饰。 |
LPCSTR | Const char\ | System.String 或 System.Text.StringBuilder | 使用 ANSI 修饰。 |
LPWSTR | wchar_t* | System.String 或 System.Text.StringBuilder | 使用 Unicode 修饰。 |
LPCWSTR | Const wchar_t* | System.String 或 System.Text.StringBuilder | 使用 Unicode 修饰。 |
FLOAT | Float | System.Single | 32 位 |
DOUBLE | 双精度 | System.Double | 64 位 |