互操作中的数据错误通常不在“能不能转换”,而在“谁分配、谁释放、能写多少、指针能留多久”。同一个 char* 可能表示只读输入、调用者提供的输出缓冲区、库分配的返回值,或只在下一次 SDK 调用前有效的借用视图。
本文把字符串、数组、ref/out 和指针放回完整契约中讨论。示例以 UTF-8 C API 为主,目标环境为 .NET 10。
1. 先为每个指针写五项契约
看到 T*、void* 或 char* 时,先记录:
- 方向:输入、输出还是输入输出;
- 长度:固定、以零结尾、由参数给出,还是由返回值给出;
- 分配者:调用者、SDK 还是操作系统;
- 生命周期:仅本次调用、直到下一次调用、直到显式释放,还是与句柄同寿命;
- 可变性与线程:原生代码能否写入,是否会跨线程保留指针。
缺少任一项,都不应急着写 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 value | int value | 按值输入 |
const int32_t* value | in int value 或指针 | 指向只读单值 |
int32_t* out_value | out int value | 输出一个单值 |
int32_t* values + 长度 | 数组/Span<T> 固定后的指针 | 连续元素 |
device_handle* out_handle | out nint handle | 输出一个句柄 |
void** out_buffer | out 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 函数释放。
下一篇进入加载阶段:调用约定、进程位数、导出符号和依赖库如何系统诊断。
参考资料