编写 .NET 与非托管资源互操作的绑定代码

TL;DR

建议使用 ClangSharpPInvokeGenerator 生成 P/Invoke 绑定代码。在需要人工介入的情况下可以参照本文前半部分介绍绑定技巧的部分进行修改。生成绑定后仍然建议对其二次封装使之更符合 .NET 的使用习惯。

展示

我在 OptimeGBA.io 中手写了 libvpx 的绑定,而用 ClangSharpPInvokeGenerator 生成了 libopenh264 的绑定,供各位对比绑定生成的质量。此外,这两个库的二次封装也可作为案例参考如何将 .NET 与非托管资源的互操作体验更加原生。

运行效果可以在此体验:https://aws.martincl2.me/OptimeGBA.io/。部署的版本使用了 h264 编码。

P/Invoke 是什么

P/Invoke 是 .NET 中与二进制代码库互操作的机制。得益于 .NET 优秀的底层操作能力,P/Invoke 可以在没有额外非托管代码包装的情况下直接与二进制接口互操作。相较 Java 的 JNI 或是 Node.js 的 V8 binding 之下,P/Invoke 可以省去一部分 native code 编译链的耦合,而更重要的是 P/Invoke 的绑定不受 .NET 大版本更新的影响,即一次绑定,所有大版本兼容。

P/Invoke 举例来说, getpid 的 C 接口可以被直接在 C# 中引用:

当然, getpid 是极为简单的例子。常见的互操作中需要使用更为复杂的结构体、指针、生命周期管理,同时还要保证 C# 侧调用的时候能尽可能贴合 C# 的代码风格。此文就以我在 OptimeGBA.io 中的经验为基础,简单分享一下如何迅速而优雅地为一个二进制库实现 P/Invoke 绑定。

生成 P/Invoke 绑定

简单结构体

C# 的结构体声明与内存结构(Layout)几乎是完美兼容 C 的。甚至对于一个简单的结构体而言,只需要简单的复制粘贴就能完成绑定。例如以下的 C 结构体便可以直接翻译为 C# 的结构体:

其中字段的顺序、大小,甚至是内存对齐( byte b 后会留空三字节),C# 都与 C 的行为一致。

翻译 typedef – 预设别名

C 接口中大量使用了 typedef,甚至可以说是无可避免地会使用 typedef,因为在实际操作上 C 依赖各种编译器预设的 typedef 来实现跨平台源代码兼容,因而 C 标准库中的函数签名就大量使用了 typedef。例如在上文 getpid 的例子中,该函数的返回值是 pid_t,而如果在 amd64 的目标平台编译器下顺着标准库的头文件查找并且把 typedef 的声明全都实体化,最终就会发现 pid_t 在本平台的定义实际上是 int

在 C# 中显然并没有诸如 pid_t 的类型别名,因此在写 P/Invoke binding 的时候需要逐个找到这些别名在本平台的实际定义。通常这在 IDE 环境下跳转几次就能找到了。如果对结果不确定可以用 sizeof 验算一下字段长度——毕竟只要字段长度对了,最不济也就一个字段数据不对,而如果长度错了那后面的字段偏移量就全部完蛋了。

此外,虽说如今主流平台的这些 *_t 类型的实际类型基本上是一致的,还是要小心跨平台的时候会有平台差异。

翻译 typedef – 自定义别名

一些情况下 typedef 可以为某个类型设置一个友好而可读的别名。例如说在 C API 中非常常见的一种做法是在结构体或参数里放一个 void* 存放一些内部实现相关的数据。调用方并不需要知道这个指针怎么来的以及存了什么,而只需要保证指针传递到了就行。而此时设计函数签名的时候就可以通过 typedef 给这个 void* 设置别名以便跟别的指针做出区隔:

虽说 C 编译器不会对设置了别名的 void* 做出任何区隔,换言之随便传一个指针进去都能通过编译(然后运行的时候爆炸),但在函数中使用了别名后作为调用方可以更直观地理解应该具体传什么指针进去而不是完全依赖阅读文档。

