Skip to content

Embedded Rust: Microcontrollers, no_std and Embassy

Embedded Rust means writing Rust for small computers that live inside things: a thermostat, a keyboard, a drone, a plant-watering sensor. These computers are called microcontrollers. One chip holds the processor, a few hundred kilobytes of memory and the pins that talk to LEDs, buttons and sensors. There is usually no operating system at all. Your program is the only thing running, from power-on until power-off.

That sounds scary, but it’s also a lot of fun: you write a few lines, and a real light blinks on your desk. This page shows you what’s possible, which board and crates to pick, and how to get your first program onto a chip. Along the way you’ll practise the “embedded way of thinking” in examples you can run right in your browser.

For decades, embedded code has been written mostly in C. Rust brings a few things that matter a lot on a tiny chip:

  • No garbage collector. Nothing pauses your program to clean up memory, so timing stays predictable. That’s important when you must react to a signal within microseconds.
  • Memory safety without a runtime. The ownership and borrowing rules are checked when you compile, so they cost nothing on the chip. Whole classes of crashes (dangling pointers, data races between an interrupt and your main loop) are caught before you flash anything.
  • Zero-cost abstractions. A HAL lets you write led.set_high() instead of poking memory addresses, and it compiles down to the same single register write you’d write by hand.
  • Ownership models hardware. A pin is a value. Once it’s moved into an Output, nobody else can grab it and use it as an input by mistake. The compiler enforces what used to be a comment in a C file.
  • Great tooling. cargo, rustup targets and probe-rs replace a lot of vendor IDEs and makefiles.

To be honest about the rough edges: chip vendors still ship their SDKs and examples in C first, so for some chips Rust support is thinner or community-only. You still need to read datasheets. And some crates are pre-1.0 and change their API between releases, so always follow the examples for the version you use.

  • Sensors and gadgets: a weather station, a soil-moisture alarm, a step counter.
  • USB devices: custom keyboards, game controllers, MIDI controllers.
  • Connected (IoT) devices: Wi-Fi or Bluetooth sensors with an ESP32 that send readings to a server.
  • Robots and motors: line followers, stepper and servo control, LED strips and displays.
  • Serious systems: Oxide Computer wrote Hubris, a small operating system for microcontrollers, entirely in Rust. Espressif, the company behind the ESP32, maintains its own Rust support and publishes The Rust on ESP Book.

Any of these is a good first board. If you can’t decide, take a Raspberry Pi Pico: it’s cheap, very well documented, and the Embassy example below is written for it.

Board Chip Rust target Why it’s nice Start with
Raspberry Pi Pico / Pico W RP2040 (Arm Cortex-M0+) thumbv6m-none-eabi Cheap, popular, excellent Embassy support embassy-rp, or rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350 (Arm Cortex-M33, or RISC-V) thumbv8m.main-none-eabihf Newer and faster, has a floating-point unit embassy-rp (feature rp235xa), rp235x-hal
ESP32-C3 / ESP32-C6 boards Espressif RISC-V chips riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Built-in Wi-Fi and Bluetooth, official Rust support esp-hal and the esp-generate tool
STM32 Nucleo, “Black Pill” STM32 (for example STM32F4) thumbv7em-none-eabihf Huge chip family used in industry; Nucleo boards have a debug probe built in embassy-stm32, stm32f4xx-hal
BBC micro:bit v2 nRF52833 (Arm Cortex-M4F) thumbv7em-none-eabihf Debug probe, LEDs, buttons and sensors on the board microbit-v2, plus the Discovery book

The micro:bit is the board used by the official Embedded Rust Discovery book, a free, step-by-step course for beginners. The original ESP32 and the ESP32-S3 use Xtensa cores instead of RISC-V; for those, Espressif’s espup tool installs a special Rust toolchain. The Pico W’s LED is wired to its Wi-Fi chip, not to a normal pin, so it needs a different blinky (Embassy has a wifi_blinky example for it).

