Rust 和 C 间交互及 FFI 基础

本文最后更新于 2023年7月30日 上午

Rust 开发过程中, 始终绕不开 FFI, 而其中最常见的部分是和 C 之间的交互. 本文主要讲的就是 Rust FFI 基础, 以及如何和 C 系语言交互.

Rust 和 C 互操作

Rust 中使用 C 代码

在 Rust 工程中使用 C/C++ 代码包含两个部分的内容:

  1. 将需要在 Rust 中用到的 C API 包裹起来
  2. 构建 C/C++ 代码并在 Rust 代码中使用

由于 C++ 没有能够在 Rust 编译器中使用的稳定 ABI, 因此推荐在 Rust 和 C/C++ 交互的场景下使用 C ABI.

在 Rust 代码中使用 C/C++ 代码前, 需要定义在 Rust 中使用的 C 数据类型和函数. 在 Rust 使用场景下, 可以通过手动或工具(bindgen)生成这些类型或函数对应的 Rust 代码.

如果手动实现, 需要根据 C 头文件对应编写 Rust 代码:

C 头文件如下所示:

1
2
3
4
5
6
7
/* File: cool.h */
typedef struct CoolStruct {
int x;
int y;
} CoolStruct;

void cool_function(int i, char c, CoolStruct* cs);

Rust 对应代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* File: cool_bindings.rs */
#[repr(C)]
pub struct CoolStruct {
pub x: cty::c_int,
pub y: cty::c_int,
}

extern "C" {
pub fn cool_function(
i: cty::c_int,
c: cty::c_char,
cs: *mut CoolStruct
);
}

默认情况下, Rust 不会保证 struct 中成员顺序, padding, 以及数据 size. 为此, 需要标记 Rust struct 为 #[repr(C)], 用于向 Rust 编译器指定这个 struct 总是使用和 C 代码相同的规则来组织内部成员的内存布局.

同时因为 C/C++ 中的 int 和 char 的灵活性(可以互换), 因此推荐使用 cty 中的类型来表示对应类型.

同时 Rust 对应代码中使用 extern "C" 表示这些函数符合 C ABI. 在 Rust 中仅声明函数而不提供函数体实现的目的是表明这个函数是在其他地方提供的(比如在最终二进制文件或其他被链接的库中). 为了清晰地进行说明, 函数中参数名称使用的是和 C 头文件中相同的名字, 同时参数类型也是 cty 中的.

另外还在函数中使用了 *mut CoolStruct 原始指针参数, 原因是 C 中没有 Rust 的引用, 因此通过原始指针的方式引用数据. 但在 Rust 中解引用原始指针是 unsafe 的(比如指针可能是 null 的), 因此需要额外注意.

C 中使用 Rust 代码

在 C/C++ 代码中使用 Rust 也由两部分组成:

  1. 在 Rust 中创建一套对 C 友好的 API
  2. 将 Rust 集成到外部构建系统中

由于绝大部分外部构建系统都没有对 Rust 的原生支持, 因此一个方式是通过 Cargo 构建 rust 代码, 然后链接到使用者.

和 C 交互的 Rust 工程产物定义可以如下:

1
2
3
4
[lib]
name = "your_crate"
crate-type = ["cdylib"] # Creates dynamic lib
# crate-type = ["staticlib"] # Creates static lib

通过上述配置可以指定产物名字, 同时指定产物的格式.

有了这个, 下一步就是在 Rust 中暴露 C 友好的 API. 由于 C++ 没有稳定的 ABI 可供 Rust 编译器对应, 因此 Rust API 都是通过 C ABI 方式暴露.

由于 Rust 编译器会对符号进行 mangle, 且一般来说 mangle 方式都和原生 linker 期待的不一样, 且 Rust 函数使用的是 Rust ABI(未稳定), 如果需要构建对外暴露的 FFI API, 则需要告知编译器使用系统 ABI. 因此如果 Rust 要暴露 C API, 需要添加 #[no_mangle] extern "C" 在函数上.

下一步就是生成 C 头文件, 手写或工具(cbindgen)生成均可. 头文件生成好, 并且打包 Rust 为静态/动态库, 就可以在任意可以和 C 交互的工程中使用了.

Swift 和 Rust 的互操

Swift 和 Rust 的互操时, 首先暴露 C 接口, 然后 Swift 调用 C 接口即可.

Rust FFI 概述(参考 RFR 书 C11)

并非所有代码都是 Rust 编写的, 在其他语言中调用 Rust 或在 Rust 中调用其他语言都需要 FFI 支持. Rust 的 FFI 主要通过 extern 关键字提供支持, 另外还需要看看数据如何在语言边界间流转.

使用 extern 跨越语言边界

FFI 的最终目的是访问本工程 Rust 代码外的其他区域. Rust 为此提供了两种构造块:

  • symbols: 即在二进制文件中指定段内的特定内存地址对应的名字, 用于和外部共享内存(可以是数据或代码).
  • calling convention: 即和外部代码间的 symbol 代码调用约定, 外部代码获取 symbol 后通过调用约定去调用这个 symbol 地址对应的代码.

