Skip to content

The Anatomy of a Perfect Stack Trace: Making Hand-Written Assembly Production-Ready

Writing raw assembly is a rite of passage for systems engineers. You spend hours meticulously crafting the perfect SIMD-vectorized loop, you assemble it, you link it, and you run it. It immediately segfaults.

You do what any sane engineer would do: you load the binary into GDB and ask for a backtrace. Instead of a helpful trace showing the path of execution, you are greeted with this insult:

gdb-output.txt plaintext
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401015 in optimized_math_routine ()
(gdb) bt
#0  0x0000000000401015 in optimized_math_routine ()
#1  0x0000000000000000 in ?? ()

?? () is the debugger’s way of saying it has absolutely no idea how you arrived at this instruction, what the stack frame looks like, or where to return.

When you compile C code with gcc -g, the compiler magically avoids this. Even heavily optimized -O3 code with omitted frame pointers (-fomit-frame-pointer) can be perfectly unwound by gdb, lldb, and perf.

This isn’t magic. It is the result of Call Frame Information (CFI) and the DWARF debugging standard. If you want your hand-written assembly to be production-ready, you must write the metadata yourself.


The Illusion of the Frame Pointer

Historically, stack unwinding was trivial. The x86 architecture heavily relied on the base pointer (%rbp or %ebp). The standard function prologue looked like this:

prologue.s nasm
push rbp        ; Save the caller's base pointer
mov rbp, rsp    ; Establish the new frame

Because every function saved the previous frame pointer on the stack, the stack formed a perfect linked list of frames. A debugger could simply walk this linked list to reconstruct the entire call stack.

But modern compilers (and modern ABIs like AMD64 System V) aggressively optimize this away. The %rbp register is highly valuable as a general-purpose register. By omitting the frame pointer, compilers gain an extra register and save two memory operations per function call.

Without %rbp acting as a linked list, how does a debugger know where the previous stack frame begins? It needs a map.

Enter Call Frame Information (CFI)

When a modern compiler generates assembly, it silently injects a parallel stream of metadata called Call Frame Information (CFI). This metadata describes exactly how the stack frame changes at every single instruction.

During linking, this metadata is compiled into the .eh_frame section of the ELF binary (or .debug_frame for pure debugging). Originally designed for C++ exception handling (hence .eh_frame), CFI proved so robust that debuggers adopted it for stack unwinding.

To make our assembly production-ready, we must write these CFI directives alongside our instructions.

Instrumenting Assembly with .cfi Directives

Let’s take a raw assembly function that intentionally crashes and instrument it. We are using GNU Assembler (GAS) syntax.

1. Declaring the Procedure

First, we must tell the assembler where our function begins and ends. We use .cfi_startproc and .cfi_endproc.

function_declaration.s nasm
.global optimized_math_routine
.type optimized_math_routine, @function

optimized_math_routine:
  .cfi_startproc

  ; ... our code ...

  .cfi_endproc

When the assembler sees .cfi_startproc, it initializes a CFI state machine for this function. It records the starting address and assumes the Canonical Frame Address (CFA) is exactly where it expects to be upon function entry.

2. Defining the Canonical Frame Address (CFA)

The Canonical Frame Address (CFA) is the holy grail of stack unwinding. It is defined as the value of the stack pointer (%rsp) in the caller’s frame, just before the call instruction was executed.

If the debugger knows the CFA, it knows exactly where the return address is stored, and it can perfectly unwind one level up the stack.

Upon function entry, the caller has just pushed the 8-byte return address onto the stack. Therefore, the CFA is currently %rsp + 8. We declare this immediately:

define_cfa.s nasm
optimized_math_routine:
  .cfi_startproc
  .cfi_def_cfa rsp, 8

3. Tracking Stack Adjustments

Now, suppose our function allocates 32 bytes of local stack space. The moment we subtract from %rsp, the CFA is no longer %rsp + 8. It is now %rsp + 40 (8 bytes for the return address + 32 bytes of local space).

We must explicitly tell the debugger that the CFA offset has changed:

stack_adjustment.s nasm
    sub rsp, 32
  .cfi_def_cfa_offset 40

If we push a register to preserve it, we change the stack pointer again:

push_register.s nasm
    push rbx
  .cfi_def_cfa_offset 48

4. Tracking Preserved Registers

If we push %rbx to the stack, we are saving the caller’s value of %rbx. If the debugger is unwinding the stack, it needs to restore %rbx to the exact state it was in before our function executed.

We tell the debugger where we saved it using .cfi_offset. The offset is relative to the CFA. Since the CFA is %rsp + 48 (before our local variables and return address), and %rbx is currently sitting at %rsp, %rbx is located at CFA - 48.

cfi_offset.s nasm
    push rbx
  .cfi_def_cfa_offset 48
  .cfi_offset rbx, -48

Now, if a segfault happens deeper in this function, GDB will read the .eh_frame section, calculate the CFA, pull the original %rbx out of CFA - 48, and successfully recreate the caller’s state.

The Final Production-Ready Skeleton

Here is our complete, debuggable assembly function:

optimized_math.s nasm
.text
.global optimized_math_routine
.type optimized_math_routine, @function

optimized_math_routine:
  .cfi_startproc
  .cfi_def_cfa rsp, 8       ; CFA is %rsp + 8 (caller's stack)

  push rbx
  .cfi_def_cfa_offset 16    ; CFA is now %rsp + 16
  .cfi_offset rbx, -16      ; The caller's %rbx is saved at CFA - 16

  sub rsp, 32
  .cfi_def_cfa_offset 48    ; CFA is now %rsp + 48

  ; ... complex logic ...
  
  ; INTENTIONAL CRASH
  mov rax, 0
  mov [rax], rcx            ; Segfault here

  ; ... teardown ...
  add rsp, 32
  .cfi_def_cfa_offset 16    ; Restore CFA offset
  
  pop rbx
  .cfi_def_cfa_offset 8     ; Restore CFA offset
  .cfi_restore rbx          ; Tell debugger %rbx is back in the register

  ret
  .cfi_endproc

The Result

Assemble and link this code into a C program, compile it, and run it. When the inevitable segfault occurs, load it into GDB:

gdb-perfect-backtrace.txt plaintext
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401128 in optimized_math_routine ()
(gdb) bt
#0  0x0000000000401128 in optimized_math_routine ()
#1  0x0000000000401165 in main () at main.c:12
#2  0x00007ffff7a03b97 in __libc_start_main () from /lib/x86_64-linux-gnu/libc.so.6
#3  0x000000000040106a in _start ()

A perfect, seamless stack trace. The debugger successfully traversed from your raw machine code back into C, through libc, all the way to _start.

Furthermore, profiling tools like perf will now be able to attribute CPU cycles to this function correctly when generating flame graphs, since they rely on the exact same .eh_frame unwinding logic.

Writing assembly without CFI directives is like shipping an API without documentation. It works when you write it, but the moment it breaks in production, you are flying completely blind.

Coder Musings

A modern technical laboratory for systems programming. Master Assembly, Compilers, and Low-Level Engineering through curated paths and interactive visualizations.

© 2026 Coder Musings. All rights reserved. Built for the systems community.