C++ 基础与原生互操作(五):为 P/Invoke 设计稳定的 C 接口

前四篇已经建立了语言、生命周期和 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_createdevice_destroydevice_readdevice_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.dlllibdevice_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::stringstd::vector 或 C++ 类给 P/Invoke;
  • 让 C++ 异常越过导出函数;
  • 返回内部临时对象的地址;
  • int 同时表示状态和任意长度,却不说明负值、溢出和单位;
  • 使用 boollongwchar_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# 只依赖可验证的二进制接口。

参考资料

Licensed under CC BY-NC-SA 4.0