Language Runtimes · FFI and Interop

Native Callbacks Into Managed Code

How a C library invokes a function pointer that lands in a garbage-collected runtime, why the trampoline cannot simply be the managed function, and what keeps the target alive.

A C library that accepts a callback declares a parameter as a pointer to a function with C linkage. The library stores the pointer, and at some later point, from code the application does not control, it loads the address and executes a call. Everything about that moment is decided by the library: which thread, whether a lock is held, whether the callback may re-enter the library, whether it may block, and what happens if it does.

A managed runtime cannot supply such a pointer directly. Its functions are not laid out as native code with a C prologue, its calling convention may differ, its objects move during collection, and the address of a managed method is not stable across compilation tiers. What the runtime supplies instead is a small piece of native code whose entire job is to translate the call into something the runtime understands.

Why the function pointer cannot point at managed code

Three properties of a managed method make its address unusable as a C function pointer.

The first is representation. A managed method compiled ahead of time may still expect a runtime-specific frame layout, a register holding the runtime’s context, or a thread-local pointer to a managed state block. A C caller sets up none of these. On architectures where the runtime reserves a register for its own use, a common design on 64-bit Arm and on some x86-64 configurations, the C caller’s value in that register is whatever the library happened to leave there, and the managed code’s first access through it faults.

The second is stability. A runtime with a tiered compiler may produce a new version of a method while the program runs, and the old version may be discarded. A runtime with a moving collector may relocate objects that the method closes over. A function pointer handed to a C library is expected to remain valid for as long as the library holds it, which can be the lifetime of the process.

The third is identity. A callback typically carries state: which object receives the event, which request it belongs to. C has no closure, so the state must travel separately, through a void * context argument that the library passes back on every invocation. A managed runtime’s delegate or closure already carries its state internally, and there is no place in the C signature to put it unless the API provides one.

The trampoline solves all three by being ordinary native code with a fixed address that knows how to reach the runtime. It may be generated at run time into executable memory, or it may be a hand-written assembly stub per signature, or it may be a compiler-generated thunk from a special declaration. In each case the contract is the same: the trampoline receives arguments in the platform’s C convention and forwards them to the runtime’s invocation mechanism.

The three things a trampoline must do

A trampoline must attach the calling thread to the runtime, recover the state associated with this particular callback, and enter the runtime with arguments converted to its representation.

Thread attachment is the part most often omitted. A C library may invoke the callback on a thread it created itself, either at initialization or with a worker pool. That thread has a native stack and nothing else; it has never executed runtime code and has no thread-local state the runtime requires. Entering the runtime from such a thread without registering it produces a crash at the first allocation, or a silent failure when the runtime looks up a thread-local that does not exist. Most runtimes provide an explicit attach and detach pair for exactly this purpose, and the attach must happen before any managed code runs on the thread.

The recovery of state is what the context pointer is for. Because C has no closures, the conventional signature includes a void *user argument that the library stores and passes back unchanged. The trampoline reads that pointer, converts it to a handle for the managed delegate, and invokes the delegate. The handle is typically an index into a table or an opaque token rather than a raw managed reference, because a raw reference stored in C memory is invisible to the collector.

That invisibility is the third requirement: reachability. If the only reference to a managed delegate lives in a C library’s data structure, the collector has no way to see it and may collect the delegate while the library still holds a pointer to its trampoline. The trampoline’s code survives because it is native code outside the managed heap, but the state it forwards to is gone. Runtimes address this with an explicit root: a handle table that the collector scans, or a registration call that pins the delegate for the duration of the registration. Releasing the callback therefore requires an explicit unregister, not merely dropping the reference.

#include <stdint.h>

typedef struct handler handler;   /* managed delegate, opaque on this side */

/* The library's view: a function pointer and an opaque context. */
typedef void (*event_cb)(void *user, const char *msg);
int  subscribe(event_cb cb, void *user);   /* 0 on success */
void unsubscribe(event_cb cb, void *user);

/* Binding-side entry points, named here to stand in for the runtime's own. */
int      binding_attach(void);
void     binding_invoke(uint64_t handle, const char *msg);
uint64_t handle_of(const handler *h);  /* roots the delegate, returns its token */

static void trampoline(void *user, const char *msg) {
    uint64_t handle = (uint64_t)(uintptr_t)user;  /* token, not a managed ref */
    binding_attach();                             /* idempotent in most runtimes */
    binding_invoke(handle, msg);                  /* looks up the rooted delegate */
}

int subscribe_managed(const handler *h) {         /* caller-side registration */
    return subscribe(trampoline, (void *)(uintptr_t)handle_of(h));
}

