很多设备 SDK 的核心由 C++ 编写,却不适合把类、异常、std::string 和 STL 容器直接暴露给 C#。C++ 没有覆盖 MSVC、GCC、Clang 和所有 .NET 支持平台的统一 ABI;编译器版本、运行库和构建选项变化,也可能改变名称修饰与对象布局。
更稳定的边界是保留 C++ 实现,在外面导出一层窄 C ABI。本文给出完整设计原则,并说明怎样让同一托管适配器连接 Windows .dll、Linux .so 和 macOS .dylib。
1. 为什么不直接导出 C++ 类
下面的接口对同一工具链内的 C++ 调用者很自然:
1
2
3
4
5
6
| class Camera
{
public:
virtual ~Camera();
std::vector<std::byte> Capture(const std::string& recipe);
};
|
跨 ABI 时却产生一串隐含约定:
- 类名和方法名如何修饰;
this 怎样传递、虚表怎样布局;std::string/std::vector 使用哪个标准库实现;- 对象由哪个运行库分配和释放;
- C++ 异常怎样传播;
- 编译器、调试/发布运行库和迭代器调试选项是否一致。
把这些内部细节复制到 P/Invoke 签名不是稳定集成。Microsoft 的 .NET ABI 指南也建议通过 extern "C" 导出 C 函数来连接 C++。
2. 用不透明句柄隐藏 C++ 对象
公共头文件只暴露 C 能表达的类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| #pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
#define DEVICE_EXTERN extern "C"
#else
#define DEVICE_EXTERN
#endif
#ifdef _WIN32
// 按 SDK 构建方视角书写;完整的 SDK 头文件通常再用构建宏在 dllexport 与 dllimport 间切换。
#define DEVICE_EXPORT DEVICE_EXTERN __declspec(dllexport)
#else
#define DEVICE_EXPORT DEVICE_EXTERN __attribute__((visibility("default")))
#endif
typedef struct device_context device_context;
typedef struct device_open_options {
uint32_t struct_size;
uint32_t api_version;
int32_t device_index;
uint32_t reserved;
} device_open_options;
DEVICE_EXPORT int32_t device_open(
const device_open_options* options,
device_context** out_context);
DEVICE_EXPORT int32_t device_capture(
device_context* context,
const uint8_t* recipe_utf8,
size_t recipe_length,
uint8_t* output,
size_t capacity,
size_t* out_required);
DEVICE_EXPORT int32_t device_close(device_context* context);
|
device_context 的定义只存在于 C++ 实现文件中。C# 看到的只是一个不透明句柄,再用 SafeHandle 管理。这样可以修改内部类、容器和继承关系,而不改变公开布局。
3. 异常不能穿过 C 边界
C++ 包装函数要在边界内捕获异常并转换为稳定错误码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| #include "device_api.h"
#include "camera.hpp"
#include <new>
struct device_context
{
explicit device_context(int32_t index) : camera(index) {}
Camera camera;
};
int32_t device_open(
const device_open_options* options,
device_context** out_context)
{
if (options == nullptr || out_context == nullptr)
return -1;
if (options->struct_size < sizeof(device_open_options))
return -2;
*out_context = nullptr;
try
{
auto* context = new device_context(options->device_index);
*out_context = context;
return 0;
}
catch (const std::bad_alloc&)
{
return -3;
}
catch (...)
{
return -100;
}
}
int32_t device_close(device_context* context)
{
try
{
delete context;
return 0;
}
catch (...)
{
return -100;
}
}
|
不要让 C++ 异常越过 C 函数进入 .NET。若需要错误文本,可提供“调用者缓冲区 + 长度”的错误查询函数,并定义错误信息是按线程、按 context 还是按最近调用保存,避免全局字符串在并发下相互覆盖。
析构函数原则上也不应抛异常;包装层的 catch (...) 是边界兜底,不是用来掩盖不可恢复的资源损坏。
4. ABI 从第一版就为演进留位置
公开结构加入 struct_size 与 api_version,可以让新库识别旧调用者实际提供了多少字段。常见兼容策略是:
- 只在结构尾部追加字段;
- 保留并清零
reserved 字段; - 不改变已发布字段的类型、偏移和含义;
- 新能力用新函数或能力查询暴露;
- 不复用已经发布的错误码和枚举值;
- 提供
device_get_api_version 与运行时版本信息。
“DLL 文件名没变”不代表 ABI 兼容。应保存公共头文件基线,并在 CI 中对导出符号、结构大小和兼容样例做回归。
5. 内存始终回到分配它的模块
优先让调用者提供输出缓冲区。必须返回动态内存时,C API 应成对提供分配与释放:
1
2
3
4
5
6
| DEVICE_EXPORT int32_t device_create_blob(
device_context* context,
uint8_t** out_data,
size_t* out_length);
DEVICE_EXPORT void device_free(void* memory);
|
C# 复制数据后调用 device_free。不要要求调用者猜测内部使用 new[]、malloc、COM 任务分配器还是特定 CRT 堆。
同理,字符串使用 UTF-8 字节加显式长度可以避开 wchar_t 在平台间宽度不同的问题。若必须以零结尾,要写清长度是否包含终止符。
6. 托管声明保持同一逻辑库名
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_bridge";
// DeviceOpenOptionsNative 遵循本系列(三)的 *Native 布局约定(Sequential + struct_size)。
[LibraryImport(LibraryName, EntryPoint = "device_open")]
internal static partial int Open(
in DeviceOpenOptionsNative options,
out SafeDeviceHandle handle);
[LibraryImport(LibraryName, EntryPoint = "device_close")]
internal static partial int CloseRaw(nint handle);
}
|
构建系统分别产出:
1
2
3
4
| runtimes/
├── win-x64/native/device_bridge.dll
├── linux-x64/native/libdevice_bridge.so
└── osx-arm64/native/libdevice_bridge.dylib
|
NuGet/RID 资产或自定义 DllImportResolver 负责把逻辑名映射到当前平台文件。每个产物仍需在对应 OS、架构和运行库环境中测试,不能因为 C API 相同就只测试 Windows。
7. C 包装层不等于最低公分母
稳定 C ABI 可以继续表达现代能力:
- 不透明句柄表达对象;
- 结构 +
struct_size 表达可演进参数; - 函数指针 + context 表达事件;
- 调用者缓冲区表达高吞吐数据;
- 明确错误码和查询函数表达诊断;
- 能力位或查询函数表达可选特性。
边界应该窄,但不应把所有错误压成一个 false,也不应让上层通过几十次 getter 拼出一份本可原子返回的状态快照。
8. 何时考虑其他桥接方式
- C++/CLI:适合 Windows 内部已有大量 C++ 类且团队能维护
.vcxproj 的场景;现代 .NET 的 C++/CLI 支持仅限 Windows,不能作为跨平台桥。 - COM:适合已有稳定 COM 契约、注册与版本治理体系的 Windows 组件。
- 独立进程/RPC:适合 SDK 崩溃隔离、位数隔离、许可隔离或跨机器部署,但引入序列化、进程管理和故障恢复成本。
- C ABI:通常是跨平台、跨语言、部署在同一进程中的默认选择。
选择不是只看调用方便程度,还要评估故障是否会拖垮主进程、厂商库能否重入、升级是否需要独立回滚,以及许可是否允许重新封装。
9. 发布前验证矩阵
| 维度 | 最低验证 |
|---|
| 编译器 | 每个受支持工具链构建公开头文件与桥接库 |
| OS/架构 | 每个声明支持的 RID 加载并运行冒烟测试 |
| ABI | 导出名、调用约定、结构大小和偏移断言 |
| 版本 | 旧托管适配器连接新库,新适配器识别旧库 |
| 错误 | 空指针、缓冲区不足、设备断连、异常转换 |
| 资源 | 重复打开关闭、失败注入、句柄和内存泄漏 |
| 并发 | 多线程调用、回调与关闭竞争、重入限制 |
跨平台不是“能编译三份文件”,而是每个承诺的平台都拥有可重复的构建、打包和测试证据。
总结
C++ 实现可以保持复杂,公开 ABI 应保持简单、明确、可演进。用 extern "C"、不透明句柄、定宽类型、显式长度、配对释放和错误码建立稳定边界,再由 LibraryImport、SafeHandle 和托管适配器恢复面向对象语义,是设备 SDK 长期维护成本较低的组合。
至此,本系列从 P/Invoke 入口一路走到类型、布局、内存、加载、回调、句柄和跨平台桥接。下一阶段可以在这层可靠边界之上展开串口、TCP、Modbus、CAN 与工业协议编程。
参考资料