Global training solutions for engineers creating the world's electronics
Menu

What Changes from C?

Debugging Embedded Rust: Article 1

The supporting code for this article lives in this GitHub repo.

If you've spent years writing embedded C or C++, you already have a routine for when firmware misbehaves. Stop the processor. Set a breakpoint. Read the peripheral registers. Look at memory and the stack. Check the program counter. When the source still won't explain what's happening, drop to the generated instructions.

Rust can make that routine feel further away at first. A bare-metal Rust project brings in ownership, generic peripheral APIs, no_std, Cargo, and abstractions with no obvious C equivalent, and the tools shift too: alongside GDB and OpenOCD you'll see probe-rs and defmt. It's easy to read all that as a thicker layer between you and the silicon.

It mostly isn't. A bare-metal Rust application compiles to native machine code the processor runs directly, the executable still carries the information a debugger needs to tie those instructions back to the source, and the Cortex-M registers, memory map, stack, exception state, and memory-mapped peripherals are all exactly where you left them. What actually changes is narrower than the first impression suggests: the route we sometimes take to reach that information, and the ways Rust's abstractions and the compiler's optimisations affect what a source-level debugger can show.

This article sets out that model: what a Rust build produces, and why none of it is hidden from a debugger. The next one puts it to work, attaching GDB and probe-rs to real hardware and following a live program down to the instructions the processor executes.

A reference platform for the series

Throughout the series we'll use the NUCLEO-F446RE. It's built around STMicroelectronics' STM32F446RE, an Arm Cortex-M4F part, and carries an on-board ST-LINK debug probe.

rust to elf debug info

STM32F446 NUCLEO

What matters here isn't the specific STM32 so much as the debugging it gives us access to. The board programs and debugs over SWD, behaves well under a conventional GDB/OpenOCD workflow, and is equally at home with the Rust-oriented probe-rs toolchain. Being a Cortex-M4, it also has the features later articles will lean on: hardware breakpoints and watchpoints, Cortex-M fault handling, interrupt behaviour, stack diagnostics, DMA, ITM/SWO trace, and RTT-based logging.

Staying on one board for the whole series is a deliberate choice. The examples get harder as we go; the hardware and project skeleton stay familiar, so the attention stays on the technique rather than on relearning a new board each time.

None of this is STM32-specific, though. The processor-level ideas carry across the Cortex-M family, and comparable Cortex-M4 boards with on-board debugging (the Infineon XMC4700 Relax Kit, the NXP FRDM-K64F, the Nordic nRF52840 DK) would serve just as well. The peripheral registers and vendor HALs differ; breakpoints, watchpoints, memory inspection, core registers, fault analysis, and source-to-instruction debugging do not.

Debugging Paths Gdb Openocd Probe Rs.Drawio

A small program to debug

We need something on the target before any of the tools are useful, and it should be deliberately dull: configure the user LED as an output, increment a counter, toggle the LED twice a second. Predictability is the whole point. We want enough live software state to inspect, and little enough generated code that we can later follow it instruction by instruction.

The main loop, in project1-baseline-counter/src/main.rs, does just three things:

let mut counter = 0_u32;

loop {
    update_counter(&mut counter);   // step 1
    led.toggle();                   // step 2
    delay.delay_ms(500_u32);        // step 3
}

The counter update sits in its own function:

#[inline(never)]
fn update_counter(counter: &mut u32) {
    *counter = counter.wrapping_add(1);
}

#[inline(never)] is there on purpose; it gives us a function with a stable boundary to stop inside and inspect in the next article. Later in the series we'll take that restriction off and watch what an optimised build does to the same code. The wrapping_add() is housekeeping: it keeps the counter well-defined across build profiles so an overflow never wanders into an exercise that isn't about overflow.

The delay comes from the Cortex-M SysTick timer rather than an STM32 peripheral timer, which keeps the baseline close to vendor-neutral. SysTick supplies the timing, GPIOA pin PA5 drives the NUCLEO's user LED, and the local u32 counter is our piece of observable state. The LED tells us the firmware is alive; the counter gives the debugger something concrete to watch. Between them, we can start to separate what the source says should happen from what the processor is actually doing.

