3401 字
17 分钟
[.NET] 平台调用(P/Invoke) 与 DllImport 使用的相关讲解与注意事项,

通过对静态外部方法标记 DllImport 特性, 我们可以实现使用 C# 调用非托管动态链接库的函数, 这种使用方式就叫做平台调用(Platform Invoke, 或 P/Invoke)

基本使用:#

下面, 我们通过一个获取控制台窗口句柄的函数来演示最基本的平台调用

using System.Runtime.InteropServices;

[DllImport("kernel32.dll")]                # DllImport 特性与函数所在链接库
static extern IntPtr GetConsoleWindow();   # 方法基本声明 (静态外部方法)

IntPtr currentConsoleWindow = GetConsoleWindow();
Console.WriteLine($"当前控制台的窗口句柄是: 0x{currentConsoleWindow:X}");

DllImport 特性成员字段#

1. EntryPoint (入口点)#

指示要调用的 DLL 入口点的名称或序号。

可以指定入口点函数名称, 也可以按入口点序号标识入口点. 序号的前缀为 # 符号,例如 #1。 如果省略此字段, CLR 会使用标记了当前 DllImport 特性的方法名称作为入口点名称

2. CharSet (字符集)#

指示方法调用时要使用的字符集, CLR 会将传入的字符串参数按照指定字符集封送到对应函数.

将此字段与枚举的成员 CharSet 一起使用, 可以指定字符串参数的封送处理行为, 并指定要调用的入口点名称 (给定的确切名称或以“A”或“W”为后缀的名称). C# 和 Visual Basic 的默认枚举成员是 CharSet.Ansi , C++ 的默认枚举成员是 CharSet.None, 这等效于 CharSet.Ansi. 在 Visual Basic 中, 使用 Declare 语句指定 CharSet 字段.

举个例子, 当你入口点名称为 GetWindowText 并且 CharSet 为 Unicode 时, 实际调用的方法是 GetWindowTextW. (因为虽然不存在 GetWindowText 函数, 但 CLR 搜索到了 GetWindowTextW 函数, 所以就会调用它)

3. SetLastError (设置最后一个错误)#

指定调用方在返回之前是否设置一个错误(在 Windows 上调用 SetLastError 或者其他平台上调用 errno).

如果这个字段设置为 true, 运行时的封送拆收器^1^会调用 GetLastErrorerrno 并缓存返回值以避免它被其他的 API 调用覆盖. 你可以在 .NET 6.0 及以上版本中调用 GetLastPInvokeError 或在 .NET 5 及以下版本与 .NET Framework 中调用 GetLastWin32Error 来接收错误码.

在 .NET 中, 当这个字段设置为 true 时, 错误信息在调用被调用方之前会被清除(设置为0). 但在 .NET Framework 中, 错误信息则不会被清除. 这意味着由 GetLastPInvokeErrorGetLastWin32Error 返回的错误信息, 在 .NET 中只表示上一个拥有 DllImportAttribute.SetLastError 值设定为 trueDlllImport 特性的平台调用的错误信息. 而在 .NET Framework 中, 这个错误信息可以由一个平台调用保留到下一个.

4. ExtractSpelling (精准拼写)#

控制 CLR 是否根据 CharSet 字段的值来在非托管 DLL 中搜索入口点名称, 或直接使用指定的入口点名称.

当值为 false, 且在找不到指定入口点名称的函数时, CLR 会根据 CharSet 字段的值来搜索入口点名称. 此时, 当 CharSetCharSet.Ansi 时, 会尝试调用尾部追加字母 ‘A’ 的入口点名称, 当 CharSetCharSet.Unicode 时, 则会尝试调用尾部追加字母 ‘W’ 的入口点名称. 通常, 托管编译器会设定这个字段的值.

在 VB 以及 C# 和 C++ 中, ExactSpelling 的值会根据 CharSet 的值有不同的表现.

语言ANSIUnicodeAuto
Visual BasicExactSpelling := TrueExactSpelling := TrueExactSpelling := False
C#ExactSpelling = falseExactSpelling = falseExactSpelling = false
C++ExactSpelling = falseExactSpelling = falseExactSpelling = false

也就是说, 在 VB 中, 如果你将 CharSet 设置为确切的字符集, ANSI 或者 Unicode 时, ExactSpelling 会是 True, CLR 不会再去搜索与字符集匹配的名称, 而是直接使用你匹配的名称. 而在 C# 和 C++ 中, 即便你指定了确切的字符集, CLR 也会在找不到你指定的入口点名称时, 尝试搜索与字符集相匹配的函数.

5. CallingConvertion (调用约定)#

指定入口点的调用约定^2^

你可以将它设置为 CallingConvertion 枚举的一个成员. 这个 CallingConvertion 字段的默认值是 Winapi, 它默认在 Windows 平台上是 StdCall 约定, 并且其他所有平台上是 Cdecl 约定.

6. BestFitMapping (最合适映射)#

将 Unicode 字符转换为 ANSI 字符时, 启用或禁用最佳映射行为.

7. PreserveSig#

指示是否直接转换具有 HRESULT 返回值的非托管方法,或者 HRESULT 是否自动将返回值转换为异常.

8. ThrowOnUnmappableChar#

启用或禁用在遇到已被转换为 ANSI ”?” 字符的无法映射的 Unicode 字符时引发异常.


对于字符串的处理#

1. 传入字符串, 但不更改.#

例如我的动态链接库中有以下函数:

#include <windows.h>
extern "C" __declspec(dllexport) void PrintW(wchar_t* lpstr)
{
    wprintf(L"%s\n", lpstr);
}

它接收一个宽字符串指针, 并将它打印到标准输出流中. 下面列举常用的字符串传值方式

  1. 直接传 string
    [DllImport("test.dll", EntryPoint = "PrintW", CharSet = CharSet.Unicode)]
    extern static void Print1(string str);
    
  2. 传 StringBuilder
    [DllImport("test.dll", EntryPoint = "PrintW", CharSet = CharSet.Unicode)]
    extern static void Print1(StringBuilder str);
    
  3. 传 char 数组
    [DllImport("test.dll", EntryPoint = "PrintW", CharSet = CharSet.Unicode)]
    extern static void Print1(char[] str);
    
  4. 传 char 指针
    [DllImport("test.dll", EntryPoint = "PrintW", CharSet = CharSet.Unicode)]
    extern static void Print1(char* str);
    

2. 传入字符串并做更改#

注意, 如果在函数中有对字符串更改, 那么, 你需要注意, 传入的 string 可能会被更改. 例如我们有以下函数:

#define _CRT_SECURE_NO_WARNINGS
#include <stdlib.h>
#include <windows.h>

extern "C" __declspec(dllexport) void FuckYouWorldW(wchar_t* lpstr)
{
    wsprintf(lpstr, L"Fuck you world, 撒比世界");
}

extern "C" __declspec(dllexport) void FuckYouWorldA(char* lpstr)
{
    sprintf(lpstr, "Fuck you world, 撒比世界");
}
  1. 当使用 Unicode 所对应的函数时, string 会被更改:
    [DllImport("test.dll", EntryPoint = "FuckYouWorldW",CharSet = CharSet.Unicode)]
    extern static void FuckYouWorldW(string str);
    
    string buf = new string('\0', 32);   // 声明一个长度为 32 的字符串
    FuckYouWorldW(buf);
    
    Console.WriteLine(buf);              // 你会得到一个 "Fuck you world, 撒比世界"
                                         // 但是注意, buf 字符串后面还是有很多 \0 的, 只是没打印出来
    
  2. 但是当你使用 ANSI 所对应的函数时, 传入的 string 即便被更改了, 也不会体现出来:
    [DllImport("test.dll", EntryPoint = "FuckYouWorldA",CharSet = CharSet.Ansi)]
    extern static void FuckYouWorldA(string str);
    
    string buf = new string('\0', 32);   // 声明一个长度为 32 的字符串
    
    // 在传入 buf 时, Marshaler 会帮我们将字符串转为 ANSI 字符串, 并传入指针
    FuckYouWorldA(buf);
    // 这导致, 虽然函数变更了指针指向的字符串值, 但没有对我们原来的字符串有任何更改
    
    Console.WriteLine(buf);              // 你什么也看不到
    
  3. 如果你使用 StringBuilder 的时候, 无论是 Unicode 还是 ANSI 方法, 你都能看到函数对字符串的更改, Marshal 会帮助我们处理 Unicode 字符串与 ANSI 字符串的转换, 并且处理字符串变更后的结果, 所以你无论如何都能看到变更后的字符串.
  4. 如果你使用 char[], 它的表现和 string 一样, 能否看到结果取决于你使用的字符集
  5. 如果你使用 char*, 虽然原始的指针会直接传进去, 但是如果你在使用 ANSI 的函数, 那么肯定的, 你得到的更改后的字符串并不会正常显示. 因为它是 ANSI 格式的, 而 C# 按照 Unicode 来解码字符串.

参考: Marshaling between Managed and Unmanaged Code 原文似乎找不到了, 只有 CSDN 的转载

3. 我应该使用哪种方式传字符串?#

当你需要向函数传入字符串, 并且这个字符串不会被函数更改时, 我建议你直接传入 string, 这样最方便. 而当你需要传出字符串时, 也就是调用类似于 GetWindowTextW 这类函数时, 我建议你使用 StringBuilder, 提前设置好容量, 并在调用函数时, 传入这个容量. 这样函数就能够正确的处理, 并且不造成有关内存访问的异常.

当然, 传入其他的类型也完全可以, 这取决于你的需求, 例如你大量操作非托管的类型, 你的代码周围全都是一些指针, 那么你大可以将函数声明为指针, 然后直接调用.

4. 关于字符串拷贝#

有人认为, 在调用非托管动态链接库的时候, CLR 会拷贝一份 string 供使用, 这并不完全正确.

如果你在使用 Unicode 版本的函数(设置了 CharSet 为 Unicode), 那么在调用的时候, 完全不会有任何拷贝, 而是直接使用源字符串, 这也就是为什么这种情况下函数可以对字符串进行更改并正确得到结果

如果你在使用 ANSI 版本的函数(设置了 CharSet), 那么在调用的时候, CLR 会帮你把 .NET 字符串转为非托管的 ANSI 字符串, 并传入指针, 显然这个过程造成了字符串拷贝.

5. 我应该使用哪个字符集的函数?#

至少在 Windows 平台中, 你最好使用 Unicode 字符集的函数, 也就是后缀带 W 的函数. 因为在高版本的 Windows 平台内部也是使用的宽字符串(Wide char string), 而且 C# 的 string 可以直接传入, 而不经任何操作, 这样性能较好.

参考: Wide String vs String , Does it affect performance in Windows C++ 这里表明, Windows 内核使用的是宽字符

注意:#

不要尝试直接取字符串地址并传入, 因为你取的地址不能直接作为 wchar_t* 使用, 而是栈上这个变量的地址, 你需要进行一次 指针取值 操作才能拿到栈上存储的字符串地址值, 并且 string 在堆上的存储内容还包含一个 “类型头”, 你至少需要计算这个偏移量才可以. 总而言之, 很复杂, 不要这么做.


对于指针的处理#

如果一个动态链接库的函数传入一个 int 指针, 并且它将会更改这个指针指向的 int 作为返回值, 你可以直接使用 C# 的 out 关键字来声明这个参数:

extern "C" __declspec(dllexport) void add(int a, int b, int* result)
{
    *result = a + b;
}
[DllImport("test.dll", EntryPoint = "add")]
extern static void add(int a, int b, out int result);

同理, 如果一个函数传入一个 int 指针, 它会读取这个指针指向的 int 值, 也会更改指针指向的值, 那么你可以使用 C# 的 ref 关键字来声明这个参数:

extern "C" __declspec(dllexport) void add114514(int* val)
{
    *val += 114514;
}
[DllImport("test.dll", EntryPoint = "add")]
extern static void add114514(ref int val);

但是注意, 你不能使用 out string, 这个雀食是不可以的, 想要传出一个字符串, 你得用 StringBuilder

对于结构体的处理#

事实上, 在进行非托管动态链接库函数调用的时候, CLR 会将所有我们用到的值都转换为非托管的格式, 然后进行封送. 例如在 C# 中, bool 占一个字节, 但是在 WinAPI 中, bool 可是要占四个字节的. 调用参数的传入与返回值的返回, 都有 CLR 参与这个转换.

例如我们有这样的一个 C++ 函数:

struct StringWrapper
{
    wchar_t* StrPtr;
    int Length;
};

extern "C" __declspec(dllexport) void print(StringWrapper* str)
{
    for (int i = 0; i < str->Length; i++) {
        putwchar(str->StrPtr[i]);
    }

    putwchar(L'\n');
}

那么我在编写 C# 的时候, 就可以这样写:

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]   // 注意, 这里需要写明字符集
struct StringWrapper
{
    string _value;       // 字符串会自动被转换为正确的 wchar_t 指针以供使用
    int    _length;      // 整数的话, 则是原封不动的传过去

    public StringWrapper()
    {
        _value = string.Empty;
        _length = 0;
    }

    public StringWrapper(string value)
    {
        _value = value;
        _length = value.Length;
    }
}

[DllImport("test.dll", EntryPoint = "print", CharSet = CharSet.Unicode)]
private extern static void PrintString([In, Optional] ref StringWrapper str);

但是注意, 当参数是结构体指针, 并且你希望这个结构体能够被 CLR 进行封送, 那么你必须在 DllImport 那里, 将方法参数声明为 refout 参数, 而不是直接声明为结构体的指针.

还有就是, 非常重要的一点, 当结构体进行封送的时候, 里面的字符串会根据结构体的 StructLayout 所指定的 CharSet 进行封送, 默认是 ANSI, 所以会被封送为 char*, 如果你要使用 wchar_t*, 那你必须指定 CharSetCharSet.Unicode

如果你希望你的结构体能够在直接传指针的情况下仍然可用, 那么你必须使你的结构体内存布局与非托管中结构体的内存布局保持一致. 参考: [.NET] 结构体布局详解与结构体内存对齐具体方式


入口点查找大概逻辑#

下面是伪代码:

如果存在 Xxx 函数:
    调用 Xxx 函数;
    返回;
否则:
    如果 ExactSpelling:   // 精确拼写
        抛异常("入口点找不到");
    否则:
        如果 CharSet 是 Auto:
            如果是高系统版本:
                确认 CharSet 为 Unicode;
            否则:
                确认 CharSet 为 ANSI;
                
        如果 CharSet 是 Unicode:
            如果存在 XxxW 函数:   // 就是后面加了个 W 后缀
                调用 XxxW 函数;
                返回;
            否则:
                抛异常("入口点找不到");
        否则如果 CharSet 是 ANSI:
            如果存在 XxxA 函数:   // 就是后面加了个 A 后缀
                调用 XxxA 函数;
                返回;
            否则:
                抛异常("入口点找不到");
        否则:
            抛异常("入口点找不到");

注解#

  1. 封送拆收器: CLR 在与非托管动态链接库操作时, 负责将托管类型转换为非托管或将非托管类型转换为托管类型.
  2. 调用约定: 调用约定(Calling Convention)是规定子函数(过程)如何获取参数以及如何返回的方案. 其通常与架构, 编译器等相关.

文章会不定时更新, 以 CSDN 这边内容为准

[.NET] 平台调用(P/Invoke) 与 DllImport 使用的相关讲解与注意事项,
https://slimenull.com/posts/20230106035930/
作者
SlimeNull
发布于
2023-01-06
许可协议
CC BY-NC-SA 4.0