如何传递 Rust 闭包到 C?
解读
面试官问“如何把 Rust 闭包传给 C”时,真正想考察的是你对 FFI 边界内存模型、调用约定与生命周期三者的综合掌控能力。国内一线厂用 Rust 做高性能网关、数据库或区块链底层时,经常要把 Rust 写的“事件回调”注册到现有 C 框架(如 epoll、DPDK、硬件 SDK)。如果候选人只说“用 extern 导出函数”而闭口不谈捕获变量如何落地、调用约定是否匹配、析构由谁负责,基本会被判定为“只写过 Demo,没上过生产”。
知识点
- Rust 闭包不是单一函数指针,而是 trait 对象:Fn/FnMut/FnOnce,大小不固定,不能直接塞进 C 的 void*。
- FFI 安全三件套:
- repr(C) 结构体做“胖指针”瘦身,只保留 call 指针与上下文指针;
- 使用
extern "C"ABI,禁用 unwind,禁止 Rust 特有类型(str、Vec、Box< dyn …>)裸穿边界; - 提供 Box::into_raw / from_raw 配对,把堆上闭包当“不透明句柄”交给 C,确保Rust 端拥有最终释放权。
- 捕获变量生命周期必须 'static,否则编译期无法证明 C 端使用时 Rust 栈帧仍存在;若必须捕获局部数据,需 Arc + Mutex 搬到堆上并转成 'static。
- 线程安全:若 C 会在多线程里调用,闭包需实现 Send + Sync;否则注册时直接 assert_unsafe_send 并在文档里写死“仅主线程回调”。
- 析构钩子:C 端注册完成后,必须再导出一个 unregister(void *handle),内部调回 Rust 的 extern "C" fn drop_opaque(handle: *mut c_void),把 Box 回收,防止“C 端 free 掉 Rust 对象”造成 double free 或内存泄漏。
- 工具链细节:Cargo 的 cdylib crate-type 才能生成 .so/.dll;Linux 下用 -C target-feature=-crt-static 避免把 glibc 静态链进去;Windows 需 __stdcall 或 __cdecl 显式指定,与 MSVC 侧保持一致。
答案
分四步落地,代码可直接搬进生产。
第一步:在 Rust 侧把闭包“擦成” C 可调用的函数指针 + 上下文指针。
use std::ffi::c_void;
use std::mem::transmute;
#[repr(C)]
pub struct RustClosure {
call: extern "C" fn(*mut c_void),
env: *mut c_void,
}
unsafe extern "C" fn trampoline<F>(env: *mut c_void)
where
F: FnMut(),
{
let f = &mut *(env as *mut F);
f();
}
#[no_mangle]
pub extern "C" fn rust_closure_new<F>(f: F) -> RustClosure
where
F: FnMut() + 'static,
{
let raw = Box::into_raw(Box::new(f));
RustClosure {
call: trampoline::<F>,
env: raw as *mut c_void,
}
}
#[no_mangle]
pub extern "C" fn rust_closure_drop(closure: RustClosure) {
unsafe {
// 把 env 还原成 Box<F> 并自动 drop
let _ = Box::from_raw(closure.env as *mut dyn FnMut());
}
}
第二步:在 C 侧声明同样布局的结构体并调用。
typedef struct {
void (*call)(void*);
void* env;
} rust_closure_t;
void rust_closure_drop(rust_closure_t);
static void c_api_register_callback(rust_closure_t cb) {
/* 假设底层框架需要 void (*)(void*) 类型的回调 */
register_framework_cb(cb.call, cb.env);
/* 用完记得 rust_closure_drop(cb) */
}
第三步:Rust 侧生成 .so。
Cargo.toml
[lib]
name = "myrust"
crate-type = ["cdylib"]
build 命令
cargo build --release
第四步:C 侧 dlopen 后把函数指针拿出来,按上述结构体注册即可。
至此,捕获变量、调用约定、内存释放三大风险点全部在 Rust 端收口,C 端只看见两个裸指针,符合国内大厂“C 不改、Rust 兜底”的渐进式迁移策略。
拓展思考
-
异步闭包怎么办?
如果 C 框架支持“异步完成通知”,可以把 Rust 的async move { ... }用 futures::executor::block_on 包一层,再喂给上面的 trampoline;但注意 block_on 会卡住当前线程,生产环境建议用 tokio::runtime::Handle::spawn 把任务扔到 Rust 异步线程池,trampoline 里仅做 wake 通知。 -
错误处理与 Panic 跨语言传播
Rust 侧一旦 panic 越过 FFI 边界就是 UB。务必在 trampoline 内包 std::panic::catch_unwind,并把错误码写进 C 提供的 errno 风格变量;否则线上出现 segfault 很难定位。 -
零拷贝高性能场景
若闭包需要频繁读写大块数据(如 DPDK mbuf),可让 C 把指针和长度一起传回 Rust,Rust 用 std::slice::from_raw_parts_mut 构造切片,全程零拷贝;但要和 C 侧约定“Rust 借用的生命周期内 C 不得释放”,并在文档里写清“回调返回即代表借用结束”。 -
国内合规小提示
银行、证券类项目上线前需通过源代码扫描+渗透测试。FFI 模块会被重点审计,务必在 CI 里加 cargo-geiger 统计 unsafe 行数,并写清“unsafe 仅用于 repr(C) 与 Box::into_raw,无越界读写”的审计报告,可显著缩短合规流程。
把以上四点准备成 3 分钟“口袋发言”,面试官基本会给出“FFI 经验扎实,可独立承担底层桥接任务”的评价。