设备厂商常把 SDK 交付为 C/C++ 头文件、动态库和少量示例。C# 程序不能仅凭函数名调用这些二进制接口:双方还必须对参数布局、调用约定、字符串编码、资源所有权和错误模型达成完全一致的约定,这份约定就是应用二进制接口(Application Binary Interface,ABI)。
本文以 .NET 10 为环境,从一个最小 C 接口出发,说明 P/Invoke 的工作模型,以及 LibraryImport 和 DllImport 各自适合什么场景;运行时手动选择库文件的加载方式见本系列《C# 原生互操作与设备 SDK(五):调用约定、位数与 DLL 加载诊断》。业务侧的硬件抽象方法见《设备软件架构与控制模型(二):硬件抽象层与设备适配器》;本系列聚焦适配器下面的二进制边界。
1. P/Invoke 连接的是 ABI,不是源代码
假设厂商头文件给出以下 C API:
| |
device_open 的托管声明不能只做到“看起来像”。必须逐项回答:
- 动态库的逻辑名称和导出符号是什么;
int32_t是否映射为 32 位有符号整数;device_handle是整数、指针还是需要释放的资源;device_handle*是输出一个句柄,还是传入句柄数组;- 返回值是 SDK 状态码,还是操作系统最后错误;
- Windows 导出是否使用了非默认调用约定。
任何一项不匹配,都可能表现为错误值、找不到入口点,甚至栈或堆损坏。
2. .NET 7 及以上优先使用 LibraryImport
LibraryImportAttribute 让源生成器在编译期生成封送代码。声明方法必须是 static partial,包含它的类型也要是 partial:
| |
库名不写扩展名时,运行时会按平台尝试相应变体,例如 Windows 的 .dll、Linux 的 .so 和 macOS 的 .dylib;Unix 平台还可能尝试 lib 前缀。绝对路径则按原样处理,不会追加这些变体。
LibraryImport 的优势不是让错误签名变安全,而是让许多封送步骤可在编译期生成,便于分析器检查,也更适合 Native AOT。使用它需要在工程文件中设置 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>,否则构建会报 SYSLIB1062 错误。头文件仍然是签名、布局和调用约定的最终依据。
3. DllImport 仍有适用场景
DllImportAttribute 由运行时建立 P/Invoke 存根,在旧版 .NET、源生成器尚不支持的封送方式,或分析器明确建议保留时仍然合理:
| |
面向 .NET 7 及以上的新代码,可启用分析器并关注 SYSLIB1054:它会标出可用源生成器改写的 DllImport;迁移过程中若封送配置不被源生成器支持,SYSLIB1051/SYSLIB1052 会明确指出,此时保留 DllImport 即可。不要为了形式统一而忽略分析器给出的限制。
4. SDK 状态码和系统最后错误是两条通道
许多设备 SDK 用返回整数表示自身错误,例如 0 成功、负数失败。这与 Windows GetLastError 或 Unix errno 不是一回事:
| |
只有头文件明确说明函数通过系统最后错误报告细节时,才设置 SetLastError = true,并在判断失败后立即读取缓存值:
| |
调用方沿用本篇"0 成功、非 0 失败"的约定:
| |
这里沿用示例约定,把非零返回值视为失败;GetLastPInvokeError 提供的是系统错误细节,不能替代 SDK 状态码。真实项目必须照抄厂商约定,不能根据 Win32 API 的习惯猜测。
5. 把原生声明限制在最薄的一层
推荐把代码分成三层:
DeviceNative精确复刻头文件,不加入业务语义;DeviceSession管理句柄、错误转换、单位和线程限制;- 上层
IDevice、IAxis等能力接口服务于流程和测试。
这样升级 SDK 时,可以先用头文件和导出表审查第一层,再验证适配器,不必让 nint、错误码和厂商枚举扩散到界面与业务流程。
6. 首次接入按故障类型定位
| 异常或现象 | 常见原因 | 首要检查 |
|---|---|---|
DllNotFoundException | 主库不存在或其依赖缺失 | 部署目录、依赖库、搜索路径 |
EntryPointNotFoundException | 导出名、大小写或名称修饰不匹配 | 头文件和实际导出表 |
BadImageFormatException | 进程与库架构不匹配,或文件并非有效动态库 | x86/x64/Arm64 与文件格式 |
| 返回值偶发错误 | 参数类型、布局、编码或生命周期不匹配 | 原生签名逐字段对照 |
| 调用后崩溃 | 调用约定、缓冲区、回调或所有权错误 | 最小化签名并启用原生调试 |
先用只含一次调用的控制台程序建立最小闭环,再接入 UI、DI 和状态机。能够加载动态库只证明加载阶段成功,不证明 ABI 声明正确。
总结
P/Invoke 的核心是让托管声明与原生 ABI 精确一致。现代 .NET 项目优先考虑 LibraryImport,在不支持的场景保留 DllImport,需要运行时选择库或符号时再使用 NativeLibrary。无论入口方式如何变化,头文件、实际导出和资源契约始终是事实来源。
下一篇将逐一处理最容易写错的类型:整数、布尔、枚举、句柄和指针。