Guide contents

Lesson 82 of 94

no_std: core and alloc

The standard library assumes an operating system: threads, files, sockets, a clock, and a heap. Firmware for a microcontroller, a bootloader, a kernel, or a WebAssembly module with no host functions has none of those. Rust handles this by splitting the standard library into layers, and a crate marked #![no_std] uses only the lower ones.

This matters even if you never write firmware. Many widely used crates (serde, hashbrown, bytes, log, thiserror, heapless) are no_std or have a no_std mode, and their source starts with the attributes shown in this lesson.

Crate Needs Contains
core nothing Option, Result, iterators, slices, str, char, fmt, cell, sync::atomic, mem, ptr, time::Duration, error::Error
alloc a heap allocator Box, Vec, String, Rc, Arc, BTreeMap, VecDeque, format!
std an operating system everything above, plus HashMap, threads, Mutex, files, networking, println!, Instant

std re-exports core and alloc, so std::option::Option and core::option::Option are the same type.

1. A no_std library

#![no_std] at the top of a crate removes std from scope and switches the prelude to core's. What's left is still a lot of Rust. This library compiles as it is:

Rust
#![no_std]

/// CRC-8 (polynomial 0x07), used by many sensors to check each reading.
pub fn crc8(data: &[u8]) -> u8 {
    let mut crc = 0u8;
    for &byte in data {
        crc ^= byte;
        for _ in 0..8 {
            crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
        }
    }
    crc
}

/// Parses "21.5" into tenths (215), without floating point or allocation.
pub fn parse_tenths(text: &str) -> Option<i32> {
    let (whole, fraction) = text.split_once('.').unwrap_or((text, "0"));
    if fraction.len() != 1 {
        return None;
    }
    let negative = whole.starts_with('-');
    let whole: i32 = whole.trim_start_matches('-').parse().ok()?;
    let tenth: i32 = fraction.parse().ok()?;
    let value = whole * 10 + tenth;
    Some(if negative { -value } else { value })
}

Everything used here, slices, &str methods, parse, Option, ?, comes from core.

2. Trying no_std code in a normal program

To test no_std code on your machine, keep the crate #![no_std] and bring std back explicitly with extern crate std;. Code in the crate still gets only the core prelude, so anything that accidentally needs std fails to compile, but main can print. The rest of this lesson uses this setup.

Rust
#![no_std]
extern crate std;

use std::println;

mod sensor {
    // This module uses only `core`.
    pub fn crc8(data: &[u8]) -> u8 {
        let mut crc = 0u8;
        for &byte in data {
            crc ^= byte;
            for _ in 0..8 {
                crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
            }
        }
        crc
    }

    pub fn average(readings: &[i16]) -> Option<i16> {
        if readings.is_empty() {
            return None;
        }
        let sum: i32 = readings.iter().map(|&r| r as i32).sum();
        Some((sum / readings.len() as i32) as i16)
    }
}

fn main() {
    let frame = [0x01, 0x5A];
    println!("crc: {:#04x}", sensor::crc8(&frame));
    println!("average: {:?}", sensor::average(&[215, 218, 220]));
    println!("average of nothing: {:?}", sensor::average(&[]));
}
Output
crc: 0x94
average: Some(217)
average of nothing: None

3. The heap isn't there by default

Without alloc, there's no Vec, String, or Box. They aren't in the core prelude:

Rust✗ does not compile · E0433
#![no_std]
extern crate std;

fn collect_errors(codes: &[u8]) -> usize {
    let mut errors = Vec::new();
    for &code in codes {
        if code >= 0x80 {
            errors.push(code);
        }
    }
    errors.len()
}

fn main() {
    std::println!("{}", collect_errors(&[0x01, 0x81, 0x90]));
}
Output
error[E0433]: failed to resolve: use of undeclared type `Vec`
 --> src/main.rs:5:22
  |
5 |     let mut errors = Vec::new();
  |                      ^^^ use of undeclared type `Vec`
  |
help: consider importing this struct
  |
2 + use std::vec::Vec;
  |

The compiler suggests std::vec::Vec because this program happens to link std. In a real no_std crate, the fix is either alloc or a design that doesn't need the heap.

4. alloc: collections without an operating system

