Language Runtimes · FFI and Interop

Unwinding Across an FFI Boundary

Why an exception cannot safely propagate through a C frame, how table-based unwinding locates a handler, and what a boundary must do instead of throwing through it.

Raising an exception is a control transfer that crosses function boundaries without returning from them. On the way it must run cleanup code for every frame it abandons, restore the stack and registers of each, and stop at the first frame that can handle the exception. Each of those steps requires information about the frame: where its saved registers are, whether it has cleanups, whether it has a handler. That information lives in metadata the compiler emitted.

A C function compiled without exception support has no such metadata, or has metadata that describes its frame but declares no handlers. Unwinding through it is therefore not merely unsupported by convention; it is a violation of the assumption that the unwinder makes about what it will find. In practice the result ranges from a clean abort to a corrupted stack to a silently skipped destructor, depending on which metadata the frame happens to have.

How table-based unwinding finds a handler

The dominant scheme on ELF platforms is table-based: the compiler emits a call-frame information table, .eh_frame on most systems, describing for each function how to compute the caller’s stack pointer and restore the callee-saved registers at any instruction address within the function. The unwinder walks this table one frame at a time, using the return address in the current frame to find the corresponding entry.

The same unwind information, in the Itanium ABI’s scheme, carries an augmentation that names a personality routine and points at a language-specific exception table. The personality routine is a function supplied by the language runtime; the unwinder calls it once per frame with the frame’s context and the exception object, and the routine answers with one of a small set of decisions: continue searching, handle here, or install a cleanup and continue. For C++ this routine is __gxx_personality_v0, and it consults the exception table to determine whether this frame has a matching catch clause or a destructor to run.

The critical detail is that the personality routine is registered per function in the frame’s unwind information. A frame whose metadata names no personality routine is not offered the chance to handle anything. The unwinder does not know what language the frame was written in; it knows only what the metadata says. A C function compiled with default settings may have a personality routine if the compiler emitted one for cleanup purposes, or none at all, and the difference is a compiler flag rather than a language property.

Windows x64 uses a different structure. Unwinding is still table-driven, but the table lives in the PE image’s exception directory rather than in .eh_frame, and the handler that inspects a frame is named in that frame’s own unwind data rather than discovered by a generic unwinder from exception-table entries. What the two schemes share is the requirement: unwinding through a frame needs the frame to be described, and a function compiled without unwind data cannot be traversed, so the system terminates the process rather than guessing.

The four outcomes of throwing through C

The first outcome is that the C frame has unwind metadata and a personality routine that reports “continue”. The unwinder restores the frame’s registers, moves to the caller, and the exception arrives in the next frame up. Nothing in the C function ran except whatever the unwinder did on its behalf; local variables that needed cleanup, such as a mutex or an open file, were not cleaned up, because C has no mechanism to request it. The exception propagates and the C code’s invariants are broken silently. This is the outcome that produces the “it worked in testing” reports: the unwind succeeds mechanically and the resource leak is invisible.

The second outcome is that the C frame has no metadata at all. The unwinder cannot compute the caller’s stack pointer and cannot find the return address. Depending on the implementation it either stops and calls the terminate handler, or falls back to a frame-pointer chain if one exists, or aborts. On platforms where the C compiler omits frame pointers by default, the fallback does not exist.

The third is that the unwinder finds the metadata but the personality routine encounters a frame state it cannot describe, such as one built by hand-written assembly. The result is typically an abort with a message about a failed unwind.

The fourth is the worst: the exception is caught by something that should not have caught it. A C++ frame compiled with exceptions enabled but with no matching handler will be traversed correctly and will run its destructors, which is fine. But a foreign runtime’s catch-all installed for its own purposes may intercept an exception whose type it does not understand, and either swallow it or convert it into a foreign exception object. The original error is then lost.

Why the C side cannot be asked to cooperate

The temptation is to say that C code called from a language with exceptions should simply be compiled with exception support. That addresses only part of the problem.

Compiling a C file with a C++ compiler gives its frames unwind metadata and personality routines, but it does not give the C code any way to run cleanup on unwind. C has no destructors. If the function holds a lock, the lock is not released when the frame is abandoned, and the next attempt to acquire it deadlocks. If it holds a heap allocation, the allocation leaks. Adding cleanup would mean rewriting the function with explicit try/finally constructs, which is no longer C.