A normal Rust program uses the standard library (std). std expects an operating system: files, threads, a heap, a terminal for println!. A microcontroller has none of that. So embedded programs start with two lines:

#![no_std] // don't link the standard library, only `core`
#![no_main] // don't use the usual `fn main()` entry point
  • #![no_std] means you only get core: integers, Option, Result, iterators, slices, traits, formatting. No Vec, String, Box, files or threads. (With an allocator you can opt back into Vec and friends through the alloc crate, but many projects never need it.)
  • #![no_main] is needed because on a chip there’s no operating system to call main. A runtime crate such as cortex-m-rt sets up memory after reset and then jumps to your function, which you mark with an attribute like #[cortex_m_rt::entry] or, with Embassy, #[embassy_executor::main].
  • You must also say what happens on a panic, because there’s no terminal to print to. Crates like panic-probe or panic-halt provide this; you add one with use panic_probe as _;.

Since there’s no Vec, how do you store a list? The heapless crate gives you Vec, String and queues with a fixed capacity chosen at compile time. They live on the stack or in a static, so there’s no heap and no out-of-memory surprise. A full buffer simply hands your value back:

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// Room for exactly 4 readings. The memory is part of the variable:
// no heap, no allocator, and the size is known at compile time.
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// A String with a fixed capacity of 16 bytes
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

heapless also works on a PC, so you can try this with cargo add heapless@0.9 and cargo run (it isn’t in the Playground):

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

Embedded Rust crates stack on top of each other. You’ll mostly work at the HAL or framework level, but it helps to know what’s underneath:

Layer What it is Examples
PAC (Peripheral Access Crate) Generated from the chip’s register description file. One safe-ish function per register and field. Very low-level. rp2040-pac, stm32f4, esp32c3
HAL (Hardware Abstraction Layer) Friendly types for one chip family: Output, Uart, I2c, timers. Built on a PAC. rp2040-hal, esp-hal, stm32f4xx-hal, embassy-rp, embassy-stm32
embedded-hal traits Shared traits like OutputPin, I2c, SpiDevice and DelayNs. HALs implement them; drivers use them. Version 1.0 is stable. embedded-hal, embedded-hal-async
BSP (Board Support Package) A HAL plus names for one specific board’s pins and parts (“the LED”, “button A”). rp-pico, microbit-v2
RTIC A framework for interrupt-driven apps. Tasks are tied to interrupts and priorities, and the compiler checks shared data at build time. rtic
Embassy An async framework: an executor, timers, networking and USB, plus its own HALs. You write async fn tasks and .await hardware events. embassy-executor, embassy-time, embassy-rp

The magic is in the middle row. A temperature-sensor driver written against embedded-hal’s I2c trait works on a Pico, an ESP32 and an STM32 without changes, because each HAL implements the same trait. You’ll write such a driver yourself further down.

Your PC has an x86 or Arm desktop processor; the Pico has a Cortex-M0+. So you cross-compile: build on one machine, for another. rustup downloads the precompiled core library for the chip:

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

To copy the program onto the chip and see its logs, you need probe-rs and a debug probe: a small USB device that connects to the chip’s debug pins. The micro:bit and STM32 Nucleo boards have one built in. For a Pico, use a Raspberry Pi Debug Probe, or a second Pico flashed with the free debugprobe firmware. Install probe-rs with its installer script (see the installation page for Windows) or with cargo:

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# or, slower, from source:
cargo install probe-rs-tools --locked
probe-rs list # shows the probes it can see

No probe yet? Hold the Pico’s BOOTSEL button while you plug it in, and it shows up as a USB drive. Tools like picotool or elf2uf2-rs can copy your program onto it that way. You won’t see defmt logs without a probe, though.

On an ESP32-C3 or C6 you usually don’t need a probe at all: the chip has USB built in, and the espflash tool flashes it over the normal USB cable. Run cargo install esp-generate espflash --locked, then esp-generate, pick your chip and options, and cargo run --release builds, flashes and shows the logs.

Blinky with Embassy on a Raspberry Pi Pico

Section titled “Blinky with Embassy on a Raspberry Pi Pico”

“Blinky” is the “Hello, world!” of embedded: turn the LED on, wait, turn it off, wait, forever. This is the official Embassy RP2040 blinky example (with comments added). It needs a real board and won’t run in the Playground.

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // send defmt logs to the probe
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // on panic: print the message through the probe, then stop
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// Take ownership of every peripheral on the chip, exactly once
let p = embassy_rp::init(Default::default());
// The Pico's LED is on pin 25. PIN_25 moves into `led`.
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

A few things to notice:

  • main is an async fn. Timer::after_millis(500).await doesn’t spin in a busy loop; the executor puts the chip to sleep until the timer fires. With more tasks (spawned through the Spawner), the others run while this one waits. If async/await is new to you, the idea is the same as with Tokio on a PC, just without an operating system.
  • p.PIN_25 is moved into Output::new. Try to use p.PIN_25 a second time and the compiler stops you: ownership, applied to hardware.
  • use defmt_rtt as _; and use panic_probe as _; import crates only for what they set up (the log channel and the panic handler), not for any name.

The quickest way to see it blink is the official example folder itself: clone the Embassy repository, then in examples/rp run cargo run --bin blinky --release. For your own project, the dependencies come from that example’s Cargo.toml:

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # keep debug info for probe-rs; it isn't flashed to the chip

Two more files tell cargo what to build and how to run it. .cargo/config.toml sets the target and makes cargo run flash through probe-rs:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

Finally, copy memory.x (where flash and RAM are on the chip) and build.rs (which passes the linker scripts) from the same examples/rp folder. Then plug in your probe and run:

Terminal window
cargo run --release

cargo builds for thumbv6m-none-eabi, probe-rs flashes the chip, the LED starts blinking, and the led on! / led off! messages stream into your terminal with timestamps.

A microcontroller has no screen, and formatting text with core::fmt costs a lot of flash space. defmt (“deferred formatting”) solves both. The format strings are stored on your PC, not on the chip; the chip only sends a tiny ID plus the raw values through the probe, and probe-rs turns them back into readable text.

