Rust 104 🦀 Embedded Rust: no_std Environment and Bare-Metal Executables
A bare-metal Rust program runs on hardware that has no operating system. There is no main function called by a runtime, no heap allocator, no file system, and no console. The CPU powers on, reads a vector table from a fixed address, jumps to a reset handler, and begins executing your code. Everything that std assumes—threads, Vec, println!, stack overflow protection—is absent. #![no_std] is the attribute that tells the compiler not to link that machinery, and #![no_main] tells it not to expect an entry point that the runtime provides .
This chapter covers the no_std environment as it applies to bare-metal firmware. It begins with the core crate and the specific capabilities that are removed when std is absent, then explains the startup sequence that replaces the standard runtime: the vector table, the reset handler, and the cortex-m-rt crate that provides both. It addresses the project configuration—the target triple, the linker script, and the panic handler—that every bare-metal executable requires. The final sections cover the alloc escape hatch for dynamic memory and the constraints that make bare-metal Rust different from host-side programming.
Key point: #![no_std] does not mean “no functionality.” It means “no operating system assumptions.” The core crate provides ownership, borrowing, iterators, Option, Result, and all the language primitives. What it removes is the platform integration that a kernel would provide.
Why no_std exists
The runtime dependency. The standard library is not just a collection of convenience types. It includes a runtime that runs before main: it sets up stack overflow protection, processes command line arguments, spawns the main thread, and initializes the environment that std functions expect . On a microcontroller, there is no operating system to provide those services, and no thread to spawn. The runtime cannot exist, so std cannot be linked.
The heap problem. Vec, String, Box, and HashMap all require a heap allocator. A bare-metal environment has no allocator unless the programmer provides one. core excludes these types entirely, which forces the programmer to think about memory usage at compile time. For firmware with deterministic memory requirements, this is a feature: the absence of dynamic allocation means the absence of allocation failures at runtime .
The startup sequence. A normal Rust binary begins at main. A bare-metal binary begins at a reset vector. The CPU reads the initial stack pointer from the first word of the vector table and the reset handler address from the second word, then jumps to the handler . The handler initializes memory, copies initialized data from flash to RAM, zeroes the BSS section, and calls the Rust entry point. cortex-m-rt provides this sequence so that the programmer does not have to write it in assembly .
The panic handler. When a normal Rust program panics, the runtime unwinds the stack and prints a message. A bare-metal program has no stack unwinding and no console. The #[panic_handler] attribute defines what happens instead. The common choice is panic-halt, which enters an infinite loop, or a custom handler that signals the debugger and halts . Only one panic handler can exist per binary, so the choice is exclusive.
a. The core crate and what is removed
#![no_std] replaces the std prelude with the core prelude. Types like Option, Result, Copy, Clone, and the primitive types remain available without imports . The language features—ownership, borrowing, traits, generics, pattern matching—are unchanged. What disappears is the platform integration.
The overview table from the Embedded Rust Book makes the distinction precise :
| Feature | no_std | std |
|---|---|---|
| Heap (dynamic memory) | Only with alloc | Yes |
Collections (Vec, BTreeMap) | Only with alloc | Yes |
| Stack overflow protection | No | Yes |
Runs init code before main | No | Yes |
libstd available | No | Yes |
libcore available | Yes | Yes |
| Firmware, kernel, bootloader | Yes | No |
HashMap and HashSet are not available even with alloc, because they require a secure random number generator for hash seed randomization, which a bare-metal environment cannot provide . Floating-point operations that depend on std—like f32::atan2—are also missing; crates like micromath provide replacements .
#![no_std]
// These are available from core without imports.
fn halve(x: u32) -> Option<u32> {
if x % 2 == 0 { Some(x / 2) } else { None }
}
// Vec is NOT available without the alloc crate.
// use alloc::vec::Vec; // requires an allocator
The core crate is sufficient for drivers, bootloaders, and firmware that can work with fixed-size buffers and stack-allocated data. The constraints are not limitations for most embedded code; they are the discipline that makes embedded systems predictable.
b. The startup sequence and cortex-m-rt
A bare-metal Rust binary does not begin at a function called main. The CPU begins at the reset vector, which is the second word of the vector table. The first word is the initial stack pointer. The vector table is placed at a fixed address in flash by the linker script, and cortex-m-rt provides both the table and the reset handler that the table points to .
The reset handler performs the initialization that the standard runtime would otherwise perform. It copies the .data section from flash to RAM (initialized static variables), zeroes the .bss section (uninitialized statics), and then calls the main function that the programmer defines with the #[entry] attribute.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _; // provides the #[panic_handler]
#[entry]
fn main() -> ! {
// The reset handler has already initialized memory.
// This is the first Rust code that runs.
loop {}
}
The #![no_main] attribute is required because the compiler would otherwise expect a main function that the standard runtime calls. With no_main, the #[entry] attribute from cortex-m-rt marks the function that the reset handler calls instead . The -> ! return type is required because the entry point must never return; there is no caller to return to.
The linker script is the other half of the startup configuration. It defines the memory regions—flash at one address, RAM at another—and places the vector table at the start of flash. For many boards, cortex-m-rt provides a default linker script through memory.x, a small file that declares the memory regions.
/* memory.x — defines the memory regions for the linker. */
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 64K
}
The .cargo/config.toml file ties the target, the linker script, and the runner together. The target triple selects the architecture; the linker flag points at the cortex-m-rt link script; the runner specifies how to flash or emulate the binary .
[build]
target = "thumbv7m-none-eabi"
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
rustflags = ["-C", "link-arg=-Tlink.x"]
c. The panic handler and the alloc escape hatch
Every no_std binary must define a #[panic_handler]. The function receives a &PanicInfo and must diverge (-> !). The simplest implementation halts the CPU in an infinite loop, which is what panic-halt provides .
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
// Halt the CPU. The debugger can inspect the state.
loop {}
}
A more useful handler during development signals the debugger with a breakpoint instruction, so that the panic location can be inspected. In production, halting is often the safest behavior: a corrupted system that continues running is worse than one that stops.
The alloc crate provides the heap-allocated types that core excludes: Vec, String, Box, Rc, and Arc. Using it requires two steps. First, the crate must be declared with extern crate alloc;. Second, a global allocator must be defined with the #[global_allocator] attribute. The allocator is typically a crate like alloc-cortex-m that implements a simple bump or linked-list allocator over a fixed memory region .
extern crate alloc;
use alloc::vec::Vec;
use alloc_cortex_m::CortexMHeap;
use core::alloc::GlobalAlloc;
#[global_allocator]
static ALLOCATOR: CortexMHeap = CortexMHeap::empty();
// The allocator must be initialized with a memory region
// before any allocation occurs. This typically happens
// in the entry point, using a static array as the heap.
The alloc crate does not remove the constraints of bare-metal programming. It moves the responsibility for memory management from the compiler to the programmer. A Vec that grows without bound will eventually exhaust the heap, and the allocator’s behavior on exhaustion—abort, panic, or return null—is determined by the implementation.
Complete Example Session
// ============================================
// PART 1: THE CRATE ATTRIBUTES
// ============================================
// no_std: do not link the standard library.
// no_main: do not expect a standard entry point.
#![no_std]
#![no_main]
// ============================================
// PART 2: THE IMPORTS
// ============================================
// cortex-m-rt provides the entry point and startup.
// panic-halt provides the panic handler.
use cortex_m_rt::entry;
use panic_halt as _;
// ============================================
// PART 3: THE ENTRY POINT
// ============================================
// The reset handler calls this function after
// initializing memory.
#[entry]
fn main() -> ! {
// This is the first Rust code that runs.
// No heap, no threads, no console.
loop {
// The firmware's main loop.
}
}
// ============================================
// PART 4: THE LINKER SCRIPT
// ============================================
// memory.x defines the memory regions.
// The cortex-m-rt link.x places the vector table.
// MEMORY
// {
// FLASH : ORIGIN = 0x08000000, LENGTH = 256K
// RAM : ORIGIN = 0x20000000, LENGTH = 64K
// }
// ============================================
// PART 5: THE CARGO CONFIGURATION
// ============================================
// .cargo/config.toml selects the target and runner.
// [build]
// target = "thumbv7m-none-eabi"
//
// [target.thumbv7m-none-eabi]
// rustflags = ["-C", "link-arg=-Tlink.x"]
// ============================================
// PART 6: THE PANIC HANDLER
// ============================================
// Required for every no_std binary.
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
// ============================================
// PART 7: THE ALLOC ESCAPE HATCH
// ============================================
// Dynamic allocation requires the alloc crate
// and a global allocator.
extern crate alloc;
use alloc::vec::Vec;
// The allocator must be defined and initialized.
// use alloc_cortex_m::CortexMHeap;
// #[global_allocator]
// static ALLOCATOR: CortexMHeap = CortexMHeap::empty();
// ============================================
// PART 8: THE HARDWARE ABSTRACTION LAYER
// ============================================
// A HAL crate provides safe access to peripherals.
// The PAC provides raw register access.
// use stm32f1xx_hal as hal;
// use hal::prelude::*;
// ============================================
// PART 9: THE BUILD COMMANDS
// ============================================
// rustup target add thumbv7m-none-eabi
// cargo build --release
// cargo run (flashes or emulates)
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// Bare-metal Rust replaces the std runtime with
// a hardware-specific startup sequence.
// The CPU powers on.
// Vector table at flash start.
// Reset handler from cortex-m-rt.
// Memory initialized.
// main() called.
// Panics halt.
// No heap unless alloc is configured.
The ten parts build the minimal bare-metal executable: the attributes, the entry point, the linker configuration, the panic handler, and the optional alloc escape hatch. The pattern is consistent: every piece that std would provide is either removed or replaced with a hardware-specific equivalent.
Quick Reference
Crate Attributes
| Attribute | Effect | Required |
|---|---|---|
#![no_std] | Links core, not std | Yes |
#![no_main] | No standard entry point | Yes |
Required Components
| Component | Purpose | Provided By |
|---|---|---|
| Vector table | Initial SP and reset handler | cortex-m-rt |
| Reset handler | Memory initialization, call main | cortex-m-rt |
| Panic handler | Behavior on panic | panic-halt or custom |
| Linker script | Memory regions, section placement | memory.x + link.x |
std vs no_std Capabilities
| Feature | std | no_std |
|---|---|---|
Option, Result | Yes | Yes |
Vec, String | Yes | With alloc + allocator |
HashMap | Yes | No (no secure RNG) |
println! | Yes | No (no console) |
| Threads | Yes | No |
| Stack overflow protection | Yes | No |
Runtime before main | Yes | No (reset handler instead) |
Target and Toolchain
| Item | Example |
|---|---|
| Target triple | thumbv7m-none-eabi |
| Linker flag | -C link-arg=-Tlink.x |
| Runner | qemu-system-arm or probe-run |
| Install target | rustup target add thumbv7m-none-eabi |
Best Practices
✅ Do This:
// Use both no_std and no_main for binaries.
#![no_std]
#![no_main] // ✅
// Define a panic handler that halts.
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { loop {} } // ✅
// Use cortex-m-rt for the startup sequence.
use cortex_m_rt::entry; // ✅
// Configure the target and linker in .cargo/config.toml.
target = "thumbv7m-none-eabi" // ✅
// Use a HAL for safe peripheral access.
use stm32f1xx_hal as hal; // ✅
❌ Don’t Do This:
// Don't use std types without alloc.
let v = Vec::new(); // no allocator configured // ❌
// Don't forget the panic handler.
// The linker will fail with a missing symbol. // ❌
// Don't expect println! to work.
println!("debug"); // no console in no_std // ❌
// Don't return from the entry point.
fn main() {} // must be -> ! // ❌
// Don't use HashMap without a secure RNG.
// HashMap is unavailable in no_std. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Linker error: missing _start | No #[entry] from cortex-m-rt | Use cortex_m_rt::entry |
| Linker error: missing panic handler | No #[panic_handler] | Import panic-halt or write one |
Vec not found | No alloc crate or allocator | Add extern crate alloc and #[global_allocator] |
| Stack overflow | No protection in no_std | Size the stack correctly, use flip-link |
| Wrong memory addresses | Linker script does not match hardware | Update memory.x with correct flash/RAM origins |
Real-World Examples
1. Minimal Entry
#![no_std]
#![no_main]
use cortex_m_rt::entry;
#[entry] fn main() -> ! { loop {} }
2. Panic Halt
use panic_halt as _;
3. Custom Panic
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { loop {} }
4. Memory Regions
MEMORY { FLASH : ORIGIN = 0x08000000, LENGTH = 256K }
5. Target Config
[build] target = "thumbv7m-none-eabi"
6. Alloc Setup
extern crate alloc;
#[global_allocator] static ALLOC: CortexMHeap = CortexMHeap::empty();
7. HAL Import
use stm32f1xx_hal as hal;
8. QEMU Runner
runner = "qemu-system-arm -machine lm3s6965evb -nographic -kernel"
9. Vector Table (conceptual)
/* .vector_table at ORIGIN(FLASH) */
10. Build Command
cargo build --release --target thumbv7m-none-eabi
Visual
The Boot Sequence
┌─────────────────────────────────────────────────────────┐
│ POWER ON │
│ │ │
│ ▼ │
│ CPU reads vector table at flash start │
│ Word 0: initial stack pointer │
│ Word 1: reset handler address │
│ │ │
│ ▼ │
│ Reset handler (cortex-m-rt) │
│ - Copy .data from flash to RAM │
│ - Zero .bss │
│ - Call main() │
│ │ │
│ ▼ │
│ main() from #[entry] │
│ - Your firmware runs │
│ - Never returns │
└─────────────────────────────────────────────────────────┘
std vs no_std
┌─────────────────────────────────────────────────────────┐
│ std (host) no_std (bare metal) │
│ │
│ Runtime sets up threads No runtime │
│ main() called Reset handler calls │
│ Heap available Heap only with alloc │
│ println! works No console │
│ Panic unwinds Panic halts │
│ Stack overflow protected Not protected │
└─────────────────────────────────────────────────────────┘
The Crate Dependencies
┌─────────────────────────────────────────────────────────┐
│ Your crate │
│ #![no_std] │
│ │ │
│ ┌────┴────┬────────────┬──────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ core cortex-m-rt panic-halt HAL crate │
│ (lang) (startup) (panic) (peripherals) │
│ │
│ core is always available. │
│ cortex-m-rt provides the reset handler. │
│ panic-halt provides the panic handler. │
│ HAL provides safe peripheral access. │
└─────────────────────────────────────────────────────────┘
The alloc Escape Hatch
┌─────────────────────────────────────────────────────────┐
│ Without alloc With alloc │
│ │
│ Fixed buffers only Vec, String, Box │
│ Stack allocation Heap allocation │
│ Deterministic memory Allocator required │
│ No allocation failure OOM possible │
│ │
│ alloc requires: │
│ 1. extern crate alloc; │
│ 2. #[global_allocator] with a suitable allocator │
│ 3. Initialization of the allocator's memory region │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Attribute | #![no_std] links core, not std |
| Entry attribute | #![no_main] removes standard entry point |
| Entry point | #[entry] from cortex-m-rt |
| Startup | Reset handler initializes memory, calls main |
| Panic | #[panic_handler] required, panic-halt common |
| Linker | memory.x defines flash/RAM regions |
| Target | thumbv7m-none-eabi (Cortex-M) |
| Dynamic memory | alloc crate + global allocator |
Missing from no_std | Heap, threads, println!, HashMap |
Available from core | Option, Result, iterators, primitives |
Key takeaways:
no_stdremoves the operating system assumptions, not the language. Ownership, borrowing, traits, and generics work unchanged. Thecorecrate provides the language primitives .- The runtime is replaced by the reset handler.
cortex-m-rtprovides the vector table and the initialization sequence thatstdwould otherwise provide . - A panic handler is mandatory. Every
no_stdbinary must define what happens when the program panics.panic-haltis the simplest safe choice . - The linker script defines the memory map. The vector table must be placed at the address the CPU expects, and the stack must be sized for the application.
allocis the escape hatch for dynamic memory. It requires an allocator implementation and initialization. The absence ofallocmeans fixed-size buffers and stack allocation, which is often the right choice for firmware.HashMapis unavailable even withalloc. The lack of a secure random number generator makes hash seed randomization impossible .- The target triple and
.cargo/config.tomlselect the architecture. The runner determines whether the binary is flashed to hardware or emulated in QEMU. - The entry point never returns. The
-> !return type is required because there is no caller to return to.
Remember: Bare-metal Rust is Rust with the operating system removed. The compiler still enforces ownership and borrowing, the type system still prevents data races, and the core crate still provides the standard library’s language-level functionality. What changes is the environment: no heap unless you provide one, no console unless you configure one, and no runtime unless cortex-m-rt provides it. The #![no_std] attribute is the declaration that your code will run on hardware that has none of those things, and the startup sequence, panic handler, and linker script are the replacements you provide.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!