symbol

symbol 仅在二进制代码内部使用, 是编译器生成的, 以便都是随机字符串, 但在 FFI 场景下, 随机的就不好用了. 为了使用外部代码中的 symbol, 我们需要告诉 Rust 编译器 symbol 是什么, 以便它去被链接的库或其他地方找到这个 symbol, 否则在某些情况下会有多个相同的 symbol. 另外 symbol 可以被多次声明, 但只能有一次定义, 每个声明都会链接到同一个定义. 如果在链接时找不到某个 symbol, 或有一个 symbol 的多个定义, 链接器就会报错.

理解到 symbol 只是一个名称后, 我们可以仅使用 #[no_mangle] 定义一个外部变量, 这样实际上仍然可以在外部代码中使用到这个 symbol, 因为名字不会被随机. 如果在 Rust 中声明一个外部定义的变量, 则这些符号只能在 unsafe 中访问, 因为 Rust 无法对它进行检查.

如果有多个同名的外部符号, 还可以通过 #[link(name = "some_lib")] 注解来表示它们是来自哪个库的, 否则 linker 会报错. 另外还可以对外部 symbol 进行重命名(包括变量或函数), 方法是使用 #[link_name = "my_name"] 注解, 如果想修改 Rust 暴露出去的 symbol 名字, 则使用 #[export_name = "my_name"].

#[link] 注解还支持 link kind 参数, 表示这个符号是如何链接的, 比如 staticdylib(默认值), 除了二者还有一些其他的 kind.

calling convention

调用约定用于指定一段用于调用函数的汇编代码, 约定主要是如下:

  1. 调用这个函数时调用栈帧的创建方式
  2. 参数的传递方式(在栈上还是寄存器, 参数是正序还是逆序传入)
  3. 当函数返回时如何返回(jump back)
  4. 当被调函数返回后, 调用者如何去保存调用时的 CPU 状态(比如寄存器在被调用函数返回后会需要存放到调用者栈帧中)

如果 rust 函数没有使用 extern 显式修饰, 实际它用到的是 extern "Rust", 即 Rust 调用约定(如果没有显式修饰, 每个 Rust 函数都会默认成这个).

如果函数仅修饰为 extern, 则和 extern "C" 一个效果, 即 “标准C的调用约定”.

extern 还支持一些其他的调用约定, 比如 "system", 表示使用操作系统标准库接口相同的调用约定, 除 Win32 外(是 "stdcall")其余系统当前都和 "C" 一致.

同时需要注意, 调用约定也是函数类型的一部分, 即一个 extern "C" fn()extern "Rust" fn()(或简单写成 fn()) 是两个不同的函数, 和 extern "system" fn() 也是不同的, 即便是 C 和 system 的约定一致.

跨语言的数据类型

在使用 FFI 时, 数据类型的内存布局就尤其重要了. 如果两个语言对数据类型的布局约定不同, 则同一个数据的解释二者就不同, 从而造成不可预料的结果.

在跨语言数据传递时, 最好不要进行大小端的假定(比如使用 __be32 类型), 而是使用 [u8;4] 进行字节数组传递.

Rust 的 String 在传递时, 由于 C 中字符串是 null 结尾的字符数组, 而 Rust 中是 UTF-8 编码的字符串, 这个时候需要使用 std::ffi:CStrstd:ffi:CString 对字符串进行转换后提供给 C 端使用. Vector 也类似, 一般来说通过传递第一个元素指针的方式来传递 Vector, 比如使用 Vector::into_raw_parts.

如果要表示在 C 中可空的指针类型, Rust 中使用 Option<*const T>Option<*mut T> 即可办到, 可空的函数指针也类似 Option<extern fn(...)>, 如果外部传了全 0 的数据就可以被 Rust 识别为 None 值.

内存分配

内存分配时, 有一个非常重要的注意事项: 分配是属于它自己的 allocator 的, 只能被同一个 allocator 释放.

当 Rust FFI 时, 在 Rust 侧有 allocator, 在另外语言中也有. 可以通过指针相互传递在不同语言下 allocator 分配的对象, 但只能对应 allocator 去释放自己分配的对象.

绝大部分 FFI 接口都有一到两个配置用于处理分配: 要么调用者提供数据在内存的指针, 或接口暴露释放内存的专用方法去释放已分配的资源.

比如 Rust 实现的 OpenSSL 库中提供的分配和释放接口:

1
2
pub extern fn ECDSA_SIG_new() -> *mut ECDSA_SIG;
pub extern fn ECDSA_SIG_free(sig: *mut ECDSA_SIG);

调用者使用 ECDSA_SIG_new 函数创建资源, 使用完毕后再通过 ECDSA_SIG_free 释放资源. 在创建资源时可能通过 Box::new 创建, 而释放时通过 Box::from_raw 释放.

