What Is no_std in Rust and When Do You Need It?

The no_std attribute disables the Rust standard library for embedded targets, requiring manual implementation of panic handlers and memory allocation.

When the OS disappears

You write a Rust program to blink an LED on a microcontroller. You compile it, flash the binary, and the linker screams about missing symbols like __libc_start_main. You didn't ask for a C library. You just wanted to toggle a pin. The problem isn't your code. It's the invisible baggage you dragged along: the standard library.

Rust's default mode assumes you are running on an operating system. It links std, which talks to file systems, spawns threads via OS calls, allocates memory through system APIs, and prints to a terminal. When you target a bare-metal chip, a kernel, or a bootloader, there is no OS. There is no terminal. There is no memory manager. Linking std is like trying to run a desktop application inside a calculator.

The #![no_std] attribute tells the compiler to drop the standard library. It forces your crate to rely only on core, the layer of Rust that knows nothing about operating systems. This is how you write code that runs on hardware with no OS, or how you build libraries that work everywhere from desktops to embedded devices.

The three layers of Rust

Rust's library ecosystem is split into three distinct layers. Understanding the boundary between them is the key to no_std.

The bottom layer is core. It contains the fundamental building blocks of the language: integers, floats, booleans, slices, arrays, Option, Result, traits, and basic math. It has no dependency on an OS. It has no heap allocator. It runs on any target that can execute Rust code, including chips with kilobytes of RAM and no memory management unit.

The middle layer is alloc. It sits on top of core and adds heap allocation. It provides Box, Vec, String, Rc, and Arc. It requires a global allocator, but it still assumes no OS. You can use alloc on bare metal if you provide your own allocator.

The top layer is std. It sits on top of alloc and adds OS integration. It provides file I/O, networking, threads, println!, and the default global allocator that calls malloc or new under the hood.

Think of std as a fully furnished apartment. It has a fridge, a stove, and a doorbell. It's comfortable, but you can't move it into a cave. core is a survival kit. It has a knife, a fire starter, and a compass. It's lighter, and it works in the cave. alloc is a portable water filter. It's useful in the cave, but you have to bring your own water source.

#![no_std] tells the compiler to pack the survival kit and the filter, but leave the apartment behind.

Minimal no_std library

The easiest way to start with no_std is a library crate. Libraries don't need an entry point, so you avoid the complexity of startup code and panic handlers.

// src/lib.rs
#![no_std]

/// Calculates the square of a number.
/// This compiles on any target, including those without an OS.
pub fn square(n: i32) -> i32 {
    n * n
}

/// Adds two numbers using core-only types.
/// Returns None if the addition would overflow.
pub fn checked_add(a: i32, b: i32) -> Option<i32> {
    // Option is in core, so it works in no_std.
    a.checked_add(b)
}

This code compiles instantly with cargo build. You get Option, i32, and arithmetic. You lose String, Vec, and println!. The compiler links core automatically. You don't need extern crate core;. It's always available.

Convention aside: core is part of the Rust prelude. You can use types like Option and Result without importing them. This holds true in no_std crates. The prelude adapts to the context.

What you keep and what you lose

When you enable no_std, the compiler strips std from the dependency graph. Here is what remains and what vanishes.

You keep core. That means you have Option, Result, slice, array, str, char, bool, i32, u8, f32, f64, trait, impl, enum, struct, match, if, loop, while, for, fn, const, static, unsafe, as, ref, mut, let, return, break, continue, self, Self, super, crate, pub, priv, where, type, use, mod, extern, impl, trait, async, await, dyn, move, catch_unwind (wait, catch_unwind is in std). You have the language itself. You have the basic types. You have the control flow.

You lose std. That means no String, no Vec, no HashMap, no HashSet, no Box (unless you add alloc), no Rc, no Arc, no Mutex, no thread::spawn, no fs::File, no io::stdout, no println!, no panic! (the macro exists in core, but the handler is missing), no env::args, no time::SystemTime.

If you try to use a type from std, the compiler rejects you immediately. Attempting to import std::vec::Vec triggers E0432 (unresolved import) because the std crate isn't linked. Trying to call println! triggers E0425 (cannot find value println in this scope) because the macro isn't defined.

Strip the OS assumption. Let the hardware speak.

Building a binary without an OS

