Skip to content
WindowsDeep Dive Published Updated 8 min readViews unavailable

Windows x64 JIT Unwind Tables: Dynamic Function Registration and Safe Retirement

Register Windows x64 unwind metadata for generated code with RtlAddFunctionTable, and retire JIT ranges without breaking stack walks.

Windows x64 stack walking depends on machine-readable unwind metadata. A compiler normally places that metadata in a PE image, but a just-in-time compiler emits code after the image has loaded. The runtime must therefore make function-table entries discoverable while generated functions can be executed, inspected, or unwound. Without a matching table, an exception dispatcher or profiler may not be able to reconstruct caller state reliably.

This guide covers the conventional, non-APX x64 runtime-function-table contract and the lifecycle of generated code. Microsoft documents a separate unwind-information V3 format for Intel APX; this article does not cover it. It also does not teach how to encode arbitrary UNWIND_INFO, implement a JIT compiler, or register C++ language-specific exception handlers. Its scope is Microsoft x64 on 64-bit Windows, using the Windows SDK 10.0.28000.2957 published in September 2026. Microsoft documents different unwind encodings for ARM64, so do not copy the structures or offsets below into an ARM64 emitter. The growable-table APIs discussed here require Windows 8 or Windows Server 2012 for desktop applications; validate all API availability against the deployment baseline.

Why generated code needs a function table

On x64, the operating system needs unwind descriptions to recover nonvolatile registers and the caller’s stack state for non-leaf functions. This matters not only when an exception is raised. Debuggers and stack-backtrace collectors also need to interpret frames through generated code. A leaf routine that neither changes the stack nor saves nonvolatile registers may be unwound without a table entry, but a generated routine that allocates stack space, calls another function, or saves nonvolatile state needs correct metadata for the operations its prologue performs.

Registering a table does not make code executable, validate its instructions, or make an arbitrary C++ exception handler compatible with the language runtime. It tells Windows where the runtime-function records are. The records and their unwind data must already describe the generated machine code correctly. If a JIT wants language-level catch or cleanup semantics, it must also generate the handler data and follow the relevant runtime’s contract; a table entry by itself is not that implementation.

Make the offsets and storage lifetime explicit

For the x64 ABI, a RUNTIME_FUNCTION record contains BeginAddress, EndAddress, and UnwindData. These fields are 32-bit relative addresses interpreted using the base passed to RtlAddFunctionTable; the code and referenced unwind records must therefore be addressable by valid 32-bit relative offsets from that same base and remain valid while registered. Microsoft’s x64 format requires the RUNTIME_FUNCTION record and UNWIND_INFO structures to be DWORD-aligned; the unwind-code array is padded to an even number of slots so trailing data remains aligned. PE .pdata entries must be sorted by function address (BeginAddress) before the final image is emitted. The RtlAddGrowableFunctionTable documentation explicitly requires ascending BeginAddress order. The RtlAddFunctionTable page does not state a sorting precondition, so keeping its entries sorted is a conservative design choice, not a documented failure condition for that API. A practical JIT design keeps the code range, function table, and unwind records in a generation object whose ownership is explicit, rather than scattering these allocations across short-lived compiler buffers.

Before registration, validate that each entry’s start and end describe the intended function and that every base-relative field resolves to valid, correctly representable memory. Function start and end addresses describe code in the managed range; an UnwindData/unwind-info address may refer to separately allocated metadata and need not lie inside that code range. Keep fixed-table entries sorted as a conservative lookup-friendly policy; for a growable table, ascending order is an explicit API requirement. The unwind codes must match the actual prologue, including partially executed prologue states. A table that describes an earlier version of the bytes is unsafe even if its addresses still look plausible. Treat code bytes and unwind metadata as one immutable published generation.

Register fixed generated code

RtlAddFunctionTable is intended for generated code whose table is known at publication time, such as a template or code generated once for the process lifetime. It returns TRUE on success and FALSE on failure. The table storage and its referenced unwind data must not be freed or repurposed while the table remains registered.

The following is a lifecycle skeleton, not a code emitter. functions must refer to complete, correctly encoded x64 metadata produced for the bytes in code; this function cannot validate that encoding.

#include <windows.h>

struct JitImage {
    void* code; // Base used to interpret the table's 32-bit relative addresses.
    SIZE_T codeBytes;
    PRUNTIME_FUNCTION functions;
    DWORD functionCount;
    bool functionTableRegistered;
};

bool publish_unwind_table(JitImage& image) noexcept {
    if (image.code == nullptr || image.codeBytes == 0 ||
        image.functions == nullptr || image.functionCount == 0) {
        return false;
    }

    // The caller must serialize all publication/retirement operations and
    // accesses to functionTableRegistered. Call after writing the generated
    // instructions and before exposing an entry point to another thread.
    if (!FlushInstructionCache(GetCurrentProcess(), image.code,
                               image.codeBytes)) {
        return false;
    }

    const auto base = static_cast<DWORD64>(
        reinterpret_cast<ULONG_PTR>(image.code));
    if (!RtlAddFunctionTable(image.functions, image.functionCount, base)) {
        return false;
    }

    image.functionTableRegistered = true;
    return true;
}

// Call only after the JIT has blocked new entries and drained active frames,
// stack walkers, and other readers of this generation.
bool remove_unwind_table(JitImage& image) noexcept {
    if (!image.functionTableRegistered) {
        return true;
    }
    if (!RtlDeleteFunctionTable(image.functions)) {
        return false;
    }
    image.functionTableRegistered = false;
    return true;
}

The example assumes the caller serializes publication and retirement, owns all pointed-to storage, and checks that the base-relative records are correct. The caller must also complete the reader-drain protocol above before calling remove_unwind_table; the function only unregisters the table and does not quiesce execution. FlushInstructionCache is included because Microsoft tells applications to call it after generating or modifying code in memory; it addresses instruction-cache coherency, not unwind-table correctness. Use a separate executable-memory policy for the code pages and do not treat a successful registration as a security validation of emitted instructions. The Boolean result is the documented API failure signal; do not infer an error code from an undocumented GetLastError contract.

Select a table strategy that matches code growth

For a region where functions arrive over time, Microsoft provides RtlAddGrowableFunctionTable. It receives the current entry count, maximum capacity, and the managed address range. The array must stay sorted by ascending BeginAddress. After filling new entries, call RtlGrowFunctionTable with a count greater than the previously reported count. The API documentation specifically says to populate the corresponding records before reporting the larger count. Keep the array at the same address and retain its unwind data for the entire registration lifetime.

The growable-table handle is opaque. On teardown, RtlDeleteGrowableFunctionTable takes that handle and has no return value. RtlDeleteFunctionTable is different: for a fixed table, pass the original function-table pointer; for a callback table installed with RtlInstallFunctionTableCallback, pass its table identifier. It reports success as a Boolean in either case. For code whose metadata is generated on demand for highly dynamic ranges, RtlInstallFunctionTableCallback is another option. Its callback supplies an entry for a control address, and Microsoft warns that the callback must not deadlock with the code generator. Prefer the simpler fixed table unless lazy lookup solves a measured design problem.

Retire a generation only after its readers are gone

Unregistration and memory reclamation are separate steps. A safe JIT retirement protocol first prevents new calls and new readers, then waits until no thread can execute or retain a frame or instruction pointer into the generation and any in-flight stack walkers have stopped inspecting it. Include parked or suspended threads in that accounting. Only then should it remove the function table and release or reuse the code and unwind buffers. RtlDeleteFunctionTable removes the table from Windows’ dynamic-function-table list, but Microsoft does not document it as stopping execution or providing a reader-drain barrier; synchronize those conditions in the JIT’s own lifecycle. Reusing memory too early can combine a stale instruction pointer with missing or unrelated metadata.

Keep generation identifiers in diagnostics: record the code-range base and size, function count, table strategy, registration result, and retirement result. If a crash dump or profiler trace stops at a JIT frame, correlate the instruction pointer with the generation that was active at that time. Preserve enough metadata or a symbolization record to interpret retained dumps after the in-memory table has been removed.

Test the lifecycle, not just the happy-path call

In a disposable x64 test process, exercise a generated non-leaf function with known stack allocation and nonvolatile-register saves. Capture a debugger or profiler backtrace while the function is active, then test OS unwinding through the generated frame with a controlled exception caught in caller code. Test a catch, finally, or destructor inside generated code only if the JIT implements the corresponding language-handler metadata and runtime contract. Verify that growable-table entries remain sorted; do not treat unsorted RtlAddFunctionTable input as a documented rejection test. Test the JIT’s own range and metadata validators, exercise documented API failure handling without assuming registration validates unwind semantics, and verify that retirement waits for active frames and readers to drain. Verify the exact Windows build, SDK, architecture, and JIT version with each result.

Do not use a successful RtlAddFunctionTable return as proof that unwind codes are semantically correct: Microsoft documents the registration interface, while the code generator is responsible for emitting valid metadata. Compare emitted records against the Microsoft x64 unwind format and test at points inside the prologue and epilogue, where incomplete stack transitions are easy to miss. Keep the generated code, metadata, and test trace together for reproducible debugging.

Related:

Sources:

Comments