The trampoline is compiled as native code, so its address is stable and its convention is C. binding_invoke is the runtime’s own entry point; the binding is responsible for keeping the delegate reachable under handle until unsubscribe is called. Note that the context value is an integer converted to a pointer, not a pointer to managed memory, which keeps the collector’s view of the world consistent.

Signatures, conventions, and the mismatch that compiles

The trampoline must match the library’s declared function type exactly, including the calling convention. On 32-bit x86 Windows, where cdecl and stdcall differ in who adjusts the stack, a callback declared with the wrong convention leaves the stack unbalanced after every invocation. The first call may appear to work; the stack pointer drifts by four bytes per event until something faults. On 64-bit platforms the convention is usually uniform, but the variadic case remains: the vector-register count is part of a variadic call, so a trampoline that re-issues a variadic call from its own code must set that count itself, and getting it wrong is visible only when a floating-point argument is passed. C offers no portable way to build a variadic argument list at run time other than the va_list machinery, which is why most bindings restrict variadic callbacks to a fixed set of signatures.

Return values carry a similar trap. A C callback returning int that the library interprets as a status code must receive a value the library’s own code treats as valid; a managed delegate returning a boolean that the binding marshals as 1 or 0 may be correct for a predicate and wrong for a status where zero means success. The mapping is per-API and must be documented, because the two conventions coexist in real libraries.

Exceptions are the sharpest edge. If the managed callback throws, the exception must not propagate into the C library, which has no unwinding metadata for it and no way to handle it. The trampoline must catch everything, convert the failure into whatever the C API uses to signal an error, and return normally. If the API has no error channel, as when a callback returns void and the library ignores the result, the only correct action is to record the failure somewhere the application can observe it and return, because unwinding through the library would either abort the process or corrupt its state. The general treatment of unwinding is in unwinding across an FFI boundary.

Re-entrancy and the locks a library holds

A callback is invoked at a point the library chooses, and that point may be inside a lock. A library that notifies subscribers while holding its internal mutex will deadlock if the callback calls back into the same library, and it will deadlock differently depending on whether the mutex is recursive. Nothing in the C declaration says which functions are safe to call from a callback.

The standard defence is a documented rule about what the callback may do: typically, it may not call back into the library, and it may not block on anything the library’s thread might need. Bindings that must call back in therefore queue the work: the trampoline copies the arguments into managed memory and posts the invocation to a runtime thread, returning immediately. This decouples the library’s thread from the managed execution and removes the deadlock, at the cost of an allocation and a delay, and at the cost of losing the library’s guarantee about ordering if the queue is not FIFO.

Queueing also changes the lifetime question. The arguments the library passed may be valid only for the duration of the call, for instance a pointer into a buffer the library reuses. A trampoline that queues must copy them before returning, and the copy must be deep enough that nothing in it points back into library-owned memory. This is the callback analogue of the ownership problem in string encoding and buffer ownership.

Unregistering, and the race at teardown

Removing a callback is not the inverse of adding it. unsubscribe removes the registration, but an invocation may already be in flight on another thread, and the library may not synchronize the removal against in-progress calls. A binding that frees the handle immediately after unsubscribe returns can have the trampoline read a freed handle a moment later.

The correct pattern is a reference count or a deferred release: the handle is marked dead, and the memory is reclaimed only after the library confirms no further calls will arrive. Some libraries provide a drain or flush call for exactly this; others document that unsubscribe blocks until in-flight callbacks complete, which makes the immediate release safe. When the documentation says neither, the only safe assumption is that calls may still arrive, and the handle must outlive the process’s use of the library.

The same reasoning applies at shutdown. A library that is unloaded while it holds callback pointers, or a runtime that shuts down while a library’s thread is still running, produces a call into unmapped memory. Ordering the teardown (stop the library’s threads, then unregister, then shut down the runtime) is the caller’s responsibility, and it is not expressible in the type of any of the functions involved.

The design constraint is that callbacks are the part of an FFI where the boundary is genuinely bidirectional, and where the C side’s assumptions about lifetime, threading, and re-entrancy are invisible from the declaration. The interfaces that work across many languages are those that state these rules explicitly: which thread the callback runs on, whether it may block, whether it may re-enter, and when the registration stops being valid. Designing a stable C ABI treats those statements as part of the interface rather than as documentation, which is the difference between a callback that can be bound safely and one that must be reverse-engineered.

References

  1. System V Application Binary Interface, AMD64 Architecture Processor Supplement — AMD64 psABI maintainers
  2. ECMA-335: Common Language Infrastructure (CLI), Partition I — Ecma International
  3. JNI Specification — Invocation Interface — Oracle