Published on

Understanding Stack Traces in Rust

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Introduction

In systems programming, understanding what happens during a program’s execution is crucial for diagnosing errors and crashes. One of the most powerful tools for this is the stack trace—a representation of the call stack at a specific point in the program, typically when an error or panic occurs. Stack traces give us a snapshot of the active functions at a particular moment, helping to trace back the origin of an issue.

This article will dive deep into the mechanics of stack traces in Rust, where they are stored, how Rust's error handling works in the context of stack traces, and how you can generate and use them. We will also provide Rust source code examples that demonstrate how to work with stack traces at a low level.

What is a Stack Trace?

A stack trace is a report of the active function calls leading up to a specific point in the program. It’s essentially a snapshot of the call stack, which is a data structure that records information about function invocations, including:

Return addresses: Where the program should return after a function call. Local variables: Data specific to the function that’s currently being executed. Frame pointers: The base address of the current stack frame (used to navigate between stack frames). The stack grows and shrinks as functions are called and return. When an error occurs, a stack trace captures the current state of the stack and can be used to trace the sequence of function calls that led to the error.

The Call Stack in Rust

Rust, like most compiled languages, uses the call stack to manage function calls. Each function call pushes a stack frame onto the call stack. This frame contains data like:

The return address, so that once the function finishes, execution can continue from where it was called. The base pointer (if present), which helps manage local variables and navigate the stack. Any local variables specific to the function. When a function exits, its stack frame is popped off the stack, and execution continues at the return address stored in the frame.

Rust’s memory safety guarantees work in harmony with the call stack, ensuring that each variable's lifetime is properly managed. When a function completes, Rust’s ownership system ensures that all local variables are dropped correctly, and memory is cleaned up safely.

Where is the Stack Trace Located?

The stack trace is essentially built from the call stack at runtime. The call stack resides in the process’s memory space, specifically in the section designated as the stack segment. On most systems, the stack grows downward in memory (from higher to lower addresses). Each thread in a Rust program has its own stack, and the stack is typically managed by the operating system.

The stack trace itself is not stored in a specific location in memory. Instead, when a stack trace is generated (such as during a panic), it is dynamically constructed by traversing the call stack. The return addresses stored in each frame of the call stack are used to reconstruct the sequence of function calls.

Rust's Panic and Stack Unwinding

When an unrecoverable error occurs in Rust (e.g., a panic), Rust begins stack unwinding. This is the process of walking back up the stack, cleaning up each stack frame by calling the destructors (i.e., the Drop trait) for all local variables in each frame.

During stack unwinding, Rust can also capture a stack trace using the std::backtrace module. This is particularly useful for debugging, as it shows the sequence of function calls that led to the panic.

Here’s a simple example that demonstrates a panic and its stack trace:

fn deep_stack() {
    level_1();
}

fn level_1() {
    level_2();
}

fn level_2() {
    panic!("Something went wrong!");
}

fn main() {
    deep_stack();
}

When this program runs, it will output a stack trace similar to this:

thread 'main' panicked at 'Something went wrong!', src/main.rs:9:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/...
   1: core::panicking::panic_fmt
             at /rustc/...
   2: main::level_2
             at src/main.rs:9
   3: main::level_1
             at src/main.rs:5
   4: main::deep_stack
             at src/main.rs:1
   5: main::main
             at src/main.rs:14
   6: std::rt::lang_start::{{closure}}
             at /rustc/...

Capturing a Stack Trace Programmatically in Rust

Rust provides the std::backtrace module, introduced in Rust 1.65, which allows you to capture and manipulate backtraces programmatically. Here’s how you can generate a stack trace when an error occurs:

use std::backtrace::Backtrace;
use std::error::Error;

fn fallible_function() -> Result<(), Box<dyn Error>> {
    Err("An error occurred".into())
}

fn main() {
    match fallible_function() {
        Ok(_) => println!("All is well!"),
        Err(e) => {
            println!("Error: {}", e);
            let backtrace = Backtrace::capture();
            println!("Stack trace:\n{:?}", backtrace);
        }
    }
}

In this example, if fallible_function() returns an error, we capture the stack trace using Backtrace::capture() and print it.

How Does Rust Retrieve the Stack Trace?

When you invoke Backtrace::capture(), Rust uses platform-specific mechanisms to retrieve the addresses stored in the call stack. On most platforms, Rust will use the frame pointer (if available) or unwind metadata (such as DWARF information on Linux) to walk the stack and collect return addresses. These addresses are then translated into function names and line numbers using debug symbols.

If debug symbols are available (typically embedded in the binary during compilation with the -g flag), the backtrace can provide detailed information, such as function names, file names, and line numbers. Without debug symbols, the backtrace will only show memory addresses.

Stack Trace in Rust's panic!

When a Rust program panics, it can print a stack trace automatically if you set the RUST_BACKTRACE environment variable to 1 or full for a more detailed trace:

RUST_BACKTRACE=1 ./my_rust_program

This is particularly useful for diagnosing issues during development. However, in production environments, Rust’s panic behavior typically avoids printing detailed stack traces to avoid leaking information.

Advanced Usage: Controlling Unwinding Behavior

