实践 Rust FFI,基于 grpc-rs 的经验

来 P 社的接触的第一个 repo 就是 grpc-rs,这是一个 rust 封装的 grpc lib,在 tonic 成熟之前应该是 rust 社区唯一可放心使用的 grpc 库了。这里记录一下实践 FFI 的经验,包括如何构建编译一个 rust 包装的 c/cpp 的心得。

FFI

Rust 提供了一系列方便 FFI 的东西,这里回顾几点:

  1. PhantomData: 这个是给编译器标记用的,runtime 的时候就当它不存在了,在 FFI 的时候主要为 rust 结构体里的一些没有 lifetime 标记的 c 的指针使用,但在 grpc-rs 里实际用处并不大,如果网上翻一翻文章,很多人喜欢用「因为你要为结构体提供一个返回引用的方法,因此需要写 ‘a,这样你的 struct 也会带一个 <’a> 最后发现你不能编译啦,就要用到 PhantomData<’&a>」这个例子。但大多数编译器会自行推导,自动为没有加注生命周期的 & 加上相同的标记,所以这个特性用到的不多。
  2. extern: 这个是为了让被声明的函数签名声明为 C ABI,以方便调用到 C 编译的库函数,插一嘴,ABI 总结为 CPU 如何具体执行函数(包括压栈顺序,返回值存放位置)的统一约定,即汇编层级上的,所以调用不同语言编译出来的库函数,大家统一一种 ABI 这样编译出来的函数签名长的也都类似,方便互相调用。BTW,rust 不写 extern “C” 默认就是 “rust” ABI 行为。如果用到 bindgen 一般不用关心 extern,因为大部分 rust crate 实则是封装下面的 C 库,很少会提供 Rust 接口给 C 调用。在 grpc-rs 中
  3. bindgen: 一个第三方自动生成 ABI 的工具

bindgen 替代手写 extern 函数签名

比较粗暴的方法是,手撸所有要用到的 C 库函数签名,并且还需要精心考虑怎么为 enum 类型设计 rust 对应的结构体,早期的 rust-rocksdb 和 grpc-rs 都是手撸出来的,但是现在 rust 社区使用 bindgen 已经基本是共识了。只要传入一些 C/C++ 头文件,就能自动生成一系列 rust 绑定的代码。

在调试的时候发现,grpc 一开始并没有设计为一个给其他仓库用来绑定的,所以头文件定义非常混乱,在 include 目录下还有 grpc,grpc++, grpcpp 三个目录,WTF!甚至里面的函数定义也区分为 API 类型的和不是 API 仅内部使用的,并没有类似 rocksdb 有一个方便的 c.h 专门用于 c 绑定读取的头文件。所以这里我用 WalkDir 遍历了所有的 include 并且通过判断是否有特定的 GRPCAPI 和 GPRAPI 关键字来判断是否有必要将这个 header 给 clang 预处理,因为各种内外使用的函数这里都有定义,所以要尽可能减少生成的结果体积。

这里不同的 c++ 版本也会对预处理的结果有干扰,甚至导致编译失败,可以看你用的项目的 cmake or makefile 文件,找找 CMAKE_CXX_STANDARD 关键字,和你用的项目保持统一的 c++ 版本。grpc 可以被外部使用的函数一般有一些固定开头比如 grpc_* gpr_* 可以通过黑白名单再次过滤,不然将一些内部函数,尤其还是系统绑定的函数 bindgen 出来就没有必要了。

这里后来还做了一个小优化,因为 unix 的平台生成的函数签名基本可以互用,所以可以通过 cfg 在 unix 平台下跳过 bindgen 用预生成的 rust 文件。当然这个也做了一个环境变量在 CI 的时候检查是否及时更新该文件内容(比如升级 grpc 库的版本的 pr)

编译和链接

解决了函数签名自动生成的问题,剩下的就是要执行的时候读取到这些接口处不能 panic,为此需要将它们涉及的函数实现即 grpc 库编译出来并链接到 rust 这里。

编译连接的逻辑主要在 build.rs 中实现,这里的 main 函数会先于构建整个 crate 之前执行。

grpc-rs 借助了 cmake 这个 crate 来帮助对 grpc repo 进行编译,这里可以对不同的 cmake 参数设置,根据需要读一下 grpc 的 CMakeLists 文件,这里有很多坑,比如在 macos 下一些需要链接 libc++ 等,这些都是在调试中发现的。

因为 ssl 接口也是一致的,所以这里我们还加入了多个 ssl 库实现供你选择,包括从系统里链接、openssl、boringssl。

关于模块化改造,不只是 ssl,只要底层 CMakeLists 提供了选项,你都可以根据自己的需要编译。

有些函数的处理不方便直接调用 grpc 的函数,像在 grpc-rs 里,还做了一个 grpc_wrap.cc 文件用于对一些函数进行 c++ 层级的封装以减少 rust 这里 unsafe 的处理,这里可以用 cc 这个库来编译。

当上面的所有库都编译好后,可以使用 cargo 的编译脚本对这些库进行链接即可。

其它

一般而言,当你需要在你的项目包含链接一个 C 库的时候,可能需要做比较多的编译连接工作,为了抽象分层,可以把这部分单独做一个 crate 出来,这样你的 rust 核心程序可以将其作为依赖引入,在那里进行 rust 化改造,以生成更为 rust 友好的接口。这部分 rust 化的封装细节可以参考 https://pingcap.com/zh/blog/tikv-source-code-reading-8

参考学习

https://github.com/tikv/grpc-rs

https://doc.rust-lang.org/nomicon/ffi.html

https://doc.rust-lang.org/cargo/index.html

Written on December 21, 2019