Mastering No_std Rust: Embedded Development Without the Standard Library

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world! Rust's embedded development with the no_std approach has revolutionized how we build software for resource-constrained environments. I've spent years working with embedded systems, and Rust's capabilities in this space continue to impress me with their balance of safety and performance. When I first encountered bare-metal programming, C was the dominant language. Today, Rust offers compelling advantages for these environments where every byte and cycle counts. The no_std paradigm removes the standard library dependencies, allowing Rust code to run directly on hardware without an operating system. This is essential for firmware, bootloaders, and applications on microcontrollers with limited resources. The Foundation of No-Std Development No_std development centers around the #[no_std] attribute, which tells the compiler not to include the standard library. Instead, programs rely on Rust's core library, providing fundamental types and functions without OS requirements. #![no_std] #![no_main] use core::panic::PanicInfo; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { // Handle panic in a resource-constrained environment loop {} } This minimal example demonstrates two critical aspects of no_std development: the #[no_std] attribute and a custom panic handler. Since the standard panic behavior requires OS features, we must provide our implementation. The core library offers basic functionality like Option, Result, and Iterator traits, but lacks features requiring OS support such as: Heap allocation Threading File I/O Network communication Standard output No_std development forces us to think differently about program design. We must carefully manage resources and rely on static allocation patterns. Memory Management Without a Heap Memory management presents significant challenges in no_std environments. Without heap allocation, we must use alternative approaches: Static allocation Stack-based data structures Memory pools Global allocators (when available) For fixed-size data, static allocation works well: // Static allocation of a buffer static mut BUFFER: [u8; 1024] = [0; 1024]; fn process_data() { // Safety: We must ensure exclusive access unsafe { BUFFER[0] = 42; // Process the buffer... } } For collections, we can use stack-based alternatives like heapless: use heapless::Vec; fn collect_measurements() { // A vector with a capacity of 16 elements allocated on the stack let mut readings = Vec::::new(); for i in 0..10 { if readings.push(sample_sensor(i)).is_err() { // Handle the case when the vector is full } } process_readings(&readings); } The static approach requires planning maximum sizes in advance, but benefits include deterministic memory usage and elimination of allocation failures during operation. Hardware Abstraction and Peripheral Access Interacting with hardware is central to embedded development. Rust's type system provides safe abstractions for hardware registers. Peripheral Access Crates (PACs) give low-level access to device peripherals: use stm32f4::stm32f411 as device; fn configure_gpio() { let peripherals = device::Peripherals::take().unwrap(); let rcc = &peripherals.RCC; let gpioa = &peripherals.GPIOA; // Enable clock for GPIOA rcc.ahb1enr.modify(|_, w| w.gpioaen().set_bit()); // Configure PA5 as output gpioa.moder.modify(|_, w| w.moder5().output()); } For higher-level abstractions, Hardware Abstraction Layer (HAL) crates provide more user-friendly APIs: use stm32f4xx_hal::{pac, prelude::*}; fn blink_led() { let dp = pac::Peripherals::take().unwrap(); let rcc = dp.RCC.constrain(); let clocks = rcc.cfgr.freeze(); let gpioa = dp.GPIOA.split(); let mut led = gpioa.pa5.into_push_pull_output(); let mut delay = dp.TIM2.delay_ms(&clocks); loop { led.set_high(); delay.delay_ms(500_u16); led.set_low(); delay.delay_ms(500_u16); } } The embedded-hal traits standardize interfaces across different microcontrollers, enabling portable code: use embedded_hal::digital::v2::OutputPin; fn toggle(pin: &mut T) -> Result { pin.set_high()?; // Delay operation... pin.set_low()?; Ok(()) } This approach means drivers written against these traits work across different hardware platforms. Interrupts and Concurrency Handling interrupts safely is crucial in embedded systems. Rust helps manage this complexity: #[interrupt] fn EXTI0() { static mut COUNTER: u32 = 0; // This is safe because interrupts have exclusive access to their static variables *COUNTER += 1; if *COUNTER % 5 == 0 { // Process every fifth

Apr 4, 2025 - 11:11
 0
Mastering No_std Rust: Embedded Development Without the Standard Library

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Rust's embedded development with the no_std approach has revolutionized how we build software for resource-constrained environments. I've spent years working with embedded systems, and Rust's capabilities in this space continue to impress me with their balance of safety and performance.

When I first encountered bare-metal programming, C was the dominant language. Today, Rust offers compelling advantages for these environments where every byte and cycle counts.

The no_std paradigm removes the standard library dependencies, allowing Rust code to run directly on hardware without an operating system. This is essential for firmware, bootloaders, and applications on microcontrollers with limited resources.

