Skip to Content
Course content

180: Working Without an Allocator (alloc-free Rust)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Most of the time, we treat the heap like an infinite playground. We throw Vecs and Strings around without a second thought because the allocator handles the messy parts. But when you're writing a bootloader, a kernel, or firmware for a tiny microcontroller, that luxury vanishes. You're in no_std territory.

Working without an allocator isn't just about removing a dependency; it's a fundamental shift in how you think about data ownership. You can't just "grow" a collection. Everything must have a known size at compile time, or you have to be very clever with lifetimes and slices. Let's build a simple packet parser for a simulated temperature sensor to see this in action.

Stripping back to no_std

First, we have to tell the compiler we aren't using the standard library. I'll start by adding the attribute at the top of my crate. We'll also need core, which is the subset of std that doesn't require an allocator.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

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

Since we have no main function in the traditional OS sense, I've added a dummy panic handler. In a real embedded project, this would probably trigger a system reset or blink an LED. Now, let's define what we're parsing. Imagine our sensor sends a packet: a 4-byte ID, a 4-byte float for temperature, and a 1-byte status flag.

Designing the SensorReading struct

I want a struct that represents this reading. Normally, I might want to include a human-readable name for the sensor, but that's where the no_std constraint hits. I can't use String.

struct SensorReading {
    id: u32,
    temperature: f32,
    status: u8,
}

impl SensorReading {
    fn from_bytes(bytes: &[u8]) -> Option<&'static SensorReading> {
        // I'll come back to this implementation in a second
        None
    }
}

The "Muscle Memory" Mistake

Here is where I usually trip up when switching back to no_std. I started writing the parser and instinctively tried to collect the bytes into a Vec to make slicing easier. I literally typed let data = bytes.to_vec(); before remembering that Vec lives in the alloc crate, and I haven't defined an allocator.

The compiler caught me immediately: cannot find type Vec in this scope. It's a jarring reminder that the heap is gone. I can't copy data into a dynamic container; I have to work with the bytes exactly where they sit in memory.

Parsing via Slices and Casting

To get this working, I'll use slices and the from_be_bytes method (big-endian), which is provided by the primitive types in core. This is the "correct" way to handle binary data without allocation: map the slice directly to the types you need.

impl SensorReading {
    fn parse(bytes: &[u8]) -> Result {
        if bytes.len() < 9 {
            return Err("Packet too short");
        }

        // Use try_into to get fixed-size arrays from the slice
        let id_bytes: [u8; 4] = bytes[0..4].try_into().map_err(|_| "ID parse failed")?;
        let temp_bytes: [u8; 4] = bytes[4..8].try_into().map_err(|_| "Temp parse failed")?;
        let status = bytes[8];

        Ok(SensorReading {
            id: u32::from_be_bytes(id_bytes),
            temperature: f32::from_be_bytes(temp_bytes),
            status,
        })
    }
}

Notice that SensorReading is now a simple stack-allocated struct. I'm returning a Result where the error is a &'static str. Since static strings are stored in the binary's read-only data section, they don't require an allocator. This pattern—using slices and static references—is your bread and butter in alloc-free Rust.

If I had needed a variable-length name for the sensor, I would have had to either use a fixed-size array (e.g., [u8; 16]) or store a reference to the original input buffer with a lifetime (e.g., &'a str). That second option is more efficient but forces you to keep the raw input buffer alive as long as the SensorReading exists.




📋 Practical Task

Implementing a CRC-8 Checksum for the Sensor Parser

In a real-world scenario, no_std packets usually include a checksum to ensure data wasn't corrupted. Your task is to extend the SensorReading example.

The Requirements:

  • Modify the parse function to expect a 10th byte at the end of the slice (the checksum).
  • Implement a simple checksum validation: sum all the first 9 bytes of the packet and check if the result modulo 256 equals the 10th byte.
  • If the checksum is invalid, return a Err("Checksum mismatch").
  • Ensure that absolutely no alloc types (like Vec or String) are used.

Starter snippet:

fn validate_checksum(bytes: &[u8]) -> bool {
    // Your logic here
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.