Rust provides two unwinding mechanisms:

  • Unwinding: This is the default behavior in panic scenarios where Rust cleans up resources and unwinds the stack.
  • Abort: In performance-critical or safety-critical environments (like in certain kernel modules), you may want to disable unwinding and instead abort the program immediately on a panic, leaving no opportunity for resource cleanup. You can control this behavior using the panic setting in Cargo.toml:
[profile.release]
panic = 'abort'

When panic = 'abort' is set, the program will immediately abort on a panic, and no stack unwinding or stack trace will be generated.

Low-Level Considerations

At a hardware level, the stack is a crucial part of memory management used by most modern CPUs and operating systems to track function calls, local variables, and control flow. In architectures such as x86-64, the stack is a contiguous region of memory that typically grows downward, meaning that each new function call or allocation pushes data at lower memory addresses.

The Stack Pointer (rsp)

The stack pointer register, known as rsp in the x86-64 architecture, is responsible for tracking the top of the stack. Every time a function is called, the address of the next instruction to execute (the return address) is pushed onto the stack, and the stack pointer is decremented to make space for any local variables or function parameters. When the function returns, the stack pointer is incremented to discard the current stack frame, and control returns to the previous instruction.

For instance, during a function call:

  1. The return address (where to resume after the function) is pushed onto the stack.
  2. The stack pointer (rsp) is decremented to allocate space for local variables.
  3. The function operates within its stack frame, utilizing this allocated space.

Upon returning:

  • The stack pointer is incremented back to its previous value, discarding the function’s local data, and the program counter is restored to the return address from the stack.

The Frame Pointer (rbp)

The frame pointer (also known as the base pointer, rbp on x86-64) points to the start of the current function’s stack frame. The frame pointer allows easy access to function parameters and local variables, which are stored at fixed offsets relative to the base pointer.

In some optimized builds, the frame pointer is omitted to save registers and stack space. This is called frame pointer omission (FPO), and it can make stack unwinding (and thus stack tracing) more difficult because the direct relationship between stack frames is lost. Debugging tools need to rely on unwind tables or debug symbols to reconstruct the call stack in such cases.

Rust's Access to the Stack

Rust, like other compiled languages, leverages the stack heavily for managing function calls and local data. However, Rust does not provide direct access to low-level registers like the stack pointer (rsp) or frame pointer (rbp) in typical application code. This ensures that Rust maintains its strong memory safety guarantees, preventing developers from accidentally corrupting the stack or introducing undefined behavior.

That said, when writing unsafe Rust code or interfacing with assembly language (e.g., through FFI or inline assembly), you can access these registers directly, but this is discouraged for safety reasons.

Debugging and Stack Examination

While Rust abstracts away direct access to the stack, it provides tools to inspect the stack indirectly. During runtime or after a panic, Rust developers can use debugging tools such as gdb (GNU Debugger), LLDB (LLVM Debugger), or perf (a performance analysis tool) to manually examine the state of the stack.

For example, in gdb, you can use the info registers command to inspect the current state of the stack pointer (rsp) and frame pointer (rbp), and backtrace (or bt) to walk through the current call stack, viewing each active function:

(gdb) info registers
rsp            0x7fffffffe030      0x7fffffffe030
rbp            0x7fffffffe040      0x7fffffffe040

(gdb) backtrace
#0  0x00007ffff7a4d9b3 in _start () from /lib64/ld-linux-x86-64.so.2
#1  0x00007ffff7a4d9b3 in main ()

This command sequence shows the contents of the stack registers and the active stack frames, helping you trace the sequence of function calls that led to the current point in the program.

Unwinding the Stack

In the context of error handling or a panic, stack unwinding is the process of walking back up the stack to clean up resources and trace back through the function calls. When a function panics, Rust’s runtime system begins unwinding the stack, calling destructors for any objects in the function’s scope (using the Drop trait).

If you want to catch a panic and generate a stack trace, you can use std::panic::catch_unwind to safely capture the current state of the stack before the program terminates. This is especially useful for debugging errors that occur deep in the call stack.

Debug Symbols and Unwinding Tables

For tools like gdb and LLDB to provide meaningful stack traces, the binary needs to contain debug symbols. Debug symbols map machine instructions back to their corresponding source code locations. These symbols are often stored in DWARF format on Linux systems and are embedded in the executable during compilation.

Without debug symbols, the stack trace will only show raw memory addresses, which are much less helpful for debugging. Additionally, some modern compilers generate unwind tables that describe how to unwind the stack even without frame pointers, making stack tracing possible even with optimizations like FPO.

Last Words

Understanding stack traces and how they are generated is critical for diagnosing and fixing bugs in Rust programs, especially in system-level or performance-critical applications. Rust’s error handling mechanisms, like panic! and Result, work in tandem with stack traces to provide detailed information about the origin of runtime errors.

While Rust’s default behavior provides safe and automatic stack unwinding, system engineers working with Rust in low-level environments must understand how stack traces are captured and where they reside in memory to effectively debug and optimize Rust code. Whether through Backtrace::capture or tools like gdb, stack traces provide invaluable insight into the state of a program when things go wrong.

By grasping the mechanisms behind stack traces, you gain deeper control over debugging, optimizing, and ensuring the reliability of your Rust applications.

I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.

Got something in mind?

Book a Discovery Call