If the target has a heap allocator, extern crate alloc; adds Box, Vec, String, format!, BTreeMap, and friends. HashMap is missing: its default hasher needs random seeds from the OS. no_std code uses BTreeMap or the hashbrown crate (the implementation behind std's HashMap).

Rust
#![no_std]
extern crate alloc;
extern crate std;

use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use alloc::{format, vec};
use std::println;

fn summarize(events: &[(&str, u32)]) -> Vec<String> {
    let mut totals: BTreeMap<&str, u32> = BTreeMap::new();
    for &(device, bytes) in events {
        *totals.entry(device).or_insert(0) += bytes;
    }
    totals.iter().map(|(device, bytes)| format!("{device}: {bytes} B")).collect()
}

fn main() {
    let events = vec![("pump", 120), ("valve", 40), ("pump", 80)];
    for line in summarize(&events) {
        println!("{line}");
    }
}
Output
pump: 200 B
valve: 40 B

A real no_std binary using alloc must also provide the allocator, with #[global_allocator] and a crate such as embedded-alloc.

5. Formatting without a heap: core::fmt::Write

format! needs alloc, but formatting itself lives in core. Anything that implements core::fmt::Write can be the target of write!. A fixed-size buffer on the stack is the standard way to build log lines on a device:

Rust
#![no_std]
extern crate std;

use core::fmt::{self, Write};
use std::println;

struct LineBuffer<const N: usize> {
    bytes: [u8; N],
    len: usize,
}

impl<const N: usize> LineBuffer<N> {
    fn new() -> Self {
        LineBuffer { bytes: [0; N], len: 0 }
    }

    fn as_str(&self) -> &str {
        // Only whole `&str` pieces are ever copied in, so the bytes are valid UTF-8.
        core::str::from_utf8(&self.bytes[..self.len]).unwrap()
    }
}

impl<const N: usize> Write for LineBuffer<N> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        let end = self.len + s.len();
        if end > N {
            return Err(fmt::Error); // full: refuse instead of allocating
        }
        self.bytes[self.len..end].copy_from_slice(s.as_bytes());
        self.len = end;
        Ok(())
    }
}

fn main() {
    let mut line = LineBuffer::<32>::new();
    write!(line, "temp={}.{}C id={:04}", 21, 5, 7).unwrap();
    println!("{} ({} bytes)", line.as_str(), line.len);

    let mut tiny = LineBuffer::<8>::new();
    let result = write!(tiny, "pressure={}", 101_325);
    println!("{result:?}, kept {:?}", tiny.as_str());
}
Output
temp=21.5C id=0007 (18 bytes)
Err(Error), kept ""

write! on a full buffer returns an error instead of growing, and the caller decides what to do.

6. Fixed-capacity collections with const generics

Without a heap, collections have a capacity chosen at compile time. The heapless crate provides Vec<T, N>, String<N>, and queues built this way. Here is a ring buffer that keeps the last N sensor readings:

Rust
#![no_std]
extern crate std;

use std::println;

struct Recent<const N: usize> {
    values: [i16; N],
    next: usize,
    count: usize,
}

impl<const N: usize> Recent<N> {
    const fn new() -> Self {
        Recent { values: [0; N], next: 0, count: 0 }
    }

    fn push(&mut self, value: i16) {
        self.values[self.next] = value;
        self.next = (self.next + 1) % N;
        self.count = (self.count + 1).min(N);
    }

    fn average(&self) -> Option<i16> {
        if self.count == 0 {
            return None;
        }
        let sum: i32 = self.values[..self.count].iter().map(|&v| v as i32).sum();
        Some((sum / self.count as i32) as i16)
    }
}

fn main() {
    let mut recent: Recent<4> = Recent::new();
    println!("{:?}", recent.average());
    for reading in [200, 210, 220, 230, 400, 410] {
        recent.push(reading);
        println!("pushed {reading}, average of last {} = {:?}", recent.count, recent.average());
    }
    println!("size: {} bytes", core::mem::size_of::<Recent<4>>());
}
Output
None
pushed 200, average of last 1 = Some(200)
pushed 210, average of last 2 = Some(205)
pushed 220, average of last 3 = Some(210)
pushed 230, average of last 4 = Some(215)
pushed 400, average of last 4 = Some(265)
pushed 410, average of last 4 = Some(315)
size: 24 bytes

The size is fixed and known at compile time, so a Recent<4> can live in a static, on the stack, or inside another struct, with no allocation ever.

7. Errors in no_std code

core::error::Error has been stable since Rust 1.81, so no_std libraries implement the same Error trait as everyone else (std::error::Error is a re-export of it). Display comes from core::fmt.

Rust
#![no_std]
extern crate std;

use core::fmt;
use std::println;