The Foundation of No-Std Development

No_std development centers around the #[no_std] attribute, which tells the compiler not to include the standard library. Instead, programs rely on Rust's core library, providing fundamental types and functions without OS requirements.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // Handle panic in a resource-constrained environment
    loop {}
}

This minimal example demonstrates two critical aspects of no_std development: the #[no_std] attribute and a custom panic handler. Since the standard panic behavior requires OS features, we must provide our implementation.

The core library offers basic functionality like Option, Result, and Iterator traits, but lacks features requiring OS support such as:

  • Heap allocation
  • Threading
  • File I/O
  • Network communication
  • Standard output

No_std development forces us to think differently about program design. We must carefully manage resources and rely on static allocation patterns.

Memory Management Without a Heap

Memory management presents significant challenges in no_std environments. Without heap allocation, we must use alternative approaches:

  1. Static allocation
  2. Stack-based data structures
  3. Memory pools
  4. Global allocators (when available)

For fixed-size data, static allocation works well:

// Static allocation of a buffer
static mut BUFFER: [u8; 1024] = [0; 1024];

fn process_data() {
    // Safety: We must ensure exclusive access
    unsafe {
        BUFFER[0] = 42;
        // Process the buffer...
    }
}

For collections, we can use stack-based alternatives like heapless:

use heapless::Vec;

fn collect_measurements() {
    // A vector with a capacity of 16 elements allocated on the stack
    let mut readings = Vec::<u16, 16>::new();

    for i in 0..10 {
        if readings.push(sample_sensor(i)).is_err() {
            // Handle the case when the vector is full
        }
    }

    process_readings(&readings);
}

The static approach requires planning maximum sizes in advance, but benefits include deterministic memory usage and elimination of allocation failures during operation.

Hardware Abstraction and Peripheral Access

Interacting with hardware is central to embedded development. Rust's type system provides safe abstractions for hardware registers.

Peripheral Access Crates (PACs) give low-level access to device peripherals:

use stm32f4::stm32f411 as device;

fn configure_gpio() {
    let peripherals = device::Peripherals::take().unwrap();
    let rcc = &peripherals.RCC;
    let gpioa = &peripherals.GPIOA;

    // Enable clock for GPIOA
    rcc.ahb1enr.modify(|_, w| w.gpioaen().set_bit());

    // Configure PA5 as output
    gpioa.moder.modify(|_, w| w.moder5().output());
}

For higher-level abstractions, Hardware Abstraction Layer (HAL) crates provide more user-friendly APIs:

use stm32f4xx_hal::{pac, prelude::*};

fn blink_led() {
    let dp = pac::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    let mut delay = dp.TIM2.delay_ms(&clocks);

    loop {
        led.set_high();
        delay.delay_ms(500_u16);
        led.set_low();
        delay.delay_ms(500_u16);
    }
}

The embedded-hal traits standardize interfaces across different microcontrollers, enabling portable code:

use embedded_hal::digital::v2::OutputPin;

fn toggle<T: OutputPin>(pin: &mut T) -> Result<(), T::Error> {
    pin.set_high()?;
    // Delay operation...
    pin.set_low()?;
    Ok(())
}

This approach means drivers written against these traits work across different hardware platforms.

Interrupts and Concurrency

Handling interrupts safely is crucial in embedded systems. Rust helps manage this complexity:

#[interrupt]
fn EXTI0() {
    static mut COUNTER: u32 = 0;

    // This is safe because interrupts have exclusive access to their static variables
    *COUNTER += 1;

    if *COUNTER % 5 == 0 {
        // Process every fifth interrupt
        process_event();
    }
}

For shared state between interrupts and the main context, we use synchronization primitives:

use core::cell::RefCell;
use cortex_m::interrupt::{free, Mutex};

static SHARED_DATA: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[interrupt]
fn SysTick() {
    free(|cs| {
        let mut data = SHARED_DATA.borrow(cs).borrow_mut();
        *data += 1;
    });
}

fn main() -> ! {
    // Setup code...

    loop {
        free(|cs| {
            let data = SHARED_DATA.borrow(cs).borrow();
            if *data > 100 {
                perform_action();
            }
        });
    }
}

Rust's ownership model prevents data races at compile time, a significant advantage over C/C++ in concurrent contexts.

Real-Time Constraints

Many embedded systems have real-time requirements. Rust helps address these needs through:

  1. Predictable execution without garbage collection pauses
  2. Zero-cost abstractions that compile to efficient machine code
  3. Fine-grained control over memory layout
  4. Direct hardware access capabilities

For time-critical sections, we can disable interrupts:

