When C calls your code
You are wrapping a C library that drives the main loop of a game engine. The engine processes input, updates physics, and renders frames. When the player presses a key, the engine needs to notify your Rust code so you can update the score. Or imagine a C event loop that triggers a handler whenever a network packet arrives. The C library doesn't know about Rust. It doesn't know about ownership, lifetimes, or the borrow checker. It only knows function pointers.
You need to give the C library a function pointer and ensure that when it calls back, your Rust code runs safely. The C side might call the function with garbage arguments, call it from a thread you didn't expect, or hold onto the pointer long after you think you're done. Crossing this boundary requires unsafe blocks, strict ABI matching, and a clear strategy for managing state.
The mechanics of a function pointer
A callback is just a function pointer. In C, you pass the address of a function, and the caller invokes it later. Rust supports function pointers, but it treats them with suspicion. A function pointer can point to invalid memory, or it can violate Rust's safety guarantees if called with the wrong arguments.
The extern "C" annotation defines the Application Binary Interface, or ABI. The ABI dictates how arguments are passed in registers, how the stack is cleaned up, and how names are mangled. C and Rust use different ABIs by default. If you pass a Rust function to C without extern "C", the stack frames will misalign, registers will hold the wrong values, and the program will crash.
Think of extern "C" as a translation layer. It tells the Rust compiler to generate a function that speaks C's dialect. The unsafe keyword on the function definition marks the function as unsafe to call. It signals that the function makes no guarantees about its inputs and might violate safety invariants if the caller passes bad data.
Minimal callback
Start with the simplest case: a stateless callback that transforms arguments and returns a result. Define the function pointer type, implement the callback, and pass it to the foreign code.
// Define the callback type matching the C signature.
// The type is a function pointer, not a closure.
pub type CallbackFn = unsafe extern "C" fn(arg1: i32, arg2: i32) -> i32;
/// Adds two integers.
/// This function uses the C ABI so the foreign code can call it.
unsafe extern "C" fn my_callback(a: i32, b: i32) -> i32 {
a + b
}
// Declare the foreign function that accepts the callback.
extern "C" {
fn register_callback(cb: CallbackFn);
}
/// Registers the callback with the foreign library.
pub fn setup() {
// SAFETY: register_callback expects a valid function pointer.
// my_callback is a valid Rust function with the correct signature.
unsafe {
register_callback(my_callback);
}
}
The type CallbackFn creates an alias for the function pointer signature. This makes the code readable and ensures type safety when passing the pointer around. The unsafe extern "C" fn syntax defines a function that is safe to implement but unsafe to call. You can call it from Rust inside an unsafe block, or pass it to C code that will call it.
The unsafe block around register_callback is required because calling any extern function is unsafe. The compiler cannot verify that the foreign function exists, has the right signature, or won't crash. You are taking responsibility for the correctness of the FFI call.
The unsafe keyword is your signature on a contract. You promise the C side won't break the rules.
Passing state with userdata
Stateless callbacks are rare. Most callbacks need to access Rust data. C libraries handle this by passing a void* pointer, often called userdata or context, to the callback. You pass a pointer to your Rust data when registering the callback, and the C library echoes that pointer back when it invokes the callback.
This pattern requires careful lifetime management. The pointer you pass must remain valid for as long as the C library holds it. If the Rust data drops while the C library still has the pointer, you get a dangling pointer and undefined behavior.
use std::ffi::c_void;
// The C signature: callback(int, void*)
// The void* is the userdata pointer.
type CCallback = unsafe extern "C" fn(value: i32, data: *mut c_void);
/// State that the callback needs to access.
struct AppState {
count: i32,
}
/// Callback implementation that C will invoke.
///
/// # Safety
/// The caller must ensure `data` points to a valid `AppState`
/// and that the `AppState` outlives the callback invocation.
unsafe extern "C" fn on_event(value: i32, raw_data: *mut c_void) {
// Cast the opaque pointer back to our Rust type.
// This is safe only if the caller passed a valid pointer.
let state = &mut *(raw_data as *mut AppState);
state.count += value;
}
// Declare the foreign function.
extern "C" {
fn setup_events(cb: CCallback, data: *mut c_void);
}
/// Registers the callback and passes state.
///
/// # Safety
/// The caller must ensure `state` remains alive for the entire
/// duration that the C library might call the callback.
pub unsafe fn init_events(state: &mut AppState) {
// SAFETY: setup_events stores the pointer and calls the callback
// while state is alive. We guarantee state doesn't drop.
setup_events(on_event, state as *mut _);
}
The c_void type is Rust's representation of C's void. It's an opaque pointer type that carries no type information. When the callback receives *mut c_void, you cast it back to *mut AppState using as. This cast tells the compiler to treat the bits as a pointer to AppState.
The cast is unchecked. If you cast the wrong type, you get memory corruption. The // SAFETY: comment documents the invariant: the pointer must be valid and point to the correct type. The init_events function is marked unsafe because it creates a raw pointer and passes it to FFI. The caller must ensure the AppState doesn't drop.
Convention aside: use c_void for opaque pointers. Don't use *mut () or *mut u8. The c_void type signals to readers that this is an FFI boundary and the type is intentionally erased.
Never let a userdata pointer outlive the data it points to. The compiler won't save you here.
Thread safety and panics
C libraries often spawn threads or call callbacks from multiple threads. If your callback accesses Rust data, you must consider thread safety. Rust's type system enforces Send and Sync traits, but FFI bypasses these checks. If the C library calls your callback from two threads simultaneously, and your callback mutates a Vec, you have a data race.
Use thread-safe types like Mutex, RwLock, or atomics inside the callback when the foreign code might invoke it from multiple threads. Alternatively, ensure the C library is single-threaded and document this requirement.
Panics are another risk. If your Rust code panics inside a C callback, the panic unwinds across the FFI boundary. This is undefined behavior in Rust and usually aborts the process. C code doesn't know how to handle Rust panics. You should catch panics inside the callback and convert them to error codes or safe defaults.
use std::panic::catch_unwind;
use std::ffi::c_void;
type CCallback = unsafe extern "C" fn(value: i32, data: *mut c_void) -> i32;
/// Safe wrapper around the callback logic.
/// Catches panics and returns an error code.
unsafe extern "C" fn safe_on_event(value: i32, raw_data: *mut c_void) -> i32 {
// Catch any panic that occurs during the callback.
let result = catch_unwind(|| {
// Cast and process data.
let state = &mut *(raw_data as *mut AppState);
state.count += value;
0 // Success code
});
// Return error code if panic occurred.
match result {
Ok(code) => code,
Err(_) => -1, // Error code indicating panic
}
}
The catch_unwind function captures panics and returns a Result. If the closure panics, you get Err, and you can return an error code to C. This prevents the process from aborting. The callback returns i32 to signal success or failure to the C library.
Convention aside: name your error codes clearly. Return 0 for success and negative values for errors, or follow the C library's convention. Document the return values so C callers know what to expect.
Catch panics before they crash the host.
Pitfalls and compiler errors
ABI mismatches are the most common source of crashes. If you define a callback as extern "Rust" and pass it to C, the calling convention differs. Arguments might be passed in different registers, or the stack cleanup might be wrong. The compiler won't catch this if you cast the function pointer. You'll get a segfault or corrupted state at runtime.
Always use extern "C" for functions that cross the FFI boundary. If you need to call a Rust function from C, mark it extern "C". If you're implementing a callback for C, mark it extern "C".
Dangling pointers are another trap. If you pass a userdata pointer to C, and the Rust data drops, the C library holds a pointer to freed memory. Accessing that memory is undefined behavior. The compiler won't warn you. You must manage the lifetime manually.
The E0308 error appears if you try to pass a function with the wrong signature. For example, passing a function that returns () when the type expects i32. The compiler catches this at compile time, which is good.
type Callback = unsafe extern "C" fn(i32) -> i32;
// This function returns i64, not i32.
unsafe extern "C" fn bad_callback(x: i32) -> i64 {
x as i64
}
pub fn register() {
// Error: mismatched types
// expected fn pointer `unsafe extern "C" fn(i32) -> i32`,
// found fn pointer `unsafe extern "C" fn(i32) -> i64`
let _cb: Callback = bad_callback;
}
The compiler rejects this with E0308 (mismatched types). This check saves you from subtle ABI issues. Always verify the return type and argument types match exactly.
ABI mismatches don't produce compiler errors. They produce segfaults. Trust the extern "C" annotation.
Decision matrix
Use a plain extern "C" fn when the callback is stateless and just transforms arguments. Use a userdata pointer when the callback needs to read or modify Rust data, passing a pointer to your struct as the opaque context. Use a wrapper struct that owns the callback registration when you need to guarantee the Rust data stays alive for the entire duration the C library holds the pointer. Use thread-safe types like Mutex or atomics inside the callback when the foreign code might invoke it from multiple threads simultaneously. Use std::panic::catch_unwind when the callback contains logic that might panic and you need to prevent process abort. Isolate the unsafe block to the exact point where the function pointer crosses the boundary; keep the callback logic safe if possible.
Wrap the chaos. Expose a safe API to your Rust users, and hide the function pointers inside.