How to Write Rust Code Without the Standard Library

Add `#![no_std]` to the top of your `main.rs` file to disable the standard library. This forces the compiler to use only the core library, which is essential for embedded systems or bare-metal programming.

When the operating system doesn't exist

You are writing code for a microcontroller that controls a rocket thruster. Or a bootloader that runs before the OS even knows it exists. Or a kernel module that manages memory for the rest of the system. You hit "run" and the simulator screams about a missing main symbol. You try to use println! and the compiler throws a fit. You try to create a Vec and the linker fails.

You have stepped out of the comfort zone. There is no file system. There is no heap. There is no thread scheduler. There is no libc. You are in no_std territory.

Rust's standard library, std, assumes an operating system. It talks to the kernel to allocate memory, print to the console, and manage threads. When you remove std, you remove those assumptions. You are left with the language itself: types, traits, memory safety, and zero-cost abstractions. You keep the safety. You lose the OS glue.

What remains when std is gone

no_std does not mean "no library." It means "no std crate." The core crate is always available. core contains the heart of Rust. You still have Option, Result, slice, str, Cell, RefCell, Mutex (atomic), Arc (atomic), fmt, iter, mem, ptr, and all the language primitives.

Think of std as a fully furnished apartment with a landlord. The landlord handles the water, electricity, and trash. no_std is a tent in the wilderness. You bring your own water filter, your own shelter, and your own rules. If you forget the water filter, you thirst. If you design the tent well, you survive.

core gives you the tools to build safe, efficient code without an OS. You can write complex data structures, implement algorithms, and manage hardware registers. You just cannot call std::fs::read or std::thread::spawn.

The minimal skeleton

A no_std binary requires three things that a normal binary hides from you. You need to disable std. You need to disable the default main wrapper. You need to provide a panic handler.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// Handles panics by looping forever.
/// In a real system, this might reset the CPU or log to hardware.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // SAFETY: Returning from a panic handler is undefined behavior.
    // The system is in an unrecoverable state.
    // An infinite loop prevents the CPU from executing garbage instructions.
    loop {}
}

/// The entry point for the binary.
/// The linker looks for this symbol when `no_main` is active.
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Initialize hardware here
    loop {}
}

The #![no_std] attribute tells the compiler to link only the core library. The #![no_main] attribute tells the compiler not to look for a main function or generate the startup glue code that initializes the runtime. The #[panic_handler] attribute marks the function that runs when panic! is called. The #[no_mangle] attribute keeps the symbol name exactly as written so the linker can find it. The extern "C" attribute defines the calling convention. The -> ! return type means the function never returns.

How the compiler and linker react

When you compile a normal Rust binary, the compiler generates a main function and links it against std. The std crate provides a default panic handler that prints to stderr and exits the process. The linker looks for the main symbol and wraps it with startup code.

When you add #![no_std], the compiler stops linking std. You lose println!, Vec, String, Box, and everything that depends on the OS. If you try to use println!, the compiler rejects you with E0433 (can't find println in std).

When you add #![no_main], the compiler stops generating the default entry point. The linker will fail with an "undefined reference to main" error unless you provide your own entry point. The convention is to use _start or a target-specific reset handler.

The panic handler is mandatory. The compiler inserts calls to the panic handler whenever panic! is invoked. Without a panic handler, the linker fails. The panic handler must have the signature fn(&PanicInfo) -> !. The PanicInfo struct contains the panic message and location. The -> ! return type ensures the function never returns, which prevents undefined behavior from resuming execution after a panic.

A realistic example with hardware interaction

Real no_std code usually talks to hardware. You write to memory-mapped registers to control LEDs, UARTs, or timers. You use core::fmt::Write to format output without std.

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use core::fmt::Write;

/// A mock UART interface for demonstration.
struct Uart;

impl Write for Uart {
    /// Writes a string slice to the mock UART.
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        // SAFETY: In production code, this would write to memory-mapped registers.
        // We simulate success here.
        for _byte in s.bytes() {
            // Hardware write logic goes here
        }
        Ok(())
    }
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    let mut uart = Uart;
    // Use core::fmt::Write to format output without std
    let _ = write!(uart, "System initialized\n");
    loop {}
}

