前四篇已经建立了语言、生命周期和 ABI 模型。最后用一个小型设备库把它们连起来:DLL 内部使用 C++ 类和 std::string,公共边界只暴露 C 函数、固定宽度整数、不透明句柄和 UTF-8 缓冲区,再由 C# 通过 LibraryImport 调用。
1. 先定义边界规则
跨语言接口应先回答这些问题,再写代码:
- 句柄:谁创建、谁销毁,空句柄是否合法;
- 所有权:内存由哪一侧分配和释放,函数返回后指针还能用多久;
- 尺寸:长度按字节还是元素,是否包含字符串终止符;
- 编码:使用 UTF-8、UTF-16 还是平台相关编码;
- 错误:返回值是数据还是状态码,如何取得详细信息;
- 并发:同一句柄能否并发调用,销毁能否与其他调用同时发生;
- 版本:怎样扩展接口而不破坏既有调用者。
本例约定:所有函数使用 C 链接和 cdecl;返回 int32_t 状态码;名称使用 UTF-8;句柄由 create 创建、destroy 销毁;输出字符串采用“两次调用”查询容量(查询容量返回“缓冲区不足”状态只是一种约定,也有 SDK 对纯查询返回成功——C# 原生互操作系列(四)的示例即采用后者);异常绝不跨出 DLL。带结构体参数时的 struct_size/api_version 演进惯例,见 C# 原生互操作与设备 SDK(八):C++ ABI 与跨平台封装。
2. 公共 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
50
51
52
53
54
55
| // device_api.h
#pragma once
#include <stddef.h>
#include <stdint.h>
#if defined(_WIN32)
#if defined(DEVICE_NATIVE_BUILD)
#define DEVICE_API __declspec(dllexport)
#else
#define DEVICE_API __declspec(dllimport)
#endif
#define DEVICE_CALL __cdecl
#else
#define DEVICE_API __attribute__((visibility("default")))
#define DEVICE_CALL
#endif
#ifdef __cplusplus
#define DEVICE_NOEXCEPT noexcept
extern "C" {
#else
#define DEVICE_NOEXCEPT
#endif
typedef struct device_handle device_handle;
enum device_status
{
DEVICE_OK = 0,
DEVICE_INVALID_ARGUMENT = 1,
DEVICE_BUFFER_TOO_SMALL = 2,
DEVICE_INTERNAL_ERROR = 3
};
DEVICE_API int32_t DEVICE_CALL device_create(
const char* endpoint_utf8,
device_handle** out_handle) DEVICE_NOEXCEPT;
DEVICE_API void DEVICE_CALL device_destroy(
device_handle* handle) DEVICE_NOEXCEPT;
DEVICE_API int32_t DEVICE_CALL device_read(
device_handle* handle,
double* out_value) DEVICE_NOEXCEPT;
DEVICE_API int32_t DEVICE_CALL device_get_name(
device_handle* handle,
char* buffer,
size_t capacity,
size_t* out_required) DEVICE_NOEXCEPT;
#ifdef __cplusplus
}
#endif
|
这份头文件也能被 C 编译器解析:C++ 专属的 extern "C" 和 noexcept 由 __cplusplus 条件保护,不透明结构体只公开名称、不公开布局。
接口刻意没有暴露 std::string、C++ 类、引用、异常或模板。device_handle** 是输出参数:成功后调用者得到一个不透明指针,只能交回本库函数。
3. DLL 内部使用 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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
| // device_api.cpp
#include "device_api.h"
#include <cstring>
#include <string>
#include <utility>
struct device_handle
{
explicit device_handle(std::string endpoint_value)
: endpoint(std::move(endpoint_value))
{
}
std::string endpoint;
double latest_value = 42.5;
};
int32_t DEVICE_CALL device_create(
const char* endpoint_utf8,
device_handle** out_handle) noexcept
{
if (endpoint_utf8 == nullptr || endpoint_utf8[0] == '\0' ||
out_handle == nullptr)
{
return DEVICE_INVALID_ARGUMENT;
}
*out_handle = nullptr;
try
{
*out_handle = new device_handle(endpoint_utf8);
return DEVICE_OK;
}
catch (...)
{
return DEVICE_INTERNAL_ERROR;
}
}
void DEVICE_CALL device_destroy(device_handle* handle) noexcept
{
delete handle; // delete nullptr 是安全的
}
int32_t DEVICE_CALL device_read(
device_handle* handle,
double* out_value) noexcept
{
if (handle == nullptr || out_value == nullptr)
{
return DEVICE_INVALID_ARGUMENT;
}
*out_value = handle->latest_value;
return DEVICE_OK;
}
int32_t DEVICE_CALL device_get_name(
device_handle* handle,
char* buffer,
size_t capacity,
size_t* out_required) noexcept
{
if (handle == nullptr || out_required == nullptr)
{
return DEVICE_INVALID_ARGUMENT;
}
const size_t required = handle->endpoint.size() + 1;
*out_required = required;
if (buffer == nullptr || capacity < required)
{
return DEVICE_BUFFER_TOO_SMALL;
}
std::memcpy(buffer, handle->endpoint.c_str(), required);
return DEVICE_OK;
}
|
公共函数的声明已在头文件中带导出属性和 C 链接,定义包含该头文件并保持签名一致即可。每个可能触发 C++ 异常的入口都必须在异常穿越 ABI 前拦住;本例 device_create 中字符串构造和内存分配都可能失败,所以用 catch (...) 转为状态码。
真实设备库还应定义句柄并发规则,并在内部加锁或要求调用者串行访问。本例的名称在创建后不再变化,因此两次调用查询字符串长度不会发生容量竞争;可变字符串则需要快照、版本或单次填充等额外设计。
4. 构建动态库
Windows 的 Developer PowerShell:
1
2
| cl /std:c++20 /EHsc /W4 /LD /DDEVICE_NATIVE_BUILD device_api.cpp /Fe:device_native.dll
dumpbin /exports device_native.dll
|
Linux:
1
2
3
4
5
| g++ -std=c++20 -Wall -Wextra -Wpedantic \
-fPIC -fvisibility=hidden -shared \
device_api.cpp -o libdevice_native.so
nm -D --defined-only libdevice_native.so
|
检查结果应包含 device_create、device_destroy、device_read 和 device_get_name。构建机器能生成 DLL 不等于目标机器必然能加载,还要随部署核对目标架构和动态依赖。
5. C# 侧声明入口
面向 .NET 7 及更高版本,Microsoft 建议在支持的场景优先使用源生成的 LibraryImport。项目需要允许不安全代码,因为字符串输出示例直接固定字节缓冲区:
1
2
3
4
| <PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
|
声明必须逐项匹配 C ABI:
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
| using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
internal static partial class NativeMethods
{
private const string LibraryName = "device_native";
internal const int Ok = 0;
internal const int BufferTooSmall = 2;
[LibraryImport(
LibraryName,
EntryPoint = "device_create",
StringMarshalling = StringMarshalling.Utf8)]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int Create(string endpointUtf8, out nint handle);
[LibraryImport(LibraryName, EntryPoint = "device_destroy")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial void Destroy(nint handle);
[LibraryImport(LibraryName, EntryPoint = "device_read")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static partial int Read(DeviceSafeHandle handle, out double value);
[LibraryImport(LibraryName, EntryPoint = "device_get_name")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
internal static unsafe partial int GetName(
DeviceSafeHandle handle,
byte* buffer,
nuint capacity,
out nuint required);
}
|
库名不写平台后缀,让 .NET 按平台尝试相应名称。部署时仍需把 device_native.dll 或 libdevice_native.so 放到运行时可解析的位置;复杂部署可使用 NativeLibrary.SetDllImportResolver 明确解析策略。
6. 用 SafeHandle 接管句柄
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| using Microsoft.Win32.SafeHandles;
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
internal DeviceSafeHandle(nint handle)
: base(ownsHandle: true)
{
SetHandle(handle);
}
protected override bool ReleaseHandle()
{
NativeMethods.Destroy(handle);
return true;
}
}
|
SafeHandle 让句柄释放进入 .NET 的可靠生命周期机制。普通调用把 DeviceSafeHandle 直接传给 P/Invoke,运行时会在调用期间保护它不被并发释放;不要为了省事到处取 DangerousGetHandle() 再传裸 nint。
再封装成业务可用的托管类型:
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
| using System.Text;
public sealed class Device : IDisposable
{
private readonly DeviceSafeHandle _handle;
private Device(DeviceSafeHandle handle)
{
_handle = handle;
}
public static Device Open(string endpoint)
{
var status = NativeMethods.Create(endpoint, out var rawHandle);
if (status != NativeMethods.Ok)
{
throw new InvalidOperationException(
$"device_create failed: {status}");
}
DeviceSafeHandle? safeHandle = null;
try
{
safeHandle = new DeviceSafeHandle(rawHandle);
return new Device(safeHandle);
}
catch
{
if (safeHandle is null)
{
NativeMethods.Destroy(rawHandle);
}
else
{
safeHandle.Dispose();
}
throw;
}
}
public double Read()
{
var status = NativeMethods.Read(_handle, out var value);
if (status != NativeMethods.Ok)
{
throw new InvalidOperationException(
$"device_read failed: {status}");
}
return value;
}
public unsafe string GetName()
{
var status = NativeMethods.GetName(
_handle, null, 0, out var required);
if (status != NativeMethods.BufferTooSmall || required == 0 ||
required > int.MaxValue)
{
throw new InvalidOperationException(
$"device_get_name(size) failed: {status}");
}
var buffer = GC.AllocateUninitializedArray<byte>((int)required);
fixed (byte* pointer = buffer)
{
status = NativeMethods.GetName(
_handle, pointer, required, out var writtenRequired);
if (status != NativeMethods.Ok || writtenRequired != required)
{
throw new InvalidOperationException(
$"device_get_name(data) failed: {status}");
}
}
return Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1);
}
public void Dispose() => _handle.Dispose();
}
|
使用方式:
1
2
| using var device = Device.Open("camera-1");
Console.WriteLine($"{device.GetName()}: {device.Read()}");
|
生产代码可以为状态码建立专用异常类型。上例还处理了一个容易遗漏的极端路径:原生创建已成功,但托管包装对象构造失败;此时根据 SafeHandle 是否已接管所有权,选择直接销毁裸句柄或释放安全句柄,避免泄漏。
7. 为什么不直接返回字符串指针
这样的接口看似方便:
1
| const char* device_get_name(device_handle* handle);
|
但调用者必须猜测返回指针:
- 是静态字符串、句柄内部缓存还是新分配内存;
- 下次调用后是否失效;
- 是否线程安全;
- 应由谁、用什么函数释放。
“调用者提供缓冲区 + 库返回所需容量”虽然多一步,却把分配和释放留在同一侧。另一种合理设计是 DLL 分配并提供专用 device_free,但必须保证任何成功返回的指针都由匹配函数释放。
8. 常见反模式
- 直接导出
std::string、std::vector 或 C++ 类给 P/Invoke; - 让 C++ 异常越过导出函数;
- 返回内部临时对象的地址;
- 用
int 同时表示状态和任意长度,却不说明负值、溢出和单位; - 使用
bool、long、wchar_t 却不固定跨平台映射; - DLL 内分配,C# 用不匹配的分配器释放;
- 只在 x64 Debug 环境测试,不验证 Release、目标架构和干净机器部署;
- 只要
EntryPoint 找得到就认为签名正确。
更完整的 C# 封送、加载诊断、回调和 SafeHandle 细节,可继续阅读 C# 原生互操作与设备 SDK 系列。
9. 本系列最终模型
现在可以把整条链路串起来:
1
2
3
4
5
6
7
| C++ 类、容器与 RAII
↓ 封装在动态库内部
窄而稳定的 C ABI
↓ 导出符号 + 明确调用约定
.NET LibraryImport / P/Invoke
↓ SafeHandle + 托管包装
上位机业务代码
|
C++ 内部可以充分使用类、模板和标准库;跨语言边界则有意退回更小、更明确的 C 兼容合同。这不是放弃 C++ 的能力,而是把变化快、依赖工具链的实现细节隔离在 DLL 内,让 C# 只依赖可验证的二进制接口。
参考资料