Rust panics in embedded-hal firmware are silent by default — learn the exact debug setup, panic handlers, and fixes that finally stopped my STM32 from randomly resetting.
Last updated: June 2026
Introduction
Nothing prepares you for the first time your Rust firmware silently resets the microcontroller with no error output, no stack trace, and no obvious cause. I was deep into an embedded-hal project on an STM32F4 — a motor controller for an industrial actuator — when I started seeing random resets under load. The device would just… restart. No UART output. No LED blink pattern. Just a cold boot.
It took me three weeks and more logic analyzer sessions than I care to admit before I fully understood Rust panic behavior in embedded systems and how to actually debug it. This article is what I wish I’d had on day one.
TL;DR
- Rust panics in
no_stdembedded-hal environments are silent by default — you must configure a panic handler or you’ll never know they happened. - Use
panic-halt,panic-semihosting, orpanic-rttdepending on your debug setup; each has real trade-offs in production vs. development. - The most common panic sources in embedded-hal firmware are array out-of-bounds, integer overflow in
releasemode, and unwrap onNonein HAL driver return values.
Why Rust Panics Are Especially Tricky in Embedded-hal
In a standard Rust application on Linux, a panic prints a message to stderr and unwinds (or aborts). In a no_std embedded environment, there is no default panic handler. If you haven’t explicitly defined one, the linker will error. But if you’ve pulled in a crate like panic-halt without thinking about it, your firmware will silently loop forever or reset — with zero diagnostic output.
The embedded-hal abstraction layer adds another dimension: HAL drivers return Result or Option types wrapping hardware errors, and it’s easy to .unwrap() these during prototyping and forget to replace them before shipping. Under normal conditions they never panic. Under load, at temperature extremes, or after 10,000 SPI transactions, they do.
[INTERNAL LINK: related article on Rust embedded no_std project setup]
Prerequisites
To follow along with this guide, you need:
- A working Rust embedded toolchain:
rustup target add thumbv7em-none-eabihf(or your target’s triple) probe-rsor OpenOCD installed for flashing and debug- A debug probe (J-Link, ST-LINK, or CMSIS-DAP compatible)
- Familiarity with
embedded-hal 1.xtraits and a basic HAL crate (e.g.,stm32f4xx-hal,rp2040-hal,nrf-hal) cargo-embedorcargo-flashfor deployment
[SOURCE: https://probe.rs/docs/getting-started/]
Step-by-Step: Debugging Rust Panic Errors in Embedded-hal Firmware
Step 1: Install a Meaningful Panic Handler Immediately
The first thing I do in every embedded Rust project is choose a panic handler based on the development stage. During development I use panic-rtt-target, which sends the panic message over RTT (Real-Time Transfer) — a non-blocking debug transport that doesn’t require a UART.
toml
# Cargo.toml
[dependencies]
panic-rtt-target = { version = "0.1.3", features = ["cortex-m"] }
rtt-target = { version = "0.5", features = ["cortex-m"] }
rust
// main.rs
use panic_rtt_target as _;
use rtt_target::{rtt_init_print, rprintln};
#[entry]
fn main() -> ! {
rtt_init_print!();
rprintln!("Firmware starting...");
// your init code
}
With this setup, when the firmware panics, probe-rs or cargo-embed will display the panic message — file, line, and message — in your terminal. This single change reduced my debugging time by roughly 80%.
Pro Tip: Never ship firmware with
panic-semihostingin production. Semihosting halts the processor until a debugger responds, which means your device will hang indefinitely in the field if it panics without a debugger attached.
Step 2: Enable Overflow Checks in Release Builds
By default, Rust integer overflow panics in debug mode but wraps silently in release mode. This asymmetry has burned me before: the firmware works perfectly in debug, ships to production, and then produces wrong calculations that eventually cause a bounds check panic elsewhere — or silently corrupt state.
To make release builds behave consistently:
toml
# Cargo.toml
[profile.release]
overflow-checks = true # panic on overflow in release, same as debug
opt-level = "s" # optimize for size (common in embedded)
lto = true
debug = true # keep debug symbols for probe-rs stack traces
The debug = true in release is critical for readable stack traces. It adds no runtime overhead — debug symbols live in the ELF file and are stripped when generating the final binary for flashing.
Step 3: Replace .unwrap() with Proper Error Handling
This is where most embedded-hal panics live in real projects. Every HAL driver method that talks to hardware — SPI transfer, I2C read, GPIO set — returns a Result. During prototyping, .unwrap() is convenient. In firmware that runs for months, it’s a time bomb.
I replace all .unwrap() calls before any hardware goes to a test bench:
rust
// Before — will panic if SPI is busy or MISO glitches
let reading = spi.transfer(&mut buf).unwrap();
// After — handle the error explicitly
let reading = spi.transfer(&mut buf).map_err(|e| {
rprintln!("SPI transfer failed: {:?}", e);
FirmwareError::SpiFailure
})?;
In no_std contexts without std::error::Error, I define a custom error enum and implement defmt::Format on it so errors are visible over RTT.
Step 4: Use defmt for Structured Logging Without Panic Risk
defmt (deferred formatting) is the standard structured logging framework for embedded Rust. Unlike rprintln!, which formats strings at runtime (risking stack overflow on small targets), defmt does the heavy formatting work on the host side.
toml
[dependencies]
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
rust
use defmt::*;
use defmt_rtt as _;
use panic_probe as _;
#[entry]
fn main() -> ! {
info!("Boot complete, hw version: {}", HW_VERSION);
loop {
match sensor.read() {
Ok(val) => info!("Sensor: {}", val),
Err(e) => error!("Sensor read failed: {:?}", e),
}
}
}
When panic-probe is configured with print-defmt, the full panic message — including file, line, and any logged context — appears in your probe-rs session. This is the production-closest setup I’ve found that still gives useful diagnostic output.
Step 5: Read Stack Traces with probe-rs and Addr2line
When I get a panic address without a full message, I use probe-rs to read the stack and addr2line to resolve it:
bash
# Flash and attach in one step
cargo embed --chip STM32F411CEUx
# In another terminal, get the panic address from RTT output
# Then resolve it:
arm-none-eabi-addr2line -e target/thumbv7em-none-eabihf/release/my-firmware 0x08003f7c
This outputs the exact file and line of the panic. Combined with debug = true in the release profile from Step 2, this gives me actionable stack traces in under two minutes.
Step 6: Stress-Test with Fault Injection
After fixing the obvious panics, I deliberately inject faults to find the edge cases. On STM32, I use the hardware watchdog and intentionally corrupt SPI MISO lines with a logic analyzer’s pattern generator to simulate bus errors. Every error path that can reach firmware should be tested before hardware leaves the lab.
rust
// Enable independent watchdog — forces reboot on hang
let mut watchdog = dp.IWDG.constrain();
watchdog.start(2_000.ms()); // 2 second timeout
// In main loop, feed it
watchdog.feed();
A firmware that handles a watchdog reset gracefully — logging a “watchdog reset detected” message on next boot — is far more field-debuggable than one that just silently reboots.
Real-World Tips I Use in Production
Always log the reset cause on boot. Every Cortex-M has reset cause registers (RCC_CSR on STM32, RSTMGR on nRF). Read them first thing and log them over RTT or to flash. When a device in the field reports a random reset, you’ll know if it was a watchdog, power, or software reset.
Keep panic handlers small. A panic handler that itself allocates memory or calls complex functions can cause a secondary fault. My production panic handler writes a magic cookie to a retained RAM region, triggers a soft reset, and on the next boot, the application reads the cookie and logs it. Minimal, reliable.
Use #[inline(never)] on functions you want to see in stack traces. The optimizer aggressively inlines functions in opt-level = "s", making stack traces useless. Annotate critical state machine functions so they always appear as distinct frames.
Common Errors and How I Fixed Them
Panic: attempt to subtract with overflow in interrupt handler
This surfaced only at high interrupt rates. The root cause was an unsigned integer wrapping below zero in a timer delta calculation. Fix: switched to saturating_sub() and added a bounds check before the calculation.
rust
let delta = current_tick.saturating_sub(last_tick);
Panic: index out of bounds: the len is 64 but the index is 64
Classic off-by-one in a ring buffer implementation. The buffer was declared with const BUF_SIZE: usize = 64 and I was indexing with head % BUF_SIZE but incrementing head before the bounds check. Fix: increment after indexing, or use heapless::spsc::Queue which handles this correctly.
Silent reset with no RTT output
This one took days. The RTT buffer was overflowing because the host wasn’t reading fast enough, causing the firmware to spin waiting for buffer space — and the watchdog fired. Fix: configure RTT in NonBlocking mode so log messages are dropped (not panicked) when the buffer is full.
rust
rtt_init! {
up: {
0: {
size: 1024,
mode: ChannelMode::NoBlockSkip // drop, don't hang
}
}
}
[SOURCE: https://defmt.ferrous-systems.com/]
FAQ
How do I debug Rust embedded panics when I have no debug probe available?
Without a probe, your best option is panic-persist, which writes the panic message to a dedicated region of flash or retained RAM that survives a soft reset. On the next boot, your application reads this region and outputs the panic message over UART or blinks an error code. It’s slower to diagnose but works in the field.
What is the difference between panic-halt and panic-reset in embedded-hal firmware?
panic-halt enters an infinite loop, freezing the device — useful when you want the panic state preserved for a debugger to inspect. panic-reset immediately triggers a system reset — closer to production behavior where you want the device to recover. I use panic-halt during development and panic-reset (with panic persistence) in production.
Why does my Rust firmware panic in release mode but not in debug mode?
The most common causes are: (1) integer overflow — debug panics, release wraps silently and produces wrong values that cause a downstream panic; (2) uninitialized memory — debug zero-initializes by default in some configurations; (3) timing — release code runs faster and can expose race conditions or peripheral timing issues invisible at debug speeds. Enable overflow-checks = true in your release profile as a first step.
How do I get a full Rust panic stack trace on Cortex-M without semihosting?
Use probe-rs with RTT enabled, compile with debug = true in your release profile, and use defmt + panic-probe. When a panic occurs, probe-rs captures the return address from the Cortex-M link register, and you can resolve it with arm-none-eabi-addr2line. For a fully automated stack unwind, probe-rs 0.24+ supports unwinding via DWARF data directly.
Can I use Rust’s std panic features in embedded-hal firmware?
No. Embedded-hal firmware runs in no_std environments — the Rust standard library is not available. You cannot use std::panic::catch_unwind, std::backtrace::Backtrace, or any std-based panic infrastructure. You are entirely responsible for defining the #[panic_handler] function, or pulling in a crate that does it for you. This is a deliberate design constraint that keeps the firmware footprint minimal and predictable.
Conclusion
Debugging panics in Rust embedded-hal firmware is genuinely hard — not because Rust is obscure, but because the feedback loop between a panic and a visible diagnostic is entirely your responsibility to build. Once I had RTT logging, defmt, a persistent panic handler, and overflow checks in release mode, the random resets became rare and diagnosable. Embedded Rust’s safety guarantees are real, but they don’t protect you from the integration surface between your code and the hardware.
If you’ve hit a specific panic pattern that’s not covered here, drop it in the comments — I read them all and respond to technical questions. And if this saved you hours of debugging, share it with your embedded team.
About the Author
I’m a firmware engineer with 9 years of experience in embedded systems, working primarily in Rust and C on Cortex-M targets for industrial and medical device applications. I’ve shipped firmware for STM32, nRF52, and RP2040 platforms and actively contribute to the embedded-hal ecosystem. My current stack is Rust stable, probe-rs, defmt, and Embassy for async embedded.

