C 里的回调接口通常长这样:
typedef void (*event_cb_t)(void *ctx, int event);
int device_register_callback(struct device *dev, event_cb_t cb, void *ctx);
void device_unregister_callback(struct device *dev);
这个设计很常见:函数指针负责执行,ctx 负责保存调用方环境。
Rust 内部可以用 closure 表达“代码 + 环境”。但跨 C ABI 时,捕获环境的 closure 不能直接变成 C 函数指针。C 只认识固定 ABI 的函数地址和一个 void *ctx。
所以 FFI 回调的核心不是语法,而是生命周期:
回调对象放在哪里?
C 保存 ctx 时,Rust owner 是否还活着?
回调在哪个线程调用?
注销后 C 会不会再调用?
释放时会不会和回调并发?
C 需要 extern 函数,不是捕获 closure
Rust 不捕获环境的函数可以作为 C 回调:
use std::ffi::c_void;
use std::os::raw::c_int;
extern "C" fn on_event(_ctx: *mut c_void, event: c_int) {
println!("event {event}");
}
但捕获环境的 closure 不行:
let count = 0;
// |event| println!("{count} {event}") 不能直接当成 extern "C" fn
原因很直接:C 函数指针只有函数地址,没有地方存 closure 捕获的环境。
跨 ABI 时,Rust 要回到 C 的形状:
extern "C" fn trampoline(ctx, event)
ctx 指向 Rust 保存的回调环境
trampoline:从 void *ctx 找回 Rust 对象
典型写法是把 Rust closure 装进 Box,再把裸指针作为 ctx 交给 C。
先定义回调类型:
type EventCallback = Box<dyn FnMut(i32)>;
再写一个 C ABI trampoline:
use std::ffi::c_void;
use std::os::raw::c_int;
extern "C" fn event_trampoline(ctx: *mut c_void, event: c_int) {
if ctx.is_null() {
return;
}
let callback = unsafe { &mut *(ctx as *mut EventCallback) };
callback(event);
}
这段代码的 unsafe 前提是:
ctx 必须来自 Box<EventCallback>
ctx 指向的对象必须还活着
回调调用期间不能有另一个线程同时可变访问同一个 FnMut
这些前提不是编译器能自动证明的,所以要靠封装类型保证。
用 owner 管住注册和注销
不要把 Box::into_raw 分散到业务代码里。更稳的做法是定义一个拥有注册关系的结构体:
struct CallbackRegistration {
dev: *mut ffi::device,
callback: *mut EventCallback,
}
构造时注册:
impl CallbackRegistration {
fn register<F>(dev: *mut ffi::device, callback: F) -> Result<Self, RegisterError>
where
F: FnMut(i32) + 'static,
{
let boxed: EventCallback = Box::new(callback);
let raw = Box::into_raw(Box::new(boxed));
let ret = unsafe {
ffi::device_register_callback(dev, Some(event_trampoline), raw.cast())
};
if ret != 0 {
unsafe {
drop(Box::from_raw(raw));
}
return Err(RegisterError(ret));
}
Ok(Self { dev, callback: raw })
}
}
这个结构体负责两件事:
- 持有 Rust 回调对象的所有权
- 记住需要在哪个 C 设备上注销回调
调用方拿到 CallbackRegistration,就表示注册关系还活着。
Drop 里先注销,再释放 ctx
释放时顺序很关键。必须先告诉 C 不要再调用,然后才能释放 Rust 回调对象:
impl Drop for CallbackRegistration {
fn drop(&mut self) {
unsafe {
ffi::device_unregister_callback(self.dev);
drop(Box::from_raw(self.callback));
}
}
}
这段代码也有前提:
device_unregister_callback 返回后,C 不会再调用旧回调
没有另一个线程正在执行 trampoline
dev 在注销期间仍然有效
如果 C 库不能保证这些,简单 Drop 不够。你可能需要显式 close()、引用计数、停止线程、等待回调完成,或者由 C API 提供同步注销函数。
FFI 回调最危险的 bug,通常不是注册失败,而是注销后 C 仍然拿着旧 ctx 调用。
FnMut 和多线程回调要小心
上面的 EventCallback = Box<dyn FnMut(i32)> 适合同一线程、串行调用的 C 回调。
如果 C 可能从多个线程并发调用同一个回调,FnMut 就不够。你需要同步:
use std::sync::Mutex;
type EventCallback = Mutex<Box<dyn FnMut(i32) + Send>>;
extern "C" fn event_trampoline(ctx: *mut c_void, event: c_int) {
if ctx.is_null() {
return;
}
let callback = unsafe { &*(ctx as *mut EventCallback) };
if let Ok(mut callback) = callback.lock() {
callback(event);
}
}
如果回调会跨线程保存,还要加上 Send,必要时还要考虑 Sync。
不要猜。C 头文件或文档必须说明:
回调在哪个线程调用
是否可能并发调用
注销函数是否等待正在执行的回调结束
回调里能不能再次调用 C API
Rust 封装要按这些规则选 Fn、FnMut、Mutex、Send 和生命周期约束。
不要让 panic 穿过 C 回调
Rust panic 不应该穿过 C ABI。回调 trampoline 也一样。
如果回调来自用户代码,最好在 trampoline 里捕获 panic,并把错误记录下来:
extern "C" fn event_trampoline(ctx: *mut c_void, event: c_int) {
if ctx.is_null() {
return;
}
let callback = unsafe { &mut *(ctx as *mut EventCallback) };
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
callback(event);
}));
}
这里的处理策略要按库语义决定:记录日志、设置错误状态、停止设备,或者让后续 API 返回错误。
不要让 panic 直接跨 C 回调栈返回到 C 代码里。
C 保存 Rust 指针时,必须有 owner
最危险的写法是把局部变量地址交给 C 保存:
pub fn bad_register(dev: *mut ffi::device) {
let mut count = 0;
let ctx = &mut count as *mut i32 as *mut c_void;
unsafe {
ffi::device_register_callback(dev, Some(raw_callback), ctx);
}
} // count 在这里消失,C 还可能保存 ctx
C 以后调用回调时,ctx 已经悬垂。
正确方向是:只要 C 保存指针,Rust 侧就要有一个 owner 负责让这块内存活到注销之后。这个 owner 通常就是 CallbackRegistration、Device 或某个更高层的连接对象。
这一篇先记住什么
FFI 回调的第一层规则是:
捕获环境的 closure 不能直接当 C 函数指针
C ABI 回调用 extern "C" trampoline
ctx 指向 Rust 保存的回调环境
Box::into_raw 之后必须有 owner 管住释放
Drop 中通常先注销 C 回调,再释放 Rust ctx
多线程回调要用 Mutex/Send 等同步约束
panic 不要穿过 C 回调边界
C 保存 Rust 指针时,Rust 必须有 owner 保证生命周期
C 里的 void *ctx 看起来很自由。Rust 封装要做的事,正是把这种自由收回到一个明确的 owner 里:注册时创建,注销后释放,线程和错误规则写清楚。
下一篇讲静态库、动态库和 ABI。回调解决的是运行时调用关系,库形态和 ABI 解决的是二进制怎样被 C 找到、链接和稳定调用。