#[derive(Debug, PartialEq)]
enum FrameError {
    TooShort { len: usize },
    BadChecksum { expected: u8, actual: u8 },
}

impl fmt::Display for FrameError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FrameError::TooShort { len } => write!(f, "frame too short: length {len}"),
            FrameError::BadChecksum { expected, actual } => {
                write!(f, "checksum mismatch: expected {expected:#04x}, got {actual:#04x}")
            }
        }
    }
}

impl core::error::Error for FrameError {}

fn checksum(data: &[u8]) -> u8 {
    data.iter().fold(0u8, |acc, &b| acc.wrapping_add(b))
}

// A frame is [payload..., checksum].
fn parse_frame(frame: &[u8]) -> Result<&[u8], FrameError> {
    let Some((&expected, payload)) = frame.split_last() else {
        return Err(FrameError::TooShort { len: 0 });
    };
    if payload.is_empty() {
        return Err(FrameError::TooShort { len: frame.len() });
    }
    let actual = checksum(payload);
    if actual != expected {
        return Err(FrameError::BadChecksum { expected, actual });
    }
    Ok(payload)
}

fn main() {
    for frame in [&[0x10, 0x20, 0x30][..], &[0x10, 0x20, 0x31], &[0x07]] {
        match parse_frame(frame) {
            Ok(payload) => println!("payload {payload:?}"),
            Err(error) => {
                let as_error: &dyn core::error::Error = &error;
                println!("error: {as_error}");
            }
        }
    }
}
Output
payload [16, 32]
error: checksum mismatch: expected 0x31, got 0x30
error: frame too short: length 1

8. The optional std feature

Most libraries don't choose between std and no_std; they support both. The crate is no_std unless a Cargo feature named std is enabled, and that feature is on by default:

TOMLCargo.toml
[features]
default = ["std"]
std = []
Rust
// lib.rs of a library that works with and without std.
#![cfg_attr(not(feature = "std"), no_std)]

#[derive(Debug)]
pub struct Reading {
    pub sensor: u8,
    pub tenths: i32,
}

impl Reading {
    pub fn celsius(&self) -> i32 {
        self.tenths / 10
    }
}

// Extra API that needs the operating system, compiled only with `std`.
#[cfg(feature = "std")]
impl Reading {
    pub fn log_line(&self) -> String {
        format!("sensor {} at {}", self.sensor, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs())
    }
}

Normal users get the full API. A firmware project opts out:

TOMLCargo.toml of a firmware project
[dependencies]
sensor-lib = { version = "1", default-features = false }

When you see #![cfg_attr(not(feature = "std"), no_std)] at the top of a crate, this is what it means: "no_std unless the std feature is on". Some crates add a third level, an alloc feature between the two.

9. What a bare-metal binary adds

A no_std library needs nothing more. A no_std binary that runs without an operating system must also supply what std normally provides:

  • #![no_main]: there is no OS to call main. A runtime crate for the chip (cortex-m-rt, riscv-rt, or a framework such as Embassy) defines the real entry point.
  • A #[panic_handler] function: what to do on a panic. There's no process to exit, so the usual choices are halting, resetting the chip, or logging over a debug probe (panic-halt, panic-probe).
  • #[global_allocator], only if it uses alloc.
Rust
#![no_std]
#![no_main]

use core::panic::PanicInfo;
use cortex_m_rt::entry;

#[entry]
fn main() -> ! {
    let mut ticks: u32 = 0;
    loop {
        ticks = ticks.wrapping_add(1); // read sensors, toggle pins, ...
    }
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {} // halt; a real project would reset or report over a probe
}

The -> ! return type means these functions never return: a microcontroller's main loop runs until power is removed. Such projects are built for a target triple such as thumbv7em-none-eabihf, added with rustup target add.

Summary

  • core needs nothing, alloc needs a heap, and std needs an operating system. std re-exports the other two.
  • #![no_std] removes std and switches to the core prelude. Iterators, slices, str, Option, Result, fmt, atomics, and core::error::Error are all still available.
  • extern crate alloc; brings back Vec, String, Box, BTreeMap, and format!, but not HashMap.
  • Without a heap, write into fixed buffers through core::fmt::Write and use fixed-capacity collections built with const generics, as the heapless crate does.
  • Libraries usually support both worlds with #![cfg_attr(not(feature = "std"), no_std)] and a default std feature.
  • A bare-metal binary adds #![no_main], a #[panic_handler], and, if it allocates, a #[global_allocator].

Next: Performance: measuring, and the habits that make Rust code fast.