Embedded Systems · Embedded Tooling

Stack Unwinding on a Target With No Frame Pointer

How a debugger reconstructs call frames from compiler-generated unwind tables, and why an interrupt frame on a bare-metal target breaks the unwinder entirely.

A backtrace requires walking a chain of saved return addresses, and the chain’s shape is not recorded anywhere at run time. On a hosted system the frame pointer provides it, or the unwinder reads tables the compiler emitted. On a target, the frame pointer is frequently omitted to free a register, the tables may be present or may not, and the one frame that matters most — the one interrupted by an exception — was not created by a function call at all.

Why the chain is not self-describing

A function’s prologue allocates a frame by subtracting from the stack pointer and saves whatever callee-saved registers it intends to use. The saved return address is somewhere in that frame, but its offset depends on how much was pushed and in what order, which varies per function and per optimisation level. A single fixed offset that works for one function will read a saved register as a return address in another, and the backtrace will terminate at an address that belongs to no function.

The frame pointer solves this by dedicating a register to hold the previous frame pointer, forming a linked list. It costs a register out of a small file — on Cortex-M, one of thirteen general-purpose registers usable for data — and it costs a few instructions per call. Compilers therefore omit it, and debuggers must recover the information another way.

Two mechanisms exist. The first is DWARF call frame information in .debug_frame or .eh_frame, a table of rules per address range stating where each register’s caller value is stored. The second is the architecture’s exception model, which saves a defined subset of registers at a defined offset from the stack pointer. Both require the debugger to know which one applies, and neither covers a stack that has been switched by an RTOS.

Call frame information as a bytecode

.debug_frame holds a small number of Common Information Entries, each shared by the Frame Description Entries that use the same initial rules, and one FDE per address range covered. The CIE declares the initial rule for each register: how the canonical frame address is computed, which column holds the return address, and which callee-saved registers are still in their own registers rather than saved. On 32-bit Arm the canonical frame address is normally the stack pointer value in the caller before the call, and the return address column is the link register, whose rule the FDE’s prologue opcodes change once the function pushes it. The FDE then supplies a program of opcodes that adjust those rules as the function’s prologue executes.

Every rule is expressed as an offset from the canonical frame address, and advancing the location within the FDE applies the opcodes up to that point. A debugger querying an address inside a prologue therefore gets the rules as they stand mid-prologue, which is why unwinding from an instruction that is part-way through saving registers still works.

The opcodes are a compact stack machine: DW_CFA_def_cfa sets the frame address register and offset, DW_CFA_offset records that a register was saved at a given offset, DW_CFA_advance_loc moves the location. Because offsets are expressed in multiples of the data alignment, a 4-byte-aligned target encodes a 16-byte offset as the value 4, and a reader that forgets the multiplier reads every saved register at a quarter of its true address.

Compact unwind information on Arm is often emitted in the exception-handling form, .ARM.exidx and .ARM.extab, which is a two-level table designed for the unwinder in a C++ runtime. The entries are sorted by address and use a prel31 encoding: a 31-bit signed offset from the entry’s own address. A reader that treats those as absolute addresses produces garbage. The same tables serve two purposes, which is why a C-only build with exceptions disabled may still contain them if the toolchain enables unwind table generation by default.

The exception frame that has no caller

An exception on Cortex-M pushes eight words onto the stack in a fixed order: r0, r1, r2, r3, r12, the link register, the program counter, and the program status register. The stack used is the one active at the time — the main stack or the process stack — selected by a bit in the control register, and the choice is recorded in the exception’s link register value rather than in the frame.

This frame is not described by any CIE, because it was created by hardware. An unwinder that reaches an exception handler and applies the handler’s FDE finds a return address pointing into the interrupted code, which is correct, but it then needs to know that the frame’s size is 32 bytes and that the saved registers are at fixed offsets. Debuggers handle this with a special rule keyed on the exception return values, and the failure mode when they do not is a backtrace that stops at the handler or produces a frame for a function that was never called.

Nested exceptions make this harder. A higher-priority exception preempting a lower-priority handler pushes a second frame, and the two frames are contiguous but not distinguishable by their contents. The unwinder has to walk them using the exception-return markers rather than the call frame information, and it must decide when the stack pointer has crossed into the handler’s own locals.

Fault handlers add a case where the frame is misleading rather than missing. A fault caused by a stacked bus error pushes a frame whose program counter points at the instruction that faulted, which may be an instruction that never completed. A backtrace from that frame is accurate about where execution was and wrong about what it had done.

Threads, and where the unwinder guesses

An RTOS gives each task its own stack, so the chain from the current task terminates at the task’s entry function rather than at main. The debugger has no way to know that a stack pointer value belongs to a particular task unless it is told, which is what the RTOS-awareness layer does: it reads the kernel’s task list from the target’s memory using the kernel’s own structures and reports each task’s saved stack pointer.

Two problems arise from reading those structures live. The first is that the list is being modified while it is read, so a task can appear twice or not at all depending on where the traversal lands relative to a context switch. The second is that a task that has never run has a stack pointer pointing at the top of an untouched stack, and unwinding from it produces a frame for a function that has not been called. Reporting such a task as “not started” rather than as a corrupt frame is the difference between a usable task list and a confusing one.

The other half of the problem is the interrupt stack. On a target with a separate main stack, the handler runs there and the interrupted task’s stack holds no trace of it. Unwinding from a handler into the task requires following the exception frame’s saved stack pointer, which the debugger can read from the frame itself. On targets with a single stack, the handler’s frame and the task’s frames are contiguous and the boundary is found only through the exception markers.

Reading a stack without unwinding

When neither the tables nor the kernel structures are available, the fallback is to scan the stack region for values that look like code addresses and report them as a probable call chain. This works often enough to be useful and is wrong in a specific way: a saved pointer to a function, a constant that happens to fall inside the text range, or a stale return address from a previous deeper call all appear as frames.

The reliability of the technique depends on the text range being narrow and the stack being mostly non-pointer data. A scan that filters candidates to addresses that are the target of a call instruction improves precision considerably, at the cost of reading the code at each candidate. For a crash where the stack has been overwritten, the scan is sometimes the only option, and the resulting list should be read as evidence rather than as a fact.

The most common practical failure is not any of these. It is a stack that overflowed into another region: the unwinder reads a frame whose saved values were overwritten by unrelated data, follows a corrupted return address, and either stops or reports a plausible chain of functions that were never called. Recognising that pattern is why the stack’s bounds matter, and why the region sizes declared in the linker script are a more reliable defence than any unwinding technique. The kernel’s own view of the same structures is what makes task lists readable at all, and the comparator budget consumed by a session’s breakpoints is covered in hardware breakpoints and watchpoints.

References

  1. DWARF Debugging Information Format, Version 5 — DWARF Standard Committee
  2. ARMv7-M Architecture Reference Manual — Arm Ltd, DDI 0403
  3. Procedure Call Standard for the Arm Architecture — ARM ABI, Arm Ltd