如何传递 Rust 闭包到 C?

解读

面试官问“如何把 Rust 闭包传给 C”时,真正想考察的是你对 FFI 边界内存模型、调用约定与生命周期三者的综合掌控能力。国内一线厂用 Rust 做高性能网关、数据库或区块链底层时,经常要把 Rust 写的“事件回调”注册到现有 C 框架(如 epoll、DPDK、硬件 SDK)。如果候选人只说“用 extern 导出函数”而闭口不谈捕获变量如何落地调用约定是否匹配析构由谁负责,基本会被判定为“只写过 Demo,没上过生产”。

知识点

  1. Rust 闭包不是单一函数指针,而是 trait 对象:Fn/FnMut/FnOnce,大小不固定,不能直接塞进 C 的 void*。
  2. FFI 安全三件套
    • repr(C) 结构体做“胖指针”瘦身,只保留 call 指针与上下文指针;
    • 使用 extern "C" ABI,禁用 unwind,禁止 Rust 特有类型(str、Vec、Box< dyn …>)裸穿边界;
    • 提供 Box::into_raw / from_raw 配对,把堆上闭包当“不透明句柄”交给 C,确保Rust 端拥有最终释放权
  3. 捕获变量生命周期必须 'static,否则编译期无法证明 C 端使用时 Rust 栈帧仍存在;若必须捕获局部数据,需 Arc + Mutex 搬到堆上并转成 'static。
  4. 线程安全:若 C 会在多线程里调用,闭包需实现 Send + Sync;否则注册时直接 assert_unsafe_send 并在文档里写死“仅主线程回调”。
  5. 析构钩子:C 端注册完成后,必须再导出一个 unregister(void *handle),内部调回 Rust 的 extern "C" fn drop_opaque(handle: *mut c_void),把 Box 回收,防止“C 端 free 掉 Rust 对象”造成 double free 或内存泄漏。
  6. 工具链细节: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 兜底”的渐进式迁移策略。

拓展思考

  1. 异步闭包怎么办?
    如果 C 框架支持“异步完成通知”,可以把 Rust 的 async move { ... }futures::executor::block_on 包一层,再喂给上面的 trampoline;但注意 block_on 会卡住当前线程,生产环境建议用 tokio::runtime::Handle::spawn 把任务扔到 Rust 异步线程池,trampoline 里仅做 wake 通知。

  2. 错误处理与 Panic 跨语言传播
    Rust 侧一旦 panic 越过 FFI 边界就是 UB。务必在 trampoline 内包 std::panic::catch_unwind,并把错误码写进 C 提供的 errno 风格变量;否则线上出现 segfault 很难定位。

  3. 零拷贝高性能场景
    若闭包需要频繁读写大块数据(如 DPDK mbuf),可让 C 把指针和长度一起传回 Rust,Rust 用 std::slice::from_raw_parts_mut 构造切片,全程零拷贝;但要和 C 侧约定“Rust 借用的生命周期内 C 不得释放”,并在文档里写清“回调返回即代表借用结束”。

  4. 国内合规小提示
    银行、证券类项目上线前需通过源代码扫描+渗透测试。FFI 模块会被重点审计,务必在 CI 里加 cargo-geiger 统计 unsafe 行数,并写清“unsafe 仅用于 repr(C) 与 Box::into_raw,无越界读写”的审计报告,可显著缩短合规流程。

把以上四点准备成 3 分钟“口袋发言”,面试官基本会给出“FFI 经验扎实,可独立承担底层桥接任务”的评价。