跳转到主内容FFI 回调和 void *ctx | 物联网民工

FFI 回调和 void *ctx

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 找到、链接和稳定调用。