而当翻译这段 API 到 C# 的时候,固然可以直接使用 void* 作为变量类型,但也可以通过一个但字段的结构体包装这个指针以达到类似 typedef 的效果,甚至可以通过这种方法在编译时就能完成指针类型的检查:

由于这个包装的结构体的数据长度和一个指针完全相同,通过包装结构体定义的函数签名与使用指针在效果上是同样的。

Enum

C 的枚举类型本质上是 int——「正巧」C# 也是。因此如果 C header 中有 enum 的定义,或是函数签名中有用作 flag 的 int,都可以用 C# 替换。例如:

* 与其说是「正巧」应该说是 C# 一步到位直接把 enum 的语法与行为定义为与 C 一致。

如果万一遇到不一致的情况,或是需要手动调整字段的内存布局,则可以使用 StructLayoyt FieldOffset 作细致的调整。具体可以参照下文介绍 union 的部分。

Fixed buffer

在 C 标准中,结构体上是可以定义固定长度的数组的。例如:

对于不熟悉 C 的读者请注意:这不同于定义一个指针字段并赋值一个数组指针(如 int* plane),这个数组字段的实际数据是存在于结构体内部的,因而在使用的时候编译器只需简单地将结构体的指针偏移几个字段就能拿到这个定长数组的指针。

而在 C# 中,简单情况下可以用 fixed buffer 声明这种数组:

但是 C# 目前只支持 primitive unmanaged type 用作 fixed buffer,也就是 int ulong 等类型。换言之,结构体或指针之类的数据类型就不能这样定义了。这种情况下就必须手动展开了:

Union

Union 是一种 C 中非常常用的结构体布局。具体而言,union 允许将多个不同的数据段存储在同一段内存中。例如:

在 C# 中可以通过手动指定字段偏移量的方式定义一个 union 结构体。结构体上的 StructLayoyt 属性告诉编译器一个结构体需要手动设置字段偏移量,而字段上的 FieldOffset 属性则设置了具体字段偏移量的数值。对于 union 而言,只需要将 FieldOffset 全部设置为 0 即可。

自动生成绑定

虽说以上讲了这么多使用场景、解决方案与技巧,在了解与攻破了这些场景以后写绑定这件事本身其实是个体力活。尤其是 C header 很可能散落各地,编排顺序也并无逻辑,而逐个翻译 header 中的每一个定义既吃力又容易出错。那么有没有方便又可靠的工具可以一键无脑生成绑定代码的呢?有!那便是 ClangSharpPInvokeGenerator

Clang 是 llvm 工具链中处理 C 与 C++ 的前端。编译器里的前端大致上指的是处理分析源代码的部分——将源代码 parse 成抽象语法树(AST)、分析符号、处理宏,等等。而 ClangSharp 是一个 Clang 的 C# 绑定,换言之,ClangSharp 是一个在 .NET 中处理 C/C++ 源代码的工具。为何要花篇幅介绍 Clang?因为 Clang 普遍认为是最模组化、功能齐全,而业界又广泛采用的 C/C++ 前端。也就是说,如果采用 ClangSharp 分析 C/C++ 源码,将会得到与业界主流编译器 clang 完全一致的结果。这也就意味着 ClangSharp 可以为自动生成 P/Invoke 绑定提供非常可靠的信息源。顺带一提,这与 C# 编译器 Roslyn 的设计目的之一相同:编译器一次实现,多处应用,包括编译本身、静态分析、Language Server (IDE 集成)等等,以保证结果的一致性。

* 注:ClangSharp 本身就是通过 ClangSharpPInvokeGenerator 自己生成的绑定代码。

安装 ClangSharpPInvokeGenerator:

生成绑定:

然后将生成的绑定代码复制到项目的源码即可。

* 注意有一些库同时提供了 C 与 C++ 的头文件。这种情况下通常直接为 C++ 的头文件生成绑定会让生成的代码更干净。

