Rust 社区有句话:“Rewrite it in Rust”。但现实比口号骨感得多。
OpenSSL 有 70 万行 C 代码,SQLite 有 15 万行,Linux 内核有 2800 万行。你不可能把它们全部重写。即使你的项目是纯 Rust,只要你需要调用操作系统 API、链接一个 C 写的压缩库、或者嵌入一个脚本引擎,你就不得不跨越那条边界——FFI(Foreign Function Interface)。
问题在于:Rust 的所有权系统在 FFI
边界彻底失效。一旦一个指针通过
extern "C" 传给 C 代码,borrow checker
就看不到了。*mut T
不携带生命周期,不携带所有权信息,不携带任何编译期保证。你在
C 那边对它做什么,Rust 完全不知道。
如果你还没读过 Unsafe Rust
完全指南,建议先看那篇。本文假设你已经理解
unsafe 块的基本语义。
一、Rust FFI 基础
extern “C” 和调用约定
Rust 有自己的 ABI(Application Binary Interface),但这个 ABI 没有稳定性承诺。不同版本的 rustc 可能生成不同的函数调用约定、参数传递方式和结构体布局。所以跨语言调用必须使用 C ABI——它是事实标准。
声明一个外部 C 函数:
extern "C" {
fn strlen(s: *const std::ffi::c_char) -> usize;
}调用时必须包在 unsafe
里,因为编译器无法验证:
- 指针是否有效
- 指针指向的内存是否已释放
- 字符串是否以 null 结尾
- 该函数是否真的存在于链接阶段
use std::ffi::CString;
fn main() {
let s = CString::new("hello").expect("CString::new failed");
let len = unsafe { strlen(s.as_ptr()) };
println!("length = {len}"); // 5
}#[repr(C)] 结构体布局
Rust 默认的结构体布局是未指定的——编译器可以自由重排字段顺序以优化对齐和大小。这意味着如果你把一个普通 Rust 结构体的指针传给 C,字段偏移量可能完全对不上。
// ❌ C 读到的字段偏移量可能是错的
struct Point {
x: f64,
y: f64,
label: u8,
}
// ✅ 保证和 C 结构体的内存布局一致
#[repr(C)]
struct Point {
x: f64,
y: f64,
label: u8,
}基本类型映射
Rust 和 C 的基本类型大小不总是一样。int
在大多数平台上是 32 位,但这不是 C
标准保证的。std::ffi 模块提供了精确匹配:
| C 类型 | Rust 类型 | 说明 |
|---|---|---|
int |
c_int |
通常 i32,但不保证 |
unsigned int |
c_uint |
通常 u32 |
long |
c_long |
平台相关:32 或 64 位 |
char |
c_char |
可能是 i8 或 u8 |
size_t |
usize |
指针大小的无符号整数 |
void* |
*mut c_void |
无类型指针 |
const char* |
*const c_char |
C 字符串指针 |
一条黄金法则:永远不要用 i32 代替
c_int,即使在你的平台上它们恰好一样大。代码要表达意图,不是碰巧能跑。
二、bindgen:从 C 头文件生成 Rust 绑定
手写 extern "C"
绑定对于三五个函数还行,但面对一个有几百个函数和几十个结构体的
C 库,手写就是自杀。这就是 bindgen 存在的理由。
工作原理
bindgen 内部使用 libclang 解析 C/C++ 头文件,构建 AST(抽象语法树),然后把类型、函数、常量、枚举翻译成对应的 Rust 代码。它不是正则表达式匹配——它是真正的编译器前端解析。
build.rs 集成
标准做法是在 build.rs 中调用 bindgen:
// build.rs
fn main() {
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out_path = std::path::PathBuf::from(
std::env::var("OUT_DIR").unwrap()
);
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}然后在 lib.rs 里引入:
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));那三行 #![allow(...)] 几乎是 bindgen
项目的标配——C 的命名风格和 Rust
不一样,你不会想去手动改几百个生成的名字。
常见配置
let bindings = bindgen::Builder::default()
.header("wrapper.h")
// 只生成以 mylib_ 开头的函数和类型
.allowlist_function("mylib_.*")
.allowlist_type("mylib_.*")
// 把 C 的 int 类型映射为 i32(而非 c_int)
.default_enum_style(bindgen::EnumVariation::Rust {
non_exhaustive: true,
})
// 对所有结构体自动实现 Debug
.derive_debug(true)
// 屏蔽某些不需要的类型
.blocklist_type("__.*")
.generate()
.unwrap();allowlist 和 blocklist
是你的武器:一个大型 C 库的头文件可能 include
了整个系统头文件树,你不需要为 struct stat
生成绑定。
处理 C 宏
bindgen 的一大痛点:C 宏。因为宏是预处理器展开的,不经过编译器前端,libclang 看不到函数式宏(function-like macros)的定义。
// bindgen 能处理这个:
#define MAX_BUFFER_SIZE 4096
// bindgen 处理不了这个:
#define ALIGN_UP(x, align) (((x) + (align) - 1) & ~((align) - 1))常量宏会被翻译成 Rust 的
pub const,但函数式宏需要你手动翻译成 Rust
函数或 const fn。
生成代码的质量
bindgen
生成的代码是正确但不优雅的。所有指针都是裸指针,没有
Option、没有
Result、没有生命周期标注。这是它的设计意图——它生成的是
-sys crate
级别的低层绑定,安全封装是你的事。
三、cbindgen:给 C 调用者提供 Rust 接口
反过来——你用 Rust 写了一个库,C/C++ 项目要调用它。这时需要 cbindgen。
#[no_mangle] 和 extern “C”
首先,你的 Rust 函数必须满足两个条件:
// #[no_mangle]:阻止 Rust 编译器修改符号名
// extern "C":使用 C 调用约定
#[no_mangle]
pub extern "C" fn mylib_create_engine(config_path: *const c_char) -> *mut Engine {
// ...
}没有 #[no_mangle],Rust 会把函数名 mangle
成类似 _ZN5mylib13create_engine17h8a3b...
这样的东西——C 的链接器找不到它。
cbindgen.toml 配置
# cbindgen.toml
language = "C"
header = "/* Auto-generated by cbindgen. Do not edit. */"
include_guard = "MYLIB_H"
autogen_warning = "/* Warning: this file is autogenerated by cbindgen. */"
tab_width = 4
[export]
include = ["Engine", "EngineConfig", "ErrorCode"]
[export.rename]
"ErrorCode" = "mylib_error_t"运行命令:
cbindgen --config cbindgen.toml --crate mylib --output mylib.h生成的头文件:
/* Auto-generated by cbindgen. Do not edit. */
#ifndef MYLIB_H
#define MYLIB_H
#include <stdint.h>
typedef struct Engine Engine;
typedef enum mylib_error_t {
Ok = 0,
InvalidInput = 1,
IoError = 2,
} mylib_error_t;
struct Engine *mylib_create_engine(const char *config_path);
void mylib_destroy_engine(struct Engine *engine);
mylib_error_t mylib_process(struct Engine *engine,
const uint8_t *data,
uintptr_t len);
#endif /* MYLIB_H */cdylib vs staticlib
在 Cargo.toml 中:
[lib]
crate-type = ["cdylib"] # 生成 .so / .dylib / .dll
# 或者
crate-type = ["staticlib"] # 生成 .a / .libcdylib 适合动态链接,但要注意:它会把 Rust
标准库静态链接进去,导致产物较大。staticlib
适合嵌入 C 项目,但需要链接时指定 Rust 的依赖(如
-lpthread -ldl -lm)。
四、内存所有权跨语言传递
这是 FFI 里最容易翻车的地方,没有之一。
如果你读过 所有权 vs RAII,你知道 Rust 的所有权系统在编译期保证「每块内存有且仅有一个 owner」。但在 FFI 边界,这个保证被打穿了。你必须自己维护一条铁律:
谁分配,谁释放。
Rust 分配的内存用 Rust 释放,C 分配的内存用 C
释放。不要用 free() 释放 Rust 的
Box,不要用 drop 释放 C 的
malloc。它们底层使用的 allocator
可能完全不同。
Box::into_raw / Box::from_raw
把 Rust 对象的所有权「借出」给 C:
// Rust 侧:创建对象,把所有权转移给 C
#[no_mangle]
pub extern "C" fn engine_new() -> *mut Engine {
let engine = Box::new(Engine::default());
Box::into_raw(engine) // 所有权转移:Rust 不再负责释放
}
// Rust 侧:C 用完后,把所有权还回来
#[no_mangle]
pub extern "C" fn engine_free(ptr: *mut Engine) {
if ptr.is_null() {
return;
}
// 重新获得所有权,函数结束时自动 drop
unsafe { drop(Box::from_raw(ptr)); }
}关键点:Box::into_raw 之后,这块内存就脱离了
Rust 的管辖。如果 C 那边忘记调用
engine_free,就是内存泄漏。如果 C 调用了两次
engine_free,就是
double-free——未定义行为。
CString / CStr
字符串是另一个雷区。Rust 的 String 是 UTF-8
编码、不以 null 结尾的。C 的字符串是以 null 结尾的
char*。
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
// Rust → C:需要创建 CString(会追加 \0)
fn rust_to_c() {
let rust_str = "hello world";
let c_string = CString::new(rust_str).expect("内含 null 字节");
let ptr: *const c_char = c_string.as_ptr();
unsafe { some_c_function(ptr); }
// c_string 在这里 drop,ptr 失效!
// 如果 some_c_function 保存了 ptr 供之后使用 → use-after-free
}
// C → Rust:需要用 CStr 包装(不获取所有权)
#[no_mangle]
pub extern "C" fn process_name(name: *const c_char) {
if name.is_null() {
return;
}
let c_str = unsafe { CStr::from_ptr(name) };
match c_str.to_str() {
Ok(s) => println!("name = {s}"),
Err(_) => eprintln!("invalid UTF-8"),
}
// 注意:我们没有获取 name 的所有权,不能释放它
}回调函数中的生命周期陷阱
C 库经常需要你传入回调函数。这在 Rust
中意味着你要用函数指针,而函数指针捕获不了环境——除非你用
void* 传递上下文。
// C 的回调签名:
// typedef void (*callback_t)(void* user_data, int event_code);
// void register_callback(callback_t cb, void* user_data);
type CallbackFn = extern "C" fn(*mut c_void, c_int);
extern "C" {
fn register_callback(cb: CallbackFn, user_data: *mut c_void);
}
struct MyContext {
counter: u64,
name: String,
}
extern "C" fn my_callback(user_data: *mut c_void, event_code: c_int) {
let ctx = unsafe { &mut *(user_data as *mut MyContext) };
ctx.counter += 1;
println!("[{}] event {event_code}, count = {}", ctx.name, ctx.counter);
}
fn setup() {
let mut ctx = Box::new(MyContext {
counter: 0,
name: "sensor-1".to_string(),
});
let ctx_ptr = &mut *ctx as *mut MyContext as *mut c_void;
unsafe { register_callback(my_callback, ctx_ptr); }
// ⚠️ 如果 ctx 在这里被 drop,C 库后续调用回调时就是 use-after-free!
// 必须用 Box::into_raw 或 mem::forget 让 ctx 活够久
std::mem::forget(ctx);
}这种模式极其常见,也极其容易出错。你必须保证上下文对象的生命周期至少和回调的注册期一样长。
安全封装模式
把所有的 unsafe 集中到一个地方,暴露安全的
API 给外部使用者:
pub struct SafeEngine {
ptr: *mut ffi::Engine,
}
impl SafeEngine {
pub fn new(config: &str) -> Result<Self, EngineError> {
let c_config = CString::new(config)
.map_err(|_| EngineError::InvalidConfig)?;
let ptr = unsafe { ffi::engine_create(c_config.as_ptr()) };
if ptr.is_null() {
Err(EngineError::CreateFailed)
} else {
Ok(SafeEngine { ptr })
}
}
pub fn process(&mut self, data: &[u8]) -> Result<(), EngineError> {
let code = unsafe {
ffi::engine_process(self.ptr, data.as_ptr(), data.len())
};
match code {
0 => Ok(()),
e => Err(EngineError::from_code(e)),
}
}
}
impl Drop for SafeEngine {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { ffi::engine_destroy(self.ptr); }
self.ptr = std::ptr::null_mut();
}
}
}
// 线程安全标注——只有在你确认 C 库是线程安全的时候才加
// unsafe impl Send for SafeEngine {}
// unsafe impl Sync for SafeEngine {}使用者完全不需要写 unsafe:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut engine = SafeEngine::new("/etc/mylib.conf")?;
engine.process(b"some data")?;
Ok(())
// engine 在这里自动 drop,C 资源自动释放
}这就是 Rust FFI 的核心哲学:unsafe
不是禁忌,而是隔离区。你在最底层用
unsafe 和 C 打交道,然后在上面盖一层安全的 Rust
API。整个 crate 只有底层那一小块是 unsafe
的。更多关于 unsafe 的设计哲学,参见 Unsafe Rust
完全指南。
五、实战:为 C 库写安全的 Rust wrapper
我们以一个简化的 zlib 绑定为例,演示完整的 sys crate + safe wrapper 模式。
-sys crate:原始绑定
社区惯例是把 C 库的原始绑定放在一个 -sys
crate 里。比如 libz-sys 只负责链接 zlib、暴露 C
函数签名,不做任何安全封装。
# libz-sys/Cargo.toml
[package]
name = "libz-sys"
version = "0.1.0"
links = "z"
build = "build.rs"
[build-dependencies]
bindgen = "0.71"
pkg-config = "0.3"// libz-sys/build.rs
fn main() {
// 用 pkg-config 找到系统的 zlib
pkg_config::Config::new()
.atleast_version("1.2.11")
.probe("zlib")
.expect("zlib not found");
let bindings = bindgen::Builder::default()
.header("wrapper.h") // 内容就一行:#include <zlib.h>
.allowlist_function("compress.*|uncompress.*|deflate.*|inflate.*")
.allowlist_type("z_stream.*")
.allowlist_var("Z_OK|Z_STREAM_END|Z_BUF_ERROR|Z_MEM_ERROR")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("bindgen failed");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
}safe wrapper crate
在 -sys crate 之上,构建用户友好的 API:
// safe-zlib/src/lib.rs
use libz_sys as ffi;
use std::io;
pub fn compress(input: &[u8]) -> io::Result<Vec<u8>> {
let mut dest_len: std::ffi::c_ulong =
(input.len() + input.len() / 1000 + 12) as _;
let mut dest = vec![0u8; dest_len as usize];
let result = unsafe {
ffi::compress(
dest.as_mut_ptr(),
&mut dest_len,
input.as_ptr(),
input.len() as _,
)
};
match result {
ffi::Z_OK => {
dest.truncate(dest_len as usize);
Ok(dest)
}
ffi::Z_MEM_ERROR => Err(io::Error::new(
io::ErrorKind::OutOfMemory,
"zlib: out of memory",
)),
ffi::Z_BUF_ERROR => Err(io::Error::new(
io::ErrorKind::Other,
"zlib: output buffer too small",
)),
code => Err(io::Error::new(
io::ErrorKind::Other,
format!("zlib: unexpected error code {code}"),
)),
}
}注意这个 API 的特点:
- 输入是
&[u8],不是裸指针——编译器保证它有效 - 输出是
Result<Vec<u8>>——错误通过类型系统传播,不是检查返回值 - 内部的 unsafe 完全被封装——调用者不需要知道 zlib 的存在
错误处理:C 的错误码 → Rust 的 Result
C 库通常用两种方式报告错误:返回值和
errno。把它们统一翻译成
Result:
use std::io;
#[derive(Debug, thiserror::Error)]
pub enum ZlibError {
#[error("zlib: stream error")]
Stream,
#[error("zlib: data error (corrupted input)")]
Data,
#[error("zlib: out of memory")]
Memory,
#[error("zlib: buffer error")]
Buffer,
#[error("zlib: version mismatch")]
Version,
#[error("zlib: unknown error ({0})")]
Unknown(i32),
}
impl ZlibError {
fn from_code(code: i32) -> Self {
match code {
-2 => ZlibError::Stream,
-3 => ZlibError::Data,
-4 => ZlibError::Memory,
-5 => ZlibError::Buffer,
-6 => ZlibError::Version,
other => ZlibError::Unknown(other),
}
}
}Drop trait 实现自动资源释放
对于有状态的 C 对象(如 zlib 的
z_stream),用 Drop 实现 RAII:
pub struct Deflater {
stream: Box<ffi::z_stream>,
initialized: bool,
}
impl Deflater {
pub fn new(level: i32) -> Result<Self, ZlibError> {
let mut stream = Box::new(unsafe { std::mem::zeroed::<ffi::z_stream>() });
let ret = unsafe {
ffi::deflateInit_(
&mut *stream,
level,
ffi::zlibVersion(),
std::mem::size_of::<ffi::z_stream>() as _,
)
};
if ret != ffi::Z_OK {
return Err(ZlibError::from_code(ret));
}
Ok(Deflater { stream, initialized: true })
}
}
impl Drop for Deflater {
fn drop(&mut self) {
if self.initialized {
unsafe { ffi::deflateEnd(&mut *self.stream); }
self.initialized = false;
}
}
}这就是 所有权 vs RAII 那篇文章讨论的模式在 FFI 场景下的应用:Rust 的 Drop 保证了即使发生 panic 或提前 return,C 的资源也会被正确释放。
Send / Sync 标注
默认情况下,包含裸指针的类型既不是 Send 也不是 Sync——编译器不知道底层 C 库是否线程安全。如果你确认它是,需要手动标注:
// 只有当你确认 C 库是线程安全的时候才这么做
unsafe impl Send for Deflater {}
// Sync 意味着多个线程可以同时持有 &Deflater
// 大多数 C 库做不到这一点——它们的内部状态不是线程安全的
// unsafe impl Sync for Deflater {}不要随意标注。一个错误的 unsafe impl Sync
可以让你的安全封装形同虚设。
六、高级话题
Rust → C → Rust 回调地狱
有些场景下,Rust 调用 C 库,C 库再通过回调调用 Rust。这种三明治结构是 FFI 最复杂的模式:
// Rust 侧的回调实现
extern "C" fn progress_callback(
user_data: *mut c_void,
percent: c_int,
) -> c_int {
// 这里从 C 的上下文「回到」了 Rust
let closure = unsafe { &mut *(user_data as *mut Box<dyn FnMut(i32) -> bool>) };
if closure(percent as i32) { 0 } else { -1 }
}
pub fn do_work_with_progress<F>(mut on_progress: F) -> Result<(), Error>
where
F: FnMut(i32) -> bool,
{
let mut closure: Box<dyn FnMut(i32) -> bool> = Box::new(on_progress);
let user_data = &mut closure as *mut _ as *mut c_void;
let ret = unsafe {
ffi::do_work(progress_callback, user_data)
};
if ret == 0 { Ok(()) } else { Err(Error::from_code(ret)) }
}这段代码有三个微妙的正确性要求:
closure必须活得比ffi::do_work调用更长(这里是 OK 的——它在栈上)progress_callback不能 panic(下面会讲)- 如果 C 库在另一个线程调用回调,
closure必须是Send
panic 跨 FFI 边界 = UB
这是 Rust FFI 中最常被忽略的陷阱:如果
Rust 代码在 extern "C" 函数里 panic,而 panic
试图穿过 C
的栈帧回退(unwind),这是未定义行为。
C 不知道 Rust 的 panic 是什么。它没有对应的 unwind 表。栈会被破坏。
解决方案——每个 extern "C" 函数都用
catch_unwind 包裹:
use std::panic;
#[no_mangle]
pub extern "C" fn mylib_process(data: *const u8, len: usize) -> c_int {
let result = panic::catch_unwind(|| {
let slice = unsafe { std::slice::from_raw_parts(data, len) };
// 正常的 Rust 逻辑,可能 panic
process_data(slice)
});
match result {
Ok(Ok(())) => 0,
Ok(Err(_)) => -1, // 业务错误
Err(_) => -2, // Rust panic 被捕获,不会跨越 FFI 边界
}
}Rust 从 1.71 开始,在 extern "C"
函数中如果发生未捕获的 panic,默认行为是
abort 而不是 UB。但你仍然应该用
catch_unwind
来优雅地处理错误,而不是直接炸掉整个进程。
async Rust + FFI 的困境
async Rust 和 FFI 天然不兼容,因为:
- C 的阻塞调用会冻结整个 async executor 线程
- 你不能在
await点上持有跨 FFI 的裸指针(指针不是Unpin/Send的) - C 库的回调可能在任意线程触发,不在 async runtime 的控制下
常见的应对策略:
use tokio::task;
async fn async_compress(data: Vec<u8>) -> io::Result<Vec<u8>> {
// 把同步 FFI 调用丢到阻塞线程池
task::spawn_blocking(move || {
compress(&data) // 同步 FFI 调用
}).await?
}用 spawn_blocking 把 FFI
调用隔离到单独的线程池,不阻塞 async runtime 的 worker
线程。代价是一次线程上下文切换。
性能注意事项
FFI 调用本身的开销非常小——大约等同于一次间接函数调用(通过 PLT 表),在大多数情况下可以忽略不计。但有几个场景需要注意:
- 高频小调用:如果你在循环里每次迭代都调用一个 C 函数,开销会累积。考虑批量接口。
- 数据拷贝:为了安全,你可能在 Rust
侧先把数据拷贝到
Vec<u8>,再传指针给 C。如果数据量大,这个拷贝可能很贵。 - 字符串转换:
CString::new需要检查内部有没有 null 字节(O(n))并追加 null 终止符。热路径上要考虑缓存。
// ❌ 每次调用都分配 + 检查
fn call_c_many_times(names: &[&str]) {
for name in names {
let c_name = CString::new(*name).unwrap();
unsafe { ffi::process_name(c_name.as_ptr()); }
}
}
// ✅ 预分配缓冲区
fn call_c_many_times_fast(names: &[&str]) {
let mut buf = Vec::with_capacity(256);
for name in names {
buf.clear();
buf.extend_from_slice(name.as_bytes());
buf.push(0); // null terminator
unsafe { ffi::process_name(buf.as_ptr() as *const c_char); }
}
}替代方案:cxx crate
如果你的目标是 C++ 而非 C,cxx crate
提供了一种完全不同的方法。它不是生成
unsafe extern "C"
绑定,而是通过一个声明式的桥接定义来同时生成 Rust 和 C++
侧的胶水代码:
// src/main.rs
#[cxx::bridge]
mod ffi {
unsafe extern "C++" {
include!("mylib/include/engine.h");
type Engine;
fn create_engine(path: &CxxString) -> UniquePtr<Engine>;
fn process(self: Pin<&mut Engine>, data: &[u8]) -> Result<()>;
}
extern "Rust" {
fn on_progress(percent: i32) -> bool;
}
}cxx 的优势:
- 编译期类型检查——Rust 和 C++ 两侧的类型签名必须匹配
- 自动内存管理——
UniquePtr、SharedPtr有 Rust 对应类型 - 异常安全——C++ 异常被翻译成 Rust 的
Result - 无需手写 unsafe——cxx 生成的 bridge 代码处理了所有不安全的部分
代价是灵活性:cxx 支持的类型有限,纯 C 库不适用,你必须按照它的 DSL 来定义接口。
| 特性 | bindgen | cxx |
|---|---|---|
| 目标语言 | C | C++ |
| 安全性 | 全部 unsafe,需手动封装 | 编译期检查,大部分操作安全 |
| 灵活性 | 任意 C 库 | 需要 C++ 配合修改 |
| 生态成熟度 | 极高(事实标准) | 中等(快速增长) |
| 适用场景 | 绑定已有 C 库 | 新建 Rust/C++ 混合项目 |
多线程 FFI 的坑
FFI 加上多线程,复杂度不是相加而是相乘。下面是几个在 code review 里反复看到的问题。
Thread-Local Storage 差异
C 的 __thread / thread_local 和
Rust 的 thread_local! 底层实现不同。C 用的是
ELF TLS(通过 fs 段寄存器),Rust 可能用
pthread_key_create。后果:
- 如果 C 库在 TLS 里缓存了状态(很多库这样做,比如 OpenSSL 的 error queue),Rust 线程调用时能正确访问
- 但如果你用
std::thread::spawn创建线程再调 C 函数,C 库的 TLS 初始化可能没跑(取决于 C 库是否有__attribute__((constructor))) - 安全做法:在每个新线程首次调用 C
库前,调用库的
xxx_thread_init()函数(如果有的话)
Mutex 跨 FFI 边界
不要试图把 Rust 的 std::sync::Mutex 传给
C:
// ❌ 绝对不要这样做
#[repr(C)]
struct SharedState {
mutex: std::sync::Mutex<Data>, // Mutex 内存布局不是 C 兼容的
}
// ✅ 用 C 兼容的锁
use libc::{pthread_mutex_t, pthread_mutex_init, pthread_mutex_lock, pthread_mutex_unlock};
struct FfiMutex {
inner: UnsafeCell<pthread_mutex_t>,
}
impl FfiMutex {
fn new() -> Self {
let mut mutex = unsafe { std::mem::zeroed::<pthread_mutex_t>() };
unsafe { pthread_mutex_init(&mut mutex, std::ptr::null()) };
Self { inner: UnsafeCell::new(mutex) }
}
fn lock(&self) {
unsafe { pthread_mutex_lock(self.inner.get()) };
}
fn unlock(&self) {
unsafe { pthread_mutex_unlock(self.inner.get()) };
}
}
// 手动标注线程安全性——你必须确保这是对的
unsafe impl Send for FfiMutex {}
unsafe impl Sync for FfiMutex {}Panic 穿越 FFI 边界(再强调一遍)
前面 6.2 节讲了
catch_unwind,但在多线程场景下还有一个额外陷阱:如果
C 库在自己创建的线程中回调 Rust,而 Rust 代码 panic
了——catch_unwind 的调用栈里没有 Rust 的 landing
pad,直接 UB。
// C 库在自己的线程池里调用这个回调
extern "C" fn worker_callback(data: *mut c_void) {
// 这里必须 catch_unwind,因为调用栈上方是 C 的线程入口
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let state = unsafe { &*(data as *const WorkerState) };
state.process(); // 可能 panic 的 Rust 代码
}));
}Send/Sync 标注的责任
FFI 类型默认不是 Send 也不是
Sync——这是 Rust
的保守默认。如果你需要跨线程使用,必须手动标注,而且你在为正确性负全部责任:
- 标注
Send:意味着这个值可以安全地移动到另一个线程 - 标注
Sync:意味着&T可以安全地在线程间共享(即并发只读是安全的) - 如果 C 库文档说 “not thread-safe”——不要标注 Sync
Async Rust + FFI 的取消安全
当 Rust async task 调用 C 阻塞函数(通过
spawn_blocking),如果 task
被取消了会怎样?
async fn risky_ffi_call(data: Vec<u8>) -> Result<()> {
let handle = tokio::task::spawn_blocking(move || {
unsafe { ffi::slow_process(data.as_ptr(), data.len()) }
// 如果 async task 被 drop,这个闭包还会继续执行到完成
// data 会在闭包结束后被 drop——这是安全的
});
// 但如果这里 await 被取消(比如 select! 的另一个分支先完成):
// handle 被 drop → JoinHandle 被 drop → 但线程还在跑!
// C 函数还在用 data → data 还活着(因为 move 了)→ OK
// 但如果 data 是 Arc 并且其他地方修改了它 → 数据竞争
handle.await?
}规则:spawn_blocking 中的 FFI
调用,所有数据都要 move
进去,不要用引用。取消后线程会继续执行到完成(JoinHandle
drop 不会 kill 线程)。
FFI Code Review 检查清单
每次提交 FFI 代码之前,过一遍这 15 条:
- 所有权文档:每个跨 FFI 传递的指针,注释里写清楚”谁分配、谁释放”
- Null 指针检查:所有从 C
返回的指针,使用前检查
is_null() - Buffer 长度校验:永远不要信任 C 传来的长度参数,做 bounds check
- 对齐验证:
#[repr(C)]的结构体用static_assert检查 size 和 alignment 与 C 侧一致 - 错误码传播:C 函数返回值 ≤ 0 时,转换为
Rust 的
Result::Err,不要 ignore - 线程安全标注:
Send/Sync标注有对应的安全性论证注释 - Panic 边界:每个
extern "C"函数体被catch_unwind包裹 - Drop 实现:持有 C 资源的 Rust
类型实现了
Drop,调用正确的释放函数 repr(C)标注:所有跨 FFI 共享的结构体都有#[repr(C)]- 生命周期文档:裸指针对应的 Rust 引用的生命周期约束在注释中明确
- 字符串处理:
CString/CStr正确使用,没有中间的 null 字节问题 - 回调安全:传给 C 的回调函数不持有悬垂引用,user_data 的生命周期 >= 回调使用期
- 内存分配器一致性:Rust 分配的内存不在 C
侧
free(),反之亦然 - ABI 兼容性:
-syscrate 的 C 头文件版本和运行时链接的库版本一致 - Fuzzing/Sanitizer:用
cargo fuzz+ AddressSanitizer 跑过 FFI 边界的输入
结语
FFI 是 Rust 安全模型里开的一扇后门。当你走过那扇门,borrow checker 不再保护你,所有权系统不再自动管理,你回到了 C 程序员几十年来面对的那些经典问题:谁分配、谁释放、指针有没有悬垂、线程安不安全。
但 Rust 给了你一样 C 没有的东西:把 unsafe
封装起来的能力。你可以在最底层和 C
赤膊相见,然后在上面盖一层安全的 API,让所有使用者都不需要碰
unsafe。这种分层隔离才是 Rust
FFI 设计的精髓。
记住三条规则:
- 谁分配,谁释放。永远不要用错误的 allocator 释放内存。
- 每个
extern "C"函数都要catch_unwind。panic 跨 FFI 边界是 UB。 - 最小化 unsafe 表面积。写一个
-syscrate 做原始绑定,再写一个安全的 wrapper crate。
如果你对 unsafe 本身的语义和使用场景还有疑问,回头看 Unsafe Rust 完全指南。如果你想理解为什么 Rust 的所有权比 C++ 的 RAII 更可靠,看 所有权 vs RAII。
在 FFI 边界写 unsafe 不可耻。可耻的是不把 unsafe 封装好就暴露给用户。