How to Use Panic Handlers in no_std Rust

Define a function with the #[panic_handler] attribute to handle fatal errors in no_std Rust environments.

When the standard library isn't there to catch you

You are writing firmware for a microcontroller. A sensor returns a value outside the expected range. Your code calls panic!(). In a web server, the operating system catches the panic, prints a stack trace to standard error, and the process terminates cleanly. On bare metal, there is no operating system. There is no standard library. The CPU continues executing whatever bytes happen to sit in memory after your function frame. The device might corrupt its flash storage, transmit malformed radio packets, or hang while the status LED blinks a pattern that tells you nothing about the failure. You need to define what a crash means for your hardware.

The panic handler is your emergency brake

Rust's standard library includes a default panic handler. It prints the panic message and aborts the process. The no_std environment removes the standard library. That removes the default handler. The compiler still needs a destination to jump to when panic!() is called. You provide that destination.

The #[panic_handler] attribute marks a function as the crash handler. The function receives a &core::panic::PanicInfo struct containing the panic message and the source location. The return type is !, the "never" type. This tells the compiler the function never returns. If the function returns, the program state is undefined.

Think of the panic handler like a circuit breaker in a remote cabin. In a house connected to a smart grid, a short circuit triggers an alert, logs the event, and isolates the fault automatically. In a cabin with no grid, you install a manual breaker. When the current spikes, the breaker trips. You decide what happens next. Does the cabin go dark? Does a backup generator kick in? Does a siren sound? The breaker is the mechanism. You define the response.

Minimal example

The simplest panic handler halts the CPU. This prevents the device from executing garbage code.

#![no_std]
#![no_main]

/// The function the compiler calls when `panic!()` is triggered.
/// Returns `!` because this function must never return to the caller.
#[panic_handler]
fn panic_handler(info: &core::panic::PanicInfo) -> ! {
    // Log the panic message if you have a way to output it.
    // For now, just halt the CPU to prevent undefined behavior.
    loop {}
}

The #![no_std] attribute tells the compiler not to link the standard library. The #![no_main] attribute tells the compiler you are providing your own entry point. The #[panic_handler] attribute registers the function. The info parameter contains the panic details. The loop {} halts execution. The ! return type ensures the compiler enforces that the function diverges.

How the handler runs

When panic!() executes, the runtime calls your handler. The info parameter holds the panic details. Your code runs. If you loop, the CPU spins. If you reset, the device restarts. The ! return type is enforced. The compiler checks that every path in the function diverges.

The PanicInfo struct has two useful methods. message() returns the panic message as a fmt::Arguments value. location() returns the file and line number where the panic occurred. Formatting fmt::Arguments in no_std requires a custom writer. You cannot use println! or format! unless you implement core::fmt::Write for your output device.

Realistic example

A production handler usually logs the crash and halts the device. This example shows how to extract the location and message, then halt the core.

#![no_std]
#![no_main]

use core::fmt::Write;
use core::panic::PanicInfo;

/// A simple writer that discards output.
/// Replace this with a UART or flash logger in real code.
struct NullWriter;

impl Write for NullWriter {
    fn write_str(&mut self, _s: &str) -> core::fmt::Result {
        // In real code, send `s` to a UART or store in flash.
        // Returning Ok means the write succeeded.
        Ok(())
    }
}

/// The panic handler.
/// Returns `!` because this function must never return.
#[panic_handler]
fn panic_handler(info: &PanicInfo) -> ! {
    // Create a writer to output the panic details.
    let mut writer = NullWriter;

    // Write the location if available.
    // The location is always present in modern Rust.
    if let Some(location) = info.location() {
        let _ = core::write!(
            writer,
            "Panic at {}:{}: ",
            location.file(),
            location.line()
        );
    }

    // Write the message.
    // The message might be None if the panic has no message.
    let _ = write!(writer, "{}", info.message().unwrap_or_default());

    // Halt the core.
    // The device is now in a known failed state.
    // Do not return from this function.
    loop {
        // Compiler barrier to prevent optimization of the loop.
        // Some architectures require a specific instruction to halt.
        core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst);
    }
}

The NullWriter implements core::fmt::Write. This allows core::write! to format the panic details. The handler writes the location and message to the writer. The loop halts the CPU. The compiler_fence ensures the compiler does not optimize the loop away.

Convention aside: In no_std code, always use core::panic::PanicInfo. Using std::panic::PanicInfo will fail to compile because the std crate is not available. The community treats core as the source of truth for no_std types.

Pitfalls and compiler errors

The panic handler is easy to break. The compiler catches some mistakes. Others cause runtime failures.

If you try to return a value from the handler, the compiler rejects you with E0308 (mismatched types). The return type must be !.

#[panic_handler]
fn bad_handler(info: &PanicInfo) -> () {
    // Error[E0308]: mismatched types
    // expected `!`, found `()`
}

If you forget the #[panic_handler] attribute, the linker fails. The compiler generates a symbol for the panic handler. The linker expects that symbol. Without it, you get a missing symbol error.

If you call panic!() from inside the handler, you get infinite recursion. The handler calls itself. The stack overflows. The device crashes in a way that is hard to debug.

If you use println! in the handler, you risk recursion. println! might panic if the output device fails. If println! panics, the handler calls itself. The stack overflows.

Convention aside: Keep the panic handler small and safe. Do not allocate memory. Do not call functions that might panic. Do not use complex data structures. The handler runs when the program is already in a bad state. Trust nothing.

Treat the ! return type as a contract. If you return, you break the contract, and the CPU does whatever it wants.

Decision matrix

Use #[panic_handler] when you are writing a no_std executable and must provide the crash behavior yourself. Use Result for errors that the caller can handle. Panics should only happen when the program is in a state where continuing is impossible or unsafe. Use core::panic::PanicInfo when you need to inspect the panic message or location inside the handler. Use a hardware reset inside the handler when your device can recover cleanly by restarting, such as after a watchdog timeout or a transient sensor error. Use an infinite loop inside the handler when the device is in a corrupted state and resetting would cause data loss or safety hazards.

Don't fight the compiler here. Reach for core::sync::atomic::compiler_fence if the optimizer eats your halt loop.

Where to go next