Rust 以 内存安全(Memory Safety) 和 无畏并发(Fearless Concurrency) 为核心卖点。但当 Rust 通过 FFI(Foreign Function Interface) 调用 C/C++ 库时,你实际上走出了 Rust 编译器的安全保护区。
换句话说:
一旦跨越 FFI 边界,Rust 不再保证安全。
在 FFI 世界中,你需要重新面对:
- ABI 兼容
- 手动内存管理
- 指针安全
- Panic 与异常传播
- 生命周期管理
本文将从 底层 ABI 到工程实践,系统讲解 Rust FFI 的核心设计原则。
一、FFI 的本质:ABI 兼容问题
很多 Rust 初学者认为,只要写上:
extern "C"
就能和 C 正常通信。
实际上,这 远远不够。
FFI 的核心不是语法,而是 ABI(Application Binary Interface)。
ABI 决定了:
- 函数参数如何传递(寄存器 / 栈)
- 结构体内存布局
- 对齐规则
- 调用约定
如果 Rust 和 C 的 数据布局不一致 ,即使代码能够编译,运行时也可能读取到 错误的数据甚至崩溃。
二、#[repr©]:跨语言结构体协议
Rust 默认不会保证结构体字段顺序。
原因是:
Rust 编译器可以自由调整字段布局,以获得更好的性能或更小的内存占用。
例如:
pub struct User {
pub id: u32,
pub age: u8,
pub score: f32,
}
Rust 编译器可能重排为:
id
score
age
这样可以减少 padding。
但 C 语言期望 严格的声明顺序布局。
因此跨语言结构体必须使用:
rust
#[repr(C)]
pub struct User {
pub id: u32,
pub age: u8,
pub score: f32,
}
#[repr©] 的作用:
- 保证字段顺序不变
- 遵循 C 的对齐规则
- 维持 ABI 稳定
例如 C 中:
rust
struct User {
uint32_t id;
uint8_t age;
float score;
};
Rust 与 C 才能安全共享该结构体。
三、字符串:FFI 中最容易踩坑的地方
Rust 和 C 的字符串模型完全不同。
Rust:
String = (ptr, len, capacity)
C:
char* -> '\0' 结尾
因此 Rust 的 String 不能直接传给 C。
正确做法是使用 CString:
rust
use std::ffi::CString;
use std::os::raw::c_char;
extern "C" {
fn print_message(s: *const c_char);
}
fn safe_print(message: &str) {
let c_str = CString::new(message)
.expect("CString conversion failed");
unsafe {
print_message(c_str.as_ptr());
}
}
CString 做了两件关键事情:
- 在末尾自动添加 \0
- 保证字符串中 不包含内部 null 字节
一个非常隐蔽的坑:Use-after-free
注意下面这段代码的生命周期:
rust
let c_str = CString::new(message)?;
print_message(c_str.as_ptr());
当函数结束时:
c_str 被 Drop
内存被释放
如果 C 代码:
- 保存了该指针
- 异步使用该字符串
就会发生:
Use-after-free(悬垂指针)
因此 FFI 中必须明确:
字符串的所有权归谁。
四、不透明指针(Opaque Pointer)模式
很多 C 库都会使用这种模式:
rust
typedef struct Engine Engine;
调用者 不知道结构体内部布局,只持有指针。
Rust 中通常这样建模:
rust
enum EngineOpaque {}
extern "C" {
fn engine_new() -> *mut EngineOpaque;
fn engine_run(e: *mut EngineOpaque);
fn engine_free(e: *mut EngineOpaque);
}
然后在 Rust 中封装一个安全对象:
rust
pub struct Engine {
raw: *mut EngineOpaque,
}
impl Drop for Engine {
fn drop(&mut self) {
unsafe {
engine_free(self.raw);
}
}
}
这种模式有几个重要优点:
- Rust 无法访问内部字段
- ABI 完全由 C 控制
- 生命周期由 Drop 自动管理
这本质上是:
用 Rust RAII 封装 C 的手动内存管理。
五、回调函数:当 C 调用 Rust
很多 C 库允许注册回调:
typedef int (*callback)(int data);
Rust 需要提供一个:
extern "C" fn
例如:
rust
#[no_mangle]
pub extern "C" fn rust_callback(data: i32) -> i32 {
println!("Rust received: {}", data);
data * 2
}
但这里有一个 非常严重的问题:
Rust 的 panic 不能跨越 FFI 边界。
如果 panic 进入 C:
Undefined Behavior
常见结果:
Segmentation Fault
安全方案:
catch_unwind
正确做法是捕获 panic:
rust
#[no_mangle]
pub extern "C" fn rust_callback(data: i32) -> i32 {
use std::panic;
let result = panic::catch_unwind(|| {
println!("Rust received: {}", data);
data * 2
});
match result {
Ok(v) => v,
Err(_) => {
eprintln!("Panic in Rust callback!");
-1
}
}
}
这样 panic 就不会越过 FFI 边界。
六、FFI 性能:真实成本在哪里
FFI 调用的成本其实很低。
通常只有:
- 调用约定切换
- 寄存器保存
- 栈布局调整
典型开销:
~10--50ns
真正的问题通常不是单次调用,而是:
高频跨语言调用
例如:
每秒 1000 万次 FFI
解决方案:
1 批量化接口
坏设计:
get_one_record()
好设计:
get_records_batch()
2 开启 LTO
如果 Rust 与 C 静态链接:
lto = true
编译器可以进行 跨语言内联优化。
七、FFI 自动化工具对比
| 工具 | 场景 | 优点 | 缺点 |
|---|---|---|---|
| bindgen | 现有 C 库 | 自动生成 Rust 绑定 | 代码 unsafe 较多 |
| cxx | Rust ↔ C++ | 类型安全 | 构建复杂 |
| PyO3 | Python 扩展 | Python 生态友好 | 仅 Python |
| Neon | Node.js 扩展 | JS 绑定简单 | 仅 Node |
工程经验建议:
C 库 → bindgen
C++ 库 → cxx
脚本语言扩展 → PyO3 / Neon
八、FFI 的三条铁律
在 Rust FFI 工程中,有三条非常重要的原则:
1 不要信任来自 C 的指针
始终检查:
if ptr.is_null()
2 谁分配,谁释放
必须明确:
malloc -> free
Rust alloc -> Rust drop
混用会导致:
double free
heap corruption
3 永远封装 unsafe
不要在业务代码中直接使用 FFI。
最佳实践:
rust
C API
↓
unsafe wrapper
↓
safe Rust API
让 unsafe 只存在于最底层边界。
结语:FFI 是 Rust 的"安全边界"
Rust 的安全保证只在 Rust 世界内部成立。
一旦跨越 FFI 边界:
Rust ↔ C
你必须重新面对:
- ABI
- 生命周期
- 指针安全
- 未定义行为(UB)
换句话说:
FFI 就是 Rust 的"内存安全边界"。
写好 FFI 的关键不是避免 unsafe,而是:
将 unsafe 控制在最小范围内