The core::fmt::Write trait allows you to use the write! macro. This gives you formatting capabilities without std. You implement Write for your hardware interface. The write! macro calls write_str with the formatted string. This pattern works for any output device.

Pitfalls and compiler errors

no_std code trips up developers who expect std behavior. The compiler catches most mistakes, but the errors can be confusing if you don't know what to look for.

If you forget the panic handler, the linker fails with an undefined reference error. The compiler cannot generate a panic handler for you because it doesn't know how to interact with your hardware. You must define one.

If you try to use Vec or String, the compiler rejects you with E0433. These types live in alloc, which is separate from core. You can use alloc in no_std code, but you need to set up a global allocator first.

If you try to use println!, the compiler rejects you with E0433. println! is a macro in std. Use write! from core::fmt instead.

If you try to return from the panic handler, the compiler warns you. Returning from a panic handler is undefined behavior. The system is in an invalid state. The panic handler should loop forever, reset the CPU, or trigger a hardware watchdog.

If you use Box without alloc, the compiler rejects you. Box requires a heap allocator. You can use Box in no_std code if you enable the alloc crate and define a global allocator.

Dynamic memory with alloc

You can use dynamic memory in no_std code. The alloc crate provides Vec, String, Box, Rc, and Arc. You need to enable the alloc crate in your Cargo.toml and define a global allocator.

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use core::alloc::Layout;
use core::ptr;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

/// A minimal global allocator for demonstration.
/// In real code, use a crate like `buddy_system_allocator` or `linked_list_allocator`.
struct MyAllocator;

unsafe impl core::alloc::GlobalAlloc for MyAllocator {
    /// Allocates memory with the given layout.
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // SAFETY: This is a placeholder.
        // Real allocators manage memory pools or linked lists.
        // Returning null indicates allocation failure.
        ptr::null_mut()
    }

    /// Deallocates memory previously allocated by this allocator.
    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // SAFETY: This is a placeholder.
        // Real allocators free memory back to the pool.
    }
}

#[global_allocator]
static ALLOC: MyAllocator = MyAllocator;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Now you can use Vec, String, Box, etc.
    let _vec: Vec<u8> = Vec::new();
    loop {}
}

The #[global_allocator] attribute marks a static variable as the global allocator. The type must implement GlobalAlloc. The alloc and dealloc methods manage memory. The unsafe impl is required because the allocator must guarantee memory safety. The community convention is to keep the allocator implementation small and isolated. Use a crate like buddy_system_allocator or linked_list_allocator for production code.

Don't reach for Vec until you've wired up a global allocator. The compiler will stop you, and for good reason. Without an allocator, Vec cannot grow.

Libraries versus binaries

no_std is often used for libraries, not just binaries. A library can be no_std by default and opt-in to std when the target supports it. This makes the library portable to both embedded systems and host applications.

Add #![no_std] to the top of your library crate. Use cfg(feature = "std") to conditionally include std features. Users can enable the std feature in their Cargo.toml to get the full functionality.

#![no_std]

#[cfg(feature = "std")]
extern crate std;

use core::fmt;

/// A type that works in both std and no_std environments.
pub struct MyType {
    value: u32,
}

impl MyType {
    /// Creates a new instance.
    pub fn new(value: u32) -> Self {
        Self { value }
    }
}

impl fmt::Display for MyType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "MyType({})", self.value)
    }
}

The #[cfg(feature = "std")] attribute conditionally compiles the extern crate std block. This allows the library to use std types when the feature is enabled. The core::fmt::Display trait works in both environments. This pattern is the standard way to write portable Rust libraries.

When to use no_std

Use no_std for bare-metal kernels when you need to control the hardware directly and the operating system does not exist. Use no_std for microcontrollers when memory is constrained and the overhead of std is unacceptable. Use no_std for libraries when you want your crate to compile on targets that lack an operating system. Use std for applications when you are running on a host operating system and need file I/O, networking, or threads. Use alloc when you need dynamic memory allocation but do not require the full standard library.

Reach for core types first. Option, Result, slice, and str work everywhere. Reach for alloc only when you need dynamic growth. Reach for std only when you need OS services.

Where to go next