还有一种方案是 caller 管理内存, 即 caller 提供存放数据用的内存区域, 而 callee 将数据放入到内存区域, 然后使用完毕后 caller 自己释放内存区域. 比如下面这个代码:

1
pub extern fn BIO_new_mem_buf(buf: *const c_void, len: c_int) -> *mut BIO

这样 caller 可以自己选择存放数据的区域是在堆还是栈上, 这种方式的好处还在于 caller 可以重用之前已经分配的内存区域.

上述两种内存管理方式在 FFI 上都是普遍使用的, 可以选择任意一种或者是组合使用. 按灵活性高的方式选择即可.

Callbacks(回调)

只要回调符合 Calling Convention, 就可以进行调用. 比如可以在 rust 中声明一个 extern "C" fn(c_int) -> c_int 回调函数, C 侧传入函数指针, 然后 Rust 就可以调用这个回调.

可以在 FFI 调用过程中在 Rust 侧的任意 extern 函数中使用 std::panic::catch_unwind 来检测 panic, 然后将 panic 转换为 FFI 兼容的 error 传递出去.

FFI 接口层的 Safety

绝大多数 FFI 层中的接口都是 unsafe 的.

保证安全性的三个原则是:

  • 准确对应数据 & 还是 &mut
  • 恰当实现 SendSync
  • 保证指针不被误用

三个原则的分解讲解如下.

引用和生命期

如果外部代码有修改某个指针指向数据的可能, 则保证 Rust 中拿到这个指针后需要转换到 &mut 可变引用.

同时要通过 Rust 的生命期系统来保证指针生命期匹配 FFI 使用的需求. 比如外部提供了一个 Context, 要 Rust 利用 Context 创建一个 Device, 且需要 Device 和 Context 的生命期一致(Context 是外部传入的, Device 是内部创建的), 这个情况下, FFI 接口的 safe wrapper 需要保证 Device 有 borrow 的 Context 相同的生命期.

Send 和 Sync

除非外部 library 保证类型是线程安全的, 否则不要在外部库类型上实现 Send 和 Sync.

指针误用

有时为了保证信息隐藏, 会将指针传递为 void* 类型的, 对应 Rust 类型为 *mut std::ffi:c_void. 但由于所有 void 指针可以互换, 在某些接口中如果传错了指针则会有无法预料的行为. 这个时候, 可以在 Rust 侧通过 Opaque pointer struct 来避免这样的问题.

1
2
3
4
5
6
7
8
#[non_exhaustive] #[repr(transparent)] pub struct Foo(c_void);
#[non_exhaustive] #[repr(transparent)] pub struct Bar(c_void);

extern {
pub fn foo() -> *mut Foo;
pub fn need_foo(arg: *mut Foo);
pub fn need_bar(arg: *mut Bar);
}

由于 Foo 和 Bar 都是零大小的 struct 类型, 它们可以用于外部方法参数中. 且由于两个类型是有区分的, 这样就不会在 Rust 侧造成指针误用了.

另外上面的 #[non_exhaustive] 表示类型无法在除了此 crate 外的其他地方进行构造.

使用工具生成代码

有两个工具: bindgencbindgen.

  • bindgen: 通过 C header 生成对应的 Rust 代码, 这样 Rust 可以使用外部 C 代码.
  • cbindgen: 通过 Rust 代码生成对应的 C header 代码, 这样外部代码可以通过 C header 来使用 Rust 代码.

Rust 支持在 build 的时候执行一段 Rust build 脚本, 办法就是在 Cargo.toml 文件的 package 块中使用 build = some.rs 指定 build 脚本. 这样每次 build 都可以对应执行脚本内容. build 脚本自己的依赖可以在 Cargo.toml 中 [build-dependencies] 单独指定. 如果构建脚本为 build.rs, 则无需显式在 package 块中指定脚本位置.

参考:

  1. https://docs.rust-embedded.org/book/interoperability/c-with-rust.html
  2. https://doc.rust-lang.org/nomicon/ffi.html
  3. https://github.com/crackcomm/rust-lang-interop
  4. https://google.github.io/autocxx/tutorial.html
  5. https://firefox-source-docs.mozilla.org/writing-rust-code/cpp-interop.html
  6. https://locka99.gitbooks.io/a-guide-to-porting-c-to-rust/content/features_of_rust/ffi.html
  7. https://medium.com/dwelo-r-d/using-c-libraries-in-rust-13961948c72a
  8. https://kuczma.dev/articles/rust-and-cpp/
  9. https://github.com/eqrion/cbindgen/blob/master/docs.md
  10. https://github.com/eqrion/cbindgen/blob/master/README.md#examples
  11. https://doc.rust-lang.org/book/
  12. BOOK: Rust for Rustaceans

Rust 和 C 间交互及 FFI 基础
https://blog.rayy.top/2023/07/29/rust-c-interop-and-ffi-basics/
作者
貘鸣
发布于
2023年7月29日
许可协议