平台调用 (P/Invoke):DllImport特性说明
接上一篇:平台调用 (P/Invoke):托管代码(C#)调用非托管代码(C/C++)
上一篇实现了C#代码以三种方式调用C/C++的非托管代码,核心是DllImportAttribute特性的使用
DllImportAttribute指示某个静态方法的入口在非托管动态链接库中
用我们面向对象的思想来理解,就是我们在托管代码中定义接口,在外部非托管代码中去实现接口,而DllImportAttribute特性就是用于指定这个非托管代码的一些设置
在网上看了一些关于DllImportAttribute特性的介绍,几乎没有一个明确的例子介绍,本文将给出一些简单的例子来说明DllImportAttribute特性中各属性/字段的作用
Value
Value是只读属性,它的值来自构造参数,在声明DllImportAttribute特性时,需要一个指定非托管动态链接库文件的路径,这个路径可以是绝对路径或者相对路径,开发过程中,我们一般都是使用相对路径(一般是相对项目运行的跟路径,也可以是系统已存在的动态库,比如user32),如上一篇介绍使用的:
//linux下的调用
[DllImport("lib/Project-linux.so", EntryPoint = "Plus")]
static extern int LinuxPlus(int n1, int n2);
//windows下的调用
[DllImport("lib/Project-win.dll", EntryPoint = "Plus")]
static extern int WindowsPlus(int n1, int n2);
EntryPoint
指定动态链接库中的入口点,默认是静态方法名称,这个应该很好,就不多说了,只是顺带提一下两点:
1、我们开发过程中,如果碰到类似Unable to find an entry point named 'XXX' in DLL 'XXXX'的错误,可以使用VS自带的命令行工具来检测动态链接库是否有我们对应的入口,这点在上一篇中已经有说过了
2、EntryPoint不仅可以使用字符串,准备的表示入口名称,还可以使用 #+序号 的格式来表示第几个入口,比如,使用VS自带的命令行工具来检测动态链接库的入口列表有:
则下面的使用方式是等价的:
[DllImport("lib/Project-win.dll", EntryPoint = "Invoke")]
//等价于
[DllImport("lib/Project-win.dll", EntryPoint = "#2")]
注意,这个序号(上面的#2)就是ordinal列的数据,但是一般我们会准确的使用入口名称标识
CharSet
首先,如果你简单的把它理解成字符集,那就大错特错了。
上面说了EntryPoint可以用于指定入口名称,如果没有指定,那么默认就是方法名称,而CharSet则是表示这个入口名称对应到动态链接库中入口的方式。
CharSet是一个枚举类型,有Ansi(同None,默认值)、Unicode、Auto。
Ansi:将会搜索匹配准确的入口名称,以及入口名称附加后缀A组成的新入口名称,搜索规则由ExactSpelling参数指定
//例如:[DllImport("lib/Project-win.dll", EntryPoint = "Test")]
//它会在动态库中搜索 Test 和 TestA 这两个名称的入口,具体使用哪个由ExactSpelling参数指定
Unicode:将会搜索匹配准确的入口名称,以及入口名称附加后缀W组成的新入口名称,搜索规则由ExactSpelling参数指定
//例如:[DllImport("lib/Project-win.dll", EntryPoint = "Test")]
//它会在动态库中搜索 Test 和 TestW 这两个名称的入口,具体使用哪个由ExactSpelling参数指定
Auto:即在运行时根据目标平台在 ANSI 和 Unicode 格式之间进行选择,一般的,在 Windows 98和 Windows Me上使用的是ANSI,其它都是Unicode
其实,CharSet就是声明入口的字符封送规则是采用窄版本 (ANSI) 还是宽版本 (Unicode),具体例子参考ExactSpelling
ExactSpelling
ExactSpelling表示是否准确的搜索入口名称,默认false
上面说道CharSet参数控制入口是采用窄版本 (ANSI) 还是宽版本 (Unicode),而ExactSpelling则是控制在此基础上的搜索顺序,也就是控制最终入口:
当CharSet为Ansi时
ExactSpelling=true时,平台调用将只搜索您指定的名称,如果它找不到完全相同的拼写则失败。
ExactSpelling=false时,平台调用将首先搜索您指定的名称,如果找不到,则搜索处理之后的名称,即加了后缀A的名称
当CharSet为Unicode时
ExactSpelling=true时,平台调用将只搜索您指定的名称,如果它找不到完全相同的拼写则失败。
ExactSpelling=false时,平台调用将首先搜索处理之后的名称,即加了后缀W的名称,如果找不到,则搜索您指定的名称
注意,当ExactSpelling=false时,窄版本 (ANSI) 和宽版本 (Unicode)下的搜索规则是不一样的
举个例子,假如我们在动态库中有三个入口:
extern "C" __declspec(dllexport) int Test(char* pc);
extern "C" __declspec(dllexport) int TestA(char* pc);
extern "C" __declspec(dllexport) int TestW(char* pc);
那么搜索匹配的结果是:
//当ExactSpelling=true时,表示准确搜索,无论是Ansi还是Unicode版本,都只会搜索到Test入口,搜索不到则抛出Unable to find an entry point named 'XXX' in DLL 'XXXX'
[DllImport("lib/Project-win.dll", EntryPoint = "Test", ExactSpelling = true)]
//当ExactSpelling=false时,
//如果是Ansi,首先搜索Test入口,如果不存在,则继续搜索TestA入口,还不存在则抛出Unable to find an entry point named 'XXX' in DLL 'XXXX'
[DllImport("lib/Project-win.dll", EntryPoint = "Test", ExactSpelling = false, CharSet = CharSet.Ansi)]
//如果是Unicode,首先搜索TestW入口,如果不存在,则继续搜索Test入口,还不存在则抛出Unable to find an entry point named 'XXX' in DLL 'XXXX'
[DllImport("lib/Project-win.dll", EntryPoint = "Test", ExactSpelling = false, CharSet = CharSet.Unicode)]
PreserveSig
表示是否启用HRESULT签名转换,如果启用,那么可能还有一个out参数作为返回值,默认为true。
注:HRESULT是常用的函数返回值,常见的有:
0x00000000:S_OK 操作成功
0x8000FFFF:E_UNEXPECTED 意外的失败
0x80004001:E_NOTIMPL 未实现
0x8007000E:E_OUTOFMEMORY 未能分配所需的内存
0x80070057:E_INVALIDARG 一个或多个参数无效
0x80004002:E_NOINTERFACE 不支持此接口
0x80004003:E_POINTER 无效指针
0x80070006:E_HANDLE 无效句柄
0x80004004:E_ABORT 操作已中止
0x80004005:E_FAIL 未指定的失败
0x80070005:E_ACCESSDENIED 一般的访问被拒绝错误
这么说可能不好理解,通常的,我们调用一个动态链接库函数,函数通常返回一个整型数据,表示处理的结果代码,就和main函数的返回值一样,比如返回0表示OK,而PreserveSig参数就是控制这个返回值结果的,也就是控制HRESULT转换,举个例子,我们有一个相加的函数,通常是定义成这个样子的:
extern "C" __declspec(dllexport) int Add(int a, int b, int* sum);
cpp中实现是这个样子的:
int Add(int a,int b,int* sum)
{
*sum = a + b;
//返回0x00000000表示成功,也就是0
return 0x00000000;
}
这个时候,我们在C#中调用时,默认情况下可以是
//方式一:这种格式时,sum才是返回结果,方法返回值是HRESULT
[DllImport("lib/Project-win.dll", EntryPoint = "Add")]
static extern int Add(int a, int b, out int sum);
//方式二:这种格式没有HRESULT,方法返回值就是结果
[DllImport("lib/Project-win.dll", EntryPoint = "Add", PreserveSig = false)]
static extern int Add(int a, int b);
对于方式一,也是我们常用的一种方式,貌似PreserveSig无论是true还是false,都能正常调用,但是原理不一样
如果PreserveSig=true,则将非托管函数返回的结果直接返回,无论返回结果是否对应HRESULT的值,比如,如果cpp源文件中Add函数返回结果是1,则方式一得到的返回值也是1,如果cpp源文件中Add函数返回结果是0x80004004(即E_ABORT),则方式一得到的返回值也是0x80004004,也就是说,想知道非托管代码是否正常运行,需要我们自己根据返回结果来判断
如果PreserveSig=false,则会将非托管函数返回的结果比较对应的HRESULT,如果比较上了且非S_OK,对抛出对应的异常,否则返回0(即S_OK),比如,如果cpp源文件中Add函数返回结果是0x80004004(即E_ABORT),则方式一抛出操作已中止的异常,如果cpp源文件中Add函数返回结果是0x80070006(即E_HANDLE),则方式一抛出无效句柄的异常,如果cpp源文件中Add函数返回结果是1这样的非HRESULT值,那么方式一直接返回0,也就是说,我们不用自己检查非托管代码的运行情况了
对于方式二,是不支持PreserveSig=true情况的,因为PreserveSig=true表示要将非托管函数的返回值原模原样的返回,而此情况下参数就不对了,因此当PreserveSig=true时,将会抛出空指针异常!对于PreserveSig=false,和方式一差不多,会将非托管函数返回的结果比较对应的HRESULT,如果比较上了且非S_OK,对抛出对应的异常,否则会将最后一个out类型参数作为返回值返回,比如,如果cpp源文件中Add函数返回结果是0x80004004(即E_ABORT),则方式一抛出操作已中止的异常,如果cpp源文件中Add函数返回结果是0x80070006(即E_HANDLE),则方式一抛出无效句柄的异常,如果cpp源文件中Add函数返回结果是1这样的非HRESULT值或者是S_OK,那么最后一个直接参数sum的结果将会被作为返回值返回
CallingConvention
CallingConvention表示入口调用契约,默认是Winapi
Winapi:使用默认平台调用约定。在 Windows x86 上默认为 StdCall,在 Linux x86 上默认为 Cdecl
Cdecl:调用者清理堆栈。这样就可以使用可变参数调用函数使它适合用于接受可变数量参数的方法,例如Printf
StdCall:被调用者清理堆栈。这是调用非托管类库的默认约定
ThisCall:第一个参数是this指针,存储在寄存器ECX中。其他参数被推送到堆栈上
FastCall:不支持此调用约定
ThisCall一般用于C/C++中对象的方法,这里一般不用,FastCall目前不支持,而Winapi只是一个过渡,所以说说Cdecl和StdCall
对于Cdecl和StdCall,它其实差不多,只是对于Cdecl,函数的多个参数由调用者按从右到左的顺序压入堆栈,被调函数获得参数的序列是从左到右的,清理堆栈的工作由调用者负责,因为函数参数的个数是可变的,而对于StdCall,函数的多个参数由调用者从右到左的顺序压入堆栈,被调函数获得参数的序列是从左到右的,清理堆栈的工作由被调用函数负责,也就是说,Cdecl支持可变参数的方式,举个例子:
头文件中有入口:
extern "C" __declspec(dllexport) int Add(int* sum, int count, ...);
源文件中实现:
int Add(int* sum, int count, ...)
{
va_list valist;
int a = 0;
va_start(valist, count);
for (int j = 0; j < count; j++) {
a = va_arg(valist, int);
*sum += a;
}
va_end(valist);
return 0;
}
这个时候,我们就可以使用Cdecl协议来实现可变参数的调用了:
[DllImport("lib/Project-win.dll", EntryPoint = "Add", CallingConvention = CallingConvention.Cdecl)]
static extern int Add(out int a, int b, __arglist);
static unsafe void Main(string[] args)
{
Add(out var s1, 3, __arglist(1, 2, 3));
Console.WriteLine(s);
Add(out var s2, 4, __arglist(5, 6, 7, 8));
Console.WriteLine(s2);
}
注意,这里我们需要使用__arglist来声明可变参数,而不是使用params,而且调用时需要使用__arglist包裹起来
如果这里我们使用StdCall契约,就会抛出异常,因为StdCall不支持可变参数调用
BestFitMapping
BestFitMapping表示将 Unicode 字符转换为 ANSI 字符时,启用或禁用最佳映射行为,默认为true
何为最佳映射行为,其实可以理解为简单的字型转换,也就是这个字符在Unicode下是存在的,但是ANSI不存在,那么找个字型上和它最匹配的字符,举个例子就懂了:
比如我们有入口:
extern "C" __declspec(dllexport) int Print(char* pc);
源文件中简单的实现就是打印字符:
int Print(char* pc)
{
printf("pc=%s\n", pc);
return 0;
}
我们调用是:
[DllImport("lib/Project-win.dll", EntryPoint = "Print")]
static extern int Print(string s);
static unsafe void Main(string[] args)
{
Print("这些字符是可以转换的:abcd");
Print("这些字符是不可以转换的:ÀÌÙÈÒ");
}
运行结果是:
可以看到,ÀÌÙÈÒ在最佳映射下变成了àìùèò,那如果不启用最佳映射,那么展示的就是问号:
[DllImport("lib/Project-win.dll", EntryPoint = "Print", BestFitMapping = false)]
static extern int Print(string s);
注:BestFitMapping可以匹配到更多的字符,但不是所有的字符都能匹配上
ThrowOnUnmappableChar
ThrowOnUnmappableChar表示将 Unicode 字符转换为 ANSI 字符时,如果找不到相匹配的字符,是否抛出异常,默认false,表示使用?代替字符,这一点在上面BestFitMapping的介绍中已经看到了,如果设置成true,则会抛出异常。
SetLastError
这个主要用于windows下非托管代码异常的收集,当让我们可以自定义
当我们调用windows的API时,如果有错误,可以通过Marshal.GetLastPInvokeError()(.net5及一下使用Marshal.GetLastWin32Error())来获取最后一个跨平台调用的异常代码,得到代码后去查对应代码对应的异常是什么
我们也可以自定,比如在windows下,我们可以通过SetLastError来自定义,比如有入口:
extern "C" __declspec(dllexport) int Error(int errCode);
源码实现:
#include <windows.h>
int Error(int errCode)
{
SetLastError(errCode);
return 0;
}
然后调用
[DllImport("lib/Project-win.dll", EntryPoint = "Error", SetLastError = true)]
static unsafe extern int Error(int code);
static unsafe void Main(string[] args)
{
Error(5);
var err = Marshal.GetLastPInvokeError();
//.net5及以下版本
//var err = Marshal.GetLastWin32Error();
Console.WriteLine(err);
}
结语
到这里,DllImportAttribute特性相关的内容就说完了,算是对上一篇的一个补充,这里也是做个笔记,确实有些东西很不好理解,比如PreserveSig,就困扰过我一段时间,希望通过这篇博文,可以对有疑问的同学有所帮助吧