C# 原生互操作与设备 SDK(二):C# 类型与 C ABI

互操作签名最危险的错误往往没有编译提示:C# 和 C 都存在名为 longboolchar 的类型,但名称相同不代表大小、编码或 ABI 表示相同。一个字段宽度错了,后续参数就可能全部错位。

本文建立从原生头文件到 C# 声明的核对方法。目标不是背一张万能映射表,而是根据平台、编译器和头文件选择位宽明确的托管类型。

1. 优先从定宽类型开始

现代 C 接口如果使用 <stdint.h>,映射最直接:

C 类型位宽C# 类型
int8_t / uint8_t8sbyte / byte
int16_t / uint16_t16short / ushort
int32_t / uint32_t32int / uint
int64_t / uint64_t64long / ulong
float通常 32float
double通常 64double

对于设备寄存器、协议字段和文件格式,定宽整数同时表达了范围和二进制布局。若厂商公开接口仍使用 shortintlong,应查目标平台 ABI 和编译器文档,不能只按名称映射。

2. C/C++ long 不是 C# long

C# long 固定为 64 位。C/C++ long 至少 32 位,但常见 64 位 ABI 并不统一:64 位 Windows 仍是 32 位,而许多 64 位 Unix 系统是 64 位。

因此跨平台 C 接口应优先导出 int32_tint64_t 等定宽类型。无法修改旧 SDK 时,Windows 的原生 long 通常映射为 C# int;目标是 Linux/macOS 时必须按该库实际构建 ABI 重新确认,不能共享一份未经验证的声明。.NET 6+ 为这种场景提供了 System.Runtime.InteropServices.CLong/CULong,按目标平台自动以正确宽度承载 C long/unsigned long

3. 指针大小跟随进程架构

以下类型表达的是地址或与地址同宽的整数:

原生含义C# 表达
void*、不透明句柄nint / IntPtr,资源句柄优先用 SafeHandle
const void*nintvoid*,同时在封装层保持只读语义
size_tnuint / UIntPtr
ptrdiff_tintptr_tnint
uintptr_tnuint

nintnuint 随进程位数变化。它们不是“任意大整数”,也不表示内存所有权;一个返回指针究竟是借用、转移还是句柄,仍要看 API 契约。

4. bool 必须先认清原生定义

常见布尔表示至少有三种:

  • C99 _Bool 或 C++ bool 通常占 1 字节;
  • Win32 BOOL 是 4 字节有符号整数,零为假、非零为真;
  • COM VARIANT_BOOL 是 2 字节,VARIANT_TRUE-1

.NET 运行时封送启用时,C# bool 默认按 4 字节 Win32 BOOL 处理。它不能直接代表原生 C++ bool。一种显式声明是:

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

internal static class LegacyNative
{
    [DllImport(
        "device_sdk",
        EntryPoint = "device_is_ready",
        ExactSpelling = true)]
    [return: MarshalAs(UnmanagedType.I1)]
    internal static extern bool IsReady(nint handle);
}

对于新设计的 C ABI,更稳妥的做法是导出 uint8_tint32_t,C# 也使用 byteint,再在封装层转换为布尔值。这样签名不依赖默认封送规则:

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

internal static partial class DeviceNative
{
    [LibraryImport("device_sdk", EntryPoint = "device_is_ready")]
    private static partial byte IsReadyRaw(nint handle);

    internal static bool IsReady(nint handle) => IsReadyRaw(handle) != 0;
}

5. char 首先是整数,编码另行约定

在 .NET 支持的常见目标平台上,C 的 char 占 1 个 8 位字节,但它是有符号还是无符号由实现决定;C# char 则是 16 位 UTF-16 代码单元。以下映射更可靠:

  • 原生单字节数值或原始字节:byte / sbyte
  • UTF-8 文本:byte*、字节缓冲区,或显式 UTF-8 字符串封送;
  • Windows UTF-16 wchar_t*:显式 UTF-16 字符串封送;
  • Unix wchar_t*:不能假设是 UTF-16,应按平台定义处理。

不要用 C# char 表达任意原生 char,也不要把“ANSI”理解成一种固定的跨平台编码。

6. 枚举的语义和底层宽度都要固定

C# 枚举默认以 int 为底层类型,但 C/C++ 枚举的 ABI 宽度可能受编译器、选项和枚举值范围影响。设备 SDK 若把枚举放入公开结构或函数参数,最好由 C 边界固定为 int32_t

1
2
3
4
5
typedef int32_t device_state;

#define DEVICE_STATE_OFFLINE 0
#define DEVICE_STATE_READY   1
#define DEVICE_STATE_BUSY    2
1
2
3
4
5
6
public enum DeviceState : int
{
    Offline = 0,
    Ready = 1,
    Busy = 2
}

解析返回值时还要允许未知数字。原生库升级后可能新增状态,托管代码不应因为 Enum.IsDefined 为假就丢失原始值。

7. 句柄不是普通整数

厂商有时把句柄声明为 void*,有时是 uint32_t,还有时是结构体指针。三者不能凭名称互换:

  • 指针型句柄用 nintSafeHandle
  • 明确定宽的数字 ID 用对应整数;
  • Windows HANDLE 是指针大小,不应写成固定 32 位整数;
  • 只有文档明确指定的无效值才可作为失败判断,例如空指针或 -1

后续文章会用 SafeHandle 把“何时关闭、由谁关闭、调用期间能否关闭”纳入类型系统。

8. 用大小断言把猜测变成测试

对于公开结构,最好让原生侧和托管侧各自输出或断言大小、对齐和偏移。纯托管的基础检查可以写成:

1
2
3
4
5
6
using System.Runtime.CompilerServices;

Console.WriteLine($"Process pointer size: {IntPtr.Size}");
Console.WriteLine($"int: {Unsafe.SizeOf<int>()}");
Console.WriteLine($"long: {Unsafe.SizeOf<long>()}");
Console.WriteLine($"nint: {Unsafe.SizeOf<nint>()}");

这只能证明托管一侧,不能替代用同一套头文件和编译选项得到的原生 sizeofalignofoffsetof

总结

互操作类型映射的原则是按 ABI 含义和位宽选择类型,而不是按名称翻译。定宽整数、显式布尔表示、指针大小整数和清晰的句柄契约,可以消除大量“在一台机器上恰好能跑”的问题。

下一篇进入组合类型:结构体的字段顺序、填充、对齐、联合体和 blittable 边界。

参考资料

Licensed under CC BY-NC-SA 4.0