Library crates are simple. Binary crates require more work. A binary needs an entry point and a panic handler. std provides both. In no_std, you must provide them yourself.

The #![no_main] attribute tells the compiler not to generate the default main entry point. The default entry point expects a C runtime and an OS. You replace it with your own entry point, which depends on the target architecture. For x86_64, you define _start. For ARM Cortex-M, you define a reset handler.

You also need a panic handler. When code panics, Rust looks for a function marked with #[panic_handler]. If you don't provide one, the linker fails with an undefined reference error.

// src/main.rs
#![no_std]
#![no_main]

use core::panic::PanicInfo;

// SAFETY: The panic handler is a global singleton.
// Implementing this function registers it as the handler.
// 1. This function must not return.
// 2. This function must be the only panic handler in the dependency graph.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // In a real system, you'd trigger a watchdog reset
    // or write error codes to a memory-mapped register.
    // Spinning is the safest fallback for a demo.
    loop {}
}

// Entry point depends on architecture.
// For x86_64 bare-metal, you'd define _start here.
// For ARM, you'd define the reset handler.
// This example omits the arch-specific entry to keep it readable.
// A real binary must define the entry point or link against a crate that does.

The panic_handler must return the never type !. It must never return. If it returns, the behavior is undefined. In embedded systems, the convention is to reset the chip or halt execution. Spinning in a loop is acceptable for debugging, but it wastes power and can mask errors.

Convention aside: Keep unsafe blocks small. The community calls this the "minimum unsafe surface" rule. Even in no_std, where you interact with hardware registers, wrap raw pointer access in safe abstractions. Expose safe APIs, hide the unsafe implementation.

The allocator trap

You might want Vec or String in a no_std binary. These types live in the alloc crate, not core. You can opt in to alloc while staying no_std.

Add extern crate alloc; to your crate root. Then you can use alloc::vec::Vec and alloc::string::String. There is a catch. alloc requires a global allocator. std provides one automatically. In no_std, you must define one.

// src/lib.rs
#![no_std]

extern crate alloc;

use alloc::vec::Vec;

/// Creates a vector of integers.
/// Requires a global allocator to be defined in the binary crate.
pub fn make_vec() -> Vec<i32> {
    // Vec is in alloc, not core.
    // This compiles, but linking fails without a global allocator.
    vec![1, 2, 3]
}

If you use alloc without defining a global allocator, the linker fails with an undefined reference to __rust_alloc. You fix this by defining a #[global_allocator]. This is usually a custom allocator tailored to your hardware, like a bump allocator or a heap manager backed by a specific memory region.

Check your imports. If it starts with std::, you're already out of bounds.

Pitfalls and compiler errors

Working with no_std introduces specific failure modes. The compiler catches most of them, but the errors can be confusing if you expect std behavior.

Using println! is the most common mistake. The macro doesn't exist in core. You get E0425 (cannot find value println in this scope). The solution is to use the log crate and implement a logger that writes to a UART or memory-mapped console. log works in both std and no_std.

Using String or Vec without alloc triggers E0432 (unresolved import). You must add extern crate alloc; and ensure a global allocator is present.

Mixing std and no_std crates causes linker errors. If a dependency uses std, your no_std crate inherits std. You can't have a no_std binary that depends on a std library. The compiler doesn't warn you about this at compile time. The linker fails with undefined references to OS functions. Check your dependency tree. Use cargo tree to verify that all dependencies support no_std.

Some crates offer no_std support behind a feature flag. You might need to disable default features and enable alloc or std explicitly. Read the crate documentation. Many popular crates like serde, log, and hashbrown support no_std.

Don't fight the compiler here. Reach for core types and alloc only when necessary.

Decision matrix

Use std when you are building applications that run on an operating system. The overhead is negligible, and you get the full power of the standard library.

Use #![no_std] with core only when you are targeting bare-metal hardware, writing a kernel, or building a library that must support both std and no_std environments. This is the minimal footprint.

Use #![no_std] with extern crate alloc when you need heap allocation like Vec or String but still lack an operating system. You must provide a global allocator.

Reach for std if you are unsure. The complexity of no_std build pipelines, panic handlers, and allocators is rarely worth it for desktop or server applications. Keep std in your toolbox. Pull out no_std only when the target has no OS.

Where to go next