The complete, buildable project is in the [repository](https://github.com/Doulos/Rust-debugging-article); the article shows only the parts that matter at each step.

Two attributes near the top of that file are worth a word before we move on:

#![no_std]
#![no_main]

#![no_std] tells Rust not to link the usual std crate, because there's no operating system underneath to provide the environment std assumes; the processor still has a stack, an exception vector table, and startup code, exactly as it would in bare-metal C. #![no_main] hands the Cortex-M startup sequence to a runtime crate like cortex-m-rt, which eventually calls the function you mark #[entry]. Neither attribute gets in the debugger's way: with debug information present, we can halt during startup, break in main, and step into application code just as we would on any bare-metal target.

From Rust source to Cortex-M instructions

It helps to know what the build actually emits before we attach anything to it.

For the STM32F446RE, the application compiles for the Rust target:

thumbv7em-none-eabihf

The name is almost a spec sheet on its own:

thumb      → Thumb instruction set
v7em       → Armv7E-M architecture profile
none       → no hosted operating system
eabi       → embedded ABI
hf         → hardware floating-point ABI

cargo build drives rustc over the application and its dependencies. Rust's own compilation stages deal with the type system, ownership, generics, and the rest of the abstractions, and then the code-generation backend produces ordinary Cortex-M4 instructions. The artefact that matters most for debugging is the ELF executable.

An embedded ELF file holds far more than the bytes that end up in flash. It describes the executable code, the constants, the initialised data, the memory layout, and the symbols that make up the firmware, and when debug information is enabled it also carries DWARF data linking machine addresses back to Rust source files, line numbers, functions, and variables. It's tempting to picture that as a second copy of the program tucked inside the executable. It isn't. The processor only ever runs the generated instructions; the debug information is metadata, there so a debugger can relate those instructions back to the Rust you wrote.

The build, drawn out:

Rust To Elf Debug Info.Drawio

The ELF then does two jobs that pull in different directions. Some of it describes what belongs on the target:

ELF
├── executable instructions
├── read-only constants
├── initialised data
└── vector and runtime information

The rest exists for tools running back on the development host:

ELF
├── symbols
├── source and line mappings
└── DWARF debug information

The microcontroller never executes a byte of DWARF. The debugger reads it to map the processor's current state onto our program: hand it a program counter sitting somewhere in flash and it can tell you the Rust function and source line that address belongs to. That mapping is what lets you break on a Rust function, single-step through source, or read a local while the Cortex-M is halted. It's also why the executable on your machine can dwarf the amount of flash the program occupies; symbols and DWARF bulk up the host-side file without ever being programmed into the part.

The target-side image is visible in the ELF's sections, and they'll look familiar from C:

Section

Typical purpose

.text

Executable instructions

.rodata

Read-only constants

.data

Initialised writable data

.bss

Zero-initialised writable data

The exact layout is down to the runtime and the linker script, but the shape is the same one you already know.

The instructions themselves tell the same story. Our deliberately trivial update_counter() needs no special Rust runtime to do its increment; compiled, it's ordinary Cortex-M instructions that load a value, change it, and store it back. We'll look at the real disassembly from our own build in the next article rather than lean on an invented instruction sequence here. This is also the moment to put Rust's abstractions in proportion: generic functions and strongly typed HAL APIs don't automatically mean runtime indirection. Given enough information at compile time, the compiler resolves most of that structure away, and the final code comes out as direct operations on registers and memory.

If you've debugged a GCC- or Clang-built ELF for a Cortex-M C project, none of this is new terrain. Rust changes the source language and some of the compiler behaviour we'll run into, but the debugger is still working with addresses, symbols, debug information, registers, memory, and native instructions.

Where we go next

So we have the model: a Rust build produces an ELF that carries both the code and data bound for flash and the DWARF metadata that lets a host-side tool map machine state back onto our source, and none of it is opaque to a debugger.

It's still just a model, though; we haven't put a debugger on real hardware yet.

That's the next article, A First Debugging Session. We'll connect to the NUCLEO-F446RE over both GDB/OpenOCD and probe-rs, break inside update_counter(), read the live counter alongside the Cortex-M registers, and then rebuild in release mode to see what optimisation does, and doesn't, take away from a source-level session.



This article was written by Dr David Cabanis, principal member technical staff at Doulos and author of Embedded Software with Rust.