use defmt::{info, warn, Format};
#[derive(Format)] // like #[derive(Debug)], but for defmt
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` and `=i32` tell defmt the exact type, which saves even more space
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

The levels are trace!, debug!, info!, warn! and error!, just like the log crate. The DEFMT_LOG setting in .cargo/config.toml picks which levels are compiled in at all; anything below it costs zero bytes.

Under every HAL, controlling hardware means writing numbers to registers: special memory addresses where each bit switches something on the chip. On a typical chip, a 32-bit GPIO output register has one bit per pin. Here a plain u32 plays the role of that register, so you can practise the four moves every embedded developer uses:

const LED: u32 = 25; // the Pico's on-board LED is on pin 25
fn main() {
// Pretend this u32 is the chip's GPIO output register.
// Bit 0 controls pin 0, bit 1 controls pin 1, and so on.
let mut gpio_out: u32 = 0b1000; // pin 3 is already on
// Set one bit: OR with a mask
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// Read one bit: AND with a mask, then compare with zero
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// Toggle one bit: XOR with a mask
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// Clear one bit: AND with the inverted mask
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

Output:

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ Try it Yourself

In Rust, ! on an integer flips every bit (C uses ~ for this). {:#034b} prints the value in binary with the 0b prefix and padding to 32 digits, so you can watch bit 25 come and go. See Operators for the full list of bitwise operators.

Many registers pack several fields into one number: a few bits for a speed setting, one bit for “enabled”. To change one field you do a read-modify-write: clear the field’s bits, then OR in the new value, leaving every other bit alone:

// A made-up UART control register:
// bit 0 = enable
// bits 4..=6 = speed setting (a 3-bit number, 0 to 7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// Clear the old field, then put the new value in.
// Every other bit stays exactly as it was.
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9 doesn't fit in 3 bits. The mask cuts it down instead of
// spilling into the neighbouring bits.
ctrl = write_speed(ctrl, 9);
println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("still enabled: {still_enabled}");
}

Output:

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ Try it Yourself

Notice that 9 silently became 1. That’s exactly the kind of bug PACs and HALs protect you from: they give each field its own named method (often with named values too), so you rarely write masks by hand. On real hardware the register is a fixed memory address, written with core::ptr::write_volatile inside unsafe code; the PAC does that part for you.

The best embedded habit you can learn: write your logic against traits, not against one chip. Then the same code runs on the board with the real HAL, and on your PC with fake (“mock”) parts in a normal test. Here, StatusLed only needs “something I can switch on and off” and “something that can wait”:

use std::cell::RefCell;
use std::rc::Rc;
// The two abilities a blink driver needs. They mirror embedded-hal 1.0's
// OutputPin and DelayNs traits, simplified so this runs in the Playground.
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// The driver knows nothing about any chip. It only uses the traits.
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// Blink `times` times: on for `on_ms`, then off for `off_ms`.
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- Fakes for testing on a PC ---
// Both fakes write into the same shared log, so we can see the order.
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("on".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("off".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("wait {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "on");
assert_eq!(events[1], "wait 100");
println!("all checks passed");
}

Output:

on, wait 100, off, wait 400, on, wait 100, off, wait 400
all checks passed
▶ Try it Yourself

The two mocks share one log through Rc<RefCell<...>> (see Smart Pointers), so you can check the exact order of events. In a real project the assert_eq! lines go into a #[test] function and run with cargo test on your PC, no board needed (see Testing and Testing Beyond the Basics).

On the board, you’d write the same driver against the real embedded-hal 1.0 traits. The only changes: pin methods return a Result (a pin on an I/O expander chip can fail), so you pass errors on with ?, and the code doesn’t need std at all:

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

Embassy’s Output implements OutputPin, and embassy_time::Delay implements DelayNs, so StatusLed::new(led, embassy_time::Delay) works on the Pico. For PC tests against these real traits, the embedded-hal-mock crate provides ready-made fake pins, I2C and SPI buses.

Many small chips, like the Cortex-M0+ in the RP2040, have no floating-point unit. Floats still work, but in software: slower, and they make your program bigger. So embedded code often uses fixed-point numbers: integers in a smaller unit. Instead of 25.25 degrees, you store 25250 millidegrees.

A TMP102-style temperature sensor sends two bytes. The top 12 bits are a signed number in steps of 1/16 °C:

// The top 12 bits are the temperature in steps of 1/16 °C (0.0625 °C),
// as a signed (two's complement) number.
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 keeps the sign. `>> 4` on a signed number keeps it too.
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62.5 m°C, so multiply by 625 and divide by 10.
raw as i32 * 625 / 10
}
// Print 25250 as "25.250" without ever touching a float.
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // a warm room
[0x00, 0x10], // the smallest step above zero
[0xFF, 0x80], // just below freezing
[0xE7, 0x00], // a freezer
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

Output:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ Try it Yourself

Three details make this work. i16::from_be_bytes reads the two bytes as one signed number, most significant byte first (“big-endian”), which is how most sensors send data. >> 4 on a signed integer keeps the sign, so negative temperatures come out right. And format_milli handles the sign separately: -500 / 1000 is 0 in integer math, so without that step -0.5 °C would print as 0.500.

Integer division also truncates: 62.5 m°C became 62. Half a millidegree doesn’t matter for a room sensor, but always ask yourself how much precision a unit gives you. And watch the range: multiplying a big reading by a big factor can overflow an i32 (see Integer Overflow). On a PC String is fine; on the chip you’d print with defmt or write into a heapless::String.

Using std things in a no_std program. println!, Vec and String come from std, so they’re simply not there:

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

Use defmt::info! for logging, and heapless (or the alloc crate with an allocator) for collections.

Forgetting the panic handler. A no_std binary must say what a panic does. Without a crate like panic-probe, you get:

error: `#[panic_handler]` function required, but not found

