C# 原生互操作与设备 SDK(一):P/Invoke、DllImport 与 LibraryImport

设备厂商常把 SDK 交付为 C/C++ 头文件、动态库和少量示例。C# 程序不能仅凭函数名调用这些二进制接口:双方还必须对参数布局、调用约定、字符串编码、资源所有权和错误模型达成完全一致的约定,这份约定就是应用二进制接口(Application Binary Interface,ABI)。

本文以 .NET 10 为环境,从一个最小 C 接口出发,说明 P/Invoke 的工作模型,以及 LibraryImportDllImport 各自适合什么场景;运行时手动选择库文件的加载方式见本系列《C# 原生互操作与设备 SDK(五):调用约定、位数与 DLL 加载诊断》。业务侧的硬件抽象方法见《设备软件架构与控制模型(二):硬件抽象层与设备适配器》;本系列聚焦适配器下面的二进制边界。

1. P/Invoke 连接的是 ABI,不是源代码

假设厂商头文件给出以下 C API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdint.h>

#ifdef _WIN32
#define DEVICE_API __declspec(dllimport)
#else
#define DEVICE_API
#endif

typedef void* device_handle;

DEVICE_API int32_t device_open(int32_t index, device_handle* out_handle);
DEVICE_API int32_t device_get_position(device_handle handle, double* out_mm);
DEVICE_API void device_close(device_handle handle);

device_open 的托管声明不能只做到“看起来像”。必须逐项回答:

  • 动态库的逻辑名称和导出符号是什么;
  • int32_t 是否映射为 32 位有符号整数;
  • device_handle 是整数、指针还是需要释放的资源;
  • device_handle* 是输出一个句柄,还是传入句柄数组;
  • 返回值是 SDK 状态码,还是操作系统最后错误;
  • Windows 导出是否使用了非默认调用约定。

任何一项不匹配,都可能表现为错误值、找不到入口点,甚至栈或堆损坏。

2. .NET 7 及以上优先使用 LibraryImport

LibraryImportAttribute 让源生成器在编译期生成封送代码。声明方法必须是 static partial,包含它的类型也要是 partial

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    private const string LibraryName = "device_sdk";

    [LibraryImport(LibraryName, EntryPoint = "device_open")]
    internal static partial int Open(int index, out nint handle);

    [LibraryImport(LibraryName, EntryPoint = "device_get_position")]
    internal static partial int GetPosition(nint handle, out double millimeters);

    [LibraryImport(LibraryName, EntryPoint = "device_close")]
    internal static partial void Close(nint handle);
}

库名不写扩展名时,运行时会按平台尝试相应变体,例如 Windows 的 .dll、Linux 的 .so 和 macOS 的 .dylib;Unix 平台还可能尝试 lib 前缀。绝对路径则按原样处理,不会追加这些变体。

LibraryImport 的优势不是让错误签名变安全,而是让许多封送步骤可在编译期生成,便于分析器检查,也更适合 Native AOT。使用它需要在工程文件中设置 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>,否则构建会报 SYSLIB1062 错误。头文件仍然是签名、布局和调用约定的最终依据。

3. DllImport 仍有适用场景

DllImportAttribute 由运行时建立 P/Invoke 存根,在旧版 .NET、源生成器尚不支持的封送方式,或分析器明确建议保留时仍然合理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
using System.Runtime.InteropServices;

internal static class LegacyNative
{
    [DllImport(
        "device_sdk",
        EntryPoint = "device_get_position",
        ExactSpelling = true)]
    internal static extern int GetPosition(nint handle, out double millimeters);
}

面向 .NET 7 及以上的新代码,可启用分析器并关注 SYSLIB1054:它会标出可用源生成器改写的 DllImport;迁移过程中若封送配置不被源生成器支持,SYSLIB1051/SYSLIB1052 会明确指出,此时保留 DllImport 即可。不要为了形式统一而忽略分析器给出的限制。

4. SDK 状态码和系统最后错误是两条通道

许多设备 SDK 用返回整数表示自身错误,例如 0 成功、负数失败。这与 Windows GetLastError 或 Unix errno 不是一回事:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public static double ReadPosition(nint handle)
{
    int status = DeviceNative.GetPosition(handle, out double position);
    if (status != 0)
    {
        throw new DeviceSdkException("device_get_position", status);
    }

    return position;
}

public sealed class DeviceSdkException(string operation, int status)
    : Exception($"{operation} failed with SDK status {status}")
{
    public int Status { get; } = status;
}

只有头文件明确说明函数通过系统最后错误报告细节时,才设置 SetLastError = true,并在判断失败后立即读取缓存值:

1
2
[LibraryImport("device_sdk", EntryPoint = "device_wait", SetLastError = true)]
internal static partial int Wait(nint handle, uint timeoutMilliseconds);

调用方沿用本篇"0 成功、非 0 失败"的约定:

1
2
3
4
5
6
int result = DeviceNative.Wait(handle, 1_000);
if (result != 0)
{
    int nativeError = Marshal.GetLastPInvokeError();
    throw new InvalidOperationException($"device_wait failed: {nativeError}");
}

这里沿用示例约定,把非零返回值视为失败;GetLastPInvokeError 提供的是系统错误细节,不能替代 SDK 状态码。真实项目必须照抄厂商约定,不能根据 Win32 API 的习惯猜测。

5. 把原生声明限制在最薄的一层

推荐把代码分成三层:

  1. DeviceNative 精确复刻头文件,不加入业务语义;
  2. DeviceSession 管理句柄、错误转换、单位和线程限制;
  3. 上层 IDeviceIAxis 等能力接口服务于流程和测试。

这样升级 SDK 时,可以先用头文件和导出表审查第一层,再验证适配器,不必让 nint、错误码和厂商枚举扩散到界面与业务流程。

6. 首次接入按故障类型定位

异常或现象常见原因首要检查
DllNotFoundException主库不存在或其依赖缺失部署目录、依赖库、搜索路径
EntryPointNotFoundException导出名、大小写或名称修饰不匹配头文件和实际导出表
BadImageFormatException进程与库架构不匹配,或文件并非有效动态库x86/x64/Arm64 与文件格式
返回值偶发错误参数类型、布局、编码或生命周期不匹配原生签名逐字段对照
调用后崩溃调用约定、缓冲区、回调或所有权错误最小化签名并启用原生调试

先用只含一次调用的控制台程序建立最小闭环,再接入 UI、DI 和状态机。能够加载动态库只证明加载阶段成功,不证明 ABI 声明正确。

总结

P/Invoke 的核心是让托管声明与原生 ABI 精确一致。现代 .NET 项目优先考虑 LibraryImport,在不支持的场景保留 DllImport,需要运行时选择库或符号时再使用 NativeLibrary。无论入口方式如何变化,头文件、实际导出和资源契约始终是事实来源。

下一篇将逐一处理最容易写错的类型:整数、布尔、枚举、句柄和指针。

参考资料

Licensed under CC BY-NC-SA 4.0