通常来说生成的绑定无需修改已经足够可读了。不对生成的代码作任何修改可以保证今后再次生成头文件时可以无缝升级。当然我也遇到了一些不理想的生成,例如有一些库将 enum 通过 #define 定义为了常数,我会手动将其改成 enum。

将绑定封装为 .NET 库

绑定其实只完成了一半的工作。为了让非托管库的调用更符合 .NET 的使用习惯,同时也保证调用安全、减少 unsafe 的使用,我们仍然需要对绑定代码作二次封装。毕竟都用 .NET 了,总不能让调用方仍然手动操作指针吧。

内存管理——IDisposable

.NET 拥有 GC,而非托管代码则并没有。因而使用 P/Invoke 的时候要格外注意来自非托管对象的生命周期。

以 libvpx(已简化)为例,一个典型的 C 库需要这样管理生命周期:

对于手动管理生命周期的运行时而言,分配与销毁必须成对出现,包括出现异常的情况下,不然轻则内存泄漏,重则整个进程原地爆炸。

.NET 标准库中的 IDisposable 则提供了一个统一且设计良好的范式——调用方既可以通过 using 语句实现类似 RAII 的作用域级自动释放,也可以将非托管资源的生命周期与托管资源的生命周期(GC)挂钩自动释放。

举例而言,上述 libvpx 的例子使用 IDisposable 封装:

IDisposable 的模板看着很庞大,但其实已经考虑好了各种边角情况,因此最好还是按照模板实现。

内存操作——Span<T>

.NET 一类的托管语言的一大卖点在于全自动的边界检查。在 .NET 的数组中任何越界的访问都会触发异常,而不是造成越界访问的漏洞。然而在非托管资源上边界检查往往要手动实现。在 .NET 中,我们可以借助 Span<T> 对非托管内存块进行封装。

例如说一个典型的 C 结构会这样暴露一段内存:

使用 Span<T> 封装后:

迭代器——IEnumerable<T>

迭代器是非常常见的一种范式,但在非托管代码中的实现不尽相同。既然要封装成 .NET 库,就要让迭代器的使用体验更接近原生的 .NET 代码。

例如 libvpx 中,要获得编码后的帧数据需要多姿迭代,其 C 接口需要这样使用迭代器(代码已简化):

而 .NET 中的标准迭代器是 IEnumberable<T>,也就是 C# 中的 foreach 语句所使用的接口。因此这个 libvpx 的例子理想情况下在 C# 中的调用应该大致上是这样的:

为了达到这个效果, IEnumerable<VpxPacket> 的实现可以写作:

当然,如果为了追求零分配,也可以手写 struct Enumerable 与 struct Enumerator,只不过会长得多:

异步回调——Task<T>

在一些涉及到 IO 的非托管库中可能会提供基于回调的异步调用的接口,例如:

这种情况下可以用 TaskCompletionSource<T> 来将回调封装成异步调用( async/ await):

此外,如果该非托管库还支持 Cancellation Token,那一定要做好对接。

由于 OptimeGBA.io 项目中并未涉及到非托管回调,我手头并没有足够的案例,也因此暂无法展开更多细节。有机会的话再开坑补充。

va_list——建议手动添加签名

C 标准是支持可变参数的函数签名的。尽管 P/Invoke 也可以通过 __arglist 兼容此类函数签名,但其适用范围非常受限(例如很难多级传递),文档也非常模糊。个人建议不如根据实际调用情况多写几个函数签名。

结语

.NET 通过 P/Invoke 对非托管库的调用由于 ClangSharp 的出现在工程上让本就颇为强大的 P/Invoke 更具可行性与可维护性。但是要让 .NET 与非托管资源之间的互操作更贴近原生体验还是需要话心思作二次封装。在此也再次欢迎读者阅读 OptimeGBA.io 项目中的 libvpx(手写绑定)与 libopenh264(ClangSharp 生成绑定)交互代码作为参考案例。

发表评论?

0 条评论。

发表评论


注意 - 你可以用以下 HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据