第5章 FFI
3 分钟阅读
译文 · 基于 Pragmatic Rust Guidelines
原文链接: https://microsoft.github.io/rust-guidelines/guidelines/ffi/index.html
FFI
在 FFI 库之间隔离 DLL 状态 (M-ISOLATE-DLL-STATE)
本条守护:跨 DLL 边界的数据完整性与已定义行为。
在同一应用中加载多个基于 Rust 的动态库(DLL)时,这些库之间只能共享「可移植」状态。 同样,在编写此类库时,你必须只接受或提供来自外部 DLL 的「可移植」数据。
此处的可移植指无论来源如何都安全且一致可处理的数据。按定义,这是 FFI 安全类型的子集。
一个类型是可移植的,若它是 #[repr(C)](或同样定义良好),并且满足下列_全部_条件:
- 不得与任何
static或线程局部有任何交互。 - 不得与任何
TypeId有任何交互。 - 不得包含指向任何非可移植数据的任何值、指针或引用(指向非可移植数据内部的可移植数据是合法的,例如
共享对
Box中所持 ASCII 字符串的引用)。
交互 指任何计算关系,因此也与类型的用法有关。在 DLL 之间发送 u128 可以,用它交换经 transmute 的 TypeId 则不行。
根本问题在于:Rust 编译器把每个 DLL 都当作全新的编译产物,相当于独立应用。这意味着每个 DLL:
- 有自己的一套
static与线程局部变量, - 任何
#[repr(Rust)]类型(默认)的类型布局可能因编译而异, - 有自己的一套唯一类型 ID,与任何其他 DLL 都不同。
尤其会影响:
- ⚠️ 任何已分配实例,例如
String、Vec<u8>、Box<Foo>、… - ⚠️ 任何依赖其他 static 的库,例如
tokio、log, - ⚠️ 任何非
#[repr(C)]的结构体, - ⚠️ 任何依赖一致
TypeId的数据结构。
实践中,在库之间传递上述任何内容都会导致数据丢失、状态损坏,通常还有未定义行为。
请特别注意,这也可能适用于在 FFI 边界上不可见的类型与方法:
| |
业务逻辑属于核心 crate,FFI 只做转译 (M-FFI-TRANSLATES)
本条守护:尽可能多的安全代码,以及清晰的关注点分离。
当用 Rust 创建 FFI 库时,核心_业务逻辑_ crate foo 与胶水 crate foo-ffi 之间应有清晰的关注点分离。
任何操作性功能都属于核心 crate,并以惯用、安全、可测试的 Rust 表达。FFI crate 的存在只为在原生 Rust 与 C 构造之间转译;核心 crate 不得被互操作关注点污染,即便这意味着重复并略微调整类型与函数签名。例如,给定核心 crate foo 中的如下类型:
| |
恰当的关注点分离可能会在 foo-ffi 中把构造与传输折叠成单个 FFI 入口点:
| |
然而,把 FFI 要求泄漏进 foo 本身是不恰当的:所有权、数据模型与签名在两个世界之间无法无缝转译。因跳过干净拆分而「节省」的时间,在后续重构中往往要加倍偿还。
| |
FFI crate 遵循既有命名约定 (M-FFI-NAMING)
本条守护:跨项目一眼可识别的 crate 角色。
用于 FFI 的 crate 应遵循既有命名实践:
-sys用于定义调用现有(C 风格)库的项的 crate-ffi用于定义被现有应用调用时的(C 风格)项的 crate
该方案有轻微变体(例如先前的 -sys crate 被废弃时用 -sys2,以及用 - 还是 _),但总体而言 -ffi 明确表示「导出」库,-sys 表示「导入」库。