Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-695 · Case file with fixtures · Case 667 of 694 · Cargo workspace evidence

A no_std Rust Binary Needs Exactly One Panic Handler

no_std removes the standard panic runtime. The final binary still needs exactly one non-returning #[panic_handler] chosen for its target and operational failure policy.

Reviewed
Rust
Rust 1.98.1, Cargo 1.98.1, edition 2024
Targets
x86_64-unknown-linux-gnu evidence, no_std executable targets
Profiles
dev with panic=abort, release with panic=abort

Direct answer

What this Rust failure means

Why it happens
Removing std also removes its panic runtime; an entrypoint and panic strategy do not provide the one required function that handles PanicInfo.
First discriminating check
Inspect the complete final dependency graph for exactly one panic handler and verify its non-returning behaviour for the actual target and failure policy.

Removing the standard library does not remove panic from the Rust language. Bounds checks, unwrap, arithmetic policy, and application code can still panic. What disappears is the standard runtime that decides what a panic does.

The failing binary has #![no_std], #![no_main], an exported _start, and panic = "abort". Rustc still rejects it because there is no #[panic_handler] function.

An entrypoint answers how execution begins. A panic handler answers what execution does after a panic. They are independent contracts.

panic=abort is not a handler implementation

The panic profile setting chooses abort rather than stack unwinding. It does not provide target-specific code to report, reset, halt, or terminate.

The Rust Reference requires a panic handler in a no_std binary. The function receives &PanicInfo and never returns:

#[panic_handler]
fn panic(info: &PanicInfo<'_>) -> ! {
    // report or record according to this platform
    loop { core::hint::spin_loop(); }
}

The repaired fixture uses this minimal loop. It proves the compiler and linker contract, not a universal production policy. Spinning forever on a server wastes a CPU; spinning on a watchdog-controlled microcontroller may be intentional for a brief period; an operating-system process can call a platform termination primitive.

The return type ! matters. Continuing after arbitrary panic would expose state whose logical invariants may be incomplete.

Exactly one means the final dependency graph

A project can also fail because it contains two handlers. The rule is not “put a handler in every no_std crate.” Libraries should normally not choose the final product's panic policy.

The final firmware, kernel, bootloader, or freestanding executable selects one handler, possibly through a dedicated panic crate. The Embedded Rust Book describes this composition and notes that the handler must appear exactly once in the dependency graph.

This makes panic behaviour an application decision. A reusable driver does not know whether the product should reset, write through semihosting, store a crash code in retained memory, or halt silently.

When feature combinations select panic crates, I test that each final product enables one and only one. “default features off” is a common way to remove the handler accidentally.

PanicInfo is useful but the handler has constraints

PanicInfo can describe the panic message and location. Formatting and output are not automatically safe or available in the failure environment.

A handler may run after memory pressure, while a device lock is held, during an interrupt-sensitive operation, or with partially initialized peripherals. Allocating, taking ordinary locks, or using the same failing output path can recurse or deadlock.

I design a small failure path:

  • write a bounded record without heap allocation;
  • avoid locks shared with normal work;
  • include a stable code or program counter when available;
  • make repeated entry safe or stop it explicitly;
  • trigger the platform's reset, halt, or termination mechanism;
  • keep secrets out of crash channels.

The best policy depends on the target. The type signature alone cannot choose it.

The fixture has a narrow host setup

The evidence uses x86_64-unknown-linux-gnu and passes -nostartfiles so its own _start does not collide with the operating system C runtime entrypoint. That setup exists only to make the missing-handler diagnostic reproducible with an installed target.

It is not a recipe for a complete Linux runtime. A real freestanding target needs a linker script, memory layout, startup initialization, stack, termination path, and target-specific ABI work. On embedded hardware, a runtime crate often owns several of these pieces.

I state this boundary because small no_std examples can appear more portable than they are. Compiling one host fixture proves the language contract; it does not validate a board image.

My no_std panic review

When the handler is missing or duplicated, I check:

  1. Is this crate a library or the final executable?
  2. Which dependency is intended to own panic policy?
  3. Do features select zero or several panic implementations?
  4. Does the profile use unwind or abort, and does the target support that choice?
  5. Can the handler run without allocation and contested locks?
  6. What observable evidence survives after reset or termination?
  7. Are the linker entrypoint and panic path tested on the actual target?

The core principle is that removing a runtime transfers responsibility; it does not erase the events that runtime handled. no_std gives control, and the one panic handler is part of the price of that control. I keep it at the final product boundary and test it like other critical failure code.