31 FFI:Rust如何和你的语言架起沟通桥梁? 你好,我是陈天。

FFI(Foreign Function Interface),也就是外部函数接口,或者说语言交互接口,对于大部分开发者来说,是一个神秘的存在,平时可能几乎不会接触到它,更别说撰写 FFI 代码了。

其实你用的语言生态有很大一部分是由 FFI 构建的。比如你在 Python 下使用着 NumPy 愉快地做着数值计算,殊不知 NumPy 的底层细节都是由 C 构建的;当你用 Rust 时,能开心地使用着 OpenSSL 为你的 HTTP 服务保驾护航,其实底下也是 C 在处理着一切协议算法。

我们现在所处的软件世界,几乎所有的编程语言都在和 C 打造出来的生态系统打交道,所以,一门语言,如果能跟 C ABI(Application Binary Interface)处理好关系,那么就几乎可以和任何语言互通

当然,对于大部分其他语言的使用者来说,不知道如何和 C 互通也无所谓,因为开源世界里总有“前辈”们替我们铺好路让我们前进;但对于 Rust 语言的使用者来说,在别人铺好的路上前进之余,偶尔,我们自己也需要为自己、为别人铺一铺路。谁让 Rust 是一门系统级别的语言呢。所谓,能力越大,责任越大嘛。

也正因为此,当大部分语言都还在吸血 C 的生态时,Rust 在大大方方地极尽所能反哺生态。比如 cloudflare 和百度的 mesalink 就分别把纯 Rust 的 HTTP/3 实现 quiche 和 TLS 实现 Rustls,引入到 C/C++ 的生态里,让 C/C++ 的生态更美好、更安全。

所以现在,除了用 C/C++ 做底层外,越来越多的库会先用 Rust 实现,再构建出对应 Python(pyo3)、JavaScript(wasm)、Node.js(neon)、Swift(uniffi)、Kotlin(uniffi)等实现。

所以学习 Rust 有一个好处就是,学着学着,你会发现,不但能造一大堆轮子给自己用,还能造一大堆轮子给其它语言用,并且 Rust 的生态还很支持和鼓励你造轮子给其它语言用。于是乎,Java 的理想“一次撰写,到处使用”,在 Rust 这里成了“一次撰写,到处调用”

好,聊了这么多,你是不是已经非常好奇 Rust FFI 能力到底如何?其实之前我们见识过冰山一角,在[第 6 讲]get hands dirty 做的那个 SQL 查询工具,我们实现了 Python 和 Node.js 的绑定。今天,就来更广泛地学习一下 Rust 如何跟你的语言架构起沟通的桥梁。

Rust 调用C的库

首先看 Rust 和 C/C++ 的互操作。一般而言,当看到一个 C/C++ 库,我们想在 Rust 中使用它的时候,可以先撰写一些简单的 shim 代码,把想要暴露出来的接口暴露出来,然后使用 bindgen 来生成对应的 Rust FFI 代码。

bindgen 会生成低层的 Rust API,Rust 下约定俗成的方式是将使用 bindgen 的 crate 命名为 xxx-sys,里面包含因为 FFI 而导致的大量 unsafe 代码。然后,在这个基础上生成 xxx crate,用更高层的代码来封装这些低层的代码,为其它 Rust 开发者提供一套感觉更加 Rusty 的代码。

比如,围绕着低层的数据结构和函数,提供 Rust 自己的 struct/enum/trait 接口。-

我们以使用 bindgen 来封装用于压缩/解压缩的 bz2 为例,看看 Rust 如何调用 C 的库(以下代码请在 OS X/Linux 下测试,使用 Windows 的同学可以参考 bzip2-sys)。

首先 cargo new bzlib-sys –lib 创建一个项目,然后在 Cargo.toml 中添入: [dependencies] anyhow = “1” [build-dependencies] bindgen = “0.59”

其中 bindgen 需要在编译期使用, 所以我们在根目录下创建一个 build.rs 使其在编译期运行:

