use rust in c project with ffi
背景
最近把 EVM 虚拟机引入到公司项目里,EVM 虚拟机本身的实现依赖的是第三方 Rust 写的rust-blockchain/evm,公司的项目是 C++开发的,所以需要把整个 EVM 作为静态库引入进来,之间的 FFI 交互本身需要(只能)按照 C 的格式传递,两端再封装一下,让使用起来舒适一些。
这个过程中搜到的资料都非常零散,在不断试错、优化的过程中也积累一些经验,如果读者也有需求要把第三方的rust 库引入到 C/C++项目里,可以参考一下。
不过注意该教程的时效性,这绝不是 FFI 的最佳实践。
如果你也是接触 rust 的跨语言 FFI 不久,可以先阅读官方 book 的 FFI 介绍
解决跨语言的调用和编译依赖问题
这一节举例说明解决跨语言的编译问题,用统一的 CMake 工具来管理编译跨语言的不同模块
C call Rust
&& Rust call C
说明
如果第三方的 lib 功能比较简单纯粹,一般只需要单向的使用。然而我实际上需要先提供给 EVM 一些 C 这边的功能,再把 EVM 封装起来提供给 C,所以两种都涉及到了。
这里需要先提一下工具链,主项目是通过 CMake
来管理模块关系的,我需要把一个 capi 的模块提供给 rustlib 使用,再把 rustlib 作为一个模块提供给其它模块使用:
依赖关系:capi.a(C/C++) -> rust_evm.a(Rust) -> xxx.a(C/C++)
后面的以这三个模块名举例说明
每个模块下都有各自的 CMakeLists.txt
,包括 rustlib 模块:此时的目录结构大致是这样的:
rust_evm
依赖 capi
: Rust call C
Rust Call C
使用 extern "C" {}
来把需要 c 提供的接口包起来,另外还可以用 mod
来增强代码可读性,比如:
在其他地方就可以通过:
的方式来使用 capi.a
里提供的方法了。
上面第二行的 #[link(name = "capi")]
用于告诉链接器链接指定的库,但是我在实操的时候发现,在后面 build.rs
里写上链接 lib 后,不写这个 link 宏其实也没问题(写于2022-05,rust 1.60版本),读者可以自行尝试。
让 CMake
来管理编译 rust_evm
上面的方式,手动编译出 capi.a
后执行 cargo build
可以使用,通过 CMake
来管理的话,需要完成:
-
在
build.rs
里完成复制capi.a
的操作,不然后面编译rust_evm
时找不到链接的依赖。 -
让
CMake
执行cargo build
的编译命令
写一下 build.rs
注意几点:
-
使用
cp
命令复制libcapi.a
,需要找到当前 CMake 项目,保管目标 lib 的目录,一般会通过根 CMake 文件设置到LIBRARY_OUTPUT_PATH
(高版本的cmake
版本里也可能用LIBRARY_OUTPUT_DIRECTORY
?)。需要从当前的目录相对路径到此处复制(到cargo
的out_dir
)。 -
可以使用
feature
来控制不同场景的编译,后面会提到,用cmake
编译的时候可以附带feature
参数,所以可以默认情况下没有编译前参数,用于单独更新这个 rust 子项目的情况;在cmake
编译时自动带上feature
参数,启用这个复制命令。
rust_evm
的 CMakeLists.txt
放一下参考配置: rust_evm/CMakeLists.txt
这个配置需要根据项目实际情况做调整,需要理解其含义。忽略中间的编译调试信息,核心步骤就是三步:
- find cargo:
- set build command:
- custom_compile && cp to library output path:
补充说明:
-
EVM_A
表示使用cargo
工具链编译出来的rust_evm
静态库的位置 -
LIBRARY_OUTPUT_PATH
就是整个项目的位置,需要复制过去供后面其他模块链接使用。 -
add_custom_target
来调用cargo
编译这个模块 -
最后的
add_dependencies
来确定编译的拓扑关系,确保编译到这一步时,前面的build.rs
一定能找到编译好的libcapi.a
至此我们已经完成了 Rust call C
的依赖关系,并且把 rust_evm
也编译成了 librust_evm.a
放到了目标 lib 目录下。
xxx
依赖 rust_evm
: C call Rust
xxx
模块的 cmake
在 xxx/CMakeLists.txt
下加上依赖关系:
其中第二行: get_target_property
获取的就是 librust_evm.a
,是在编译 rust_evm
时通过 set_target_properties
设置进去的。
C call Rust
模块 xxx
这边找个地方声明一下外部实现:就可以直接调用了。
噢,在 rust_evm
提供的接口这样写:
FFI with params
调通了依赖关系后,开始增加实现细节,第一个会面临的问题就是,怎么传参?
extern "C"
的方式,可以直接传递基础数据类型:小于等于64位宽的整型(包括 bool\char)、指针地址(本质上也是个 u64)。但是其它类型就会复杂得多,即使 FFI 两边的语言都有同样作用的容器,也不能直接传递过去,本质上实现可能不一样、内存分布可能不一样。自定义的数据结果,如果都控制到内存布局完全一致,传递指针的方式可以实现,但是对编码的要求和难度增加,可读性不强,维护性更是差。
解决方案是:使用一套两边语言都有的序列化方案,对于同一个数据结构对象,在两头分别定义好,在传递的时候、序列化成 bytes、传递过去后再反序列化出来,封装得当可以简化编码难度、大大提高可读性。
两种语言都有的序列化方案可能有很多,但是还要确认它在不同语言里支持的类型问题是否都能 cover 你需要使用的。
我这里使用了 protobuffer 作为序列化传递参数的方案,因为除了符合上面的要求外,还有一个额外优点:两边的数据结构可以统一由一个.proto
文件定义后生成,不会存在两边定义不对齐的情况。
protobuffer 具体如何使用,官网文档学习,这里简单提一些注意点:
-
.proto
里的 package 名字按C/C++
这边的命名空间定位来,Rust
这边比较好说 -
反正
.proto
可以import
其它.proto
文件,所以可以照顾 Rust 文件所属模块位置拆分掉,不要写成一个巨大的.proto
-
Rust
这边,最好实现一个本地 struct,只在Rust
这端的代码里引用,再和对应的 proto 版对象相互实现Into/From/NewFrom
等trait
,这样在模块内部可以实现其它需要的功能,仅在传递前后转换成 proto 的对象。 -
C/C++
这边也是一样,视使用情况决定要不要抽象,一般C call Rust
为目的,调用端转换一次,结果回来再转换一次,也没有抽象出单独方法的必要。
相互转换这一点在后来调研 tendermint-rs
项目时,发现他们做的更好,他们 tendermint/src
目录下的所有数据结构,在 proto
目录下都有对应的转换,推荐去学习他们的代码结构(如下面示例)。
Lifecircle
一句话:多注意变量的生命周期,特别是跨语言交互的地方,由谁创建、管理、释放每个对象。
Exception handle
跨语言不支持调用栈 unwind 官方讨论
即使 C->Rust->C
这种情况,在后面的 C 里 throw_exception
,虽然现状是经过的 rust
能 unwind
调用栈,最后被前面的 C 捕获注异常,但是这还是 UB
,不推荐使用。
所以这种现状下,只能各扫门前雪,跨语言接口只能用返回值来表示成功与否,可以通过在接口的最后用形如 unwrap_or_return_false
的方式来保证一定要返回。
TODO
- 统一 channel/register 管理传参