| |

Rust 104 🦀 Embedded Rust: no_std Environment and Bare-Metal Executables

⏱️ Estimated read: 13 min | |

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 :

Featureno_stdstd
Heap (dynamic memory)Only with allocYes
Collections (Vec, BTreeMap)Only with allocYes
Stack overflow protectionNoYes
Runs init code before mainNoYes
libstd availableNoYes
libcore availableYesYes
Firmware, kernel, bootloaderYesNo

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

AttributeEffectRequired
#![no_std]Links core, not stdYes
#![no_main]No standard entry pointYes

Required Components

ComponentPurposeProvided By
Vector tableInitial SP and reset handlercortex-m-rt
Reset handlerMemory initialization, call maincortex-m-rt
Panic handlerBehavior on panicpanic-halt or custom
Linker scriptMemory regions, section placementmemory.x + link.x

std vs no_std Capabilities

Featurestdno_std
Option, ResultYesYes
Vec, StringYesWith alloc + allocator
HashMapYesNo (no secure RNG)
println!YesNo (no console)
ThreadsYesNo
Stack overflow protectionYesNo
Runtime before mainYesNo (reset handler instead)

Target and Toolchain

ItemExample
Target triplethumbv7m-none-eabi
Linker flag-C link-arg=-Tlink.x
Runnerqemu-system-arm or probe-run
Install targetrustup 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

PitfallWhy It HappensFix
Linker error: missing _startNo #[entry] from cortex-m-rtUse cortex_m_rt::entry
Linker error: missing panic handlerNo #[panic_handler]Import panic-halt or write one
Vec not foundNo alloc crate or allocatorAdd extern crate alloc and #[global_allocator]
Stack overflowNo protection in no_stdSize the stack correctly, use flip-link
Wrong memory addressesLinker script does not match hardwareUpdate 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

ItemValue
Attribute#![no_std] links core, not std
Entry attribute#![no_main] removes standard entry point
Entry point#[entry] from cortex-m-rt
StartupReset handler initializes memory, calls main
Panic#[panic_handler] required, panic-halt common
Linkermemory.x defines flash/RAM regions
Targetthumbv7m-none-eabi (Cortex-M)
Dynamic memoryalloc crate + global allocator
Missing from no_stdHeap, threads, println!, HashMap
Available from coreOption, Result, iterators, primitives

Key takeaways:

  • no_std removes the operating system assumptions, not the language. Ownership, borrowing, traits, and generics work unchanged. The core crate provides the language primitives .
  • The runtime is replaced by the reset handler. cortex-m-rt provides the vector table and the initialization sequence that std would otherwise provide .
  • A panic handler is mandatory. Every no_std binary must define what happens when the program panics. panic-halt is 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.
  • alloc is the escape hatch for dynamic memory. It requires an allocator implementation and initialization. The absence of alloc means fixed-size buffers and stack allocation, which is often the right choice for firmware.
  • HashMap is unavailable even with alloc. The lack of a secure random number generator makes hash seed randomization impossible .
  • The target triple and .cargo/config.toml select 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.


Was this helpful?


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!