The primary surprise about no_std Rust is that it’s not about removing standard library features, but about selecting which ones you need from a minimal, core set.

Let’s see no_std in action. Imagine we’re building a tiny embedded system that blinks an LED. We’ll need a way to toggle a GPIO pin. The embedded-hal crate provides traits for hardware peripherals, and we’ll use a specific microcontroller’s HAL implementation.

First, our Cargo.toml:

[package]
name = "blinky"
version = "0.1.0"
edition = "2021"

[dependencies]
embedded-hal = "0.2.7" # For hardware peripheral traits
cortex-m-rt = "0.7.0" # For the runtime on ARM Cortex-M
panic-halt = "0.2.0" # A simple panic handler

# Replace with your specific microcontroller's HAL
# For example, for an STM32F4 series:
# stm32f4xx-hal = "0.12.0"

[profile.release]
opt-level = "z" # Optimize for size
lto = true

Now, src/main.rs:

#![no_std] // This is the key directive!
#![no_main] // We'll provide our own entry point.

use embedded_hal::digital::v2::OutputPin;
use cortex_m_rt::entry;
use panic_halt as _;

// Import the specific peripherals for your microcontroller
// Example for STM32F4:
// use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    // Get access to the microcontroller's peripherals
    // Example for STM32F4:
    // let dp = pac::Peripherals::take().unwrap();
    // let cp = cortex_m::Peripherals::take().unwrap(); // Core peripherals like SysTick

    // Configure the specific GPIO pin for output
    // Example for STM32F4, configuring PA5 as output:
    // let mut gpioa = dp.GPIOA.constrain();
    // let mut led = gpioa.pa5.into_push_pull_output();

    // Simple loop to toggle the LED
    loop {
        // Example for STM32F4:
        // led.set_high().unwrap();
        // cortex_m::asm::delay(1_000_000); // Delay for roughly 1 million cycles
        // led.set_low().unwrap();
        // cortex_m::asm::delay(1_000_000);
    }
}

The #[no_std] attribute tells the Rust compiler that it should not link against the Rust standard library. This is crucial for embedded systems where memory is scarce and a full std is impossible, and for WebAssembly (WASM) where the runtime environment is different. Instead, you rely on core primitives (like integer types, basic data structures, and control flow) and external crates that provide necessary functionality, often through traits defined in core or alloc (if dynamic allocation is needed, which requires a custom allocator).

When you use #[no_std], you lose access to things like println!, file I/O, networking, and the default heap. For println!, you’d typically use a crate like defmt or rtt-target for embedded debugging. For dynamic allocation, you’d need to implement a global allocator and use the alloc crate, which is also available in no_std contexts.

The #[no_main] attribute signifies that you are providing your own entry point function, marked with #[entry] from cortex-m-rt in this embedded example. This function is where execution begins after the hardware reset and initialization performed by the runtime. For WASM, the entry point is often implicitly handled or specified differently.

The real power comes from what you can still use. You can use core::iter, core::ops, core::cmp, and other fundamental Rust features. You can also use external crates that are designed to be no_std compatible. embedded-hal is a prime example, defining interfaces for common hardware operations (like reading a sensor or toggling a pin) that HAL crates for specific microcontrollers then implement. This allows for portable embedded code.

For WASM, the wasm-bindgen crate is fundamental. It allows you to interface between your Rust code and JavaScript, effectively bridging the gap between Rust’s no_std environment and the browser’s or Node.js’s host environment. You can expose Rust functions to JavaScript, call JavaScript functions from Rust, and manage memory.

The most impactful lever you control in no_std development is the selection of dependencies. Each crate you add, especially those that bring in complex logic or require heap allocation (even if it’s just alloc), increases your binary size. For embedded, opt-level = "z" and lto = true in your Cargo.toml are essential for minimizing code size. For WASM, while size is also important, the interoperability provided by wasm-bindgen and the ability to leverage JavaScript APIs become key considerations.

The next hurdle you’ll often encounter is managing state and complex data structures without the convenience of a standard library’s heap.

Want structured learning?

Take the full Cargo course →