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.
Why Rust fits embedded so well
Section titled “Why Rust fits embedded so well”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,rustuptargets andprobe-rsreplace 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.
What you can build
Section titled “What you can build”- 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.
Boards that are easy to start with
Section titled “Boards that are easy to start with”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).
no_std and no_main explained
Section titled “no_std and no_main explained”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 getcore: integers,Option,Result, iterators, slices, traits, formatting. NoVec,String,Box, files or threads. (With an allocator you can opt back intoVecand friends through thealloccrate, but many projects never need it.)#![no_main]is needed because on a chip there’s no operating system to callmain. A runtime crate such ascortex-m-rtsets 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-probeorpanic-haltprovide this; you add one withuse 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 215stored 218stored 221stored 224buffer full, dropped 227avg=219 (capacity 16)The ecosystem in layers
Section titled “The ecosystem in layers”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.
Install a target and probe-rs
Section titled “Install a target and probe-rs”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:
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, STM32F4To 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:
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 seeNo 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 probeuse 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:
mainis anasync fn.Timer::after_millis(500).awaitdoesn’t spin in a busy loop; the executor puts the chip to sleep until the timer fires. With more tasks (spawned through theSpawner), 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_25is moved intoOutput::new. Try to usep.PIN_25a second time and the compiler stops you: ownership, applied to hardware.use defmt_rtt as _;anduse 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 chipTwo 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:
cargo run --releasecargo 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.
Logging with defmt
Section titled “Logging with defmt”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 defmtpub 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.
Thinking in registers: bits and masks
Section titled “Thinking in registers: bits and masks”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: 0b00000010000000000000000000001000LED on? true, pin 2 on? falsetoggled: 0b00000000000000000000000000001000pin 3 off: 0b00000010000000000000000000000000as hex: 0x02000000In 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 = 2speed 5: 0b01010001, speed = 5speed 9?: 0b00010001, speed = 1still enabled: trueNotice 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.
Write a driver you can test on your PC
Section titled “Write a driver you can test on your PC”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 400all checks passedThe 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.
Sensor math without floats
Section titled “Sensor math without floats”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 °CThree 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.
Common mistakes
Section titled “Common mistakes”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 foundAdd 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.
Practice
Section titled “Practice”1. Which pins are on?
Section titled “1. Which pins are 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)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.
2. Battery level without floats
Section titled “2. Battery level without floats”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%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 withcoreonly;#![no_main]lets a runtime crate call your entry function; you must pick a panic handler.heaplessgives you fixed-capacity collections.- The layers: PAC (registers), HAL (one chip family),
embedded-haltraits (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 (orespflashon an ESP32).defmtkeeps 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
Section titled “Quick check”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
Read more
Section titled “Read more”- 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,espflashand the ESP32 toolchains. - probe-rs installation: how to install probe-rs on every operating system and set up your debug probe.