use cortex_m::interrupt::{disable, enable};

fn critical_timing_operation() {
    // Disable interrupts for precise timing
    let primask = disable();

    // Perform timing-sensitive operations
    precise_pulse();

    // Restore previous interrupt state
    enable(primask);
}

Real-time operating systems like RTIC (Real-Time Interrupt-driven Concurrency) provide frameworks for developing applications with timing guarantees:

#[rtic::app(device = stm32f4xx_hal::pac)]
mod app {
    use stm32f4xx_hal::{prelude::*, pac};

    #[shared]
    struct Shared {
        counter: u32,
    }

    #[local]
    struct Local {
        led: pac::GPIOA,
    }

    #[init]
    fn init(cx: init::Context) -> (Shared, Local, init::Monotonics) {
        // Setup hardware...

        // Schedule the periodic task
        timer_tick::spawn().ok();

        (
            Shared { counter: 0 },
            Local { led: cx.device.GPIOA },
            init::Monotonics(),
        )
    }

    #[task(shared = [counter], local = [led])]
    fn timer_tick(mut cx: timer_tick::Context) {
        // Modify shared data safely
        cx.shared.counter.lock(|counter| {
            *counter += 1;
        });

        // Toggle LED
        // ...

        // Reschedule ourselves
        timer_tick::spawn_after(100.millis()).ok();
    }
}

Communication Protocols and Drivers

Embedded systems rely on various communication protocols. The Rust ecosystem offers implementations for common interfaces:

// SPI communication example
use embedded_hal::spi::{Mode, Phase, Polarity};
use stm32f4xx_hal::{pac, prelude::*, spi::Spi};

fn setup_spi(dp: pac::Peripherals) {
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpioa = dp.GPIOA.split();

    // SPI pins
    let sck = gpioa.pa5.into_alternate();
    let miso = gpioa.pa6.into_alternate();
    let mosi = gpioa.pa7.into_alternate();

    // SPI configuration
    let mode = Mode {
        polarity: Polarity::IdleLow,
        phase: Phase::CaptureOnFirstTransition,
    };

    let mut spi = Spi::spi1(
        dp.SPI1,
        (sck, miso, mosi),
        mode,
        1.MHz(),
        clocks,
    );

    // Communicate with device
    let mut buffer = [0u8; 4];
    spi.transfer(&mut buffer).unwrap();
}

For I2C:

use stm32f4xx_hal::{i2c::I2c, pac, prelude::*};

fn read_sensor(dp: pac::Peripherals) -> [u8; 2] {
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();
    let gpiob = dp.GPIOB.split();

    let scl = gpiob.pb8.into_alternate().set_open_drain();
    let sda = gpiob.pb9.into_alternate().set_open_drain();

    let mut i2c = I2c::i2c1(
        dp.I2C1,
        (scl, sda),
        100.kHz(),
        clocks,
    );

    const SENSOR_ADDR: u8 = 0x48;
    let mut data = [0u8; 2];

    // Read temperature register
    i2c.write_read(SENSOR_ADDR, &[0x00], &mut data).unwrap();

    data
}

For more complex devices, driver crates abstract the details:

use bme280::BME280;

fn read_environmental_data<I2C, E>(i2c: &mut I2C) -> Result<(), E>
where
    I2C: embedded_hal::blocking::i2c::WriteRead<Error = E>,
{
    let mut bme280 = BME280::new_primary(i2c);
    bme280.init()?;

    let measurements = bme280.measure()?;

    let temperature = measurements.temperature;
    let pressure = measurements.pressure;
    let humidity = measurements.humidity;

    // Process data...

    Ok(())
}

Flash Memory and Persistence

Managing flash memory requires care in embedded systems:

use stm32f4xx_hal::{flash::{FlashExt, SectorSize}, pac, prelude::*};

fn write_settings(value: u32) -> Result<(), &'static str> {
    let dp = unsafe { pac::Peripherals::steal() };
    let mut flash = dp.FLASH.constrain();

    const SETTINGS_ADDR: u32 = 0x0800_C000; // Sector 3

    // Erase sector
    flash.erase(SectorSize::Sz16K, SETTINGS_ADDR).map_err(|_| "Erase failed")?;

    // Write data
    let bytes = value.to_le_bytes();
    flash.program(SETTINGS_ADDR, &bytes).map_err(|_| "Program failed")?;

    Ok(())
}

fn read_settings() -> u32 {
    const SETTINGS_ADDR: u32 = 0x0800_C000;

    // Read value from flash
    let ptr = SETTINGS_ADDR as *const u32;
    unsafe { ptr.read_volatile() }
}

For more structured storage, libraries like embedded-storage provide abstractions:

use embedded_storage::{Storage, ReadStorage};

struct FlashStorage {
    base_address: usize,
}

impl ReadStorage for FlashStorage {
    type Error = ();

    fn read(&mut self, offset: usize, bytes: &mut [u8]) -> Result<(), Self::Error> {
        // Read data from flash at base_address + offset
        for (i, byte) in bytes.iter_mut().enumerate() {
            let addr = (self.base_address + offset + i) as *const u8;
            *byte = unsafe { addr.read_volatile() };
        }
        Ok(())
    }
}

impl Storage for FlashStorage {
    fn write(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Self::Error> {
        // Write implementation...
        Ok(())
    }

    fn erase(&mut self, offset: usize, size: usize) -> Result<(), Self::Error> {
        // Erase implementation...
        Ok(())
    }
}

Power Management

Efficient power usage extends battery life in embedded devices:

use stm32f4xx_hal::{pac, prelude::*};

fn configure_low_power() {
    let dp = pac::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let pwr = dp.PWR;

    // Enable power interface clock
    rcc.apb1enr.modify(|_, w| w.pwren().set_bit());

    // Enter Stop mode when CPU enters deepsleep
    pwr.cr.modify(|_, w| w.pdds().clear_bit());

    // Configure Low-power regulator in Stop mode
    pwr.cr.modify(|_, w| w.lpds().set_bit());

    // Clear WUF wakeup flag
    pwr.cr.modify(|_, w| w.cwuf().set_bit());

    // Enter deepsleep mode when WFI/WFE is executed
    cortex_m::peripheral::SCB::set_sleepdeep();
}

fn enter_sleep() {
    // Execute Wait For Interrupt instruction
    cortex_m::asm::wfi();
}

Testing and Debugging

Testing embedded code presents unique challenges. Approaches include:

  1. Unit testing on the host machine
  2. Hardware-in-the-loop testing
  3. Simulation
  4. On-target testing

For host-based testing:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_message_parsing() {
        let data = [0x02, 0x10, 0x05, 0xFF, 0x03];
        let result = parse_message(&data);
        assert_eq!(result.command_id, 0x10);
        assert_eq!(result.payload, &[0x05, 0xFF]);
    }
}

For hardware debugging:

use cortex_m_semihosting::{hprintln, debug};

fn process_data(buffer: &[u8]) -> Result<u32, Error> {
    hprintln!("Processing {} bytes", buffer.len()).unwrap();

    let result = match perform_calculation(buffer) {
        Ok(val) => val,
        Err(e) => {
            hprintln!("Error: {:?}", e).unwrap();
            return Err(e);
        }
    };

    hprintln!("Result: {}", result).unwrap();
    Ok(result)
}

fn test_mode() -> ! {
    hprintln!("Entering test mode").unwrap();

    // Run test sequences
    run_self_test();

    hprintln!("All tests passed").unwrap();
    debug::exit(debug::EXIT_SUCCESS);

    loop {}
}

Building a Complete No-Std Application

Let's bring these concepts together in a more complete example—a temperature monitor that reads from a sensor, logs data to flash, and signals alerts via an LED:

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*, i2c::I2c};
use embedded_storage::{ReadStorage, Storage};

// Flash storage implementation
struct SettingsStorage {
    base_address: u32,
}

impl SettingsStorage {
    const MAGIC_BYTES: [u8; 4] = [0x54, 0x45, 0x4D, 0x50]; // "TEMP"

    fn new(sector_addr: u32) -> Self {
        Self { base_address: sector_addr }
    }

    fn is_initialized(&self) -> bool {
        let mut magic = [0u8; 4];
        self.read(0, &mut magic).unwrap();
        magic == Self::MAGIC_BYTES
    }

    fn initialize(&mut self) -> Result<(), ()> {
        self.erase(0, 16 * 1024)?;
        self.write(0, &Self::MAGIC_BYTES)
    }

    fn get_alarm_threshold(&mut self) -> i16 {
        let mut bytes = [0u8; 2];
        if self.read(4, &mut bytes).is_ok() {
            i16::from_le_bytes(bytes)
        } else {
            // Default threshold: 30°C
            30_i16
        }
    }

    fn set_alarm_threshold(&mut self, threshold: i16) -> Result<(), ()> {
        self.write(4, &threshold.to_le_bytes())
    }
}

// Implementation of embedded-storage traits for SettingsStorage
impl ReadStorage for SettingsStorage {
    type Error = ();

    fn read(&mut self, offset: usize, data: &mut [u8]) -> Result<(), Self::Error> {
        for (i, byte) in data.iter_mut().enumerate() {
            let addr = (self.base_address + offset as u32 + i as u32) as *const u8;
            *byte = unsafe { addr.read_volatile() };
        }
        Ok(())
    }
}

