C# 原生互操作与设备 SDK(四):字符串、数组、指针与 Marshalling

互操作中的数据错误通常不在“能不能转换”,而在“谁分配、谁释放、能写多少、指针能留多久”。同一个 char* 可能表示只读输入、调用者提供的输出缓冲区、库分配的返回值,或只在下一次 SDK 调用前有效的借用视图。

本文把字符串、数组、ref/out 和指针放回完整契约中讨论。示例以 UTF-8 C API 为主,目标环境为 .NET 10。

1. 先为每个指针写五项契约

看到 T*void*char* 时,先记录:

  1. 方向:输入、输出还是输入输出;
  2. 长度:固定、以零结尾、由参数给出,还是由返回值给出;
  3. 分配者:调用者、SDK 还是操作系统;
  4. 生命周期:仅本次调用、直到下一次调用、直到显式释放,还是与句柄同寿命;
  5. 可变性与线程:原生代码能否写入,是否会跨线程保留指针。

缺少任一项,都不应急着写 P/Invoke 声明。

2. 只读输入字符串显式指定编码

原生接口:

1
int32_t device_set_name(device_handle handle, const char* utf8_name);

若函数只在调用期间读取字符串,LibraryImport 可以生成 UTF-8 转换:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    [LibraryImport(
        "device_sdk",
        EntryPoint = "device_set_name",
        StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int SetName(nint handle, string name);
}

这不允许原生库保存收到的指针。若 API 会在返回后继续使用它,就必须按 SDK 约定分配稳定内存,并在注销或关闭后释放。

3. 输出文本优先采用调用者缓冲区

一个边界清晰的 C API 会同时接收缓冲区和容量,并返回实际所需长度:

1
2
3
4
5
int32_t device_get_name(
    device_handle handle,
    char* utf8_buffer,
    size_t capacity,
    size_t* out_required);

下面的示例约定:out_required 包含结尾零;以空缓冲区查询长度时返回成功;若第二次调用期间名称变长,函数返回可识别的“缓冲区不足”状态并更新长度。其他 SDK 可能采用不同约定,必须相应调整封装。

托管声明保留原始指针语义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using System.Runtime.InteropServices;

internal static partial class DeviceNative
{
    [LibraryImport("device_sdk", EntryPoint = "device_get_name")]
    internal static unsafe partial int GetName(
        nint handle,
        byte* buffer,
        nuint capacity,
        out nuint required);
}

封装层可以先询问长度,再固定缓冲区调用:

 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
using System.Text;

public static class DeviceNameReader
{
    public static unsafe string ReadName(nint handle)
    {
        int status = DeviceNative.GetName(handle, null, 0, out nuint required);
        if (status != 0)
        {
            throw new DeviceSdkException("device_get_name(size)", status);
        }

        if (required == 0)
        {
            throw new InvalidDataException("SDK returned an invalid name length");
        }

        var buffer = GC.AllocateUninitializedArray<byte>(
            checked((int)required),
            pinned: true);   // 分配在固定对象堆(POH)上,内存在 GC 下保持固定

        // pinned: true 保证内存固定,fixed 在这里只负责取得指向它的指针。
        fixed (byte* pointer = buffer)
        {
            status = DeviceNative.GetName(
                handle,
                pointer,
                (nuint)buffer.Length,
                out required);
        }

        if (status != 0)
        {
            throw new DeviceSdkException("device_get_name(data)", status);
        }

        if (required > (nuint)buffer.Length)
        {
            throw new InvalidDataException("SDK returned an invalid name length");
        }

        int used = checked((int)required);
        int terminator = Array.IndexOf(buffer, (byte)0, 0, used);
        if (terminator < 0)
        {
            throw new InvalidDataException("SDK returned a non-terminated name");
        }

        return Encoding.UTF8.GetString(buffer, 0, terminator);
    }
}

真实封装还应识别“缓冲区不足”状态并按更新后的所需长度重试,避免名称在两次调用间变化造成误判。

4. 不要用 Out string 充当可写缓冲区

托管字符串不可变。把按值字符串标成 [Out] string 让原生代码写入,可能破坏运行时假设;Microsoft 的互操作建议明确反对这种写法。

StringBuilder 可用于某些旧 API,但封送过程通常需要额外分配和复制,而且容量与结尾零仍容易出错。新设计优先使用显式字节/字符缓冲区加长度;旧接口则严格按官方签名处理。

5. 数组必须与元素数量一起传递

原生数组本质上通常只是首元素指针,长度不会自动跟随:

1
2
3
4
5
int32_t device_read_samples(
    device_handle handle,
    double* out_samples,
    size_t capacity,
    size_t* out_count);

如果原生代码只在调用期间写入,可固定托管数组:

 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
using System.Runtime.InteropServices;

internal static partial class SampleReader
{
    [LibraryImport("device_sdk", EntryPoint = "device_read_samples")]
    private static unsafe partial int ReadSamplesNative(
        nint handle,
        double* samples,
        nuint capacity,
        out nuint count);

    internal static unsafe int ReadSamples(
        nint handle,
        Span<double> destination)
    {
        fixed (double* pointer = destination)
        {
            int status = ReadSamplesNative(
                handle,
                pointer,
                (nuint)destination.Length,
                out nuint count);

            if (status != 0)
            {
                throw new DeviceSdkException("device_read_samples", status);
            }

            if (count > (nuint)destination.Length)
            {
                throw new InvalidDataException("SDK returned an invalid sample count");
            }

            return checked((int)count);
        }
    }
}

fixed 的保证只覆盖代码块。若 SDK 异步保留指针,必须使用拥有明确释放时机的固定内存或非托管内存,并避免长期固定大对象造成 GC 压力。

6. ref、out 和指针的层级要一致

C 参数常见 C# 表达含义
int32_t valueint value按值输入
const int32_t* valuein int value 或指针指向只读单值
int32_t* out_valueout int value输出一个单值
int32_t* values + 长度数组/Span<T> 固定后的指针连续元素
device_handle* out_handleout nint handle输出一个句柄
void** out_bufferout nint buffer输出一根指向内存的指针

ref/out 只是表达一层间接寻址,不能自动推导数组长度、分配器或所有权。

7. 谁分配,谁提供释放方式

如果 SDK 返回自己分配的内存,应该同时提供释放函数:

1
2
3
4
int32_t device_create_report(device_handle handle,
                             uint8_t** out_data,
                             size_t* out_length);
void device_free(void* memory);

托管侧复制完后在 finally 中调用同一库的释放函数:

 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
using System.Runtime.InteropServices;

internal static partial class DeviceReportReader
{
    [LibraryImport("device_sdk", EntryPoint = "device_create_report")]
    private static partial int CreateReportNative(
        nint handle,
        out nint data,
        out nuint length);

    [LibraryImport("device_sdk", EntryPoint = "device_free")]
    private static partial void Free(nint memory);

    public static byte[] CreateReport(nint handle)
    {
        nint memory = 0;
        try
        {
            int status = CreateReportNative(
                handle,
                out memory,
                out nuint length);
            if (status != 0 || memory == 0)
            {
                throw new InvalidOperationException(
                    $"device_create_report failed: {status}");
            }

            byte[] managed = new byte[checked((int)length)];
            Marshal.Copy(memory, managed, 0, managed.Length);
            return managed;
        }
        finally
        {
            if (memory != 0)
            {
                Free(memory);
            }
        }
    }
}

不能看到 malloc 就随意改用 Marshal.FreeHGlobal,也不能用托管分配器释放 DLL 内部堆上的内存。Windows 上不同 CRT/模块之间混用分配器尤其容易造成堆损坏。

8. 借用指针不要包装成永久对象

有些 SDK 返回指向内部缓存的指针,并注明“直到下一次调用有效”。此时应在有效窗口内立即复制,并序列化可能使缓存失效的调用。把它转换成 Span<T>ReadOnlySpan<T> 不会延长原生内存寿命;Span 只描述一段内存,不拥有它。

如果指针与设备句柄同寿命,读取期间还必须确保句柄不会并发关闭。后续 SafeHandle 文章会处理这项约束。

总结

Marshalling 不是类型转换清单,而是跨边界的数据契约。编码、容量、实际长度、可写性、保留时间和释放函数必须同时明确。优先使用调用者缓冲区和显式长度;对于 SDK 分配的内存,始终由匹配的 SDK 函数释放。

下一篇进入加载阶段:调用约定、进程位数、导出符号和依赖库如何系统诊断。

参考资料

Licensed under CC BY-NC-SA 4.0