C# 原生互操作与设备 SDK(八):C++ ABI 与跨平台封装

很多设备 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_sizeapi_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"、不透明句柄、定宽类型、显式长度、配对释放和错误码建立稳定边界,再由 LibraryImportSafeHandle 和托管适配器恢复面向对象语义,是设备 SDK 长期维护成本较低的组合。

至此,本系列从 P/Invoke 入口一路走到类型、布局、内存、加载、回调、句柄和跨平台桥接。下一阶段可以在这层可靠边界之上展开串口、TCP、Modbus、CAN 与工业协议编程。

参考资料

Licensed under CC BY-NC-SA 4.0