Add panic-probe (or panic-halt) to Cargo.toml and use panic_probe as _; to main.rs.

Overwriting a whole register. reg = 1 << 3 turns pin 3 on, and every other pin off. To change one bit, use reg |= 1 << 3. For fields, do the read-modify-write from above.

Following an old tutorial. Embedded crates move fast, and blog posts from a few years ago often use embedded-hal 0.2 or older Embassy APIs. Start from the examples folder of the crate version you actually depend on.

Write toggle(reg: u32, pin: u32) -> u32, which flips one bit, and pins_on(reg: u32) -> Vec<u32>, which lists every pin whose bit is 1. Start with pins 0, 5 and 25 on, print the list and how many pins are on, then toggle pins 5 and 7 and print again. Hint: test each pin from 0 to 31 with a mask, and u32 has a count_ones() method.

Show solution
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());
}

Output:

on: [0, 5, 25] (3 pins)
on: [0, 7, 25] (3 pins)
▶ Try it Yourself

XOR flips a bit whatever its old value was, so toggling pin 5 turned it off and toggling pin 7 turned it on. count_ones() counts the 1 bits for you, so you don’t need a loop for that part.

A 12-bit ADC gives readings from 0 to 4095 for 0 to 3300 mV. The battery is connected through a voltage divider that halves its voltage, so the real voltage is twice the measured one. Write battery_mv(raw: u16) -> i32 and battery_percent(mv: i32) -> i32, where 3000 mV is 0% and 4200 mV is 100%, never below 0 or above 100. Try the raw readings 2606, 2482, 2234 and 1861. Hint: multiply before you divide, so you don’t lose precision, and look at clamp.

Show solution
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// 12-bit ADC (0..=4095) measuring 0..=3300 mV, behind a divider that halves the voltage
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("raw {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

Output:

raw 2606 -> 4200 mV -> 100%
raw 2482 -> 4000 mV -> 83%
raw 2234 -> 3600 mV -> 50%
raw 1861 -> 2999 mV -> 0%
▶ Try it Yourself

Dividing by 4095 last keeps the precision: raw / 4095 * 6600 would be 0 for every reading below 4095. The biggest product, 4095 * 6600, is about 27 million, which fits easily in an i32.

  • Embedded Rust runs on microcontrollers with no operating system. No garbage collector, compile-time safety and zero-cost abstractions make Rust a great fit.
  • #![no_std] leaves you with core only; #![no_main] lets a runtime crate call your entry function; you must pick a panic handler. heapless gives you fixed-capacity collections.
  • The layers: PAC (registers), HAL (one chip family), embedded-hal traits (shared), BSP (one board), and frameworks like RTIC and Embassy on top.
  • Add the chip’s target with rustup target add, then flash and see logs with probe-rs and a debug probe (or espflash on an ESP32). defmt keeps logging tiny.
  • Practise on your PC: bit masks for registers, drivers written against traits with mock parts, and fixed-point integers instead of floats.

Quick check

1. What does #![no_std] do?

2. Which layer lets one sensor driver work on a Pico, an ESP32 and an STM32 without changes?

3. How do you turn on bit 3 of reg without changing any other bit?

4. Why does embedded code often store 25.25 °C as 25250 millidegrees?

Score: 0 / 4

  • The Embedded Rust Book: the official introduction to no_std, peripherals, registers and the embedded ecosystem.
  • Discovery (micro:bit v2): a free beginner course that takes you from a blinking LED to sensors, step by step, on a BBC micro:bit.
  • The Embassy Book: getting started with Embassy, how its executor and HALs work, and how to run the official examples.
  • The Rust on ESP Book: Espressif’s own guide to esp-hal, esp-generate, espflash and the ESP32 toolchains.
  • probe-rs installation: how to install probe-rs on every operating system and set up your debug probe.