impl Storage for SettingsStorage {
    fn write(&mut self, offset: usize, data: &[u8]) -> Result<(), Self::Error> {
        // Flash write implementation...
        Ok(())
    }

    fn erase(&mut self, offset: usize, size: usize) -> Result<(), Self::Error> {
        // Flash erase implementation...
        Ok(())
    }
}

// Temperature sensor driver
struct TemperatureSensor<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C, E> TemperatureSensor<I2C>
where
    I2C: embedded_hal::blocking::i2c::WriteRead<Error = E>,
{
    fn new(i2c: I2C, address: u8) -> Self {
        Self { i2c, address }
    }

    fn read_temperature(&mut self) -> Result<i16, E> {
        let mut data = [0u8; 2];
        self.i2c.write_read(self.address, &[0x00], &mut data)?;

        // Convert raw data to temperature
        let raw = ((data[0] as u16) << 8) | (data[1] as u16);
        let temp = (raw as i16) / 16; // Example conversion

        Ok(temp)
    }
}

// Main application state
struct App<I2C, LED> {
    sensor: TemperatureSensor<I2C>,
    alert_led: LED,
    settings: SettingsStorage,
    threshold: i16,
}

impl<I2C, LED, E> App<I2C, LED>
where
    I2C: embedded_hal::blocking::i2c::WriteRead<Error = E>,
    LED: embedded_hal::digital::v2::OutputPin,
{
    fn new(i2c: I2C, led: LED, settings: SettingsStorage) -> Self {
        let sensor = TemperatureSensor::new(i2c, 0x48);
        let mut app = Self {
            sensor,
            alert_led: led,
            settings,
            threshold: 30, // Default
        };

        // Initialize settings if needed
        if !app.settings.is_initialized() {
            app.settings.initialize().ok();
            app.settings.set_alarm_threshold(app.threshold).ok();
        } else {
            app.threshold = app.settings.get_alarm_threshold();
        }

        app
    }

    fn update(&mut self) -> Result<(), E> {
        let temp = self.sensor.read_temperature()?;

        // Check alarm condition
        if temp > self.threshold {
            self.alert_led.set_high().ok();
        } else {
            self.alert_led.set_low().ok();
        }

        Ok(())
    }

    fn set_threshold(&mut self, new_threshold: i16) {
        self.threshold = new_threshold;
        self.settings.set_alarm_threshold(new_threshold).ok();
    }
}

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

#[entry]
fn main() -> ! {
    // Initialize the hardware
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::peripheral::Peripherals::take().unwrap();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    // GPIO for LED
    let gpioc = dp.GPIOC.split();
    let alert_led = gpioc.pc13.into_push_pull_output();

    // I2C setup
    let gpiob = dp.GPIOB.split();
    let scl = gpiob.pb8.into_alternate().set_open_drain();
    let sda = gpiob.pb9.into_alternate().set_open_drain();

    let i2c = I2c::i2c1(
        dp.I2C1,
        (scl, sda),
        100.kHz(),
        clocks,
    );

    // Flash settings
    let settings = SettingsStorage::new(0x0800_C000); // Sector 3

    // Create application
    let mut app = App::new(i2c, alert_led, settings);

    // Configure SysTick for timing
    let mut delay = cp.SYST.delay(clocks);

    // Main loop
    loop {
        if app.update().is_err() {
            // Handle error - maybe flash LED in a pattern?
        }

        // Wait before next reading
        delay.delay_ms(1000_u16);
    }
}

The Future of Embedded Rust

Embedded Rust continues to evolve rapidly. Recent developments include:

  1. Better support for more microcontroller families
  2. Improved async/await capabilities for embedded systems
  3. Enhanced tooling for debugging and development
  4. Growing community of libraries and drivers

The benefits of Rust in embedded systems are increasingly clear:

  • Memory safety without runtime overhead
  • Expressive, zero-cost abstractions
  • Strong static typing preventing common bugs
  • Excellent performance characteristics
  • Growing ecosystem of well-designed libraries

I've found that Rust's no_std approach for embedded development strikes an excellent balance between safety and control. While the learning curve can be steep, the resulting code is more reliable and maintainable than traditional C/C++ alternatives.

For those looking to adopt Rust in embedded projects, start with simple applications like GPIO control and gradually incorporate more complex features. The Rust embedded working group provides excellent resources and examples to help new developers get started.

With its focus on safety without sacrificing performance, Rust is well-positioned to become a leading language for embedded systems development in the years ahead.

101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools

We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva