Intro to Rust on Embedded: Your First no_std Project with Code Examples

You’ve written Rust on a laptop. You’ve fought the borrow checker, won, and maybe even enjoyed it. But here’s the thing: all that safety machinery, the zero-cost abstractions, the absence of a garbage collector, those features matter more on a chip with 256 KB of flash and no operating system. Yet most Rust developers haven’t tried it, because the ecosystem looks like alphabet soup: PAC, HAL, BSP, SVD, probe-rs, OpenOCD. It’s enough to make you close the tab and go back to writing web servers.
This rust embedded tutorial gives you one linear path. You’ll set up a no_std project, understand the layers of the embedded Rust stack, and blink an LED on real hardware. The whole thing takes under an hour if your board’s already in front of you (and maybe 20 minutes more if it’s still in the drawer).
What Does no_std Mean in Rust?
Standard Rust programs link against std, which assumes you have an operating system underneath: a heap allocator, a filesystem, threads, networking. A microcontroller has none of that. You opt out.
core is the foundation. It’s always available, even on bare metal. You keep Option, Result, iterators, slices, traits, generics, and the full borrow checker. The Rust you know still works.
alloc sits in the middle. It gives you Vec, String, and Box, but only if you wire up a heap allocator yourself. Most embedded projects skip it entirely, at least at first.
std is the full standard library. File I/O, threads, println!, HashMap. Gone on bare metal.
| Crate | Available in no_std? | Provides |
|---|---|---|
core | ✅ Always | Option, Result, iterators, traits |
alloc | ⚠️ If you set up an allocator | Vec, String, Box |
std | ❌ No | File I/O, networking, threads, println! |
In practice, a no_std entry point looks like this:
#![no_std]
#![no_main]Those two lines tell the compiler: no standard library, and no regular fn main() (because there’s no OS to call it). You’ll also need a panic handler, since the default one lives in std. The easiest option is the panic-halt crate, which just halts the processor on panic. Good enough for now.
Most of your Rust muscle memory transfers directly. You’re giving up convenience libraries, not the language.
The Embedded Rust Stack: PAC, HAL, BSP
The ecosystem is fragmented in layers, but they make sense once you see the diagram:
┌─────────────────────────────────┐
│ Your Application │
├─────────────────────────────────┤
│ BSP (board support package) │ ← optional, board-specific
├─────────────────────────────────┤
│ HAL (hardware abstraction) │ ← e.g., nrf52840-hal
│ implements embedded-hal traits│
├─────────────────────────────────┤
│ PAC (peripheral access) │ ← auto-generated from SVD
├─────────────────────────────────┤
│ Hardware (MCU) │
└─────────────────────────────────┘PAC (Peripheral Access Crate): Auto-generated from an SVD file (an XML description of the chip’s registers). It gives you raw register access with a thin layer of type safety on top. You rarely touch this directly.
HAL (Hardware Abstraction Layer): A safe, ergonomic API built on top of the PAC. This is where you’ll spend your time. The HAL crate for your chip (like nrf52840-hal) implements traits from embedded-hal, a shared trait crate that defines interfaces like OutputPin, DelayNs, I2c, and Spi.
BSP (Board Support Package): Maps the HAL to a specific dev board’s pin names and peripherals. The nRF52840-DK has one. It’s convenient but optional; you can always use the HAL directly.
Here’s the superpower: because embedded-hal defines shared traits, driver crates for sensors and displays work across any chip that implements those traits. Write a temperature logger on an nRF52, swap to an STM32 later, and the sensor driver doesn’t change.
Hardware and Tooling Setup
Board choice: This tutorial uses the nRF52840-DK as the concrete example. It has a built-in J-Link debugger (no extra hardware needed) and excellent Rust support via nrf52840-hal. Every pattern here applies to any Cortex-M board with a HAL crate: STM32 Nucleos, the RP2040 Pico, whatever you’ve got. Just swap the HAL crate and target triple.
Tooling checklist:
| Step | Command / Action | Purpose |
|---|---|---|
| 1 | rustup target add thumbv7em-none-eabihf | Cross-compile for Cortex-M4F |
| 2 | cargo install probe-rs-tools | Flash and debug tool |
| 3 | Add memory.x to project root | Tells the linker where flash and RAM live |
| 4 | Configure .cargo/config.toml | Sets default target and uses probe-rs as the runner |
probe-rs replaces the older OpenOCD + GDB workflow. It’s a single tool that handles flashing, running, and defmt log output.
How to Blink an LED with Rust on a Microcontroller
Step 1: Scaffold the project
cargo new blink --bin
cd blinkReplace the contents of Cargo.toml:
[package]
name = "blink"
version = "0.1.0"
edition = "2021"
[dependencies]
cortex-m = "0.7.7"
cortex-m-rt = "0.7.3"
panic-halt = "0.2.0"
nrf52840-hal = "0.18.0"If you’re using a different chip, swap nrf52840-hal for your chip’s HAL crate (e.g., stm32f4xx-hal, rp2040-hal). The structure stays the same.
Step 2: Add memory.x
Create memory.x in your project root. This file tells the linker where flash and RAM start and how big they are. For the nRF52840:
MEMORY
{
FLASH : ORIGIN = 0x00000000, LENGTH = 1024K
RAM : ORIGIN = 0x20000000, LENGTH = 256K
}Your chip’s datasheet or HAL repo will have these values. Get them wrong and the firmware won’t boot.
Step 3: Configure .cargo/config.toml
Create .cargo/config.toml:
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip nRF52840_xxAA"
[build]
target = "thumbv7em-none-eabihf"This means cargo run will compile, flash, and start executing on your board. One command.
Step 4: Write main.rs
Replace src/main.rs entirely:
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use nrf52840_hal::gpio::Level;
use nrf52840_hal::pac;
use nrf52840_hal::prelude::*;
use nrf52840_hal::Timer;
use panic_halt as _;
#[entry]
fn main() -> ! {
// Take ownership of the device peripherals (this can only be called once)
let peripherals = pac::Peripherals::take().unwrap();
// Set up the GPIO port
let port0 = nrf52840_hal::gpio::p0::Parts::new(peripherals.P0);
// Configure pin P0.13 as a push-pull output, starting high (LED off on nRF52840-DK)
// On this board, LEDs are active-low: pulling the pin low turns the LED on.
let mut led = port0.p0_13.into_push_pull_output(Level::High);
// Set up a hardware timer for delays
let mut timer = Timer::new(peripherals.TIMER0);
// Blink forever
loop {
led.set_low().unwrap(); // LED on
timer.delay_ms(500u32); // Wait 500 ms
led.set_high().unwrap(); // LED off
timer.delay_ms(500u32); // Wait 500 ms
}
}A few things to notice:
-> ! on main: the function never returns. There’s no OS to return to. This is an infinite loop by design.
Peripherals::take() returns an Option. It gives you Some(peripherals) exactly once, then None forever after. This is Rust’s ownership system preventing two parts of your code from fighting over the same hardware register.
The type-state pattern on GPIO: You called .into_push_pull_output() on the pin. After that, the compiler knows this pin is an output. If you tried to call .is_high() (a read operation for input pins) on it, the code wouldn’t compile. In C, that’s a runtime bug you’d chase with an oscilloscope. In Rust, it’s a compile error.
Step 5: Flash and run
Plug in your board via USB. Then:
cargo runprobe-rs compiles your code, flashes it to the chip, and starts execution. LED1 on the nRF52840-DK should be blinking at 1 Hz.
Troubleshooting
- “No probe found”: Check that your board is connected via the debug USB port (not the nRF USB port). Run
probe-rs listto see detected probes. - “Error: wrong target”: Make sure the
--chipvalue inconfig.tomlmatches your exact chip.probe-rs chip list | grep nRFcan help. - “Cannot find memory.x”: The file must be in your project root, not in
src/. Thecortex-m-rtcrate looks for it during linking. - LED doesn’t blink but flashing succeeds: Check the pin number. Different boards use different pins for their onboard LEDs. Consult your board’s schematic or BSP crate.
What Just Happened Under the Hood
No operating system booted. The cortex-m-rt crate set up the interrupt vector table, initialized .bss and .data segments, then jumped straight to your #[entry] function. From there, your code wrote to memory-mapped registers through a safe API that prevents misuse at compile time.
The resulting binary is small. Check it yourself:
cargo install cargo-binutils
rustup component add llvm-tools
cargo size --releaseYou’ll likely see something around 4 to 8 KB of flash. Compare that to pulling in an RTOS in C.
Connecting a Peripheral: Your Next Project
Blinking an LED proves the toolchain works. The interesting stuff starts when you connect external hardware.
The embedded-hal traits you used implicitly (OutputPin and delay) also define I2c, Spi, and Uart, and your HAL crate already implements them. This means you can grab a driver crate from Awesome Embedded Rust, wire up the hardware, and go.
For example, a BME280 temperature/humidity sensor over I²C: add the bme280 crate to your Cargo.toml, initialize your HAL’s I²C peripheral, and pass it to the driver. The driver doesn’t know or care that you’re on an nRF52. It only talks to the I2c trait.
If you’re thinking about designs where sensor data needs to travel beyond your board (say, over Bluetooth to a gateway), you’ll eventually want to look at how BLE advertising fits into this stack. The Hubble device SDK introduction walks through integrating BLE connectivity into embedded firmware, and the terrestrial advertising packet guide covers the packet structure if you want to understand what’s going over the air.
Honest Trade-offs
The embedded Rust ecosystem is smaller than C’s. Some chips have incomplete HAL crates. You’ll occasionally read source code instead of documentation. Async support (via embassy) is powerful but still evolving.
That said, for Cortex-M targets (especially the nRF52, STM32, and RP2040 families) the ecosystem is production-ready and improving fast. Companies are shipping real products with embedded Rust today.
The Embedded Rust Book is the canonical deep reference. This article got you from zero to blinking; that book will take you the rest of the way.
Build Something This Weekend
You went from cargo new to running firmware on a microcontroller. You saw how no_std strips away the standard library but keeps the language, how the PAC/HAL/BSP stack organizes the ecosystem into understandable layers, and how the compiler catches hardware misconfigurations that would be silent bugs in C.
Buy a $3 BME280 breakout board, wire it to your dev board’s I²C pins, and write your second firmware project this weekend. The driver crate handles the protocol. You handle the wiring. Rust handles the rest.
Hubble Network connects embedded devices directly to satellite from a standard Bluetooth chip — no gateways, no extra radios. See how it works →