Rust Without the Standard Library A Deep Dive into no_std Development
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the vibrant world of Rust programming, we often take for granted the rich ecosystem and powerful std
library that provides everything from data structures to networking capabilities. It's a comfortable, high-level environment that accelerates development for countless applications. However, not all computing environments offer such luxuries. Imagine systems with extremely limited memory, no operating system, or strict real-time constraints – think embedded devices, microcontrollers, or even the very core of an operating system kernel. In these scenarios, the std
library, with its reliance on OS services and dynamic memory allocation, becomes a hindrance rather than a help. This is where no_std
programming in Rust shines. It empowers developers to write highly efficient, bare-metal code, extending Rust's safety and performance guarantees to the truly constrained. This article will delve into the exciting realm of no_std
, explaining its fundamentals, demonstrating its application, and showcasing why it's an indispensable tool for a growing number of developers.
The Bare Essentials of no_std
Before we embark on our no_std
journey, let's establish a clear understanding of the key concepts that underpin this programming paradigm.
Core Terminology
no_std
: This attribute, applied at the crate level (#![no_std]
), tells the Rust compiler not to link against the standard library. Instead, it links against thecore
library, which provides fundamental language primitives likeOption
,Result
, basic integer and float types, iterators, and slices, but critically, no OS-dependent features or dynamic memory allocation.std
library: Rust's standard library, providing a rich set of APIs for common programming tasks, including file I/O, networking, threading, collections (likeVec
andHashMap
), and dynamic memory management.core
library: The foundational library for Rust, required by all Rust programs, evenno_std
ones. It contains the absolute minimum necessary for Rust to function, including primitive types, fundamental traits, and basic error handling.alloc
crate: An optional crate that provides common collection types likeVec
andHashMap
without depending on thestd
library, but with a dependency on a global allocator. This means you can use these dynamic data structures in ano_std
environment, provided you supply an allocator.- Allocator: A mechanism responsible for managing dynamic memory. In
std
environments, a default system allocator is implicitly used. Inno_std
withalloc
, you must explicitly provide and register a global allocator. - Panic Handler: When a Rust program encounters an unrecoverable error (e.g., an out-of-bounds array access), it "panics." In
std
environments, this typically prints a backtrace and exits. Inno_std
, you must define your own panic handler, as there's no OS to catch the panic or print to. - Entry Point: The starting point of your program. In
std
programs, this is typically themain
function. Inno_std
environments, especially bare-metal ones, you often need to define a custom entry point, usually linked by the linker script, to perform initial setup before calling yourmain
or equivalent function.
Principles and Implementation
The core principle behind no_std
is self-reliance. Without the standard library, you are responsible for managing resources, handling errors, and interacting with hardware directly or through specialized HALs (Hardware Abstraction Layers).
Let's illustrate with a simple "Hello, World!" for a no_std
environment, aiming for the bare minimum without any printing capabilities initially.
#![no_std] // Crucial: opt out of the standard library #![no_main] // Crucial: opt out of the standard main function use core::panic::PanicInfo; // Define a custom entry point // The `cortex-m-rt` crate often provides a more robust entry point for ARM microcontrollers. // For purely illustrative purposes, we're doing it manually here. #[no_mangle] // Ensure the linker can find this function by its name pub extern "C" fn _start() -> ! { // Your initialization code // For a real embedded system, this might configure clocks, GPIO, etc. loop { // Our program does nothing but loop indefinitely } } // Define our own panic handler #[panic_handler] fn panic(_info: &PanicInfo) -> ! { // In a real application, this might: // - Light an LED to indicate an error // - Log error information to a serial port // - Trigger a system reset loop {} }
This minimal example demonstrates the two most critical aspects of no_std
: #![no_std]
and a custom panic handler. The _start
function serves as our program's entry point, which would typically be configured via a linker script for a specific target.
Using the alloc
Crate
If you need dynamic collections in a no_std
environment, you can reintroduce alloc
. This requires two things: enabling the alloc
feature in your Cargo.toml
and providing a global allocator.
Cargo.toml
:
[dependencies] # ... other dependencies ... alloc = { version = "0.0.0", package = "alloc" } # Use the `alloc` crate, it's typically 'built-in' but enabled with a feature
Actually, alloc
is not a separate crate you add to Cargo.toml
in the same way. It's a conditional compilation target of the Rust compiler itself. To enable alloc
in your no_std
project, you typically rely on build tools or libraries that handle this. For example, in an embedded project using cortex-m-alloc
, you would enable the alloc
feature on that specific allocator crate. Let's use a common pattern for embedded systems:
Example with cortex-m-alloc
:
# Cargo.toml [dependencies] cortex-m = { version = "0.7.6", features = ["critical-section"] } cortex-m-rt = "0.7.0" cortex-m-alloc = "0.4.0" # Our chosen allocator
src/main.rs
(or src/lib.rs
for a library):
#![no_std] #![no_main] #![feature(alloc_error_handler)] // Needed for custom alloc error handler extern crate alloc; // Bring the `alloc` crate into scope use core::panic::PanicInfo; use alloc::vec::Vec; // Now we can use Vec! // Define a global allocator #[global_allocator] static ALLOCATOR: cortex_m_alloc::CortexMHeap = cortex_m_alloc::CortexMHeap::empty(); // Allocator initialization // This would typically go in your `_start` routine before any allocations. // For simplicity, let's put it in a setup function here. fn init_allocator() { // Initialize the heap with a region of memory // In a real program, this memory region would be defined in a linker script // or be a static array. const HEAP_SIZE: usize = 1024; // 1KB heap static mut HEAP_MEM: [u8; HEAP_SIZE] = [0; HEAP_SIZE]; // SAFETY: We are taking mutable reference to a static // and initializing the allocator only once. unsafe { ALLOCATOR.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) } } // Our custom entry point #[cortex_m_rt::entry] // Provided by cortex-m-rt for ARM microcontrollers fn main() -> ! { init_allocator(); // Initialize the allocator let mut my_vec: Vec<u32> = Vec::new(); my_vec.push(10); my_vec.push(20); // If we had a way to print, we would print my_vec here. // For example, by sending it over a serial port. loop { // Application code } } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {} } // Define a custom error handler for out-of-memory errors #[alloc_error_handler] fn oom(_: core::alloc::Layout) -> ! { // Handle out-of-memory error // e.g., blink an LED, reset the system loop {} }
This example shows how to bring in dynamic allocations. The critical part is defining #[global_allocator]
and providing the init
function to tell it where to manage memory from. The actual memory region (HEAP_MEM
) would typically be declared and managed by your embedded build environment's linker script for proper placement.
Application Scenarios
no_std
Rust is not just an academic exercise; it's a powerful approach for real-world applications where resources are paramount.
- Embedded Systems: This is perhaps the most common and compelling use case. Microcontrollers like ARM Cortex-M series (e.g., in IoT devices, wearables, industrial control) have kilobytes of RAM and flash, far too little for a full OS and
std
library.no_std
Rust, combined with HALs, allows developers to write low-level, high-performance, and type-safe firmware. - Operating System Kernels: Rust is gaining traction in OS development. Writing an OS kernel requires direct hardware interaction, careful memory management, and no reliance on an underlying OS.
no_std
is fundamental here, empowering developers to build kernels from the ground up, leveraging Rust's strong type system for robustness. - Bootloaders: The initial piece of code that runs when a system starts up, responsible for initializing hardware and loading the main operating system or application. Bootloaders operate in a highly constrained environment and are a natural fit for
no_std
. - Device Drivers: In some bare-metal or specialized OS environments, drivers might be written in
no_std
Rust to directly interface with hardware without involving a fullstd
runtime. - High-Performance Computing (HPC) / Scientific Computing (Specialized Cases): While less common, in scenarios requiring extreme control over memory layout and avoiding any OS-level overhead for critical performance paths,
no_std
libraries or modules could be integrated into largerstd
applications, provided they manage their memory and interactions carefully.
Conclusion
no_std
programming in Rust unlocks a vast frontier for developers, extending Rust's acclaimed safety, performance, and concurrency benefits to the most resource-constrained and bare-metal environments. By opting out of the standard library and embracing the core
library, developers gain fine-grained control over their code's footprint and behavior, making Rust an ideal choice for embedded systems, operating system kernels, and other niche applications where every byte and cycle counts. Mastering no_std
is not just about writing code without the std
library; it's about understanding the fundamental layers of computing and wielding Rust's power to build reliable, efficient systems from the ground up.