Skip to main contentFFI Callbacks and void *ctx | IoT Worker

FFI Callbacks and void *ctx

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.