fn main() { // 告诉 rustc 需要 link bzip2 println!(“cargo:rustc-link-lib=bz2”); // 告诉 cargo 当 wrapper.h 变化时重新运行 println!(“cargo:rerun-if-changed=wrapper.h”); // 配置 bindgen,并生成 Bindings 结构 let bindings = bindgen::Builder::default() .header(“wrapper.h”) .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .generate() .expect(“Unable to generate bindings”); // 生成 Rust 代码 bindings .write_to_file(“src/bindings.rs”) .expect(“Failed to write bindings”); }

在 build.rs 里,引入了一个 wrapper.h,我们在根目录创建它,并引用 bzlib.h:

/#include

此时运行

cargo build ,会在 src 目录下生成 src/bindings.rs,里面大概有两千行代码,是 bindgen 根据 bzlib.h 中暴露的常量定义、数据结构和函数等生成的 Rust 代码。感兴趣的话,你可以看看。

有了生成好的代码,我们在 src/lib.rs 中引用它: // 生成的 bindings 代码根据 C/C++ 代码生成,里面有一些不符合 Rust 约定,我们不让编译期报警 /#![allow(non_upper_case_globals)] /#![allow(non_camel_case_types)] /#![allow(non_snake_case)] /#![allow(deref_nullptr)] use anyhow::{anyhow, Result}; use std::mem; mod bindings; pub use bindings::/*;

接下来就可以撰写两个高阶的接口 compress/decompress,正常情况下应该创建另一个 crate 来撰写这样的接口,之前讲这是 Rust 处理 FFI 的惯例,有助于把高阶接口和低阶接口分离。在这里,我们就直接写在 src/lib.rs 中:

// 高层的 API,处理压缩,一般应该出现在另一个 crate pub fn compress(input: &[u8]) -> Result<Vec> { let output = vec![0u8; input.len()]; unsafe { let mut stream: bz_stream = mem::zeroed(); let result = BZ2_bzCompressInit(&mut stream as /*mut _, 1, 0, 0); if result != BZ_OK as _ { return Err(anyhow!("Failed to initialize")); } // 传入 input/output 进行压缩 stream.next_in = input.as_ptr() as /*mut _; stream.avail_in = input.len() as _; stream.next_out = output.as_ptr() as /*mut _; stream.avail_out = output.len() as _; let result = BZ2_bzCompress(&mut stream as /*mut _, BZ_FINISH as _); if result != BZ_STREAM_END as _ { return Err(anyhow!("Failed to compress")); } // 结束压缩 let result = BZ2_bzCompressEnd(&mut stream as /*mut _); if result != BZ_OK as _ { return Err(anyhow!("Failed to end compression")); } } Ok(output) } // 高层的 API,处理解压缩,一般应该出现在另一个 crate pub fn decompress(input: &[u8]) -> Result<Vec> { let output = vec![0u8; input.len()]; unsafe { let mut stream: bz_stream = mem::zeroed(); let result = BZ2_bzDecompressInit(&mut stream as /*mut _, 0, 0); if result != BZ_OK as _ { return Err(anyhow!("Failed to initialize")); } // 传入 input/output 进行解压缩 stream.next_in = input.as_ptr() as /*mut _; stream.avail_in = input.len() as _; stream.next_out = output.as_ptr() as /*mut _; stream.avail_out = output.len() as _; let result = BZ2_bzDecompress(&mut stream as /*mut _); if result != BZ_STREAM_END as _ { return Err(anyhow!("Failed to compress")); } // 结束解压缩 let result = BZ2_bzDecompressEnd(&mut stream as /*mut _); if result != BZ_OK as _ { return Err(anyhow!("Failed to end compression")); } } Ok(output) }

最后,不要忘记了我们的好习惯,写个测试确保工作正常:

/#[cfg(test)] mod tests { use super::/*; /#[test] fn compression_decompression_should_work() { let input = include_str!(“bindings.rs”).as_bytes(); let compressed = compress(input).unwrap(); let decompressed = decompress(&compressed).unwrap(); assert_eq!(input, &decompressed); } }

运行

cargo test ,测试能够正常通过。你可以看到,生成的 bindings.rs 里也有不少测试,cargo test 总共执行了 16 个测试。

怎么样,我们总共写了大概 100 行代码,就用 Rust 集成了 bz2 这个 C 库。是不是非常方便?如果你曾经处理过其他语言类似的 C 绑定,对比之下,就会发现用 Rust 做 FFI 开发真是太方便,太贴心了。

如果你觉得这个例子过于简单,不够过瘾,可以看看 Rust RocksDB 的实现,它非常适合你进一步了解复杂的、需要额外集成 C 源码的库如何集成到 Rust 中。

处理 FFI 的注意事项

bindgen 这样的工具,帮我们干了很多脏活累活,虽然大部分时候我们不太需要关心生成的 FFI 代码,但在使用它们构建更高层的 API 时,还是要注意三个关键问题。

  • 如何处理数据结构的差异?

比如 C string 是 NULL 结尾,而 Rust String 是完全不同的结构。我们要清楚数据结构在内存中组织的差异,才能妥善地处理它们。Rust 提供了 std::ffi 来处理这样的问题,比如 CStrCString 来处理字符串。

  • 谁来释放内存?

没有特殊的情况,谁分配的内存,谁要负责释放。Rust 的内存分配器和其它语言的可能不一样,所以,Rust 分配的内存在 C 的上下文中释放,可能会导致未定义的行为。

  • 如何进行错误处理?

在上面的代码里我们也看到了,C 通过返回的 error code 来报告执行过程中的错误,我们使用了 anyhow! 宏来随手生成了错误,这是不好的示例。在正式的代码中,应该使用 thiserror 或者类似的机制来定义所有 error code 对应的错误情况,然后相应地生成错误。

Rust 调用其它语言

目前说了半天,都是在说 Rust 如何调用 C/C++。那么,Rust,调用其他语言呢?

前面也提到,因为 C ABI 深入人心,两门语言之间的接口往往采用 C ABI。从这个角度说,如果我们需要 Rust 调用 Golang 的代码(先不管这合不合理),那么,首先把 Golang 的代码使用 cgo 编译成兼容 C 的库;然后,Rust 就可以像调用 C/C++ 那样,使用 bindgen 来生成对应的 API 了

至于 Rust 调用其它语言,也是类似,只不过像 JavaScript/Python 这样的,与其把它们的代码想办法编译成 C 库,不如把他们的解释器编译成 C 库或者 WASM,然后在 Rust 里调用其解释器使用相关的代码,来的方便和痛快。毕竟,JavaScript/Python 是脚本语言。

把 Rust 代码编译成 C 库

讲完了 Rust 如何使用其它语言,我们再来看看如何把 Rust 代码编译成符合 C ABI 的库,这样其它语言就可以像使用 C 那样使用 Rust 了。

这里的处理逻辑和上面的 Rust 调用 C 是类似的,只不过角色对调了一下:-

要把 Rust 代码和数据结构提供给 C 使用,我们首先要构造相应的 Rust shim 层,把原有的、正常的 Rust 实现封装一下,便于 C 调用。

Rust shim 主要做四件事情:

  • 提供 Rust 方法、trait 方法等公开接口的独立函数。注意 C 是不支持泛型的,所以对于泛型函数,需要提供具体的用于某个类型的 shim 函数。
  • 所有要暴露给 C 的独立函数,都要声明成 /#[no_mangle],不做函数名称的改写。

如果不用 /#[no_mangle],Rust 编译器会为函数生成很复杂的名字,我们很难在 C 中得到正确的改写后的名字。同时,这些函数的接口要使用 C 兼容的数据结构。

  • 数据结构需要处理成和 C 兼容的结构。

如果是你自己定义的结构体,需要使用 /#[repr©],对于要暴露给 C 的函数,不能使用 String/Vec/Result 这些 C 无法正确操作的数据结构。

  • 要使用 catch_unwind 把所有可能产生 panic! 的代码包裹起来。

切记,其它语言调用 Rust 时,遇到 Rust 的 panic!(),会导致未定义的行为,所以在 FFI 的边界处,要 catch_unwind,阻止 Rust 栈回溯跑出 Rust 的世界。

来看个例子: // 使用 no_mangle 禁止函数名改编,这样其它语言可以通过 C ABI 调用这个函数 /#[no_mangle] pub extern “C” fn hello_world() -> /const c_char { // C String 以 “\0” 结尾,你可以把 “\0” 去掉看看会发生什么 “hello world!\0”.as_ptr() as /const c_char }

这段代码使用了 /#[no_mangle] ,在传回去字符串时使用 “\0” 结尾的字符串。由于这个字符串在 RODATA 段,是 ‘static 的生命周期,所以将其转换成裸指针返回,没有问题。如果要把这段代码编译为一个可用的 C 库,在 Cargo.toml 中,crate 类型要设置为 crate-type = [“cdylib”]。

刚才那个例子太简单,我们再来看一个进阶的例子。在这个例子里,C 语言那端会传过来一个字符串指针, format!() 一下后,返回一个字符串指针: /#[no_mangle] pub extern “C” fn hello_bad(name: /const c_char) -> /const c_char { let s = unsafe { CStr::from_ptr(name).to_str().unwrap() }; format!(“hello {}!\0”, s).as_ptr() as /*const c_char }

你能发现这段代码的问题么?它犯了初学者几乎会犯的所有问题。

首先,传入的 name 会不会是一个 NULL 指针?是不是一个合法的地址?虽然是否是合法的地址我们无法检测,但起码我们可以检测 NULL。

其次,unwrap() 会造成 panic!(),如果把 CStr 转换成 &str 时出现错误,这个 panic!() 就会造成未定义的行为。我们可以做 catch_unwind(),但更好的方式是进行错误处理。

最后,

format!(“hello {}!\0”, s) 生成了一个字符串结构,as_ptr() 取到它堆上的起始位置,我们也保证了堆上的内存以 NULL 结尾,看上去没有问题。然而,在这个函数结束执行时,由于字符串 s 退出作用域,所以它的堆内存会被连带 drop 掉。因此,这个函数返回的是一个悬空的指针,在 C 那侧调用时就会崩溃。

所以,正确的写法应该是: /#[no_mangle] pub extern “C” fn hello(name: /const c_char) -> /const c_char { if name.is_null() { return ptr::null(); } if let Ok(s) = unsafe { CStr::from_ptr(name).to_str() } { let result = format!(“hello {}!”, s); // 可以使用 unwrap,因为 result 不包含 \0 let s = CString::new(result).unwrap(); s.into_raw() // 相当于: // let p = s.as_ptr(); // std::mem::forget(s); // p } else { ptr::null() } }

在这段代码里,我们检查了 NULL 指针,进行了错误处理,还用 into_raw() 来让 Rust 侧放弃对内存的所有权。

注意前面的三个关键问题说过,谁分配的内存,谁来释放,所以,我们还需要提供另一个函数,供 C 语言侧使用,来释放 Rust 分配的字符串: /#[no_mangle] pub extern “C” fn free_str(s: /*mut c_char) { if !s.is_null() { unsafe { CString::from_raw(s) }; } }

C 代码必须要调用这个接口安全释放 Rust 创建的 CString。如果不调用,会有内存泄漏;如果使用 C 自己的 free(),会导致未定义的错误。

有人可能会好奇,CString::from_raw(s) 只是从裸指针中恢复出 CString,也没有释放啊?

你要习惯这样的“释放内存”的写法,因为它实际上借助了 Rust 的所有权规则:当所有者离开作用域时,拥有的内存会被释放。这里我们创建一个有所有权的对象,就是为了函数结束时的自动释放。如果你看标准库或第三方库,经常有类似的“释放内存”的代码。

上面的 hello 代码,其实还不够安全。因为虽然看上去没有使用任何会导致直接或者间接 panic! 的代码,但难保代码复杂后,隐式地调用了 panic!()。比如,如果以后我们新加一些逻辑,使用了 copy_from_slice(),这个函数内部会调用 panic!(),就会导致问题。所以,最好的方法是把主要的逻辑封装在 catch_unwind 里: /#[no_mangle] pub extern “C” fn hello(name: /const c_char) -> /const c_char { if name.is_null() { return ptr::null(); } let result = catch_unwind(|| { if let Ok(s) = unsafe { CStr::from_ptr(name).to_str() } { let result = format!(“hello {}!”, s); // 可以使用 unwrap,因为 result 不包含 \0 let s = CString::new(result).unwrap(); s.into_raw() } else { ptr::null() } }); match result { Ok(s) => s, Err(_) => ptr::null(), } }

这几段代码你可以多多体会,完整例子放在 playground

写好 Rust shim 代码后,接下来就是生成 C 的 FFI 接口了。一般来说,这个环节可以用工具来自动生成。我们可以使用 cbindgen。如果使用 cbindgen,上述的代码会生成类似这样的 bindings.h: /#include /#include /#include /#include /#include extern "C" { const char /*hello_world(); const char /*hello_bad(const char /*name); const char /*hello(const char /*name); void free_str(char /*s); } // extern "C"

有了编译好的库代码以及头文件后,在其他语言中,就可以用该语言的工具进一步生成那门语言的 FFI 绑定,然后正常使用。

和其它语言的互操作

好,搞明白 Rust 代码如何编译成 C 库供 C/C++ 和其它语言使用,我们再看看具体语言有没有额外的工具更方便地和 Rust 互操作。

对于 Python 和 Node.js,我们之前已经见到了 PyO3Neon 这两个库,用起来都非常简单直观,下一讲会再深入使用一下。

对于 Erlang/Elixir,可以使用非常不错的 rustler。如果你对此感兴趣,可以看这个 repo 中的演示文稿和例子。下面是一个把 Rust 代码安全地给 Erlang/Elixir 使用的简单例子: /#[rustler::nif] fn add(a: i64, b: i64) -> i64 { a + b } rustler::init!(“Elixir.Math”, [add]);

对于 C++,虽然 cbindgen 就足够,但社区里还有 cxx,它可以帮助我们很方便地对 Rust 和 C++ 进行互操作。

如果你要做 Kotlin/Swift 开发,可以尝试一下 mozilla 用在生产环境下的 uniffi。使用 uniffi,你需要定义一个 UDL,这样 uniffi-bindgen 会帮你生成各种语言的 FFI 代码。

具体怎么用可以看这门课的 GitHub repo 下这一讲的 ffi-math crate 的完整代码。这里就讲一下重点,我写了个简单的 uniffi 接口(math.udl): namespace math { u32 add(u32 a, u32 b); string hello([ByRef]string name); };

并提供了 Rust 实现:

uniffi_macros::include_scaffolding!(“math”); pub fn add(a: u32, b: u32) -> u32 { a + b } pub fn hello(name: &str) -> String { format!(“hello {}!”, name) }

之后就可以用:

uniffi-bindgen generate src/math.udl –language swift uniffi-bindgen generate src/math.udl –language kotlin

生成对应的 Swift 和 Kotlin 代码。

我们看生成的 hello() 函数的代码。比如 Kotlin 代码: fun hello(name: String): String { val _retval = rustCall() { status -> _UniFFILib.INSTANCE.math_6c3d_hello(name.lower(), status) } return String.lift(_retval) }

再比如 Swift 代码:

public func hello(name: String) -> String { let _retval = try! rustCall { math_6c3d_hello(name.lower(), $0) } return try! String.lift(_retval) }

你也许注意到了这个 RustCall,它是用来调用 Rust FFI 代码的,看源码:

private func rustCall(_ callback: (UnsafeMutablePointer) -> T) throws -> T { try makeRustCall(callback, errorHandler: { $0.deallocate() return UniffiInternalError.unexpectedRustCallError }) } private func makeRustCall(_ callback: (UnsafeMutablePointer) -> T, errorHandler: (RustBuffer) throws -> Error) throws -> T { var callStatus = RustCallStatus() let returnedVal = callback(&callStatus) switch callStatus.code { case CALL_SUCCESS: return returnedVal case CALL_ERROR: throw try errorHandler(callStatus.errorBuf) case CALL_PANIC: // When the rust code sees a panic, it tries to construct a RustBuffer // with the message. But if that code panics, then it just sends back // an empty buffer. if callStatus.errorBuf.len > 0 { throw UniffiInternalError.rustPanic(try String.lift(callStatus.errorBuf)) } else { callStatus.errorBuf.deallocate() throw UniffiInternalError.rustPanic("Rust panic") } default: throw UniffiInternalError.unexpectedRustCallStatusCode } }

你可以看到,它还考虑了如果 Rust 代码 panic! 后的处理。那么 Rust 申请的内存会被 Rust 释放么?

会的。hello() 里的 String.lift() 就在做这个事情,我们看生成的代码: extension String: ViaFfi { fileprivate typealias FfiType = RustBuffer fileprivate static func lift(_ v: FfiType) throws -> Self { defer { v.deallocate() } if v.data == nil { return String() } let bytes = UnsafeBufferPointer(start: v.data!, count: Int(v.len)) return String(bytes: bytes, encoding: String.Encoding.utf8)! } ... } private extension RustBuffer { ... // Frees the buffer in place. // The buffer must not be used after this is called. func deallocate() { try! rustCall { ffi_math_6c3d_rustbuffer_free(self, $0) } } }

在 lift 时,它会分配一个 swift String,然后在函数退出时调用 deallocate(),此时会发送一个 rustCall 给 ffi_math_rustbuffer_free()。

你看,uniffi 把前面说的处理 FFI 的三个关键问题:处理数据结构的差异、释放内存、错误处理,都妥善地解决了。所以,如果你要在 Swift/Kotlin 代码中使用 Rust,非常建议你使用 uniffi。此外,uniffi 还支持 Python 和 Ruby。

FFI 的其它方式

最后,我们来简单聊一聊处理 FFI 的其它方式。其实代码的跨语言共享并非只有 FFI 一条路子。你也可以使用 REST API、gRPC 来达到代码跨语言使用的目的。不过,这样要额外走一圈网络,即便是本地网络,也效率太低,且不够安全。有没有更高效一些的方法?

有!我们可以在两个语言中使用 protobuf 来序列化/反序列化要传递的数据。在 Mozilla 的一篇博文 Crossing the Rust FFI frontier with Protocol Buffers,提到了这种方法:-

感兴趣的同学,可以读读这篇文章。也可以看看我之前写的文章深度探索:前端中的后端,详细探讨了把 Rust 用在客户端项目中的可能性以及如何做 Rust bridge。

小结

FFI 是 Rust 又一个处于领先地位的领域。

从这一讲的示例中我们可以看到,在支持很方便地使用 C/C++ 社区里的成果外,Rust 也可以非常方便地在很多地方取代 C/C++,成为其它语言使用底层库的首选。除了方便的 FFI 接口和工具链,使用 Rust 为其它语言提供底层支持,其实还有安全性这个杀手锏

比如在 Erlang/Elixir 社区,高性能的底层 NIF 代码,如果用 C/C++ 撰写的话,一个不小心就可能导致整个 VM 的崩溃;但是用 Rust 撰写,因为其严格的内存安全保证(只要保证 unsafe 代码的正确性),NIF 不会导致 VM 的崩溃。

所以,现在 Rust 越来越受到各个高级语言的青睐,用来开发高性能的底层库。

与此同时,当需要开发跨越多个端的公共库时,使用 Rust 也会是一个很好的选择,我们在前面的内容中也看到了用 uniffi 为 Android 和 iOS 构建公共代码是多么简单的一件事。

思考题

  • 阅读 std::ffi 的文档,想想 Vec 如何传递给 C?再想想 HashMap 该如何传递?有必要传递一个 HashMap 到 C 那一侧么?
  • 阅读 rocksdb 的代码,看看 Rust 如何提供 rocksDB 的绑定。
  • 如果你是个 iOS/Android 开发者,尝试使用 Rust 的 reqwest 构建 REST API 客户端,然后把得到的数据通过 FFI 传递给 Swift/Kotlin 侧。

感谢你的收听,今天完成了第31次Rust学习打卡啦。如果你觉得有收获,也欢迎你分享给身边的朋友,邀他一起讨论。我们下节课见~

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e9%99%88%e5%a4%a9%20%c2%b7%20Rust%20%e7%bc%96%e7%a8%8b%e7%ac%ac%e4%b8%80%e8%af%be/31%20FFI%ef%bc%9aRust%e5%a6%82%e4%bd%95%e5%92%8c%e4%bd%a0%e7%9a%84%e8%af%ad%e8%a8%80%e6%9e%b6%e8%b5%b7%e6%b2%9f%e9%80%9a%e6%a1%a5%e6%a2%81%ef%bc%9f.md