There is a second, sharper reason. The C function’s contract with its caller typically says nothing about exceptions, and the caller may not be in a position to handle one. A library function that was documented to return an error code and never to fail in any other way now has a third exit path. Code that calls it and does not expect unwinding may hold a resource across the call, and the unwind bypasses the release.

There is also a portability reason. The mechanism described above is the Itanium scheme, adopted by GCC and Clang. The Microsoft scheme differs in its tables and in the set of states it can describe. Code that relies on unwinding through C frames behaves differently on the two, and the difference is not detectable at compile time.

What a boundary must do instead

The rule that follows is that a boundary between an exception-using language and a C interface must not allow an exception to cross. The boundary catches everything and converts.

In the outbound direction, managed code calling C, the wrapper around the call must be prepared for the C function to fail in whatever way it fails, and must convert that failure into the calling language’s error type. If the C function cannot fail by unwinding, the wrapper has nothing to catch, but it still must not let its own exceptions escape into the C frame. A managed exception raised after the C call returns, while the wrapper still holds a C-allocated resource, must be handled by the wrapper before it returns.

In the inbound direction, C calling managed code through a callback, the trampoline must catch every exception the managed code can raise, convert it into the C API’s error channel, and return normally. If the C API has no error channel, the failure must be recorded somewhere observable, because the alternative is either an abort or a corrupted library state. The trampoline design is described in native callbacks into managed code.

/* Compiled as C++ so the frame has unwind tables, but exported with C linkage. */
extern "C" int run_task(task_fn fn, void *ctx) {
    try {
        return fn(ctx);      /* fn may point at code that throws */
    } catch (...) {
        return -1;           /* the only failure channel: the return value */
    }
}

The shape matters more than the names. The try block establishes a handler at the boundary, the call happens inside it, and every exit path leaves the frame in a consistent state. Compiling the wrapper as C++ is what makes the handler expressible; the extern "C" keeps its symbol name plain so the linker can find it, which is the separation described in symbol mangling and linkage.

Destructors, finalizers, and the frames in between

The interaction with finalization is a distinct hazard. A runtime that runs finalizers on a separate thread may execute a finalizer that calls into native code while an exception is propagating elsewhere. The finalizer’s frame is not part of the unwinding chain, so it cannot observe the exception, and if the native call it makes fails, the failure has nowhere to go.

A related case is the C++ destructor invoked during unwinding. If a destructor throws while an exception is already in flight, the standard’s response is to terminate the process, because two simultaneous exceptions cannot both propagate. A binding that lets a managed exception escape from a callback invoked during C++ stack unwinding is in the same position: the C++ runtime is mid-unwind, and a second exception arriving at that point is fatal by design.

The cost of the guard, and when it is free

Establishing a handler is not free on all platforms. Table-based unwinding makes the normal path nearly free: the tables are read-only data, and no instructions execute to “set up” a handler at run time, but the handler must still be present in the function’s metadata, which means the boundary function must be compiled with exception support and must be the frame that owns the landing pad.

Platforms with setjmp-style exception handling, and older 32-bit schemes, push a registration record onto a linked list on entry and pop it on exit. Those cost a few instructions per call and a store to thread-local state. The difference is one reason the table-based scheme replaced them.

The more significant cost is at the boundary itself: the wrapper adds a frame, and every call through it pays for the extra call and for whatever conversion the wrapper performs. For a function called in a tight loop, such as a hash update or a character classification, that overhead is real, and the interfaces that matter tend to provide a batch entry point so the conversion happens once per batch rather than once per element.

The last limitation is the one that cannot be engineered away. An exception crossing a C frame is a transfer of control that the C code did not agree to, and no amount of metadata makes the C code correct in the presence of it. The metadata makes the unwind mechanically possible; it does not make it semantically safe. The interfaces that survive contact with many languages are those whose C entry points never raise and never let a raise escape, which is a design constraint on the interface rather than on the runtime. Designing a stable C ABI states it as a rule: every exported function has exactly one failure channel, and that channel is a return value.

References

  1. Itanium C++ ABI: Exception Handling — Itanium C++ ABI Committee
  2. DWARF Debugging Information Format, Version 5 — DWARF Debugging Information Format Committee
  3. System V Application Binary Interface, AMD64 Architecture Processor Supplement — AMD64 psABI maintainers