Rust FFI & C 编程概述
关于术语 FFI
,它的英文全称是 Foregn Function Interface
,即实现不同编程语言间的函数级相互调用,对于复杂的项目,可以充分复用不同编程语言的已有功能,减小开发负担。Rust
语言作为一种新时代的系统级编程语言,它同 C/C++
一样都是直接将程序代码编译为机器指令码执行,相比 Java,Python
等底层使用虚拟机解释执行字节码及使用垃圾回收的语言,在实现 FFI
时容易得多。利用 C/C++
的已有的生态基础,Rust
很多底层代码不用重复造轮子,直接通过 FFI
封装复用已有的 C/C++
库,这对于一门新语言能快速广泛的运用到项目中是至关重要的。
编译原理简述
对于想要阅读本文的读者,你至少是已有 C 语言及 rust 的编程基础,否则阅读本文对你的意义不大。
如果你已掌握了一些基础编译原理的知识,那这对于理解 FFI 是很有帮助的,本节内容笔者不想太过牵强的去表述一些编译原理的内容,因为编译原理内容太过庞大,即使只粗粒度的去概述一下大概流程,可能中间也会出现一些特殊名词令读者难以理解,所以本节内容只会根据我们对于编程语言的已有认知,从逻辑上去做一些简单的推理,以便读者理解 FFI 。
一般我们开发的应用程序是层次化与模块化的,按照层次和模块的划分,我们将不同功能的代码编写到不同的文件中。从功能上来说,我们可以认为函数是程序的基本单位,整个程序就像是从 main
函数为根开始的一颗树结构。在程序中,对于一个函数一般会存在两个部分,函数定义和函数声明,函数定义实例如下:
1 | int add(int a, int b) |
我们可以看到,函数定义详细的描述了函数功能的实现逻辑,如果我想在其他地方调用上面的函数完成加法功能,那我需要在其他程序能看到的地方(如.h
文件)增加函数声明:
1 | int add(int, int); |
函数声明描述函数的调用方式,即调用者传递两个整型的数给目标调用函数 add
,add
函数返回值为函数执行的结果,函数名也描述了该函数实现的功能。函数调用时,调用者根据函数的声明设置了参数及接收返回值,实际程序执行时怎么去找到这个函数执行是由编译器(准确说是连接器)确定的,我们能想到的就是根据函数名去查找,在编译器内部,也把函数名连同其他变量名一起称为符号。在经过编译器编译处理后的程序,一般标记函数的符号和函数名称是不一样的(一个能想到的原因是因为在多文件程序中,我们可以有同名函数,如static声明的仅文件内部可见函数),但是函数符号是有规则生成的。
所以现在我们想要不同语言能够互相调用对方的函数,那我们只需要要求编译器在编译时按照一个统一的标准去生成函数符号,参数传递方式也有统一的约定,这样不同的语言就能根据统一的符号去找到对方的函数代码块执行。这个统一的标准叫 应用二进制接口(ABI) ,目前也存在多种规范标准,感兴趣的读者可通过其他方式了解。
Rust 与 C/C++ 语言函数互调
C 语言算是当前世界上历史最悠久的系统级编程语言,当前的几乎所有操作系统内核都是由C语言编写,后来的高级编程语言在 FFI 编程上几乎都会优先支持 C 语言自身的 ABI 规范。
我们在用 C 和 C++ 混合编程时,对于需要互相调用的接口函数,我们需要做出特定的声明,告知编译器在编译这些接口函数时以 C 的规范生成符号,否则在连接阶段,我们会收到连接器的报错信息,即找不到相应目标函数的符号。一半会采用如下的形式声明接口函数:
1 |
|
示例代码中的 extern "C"
的作用就是告知 C++ 编译器,对于当前块内的代码按 C 语言的方式生成符号,同时代码的编译也应满足 C 语言 ABI 的调用规范。
对于 Rust 和 C 相互调用的处理情况基本和 C++ 与 C 的一致,主要还是 Rust 的相关接口按 C 的标准编译,最后和 C 代码连接生成可执行文件。Rust 和 C++ 相比,在数据类型上存在一定的差异,但是 Rust 基本类型也能和 C 形成一一对应的关系,Rust 中 C 的等价数据类型已封装定义在 libc 库和标准库的 ffi 模块中,直接参照使用即可。
Rust 调用 C
Rust 调用 C 的情况,我们需要在 Rust 中声明需要调用的 C 函数,这样 Rust 在编译生成调用符号及调用规范时,便会采用 C 的规范去编译。Rust 中也是采用 extern "C"
的方式去声明 C 的调用接口,不过 Rust 并不知道实际 C 函数的实现是否安全,所以一般在申明前加上 unsafe
关键字(非必须,但是C函数调用必须处于 unsafe
块中)。 示例如下:
1 | use libc::c_int; |
我们可以看到,其实 rust 的 i32 类型是可以直接以 int 类型传递给 C 的,对于大多数的 C 类型其实都可以直接传递 rust 的相应基础类型,不过对于指针数值相关的类型需要满足 Rust 的安全检查要特殊处理及类型转换。
链接
不像 C 和 C++ 都可以使用支持 C++ 的编译器进行编译,Rust 和 C 需要使用各自不同的编译器进行编译,最后在链接起来。Rust 调用 C 通常是先将 C 代码编译为静态库文件,然后在构建 Rust 程序时,在链接阶段链接到 C 静态库。
我们可以在声明代码块前加上特定的 rust 属性宏 #[link(name = "add")]
去指导编译器链接到某个具体的静态库,也可以通过 Rust 包管理器 [Cargo](Introduction - The Cargo Book (rust-lang.org)) 配置我们需要链接的静态库,Cargo.toml 中添加如下内容:
1 | [package] |
当然也可以通过在函数申明的位置使用 rust 特定的宏进行标识链接库。详情可参考 rust 官方文档 FFI - The Rustonomicon (rust-lang.org)。
使用 build.rs 构建 C 静态库
Rust 项目我们一般使用 Cargo 即可完成创建,针对 FFI 这种场景也提供了相应的解决方案,在项目中添加 build.rs 文件,并把 C 或者其他语言的构建操作使用 build.rs 去描述,在构建项目时,Cargo 会先去编译并执行 build.rs,之后再去编译 Rust 应用程序。所以编译 C 静态库的操作我们可以放入到 build.rs 中。
这里编译 C 静态库需要用到一个 rust 库 CC — Cargo library // Lib.rs,在 Cargo.toml 添加构建依赖如下:
1 | [build-dependencies] |
同时 build.rs 中示例代码如下:
1 | // build.rs |
如上,build.rs 执行后,会将 add.c 编译为动态库文件 libadd.a
, 同时可以通过在 println! 中指定 cargo::
标记输出内容为编译器配置内容,这样当 add.c 文件发生改变后,构建项目时会重新执行 build.rs 并重新编译生成 libadd.a
静态库。
关于 CC 这个 crate 的具体详细内容可参考官方文档的描述: Build Script Examples - The Cargo Book (rust-lang.org)。
使用 bindgen 自动生成 Rust 函数声明
在 Rust 程序中添加 C 接口函数的声明是机械且无聊的,为此 Rust 社区也推出了一些自动化解决工具,bindgen 就是一个根据 C 的头文件去自动化生成 Rust 函数声明的工具。
我们可以直接使用 cargo 去安装对应的 bindgen 命令行工具做初步的尝试,命令如下:
1 | cargo install bindgen-cli |
命令行工具安装完成后,我们可以先写一个简单的 C 头文件小试牛刀:
1 | // add.h |
在 add.h
所在的目录下执行指令 bindgen add.h -o add.rs
会将生成的代码输出到 add.rs
文件中:
1 | // add.rs |
我们也可以直接在 cargo.toml
的 [build-dependencies]
中引入 bindgen,在 build.rs
添加 bindgen 相关的构建代码,让程序在构建阶段直接为我们生成相应的文件。具体参考官方文档 Introduction - The bindgen User Guide (rust-lang.github.io) 。
C 调用 Rust
C 调用 Rust 和 Rust 调 C 是差不多的,基本思想就是将 Rust 代码编译为静态库或者动态库,然后编译 C 代码是指定连接库连接到 Rust 库。
将 rust 代码编译为库
同上,这里用 Rust 来实现 add 函数,我们的库命名为 add,lib.rs 内容如下:
1 | // add lib.rs |
pub
关键字指示说该函数是外部可见函数,extern "C"
指示按照 C 的 ABI 标准去编译该函数,#[no_mangle]
指示禁止 Rust 的函数重载。
编译为库的方式也很简单,只需在 Cargo.toml 中设置库类型即可:
1 | [lib] |
staticlib
表示构建为静态库,当然也可以设置为 crate-type = ["cdylib"]
表示生成动态库。
使用 cbindgen 自动生成 C 函数声明
C 代码中,我们同样需要声明函数接口,声明如下:
1 | extern int add(int a, int b); |
Rust 社区也提供了根据 Rust FFI 接口生成其他语言声明的自动化工具,cbindgen 是将 rust 中声明为 C 调用的接口转换为 C 的头文件声明,刚好与 bindgen 相反,具体使用参考官方文档 cbindgen/docs.md at master · mozilla/cbindgen · GitHub 。
Rust 和 C 的类型传递
传递结构体,Rust 中的 struct 定义的类型可通过属性宏 #[repr(C)]
去保持成员的内存布局同 C 的内存布局一致,这样传递对象地址后,Rust 及 C 中都可以以访问结构体成员的方式处理数据。
传递数组时需要保证内存安全,所以除了传递数组的地址外,还需要传递数组的长度。
传递字符串时在Rust端需要将接收到的地址转换为 CStr
类型。
传递回调函数可以参考官方文档 FFI - The Rustonomicon (rust-lang.org)。
闭包及传递闭包到 Rust
Rust 是支持闭包特性的,某些特定场景下我们可能需要在 C 中调用 Rust 闭包,对于这一情况需要做一些较为复杂的处理。
C语言的函数内 static 变量初步具有了闭包的思想,但是C语言函数内的static影响到了函数的可重入性,该函数是有副作用的。
在 Rust 中,把闭包当成一个整体去看,即闭包对象,那闭包里面应该包含的是它所捕获的变量以及一个绑定该变量执行的函数指针,这是由编译器处理的。
ffi 中传递闭包的方式是将闭包对象的地址通过参数传递给C函数,C函数在回调时将该地址参数通过回调函数的参数传入回调函数中,在回调函数中还原出闭包并执行闭包代码。
传递闭包需要特别注意闭包的生命周期,如果传递给C一个闭包的地址指针,在rust代码中因为生命周期的原因导致闭包被drop,那C语言持有的闭包指针将变为悬垂指针,出现安全问题。
1 | // 回调函数 |
共享全局变量
Rust FFI & C 编程概述