函数参数的基础类型都正确,结构体仍可能读出离谱数据。原因是 ABI 不只规定字段类型,还规定字段顺序、对齐、填充、整体大小,以及结构是按值传递还是通过指针传递。
本文从一份可计算的布局开始,说明 StructLayout、Pack、联合体和 blittable 类型的边界,并建立“原生侧与托管侧同时验证”的方法。
1. 字段之间可能存在填充
考虑以下 C 结构:
| |
在常见的自然对齐规则下,各字段偏移依次为 0、4、8、16、17,整体为 24 字节。这个结果不是靠字段大小相加猜出的,而是由目标 ABI 和编译选项决定。
对应的 C# 声明可以写为:
| |
LayoutKind.Sequential 保持字段声明顺序,并由运行时按平台规则放置字段。fixed byte Reserved[7] 是经典的定长缓冲写法;现代 .NET(C# 12+)也可用 [InlineArray] 自定义结构体表达定长内联数组,但用于互操作时仍要验证整体大小与字段偏移。不要因为看到“结构体大小不对”就立即添加 Pack = 1;只有原生头文件确实使用紧凑布局或 #pragma pack 时,托管侧才应使用相同设置。
2. 用大小与偏移断言验证布局
对布局敏感的结构,应在测试中固定预期:
| |
原生侧也应使用同一构建链验证:
| |
如果 SDK 支持多个编译器和架构,不应把未经验证的 24 写成普遍真理,而应在每个受支持目标上执行这些检查。
3. Pack 是 ABI 约定,不是修复按钮
Pack 控制字段对齐的上限。下面的声明只有在原生侧明确采用 1 字节打包时才正确:
| |
紧凑布局可能减少空间,也可能产生未对齐访问并改变调用 ABI。常见错误是厂商示例在一个头文件中 #pragma pack(push, 1) 后未被注意,或者托管侧随意设置 Pack = 1 来“对齐数值”。正确做法是追踪头文件的 push/pop 范围和项目编译选项。
4. 联合体使用 Explicit 布局
C 联合体让多个字段共享同一段内存:
| |
C# 用 LayoutKind.Explicit 和相同的 FieldOffset 表达:
| |
读取哪个字段必须由联合体外部的类型标签决定。重叠字段只复刻存储,不会自动验证当前分支。
5. 固定数组与指针字段含义不同
这两个 C 字段不是一回事:
| |
内联数组需要固定缓冲区或相应的字段封送;指针字段则用 nint/指针表达,并另行定义指向内存的长度、编码和生命周期。把指针误写成 [MarshalAs(UnmanagedType.ByValArray)] 会直接改变结构布局。
6. blittable 让双方共享位表示
blittable 类型在托管和非托管表示之间具有相同的位级布局,通常可以固定后直接传递,避免逐字段转换。定宽整数、浮点数、指针以及只包含这类字段的顺序结构更容易做到 blittable。
以下内容会引入额外规则或转换:
bool和char的表示依赖封送设置;string、普通托管数组和对象引用需要转换或固定;- 带自动布局的类型不能作为稳定 ABI;
- 关闭运行时封送后,受支持集合与默认规则会变化。
“C# unmanaged 泛型约束”和“启用运行时封送时的 blittable”概念接近但不完全等价,尤其要关注 bool 与 char。不要在文章或代码评审中把两者当同义词。
7. 按值和按引用必须照抄签名
| |
对应 C# 签名分别是值参数和 out/指针参数。结构较大并不意味着原生 API 一定按引用传递,也不能因为 C# 中 struct 是值类型,就给原生指针参数省略 ref 或 out。
对于带 struct_size 或 version 字段的 API,调用前要按厂商约定初始化:
| |
这类字段常用于在新旧结构版本间协商可访问范围,不能留为默认零值。
8. 布局审查清单
- 使用了哪套头文件、编译器、架构和打包选项;
- 每个字段的原生大小、托管大小和偏移是否一致;
- 原生
bool、枚举、long和wchar_t是否已显式确认; - 固定数组、指针、柔性数组成员是否被正确区分;
- 结构是按值、指针还是指针的指针传递;
- 是否含版本/大小字段,谁负责初始化;
- 原生与托管测试是否同时断言
sizeof和关键offsetof。
总结
结构体互操作不是“加一个 StructLayout”就结束。字段顺序、自然对齐、显式打包、联合体、内联数组和传递方式必须共同匹配。最可靠的办法是在两侧写大小与偏移断言,让 ABI 漂移在测试阶段失败。
下一篇讨论结构之外最复杂的边界:字符串、数组、ref/out、指针与内存所有权。