C callback APIs usually look like this:
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);
The function pointer is the code to run. ctx carries the caller’s environment.
Inside Rust, closures express “code plus environment.” But across a C ABI, a capturing closure cannot directly become a C function pointer. C understands a fixed-ABI function address and a void *ctx.
So FFI callbacks are mainly a lifetime and ownership problem:
Where is the callback object stored?
If C stores ctx, is the Rust owner still alive?
Which thread invokes the callback?
Can C call after unregister?
Can destruction race with a running callback?
C Needs an extern Function, Not a Capturing Closure
A Rust function that captures no environment can be used as a C callback:
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}");
}
But a capturing closure cannot:
let count = 0;
// |event| println!("{count} {event}") cannot directly be extern "C" fn
A C function pointer has only a function address. It has nowhere to store the closure environment.
Across the ABI, Rust returns to the C shape:
extern "C" fn trampoline(ctx, event)
ctx points to the Rust callback environment
trampoline Recovers the Rust Object from void *ctx
A typical design boxes a Rust closure and passes its raw pointer as ctx.
First define the callback type:
type EventCallback = Box<dyn FnMut(i32)>;
Then write a 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);
}
The unsafe assumptions are:
ctx came from Box<EventCallback>
the object behind ctx is still alive
no other thread mutably accesses the same FnMut while the callback runs
The compiler cannot prove these. A wrapper type must enforce them.
Use an Owner for Registration and Unregistration
Do not scatter Box::into_raw through business code. Define a type that owns the registration:
struct CallbackRegistration {
dev: *mut ffi::device,
callback: *mut EventCallback,
}
Register in the constructor:
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 })
}
}
This type owns two facts:
- the Rust callback object remains allocated
- the C device has a registration that must be undone
Holding CallbackRegistration means the registration is alive.
Drop Usually Unregisters Before Freeing ctx
Release order matters. Tell C to stop calling first, then free the Rust callback object:
impl Drop for CallbackRegistration {
fn drop(&mut self) {
unsafe {
ffi::device_unregister_callback(self.dev);
drop(Box::from_raw(self.callback));
}
}
}
This also has assumptions:
after device_unregister_callback returns, C will not call the old callback
no other thread is currently inside the trampoline
dev is still valid during unregister
If the C library cannot guarantee these, a simple Drop is not enough. You may need explicit close(), reference counting, stopping a thread, waiting for callbacks to finish, or a synchronous unregister API from C.
The most dangerous FFI callback bug is not failed registration. It is C calling an old ctx after Rust has freed it.
FnMut Needs Care for Multi-Threaded Callbacks
EventCallback = Box<dyn FnMut(i32)> is suitable for same-thread, serial callbacks.
If C can invoke the same callback concurrently from multiple threads, FnMut alone is not enough. Add synchronization:
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);
}
}
If callbacks are stored across threads, add Send; sometimes Sync is relevant too.
Do not guess. The C header or documentation must state:
which thread calls the callback
whether calls can be concurrent
whether unregister waits for running callbacks to finish
whether callbacks may call back into the C API
The Rust wrapper chooses Fn, FnMut, Mutex, Send, and lifetime bounds based on those rules.
Do Not Let panic Cross a C Callback
Rust panic should not unwind through a C ABI. Callback trampolines are no exception.
If the callback is user code, the trampoline should usually catch panics and record failure:
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);
}));
}
The policy depends on the library: log, set an error flag, stop the device, or make a later API return an error.
Do not let panic return through the C callback stack.
If C Stores a Rust Pointer, Rust Needs an Owner
This is the dangerous model:
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 is gone here, but C may still store ctx
When C later calls the callback, ctx is dangling.
The correct direction is: if C stores a pointer, Rust must have an owner that keeps the pointed-to memory alive until after unregister. That owner is usually CallbackRegistration, Device, or a higher-level connection object.
What to Keep
The first rules for FFI callbacks are:
capturing closures cannot directly be C function pointers
C ABI callbacks use an extern "C" trampoline
ctx points to a Rust-owned callback environment
Box::into_raw must be paired with an owner that frees it
Drop usually unregisters the C callback before freeing Rust ctx
multi-threaded callbacks need Mutex/Send-style synchronization
panic must not cross the C callback boundary
if C stores a Rust pointer, Rust needs an owner for its lifetime
C void *ctx looks flexible. The Rust wrapper’s job is to put that flexibility back under a clear owner: created during registration, released after unregister, with thread and error rules stated explicitly.
The next topic is static libraries, dynamic libraries, and ABI. Callbacks define runtime call relationships; library form and ABI define how C finds, links, and reliably calls the binary.