rust-impl-no-std
The no_std Rust skill: how to opt out of the standard library with #![no_std], work against core only, pull in alloc when a heap is available, register a #[panic_handler] and optional #[global_allocator], and design a hybrid library that builds on both std and no_std consumers via a default = ["std"] feature.
Cross-references: [[rust-core-stdlib-overview]] (what std contains that core does not), [[rust-impl-cargo-project]] (features, profiles, panic = "abort"), [[rust-syntax-unsafe]] (#[global_allocator] involves the unsafe GlobalAlloc trait), [[rust-impl-cross-compile]] (thumbv7em-none-eabihf and other bare-metal targets).
When to use this skill
- User adds
#![no_std]tolib.rs/main.rsand the compiler starts complaining about missing types. - User targets an embedded board (Cortex-M, RISC-V MCU) and asks how to structure the crate.
- User wants
Vec/Box/Stringin a no_std crate and is unsure whetherallocis allowed. - User writes their first
#[panic_handler]or hits the "duplicate panic handler" link error. - User wants to plug in a custom allocator via
#[global_allocator]. - User has a library that some users will consume from std applications and others from no_std firmware, and asks how to support both.
- User asks why
std::format!doesn't compile in their crate. - User asks why
HashMapcannot be imported fromalloc.
For multi-target build matrices and target triples see [[rust-impl-cross-compile]]. For unsafe-trait constraints on GlobalAlloc see [[rust-syntax-unsafe]].
Decision tree: do I really need no_std?
Will this code ever run on a target WITHOUT an operating system?
NO -> Keep std. Done.
YES -> Continue.
Does the target have a heap allocator available?
YES (embedded with embedded-alloc, OS kernel with own allocator, WASM)
-> #![no_std] + extern crate alloc;
NO (bare-metal with strictly bounded memory)
-> #![no_std] alone, no alloc. Use heapless / static buffers.
Is this a library that std users ALSO need to consume?
YES -> Hybrid pattern: default = ["std"], std = [].
Code is no_std by default, `std` feature re-enables std-only APIs.
NO -> Pure no_std crate. Don't add std as a feature.
ALWAYS choose pure no_std over the hybrid pattern when the crate has no realistic std consumer: the hybrid pattern doubles testing matrix and cfg complexity. NEVER add #![no_std] to a binary crate "just in case" without an actual no_std target.
Anatomy of a #![no_std] crate
#![no_std] // 1. crate attribute, MUST be at crate root
#![cfg_attr(not(test), no_main)] // 2. embedded bin: no `main` entry point
// (libraries omit this)
extern crate alloc; // 3. opt into the alloc crate (heap types)
use core::fmt::Write; // 4. core replaces std for non-alloc items
use alloc::vec::Vec; // 5. heap types come from alloc::*
use alloc::string::String;
#[panic_handler] // 6. required for non-std binary crates
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
#[global_allocator] // 7. only if you ship a custom allocator
static GLOBAL: MyAllocator = MyAllocator;
Source: Rust Reference, names/preludes
What changes versus a std crate:
| Concern | Without no_std (std) |
With #![no_std] |
|---|---|---|
| Prelude | std::prelude::rust_2024 |
core::prelude::rust_2024 |
| Heap types | std::vec::Vec, std::string::String |
alloc::vec::Vec, alloc::string::String (needs extern crate alloc;) |
| Formatting | std::fmt, std::format! |
core::fmt, core::write!, core::writeln! (no format! without alloc) |
| Panic | runtime-provided | user MUST provide #[panic_handler] (binary / cdylib / dylib) |
| Entry point | fn main() |
platform-specific (e.g. cortex-m-rt::entry) plus #![no_main] |
println! |
yes | NO (no stdout); use semihosting, defmt, or a serial logger |
ALWAYS reach for core:: first when writing no_std code. Only escalate to alloc:: when the algorithm needs the heap, and only escalate further (std::) inside #[cfg(feature = "std")] blocks.
NEVER write extern crate std; in a no_std crate. NEVER call std::format!, std::println!, std::eprintln!, or anything under std::fs / std::net / std::process from no_std code.
core vs alloc vs std: what lives where
core = language minimum. Always available. No heap, no OS.
alloc = adds heap-backed types. Requires a global allocator.
std = adds OS abstractions (threads, files, network, time, env, process).
std re-exports everything from core and alloc, plus its own additions.
Source: alloc crate documentation
core contains (selected, no allocation needed):
core::option::Option,core::result::Result(re-exported via prelude).core::iter::Iterator,IntoIterator, adapters (map,filter,take).core::fmt::{Debug, Display, Write, Formatter}and the format-string machinery used bywrite!.core::mem(size_of,align_of,swap,replace,take,ManuallyDrop).core::ptr(raw pointer ops,NonNull,addr_of!,addr_of_mut!).core::cell::{Cell, RefCell, OnceCell, UnsafeCell}.core::sync::atomic::*(AtomicUsize,AtomicBool,Ordering, ...).core::marker::{PhantomData, Send, Sync, Sized, Copy}.core::error::Error(stable inno_stdsince 1.81).core::panic::PanicInfofor#[panic_handler].
alloc adds (heap-backed types, requires an allocator):
alloc::boxed::Box,alloc::vec::Vec,alloc::string::String.alloc::rc::{Rc, Weak},alloc::sync::{Arc, Weak}.alloc::collections::{BTreeMap, BTreeSet, VecDeque, BinaryHeap, LinkedList}.alloc::format!macro (heap-allocating formatting).alloc::borrow::Cow,alloc::borrow::ToOwned.alloc::slice::SliceConcatExtextras.
std adds (OS-bound):
std::collections::HashMap/HashSet(usehashbrownin no_std crates instead).std::fs,std::io,std::net,std::process,std::thread,std::time::SystemTime,std::env.std::sync::{Mutex, RwLock, Once, OnceLock, LazyLock}(these are NOT incore::sync;core::syncis atomics only).
ALWAYS check the docs.rs page for a crate before depending on it from no_std. Look for a default-features = false example and a no_std feature.
NEVER assume std::collections::HashMap has an alloc-only counterpart: it does not. Use hashbrown::HashMap (the same backing implementation std uses) with default-features = false.
#[panic_handler]: required signature
"must appear once in the dependency graph of a binary / dylib / cdylib crate" Source: Rustonomicon, panic-handler
The exact required signature, verified against the Rustonomicon:
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
| Constraint | Rule |
|---|---|
| Argument | exactly one: &core::panic::PanicInfo |
| Return type | ! (the never type); function must diverge |
| Visibility | private (fn, no pub) |
| Frequency | exactly ONE in the entire dependency graph of the final artefact |
| Location | typically the binary crate; libraries SHOULD NOT define one |
Production-grade handlers:
// Option A: halt the CPU (Cortex-M).
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
cortex_m::asm::udf() // undefined instruction; debugger trap
}
// Option B: rely on the panic-halt crate (no allocation, no print).
// In Cargo.toml: panic-halt = "0.2"
extern crate panic_halt; // its #[panic_handler] is picked up automatically
// Option C: log + abort (panic-probe + defmt).
// extern crate panic_probe;
ALWAYS provide the panic handler in the BINARY crate, never inside a reusable library. NEVER write panic!() inside a #[panic_handler]: re-entry into the panic machinery is undefined.
NEVER copy the handler into multiple library crates; the linker fails with "duplicate lang item panic_impl" (error E0152) when more than one is found.
#[global_allocator]: hooking the heap
Once extern crate alloc; is in scope, Box, Vec, String, and friends call into a single global allocator. For std targets that allocator is provided automatically. In no_std you provide it yourself.
// Minimal Cortex-M heap using the embedded-alloc crate.
use embedded_alloc::Heap;
#[global_allocator]
static HEAP: Heap = Heap::empty();
fn init_heap() {
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 1024;
static mut MEMORY: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(MEMORY.as_ptr() as usize, HEAP_SIZE) }
}
| Rule | Detail |
|---|---|
| Trait implemented | core::alloc::GlobalAlloc (this is an unsafe trait) |
| Frequency | exactly ONE #[global_allocator] static per final artefact |
| Lifetime | 'static; usually a static variable |
| Initialization | allocator may need a runtime init() call BEFORE the first allocation |
ALWAYS call the allocator's init routine before the first Box::new / Vec::push / heap-allocating call. NEVER allocate from inside #[panic_handler] or other interrupt-context code unless the allocator documents it as safe.
NEVER expose #[global_allocator] from a library crate: it is a global, application-level choice. Libraries depend on alloc, applications pick the allocator.
Hybrid std + no_std library pattern
The canonical Cargo recipe, verified against the Cargo features reference:
[package]
name = "mylib"
edition = "2024"
rust-version = "1.85"
[features]
default = ["std"] # std users get std automatically
std = ["alloc", "serde?/std"] # std implies alloc
alloc = [] # opt in to heap types separately
[dependencies]
serde = { version = "1", optional = true, default-features = false }
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(feature = "alloc")]
extern crate alloc;
#[cfg(feature = "alloc")]
use alloc::string::String;
#[cfg(feature = "std")]
use std::io; // only compiled in for std consumers
Three feature tiers, in dependency order:
(none) -> pure core. No heap, no OS. Most restrictive.
alloc -> core + heap. Vec, Box, String, BTreeMap available.
std -> core + alloc + OS. Files, network, threads, HashMap available.
ALWAYS gate extern crate alloc; behind #[cfg(feature = "alloc")] when the crate also supports pure-core builds. NEVER make std the only opt-in: by following the additive-features principle, the default must include everything most users want, and the absence of a feature must REMOVE functionality, not break compilation for the default case.
NEVER write default = [] for a hybrid library: every existing std consumer's build would break the moment they upgrade.
Testing a #[no_std] crate
cargo test defaults to building a test harness that depends on std. Three paths:
Std-only tests : tests use
std, library still compiles for no_std targets.#[cfg(test)] mod tests { // std is implicitly available here; this still works in a #![no_std] crate use super::*; }Verify the no_std path separately:
cargo build --no-default-features --target thumbv7em-none-eabihfIntegration tests in a separate std crate: workspace member with
[dev-dependencies] mylib = { path = "..", default-features = false }.defmt-testorembedded-testfor on-target testing (out of scope for this skill; see embedded-book chapter on testing).
ALWAYS run cargo build --no-default-features for every published no_std crate as part of CI, and ALSO cargo build --no-default-features --features alloc if the hybrid pattern is used. NEVER assume cargo test proves the no_std path: it does not.
Cargo profile: pair no_std with panic = "abort"
"
panic = "abort"... Required for someno_stdtargets." Source: vooronderzoek §19, verified against Cargo profiles reference
[profile.release]
panic = "abort" # no unwinding tables
lto = "fat"
codegen-units = 1
opt-level = "z" # size, common for embedded
strip = "symbols"
[profile.dev]
panic = "abort"
| Setting | Reason for no_std |
|---|---|
panic = "abort" |
unwinding requires eh_personality lang item; abort skips it entirely |
opt-level = "z" or "s" |
embedded flash is small; size beats speed |
lto = "fat" |
cross-crate inlining removes unused std-shim code |
strip = "symbols" |
smaller binary; debug info goes to a separate .elf |
ALWAYS set panic = "abort" for both [profile.dev] AND [profile.release] in pure embedded binaries: tests would otherwise mismatch.
NEVER mix panic = "unwind" with no_std targets that lack eh_personality: link fails with "undefined reference to rust_eh_personality".
Quick recipes
Add the alloc crate to an existing no_std lib
// lib.rs
#![no_std]
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
A minimal Cortex-M binary, no allocator
#![no_std]
#![no_main]
use core::panic::PanicInfo;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
loop {}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
cortex_m::asm::udf()
}
Replace format! with core::write! to a heapless buffer
use core::fmt::Write;
use heapless::String; // heapless = "0.8"
let mut s: String<32> = String::new();
write!(s, "value = {}", 42).unwrap(); // no heap, fixed-capacity buffer
Conditional std::error::Error impl
#![cfg_attr(not(feature = "std"), no_std)]
// Works on both std and no_std since 1.81 (core::error::Error is stable).
impl core::error::Error for MyError {}
Validation checklist
Before publishing or merging:
cargo build --no-default-featuressucceeds (proves the pure-core / pure-no_std path).cargo build --no-default-features --features allocsucceeds when the alloc tier exists.cargo buildsucceeds (proves the default std path still works for std consumers).- The binary crate has exactly ONE
#[panic_handler]in the final dependency graph. - The binary crate has at most ONE
#[global_allocator]in the final dependency graph. - No
use std::...lines exist outside#[cfg(feature = "std")]blocks. cargo build --target <embedded-triple>succeeds when an embedded target is supported.- Profile has
panic = "abort"if the target requires it. core::error::Error(notstd::error::Error) is used for error trait impls in no_std-friendly code.
Reference links
- [[references/methods.md]] : full inventory of what lives in
corevsallocvsstd, complete#[panic_handler]+#[global_allocator]signatures, every relevant#[cfg]predicate, the no_std prelude per edition. - [[references/examples.md]] : full crate templates : minimal Cortex-M binary, no_std library with alloc, hybrid std + no_std library with three feature tiers, custom allocator implementation, heapless string formatting.
- [[references/anti-patterns.md]] : seven concrete anti-patterns with diagnostics, error codes (E0152, undefined
rust_eh_personality, missingallocimport), and the exact fix per case.