A first-principles course

Embedded Rust
for Control

For someone who already understands buses, state, faults and timing. Written for the micro:bit v2 and the Pico 2, and ending in a crank decoder and a motor you can tilt into moving.

  • 16 chapters
  • 20 diagrams
  • every program compiled and run
sensor
Contents
  1. 0How this book works
  2. 1What a microcontroller really is
  3. 2Why Rust, and the one rule it enforces
  4. 3Your first program
  5. 4Numbers that don't lie
  6. 5Decisions, loops and hysteresis
  7. 6States: enums and match
  8. 7When things fail: Option and Result
  9. 8Types that hold state: structs and impl
  10. 9Ownership and borrowing
  11. 10Traits: write the controller once
  12. 11Time and the control loop
  13. 12The micro:bit v2
  14. 13The Pico 2, the motor and the CAN bus
  15. 14The crank decoder
  16. 15Checkpoints and what comes next
Chapter 0

How this book works

You already know

Buses, state machines, fault handling, timing, PLC scan cycles.

You will be able to

Use the book: read, run, try, check.

You already understand machines. This book takes what you know about buses, state, faults and timing, and shows you the Rust that says the same things.

Each chapter starts from a physical idea you already own, then shows the Rust that expresses it. Chapters 3 to 11 run on any ordinary computer, so you can learn the language at your desk with no board attached. Chapters 12 to 14 are about the hardware.

Every program in chapters 3 to 11 and in chapter 14 was compiled and run before it went into this page. The output you see is the real output. The compiler messages in chapters 6 and 9 are real too, copied from builds that were made to fail.

How a chapter is built

  • A plate opens each chapter: what you already know, and what you will be able to do by the end.
  • The text goes from the physical idea to the Rust. When a picture helps, there is one, numbered by chapter.
  • Key idea blocks hold the sentence to remember.
  • Try it is practice that nobody marks.
  • Check yourself is a few questions to answer in one or two lines. They are the same kind of question the checkpoints ask, so nothing in a checkpoint is a surprise.

What you need

Install Rust from rustup.rs. Then you can run a single file, or start a project:

terminal
rustc file.rs      # compile one file
./file             # run it (on Windows: file.exe)

cargo new motor    # or start a project
cd motor
cargo run

This book is written for the micro:bit v2 and the Pico 2. If you are unsure which micro:bit you have, look for a gold oval touch logo on the front and a small speaker on the back. The v2 has both. The v1 has neither.

Chapter 1

What a microcontroller really is

You already know

A PLC scan cycle, and a Modbus register map where writing a number makes something happen.

You will be able to

Say what is inside the chip, what an address is, why interrupts exist, and what happens at power-up.

A computer that fetches an instruction, does it, and fetches the next one. Put it on one chip with its memory and its connections to the world, and you have a microcontroller.

There is no operating system. When power arrives, the processor starts reading your program from flash memory. Nothing else is running unless you wrote it. Nothing stops you doing something stupid, either.

CPU core, runs one instruction at a timeFlashyour program, kept at power-offRAMvariables, empty at power-upinternal bus: registers are addresses on itGPIOPWMADCI2C / SPITimersCAN / UARTPins: the only part the outside world touchesAn interrupt is a peripheral tapping the CPU on the shoulder:stop what you are doing, run a short handler, then carry on.
What is inside the chip. Everything the program does to the world goes through a peripheral.

Memory is small

Flash holds the program and keeps it with the power off. RAM holds variables and starts empty at power-up. The nRF52833 on the micro:bit v2 is a 64 MHz Arm Cortex-M4 with 512 KB of flash and 128 KB of RAM. The RP2350 on the Pico 2 has two cores at up to 150 MHz and 520 KB of RAM. These figures come from the vendors' published specifications as I know them, so confirm them against the datasheets before you rely on them.

The point is the scale: kilobytes, not gigabytes. That is why embedded Rust avoids handing out memory while the program runs.

What happens at power-up

On an Arm Cortex-M chip, the first two entries in the program's vector table are the starting value for the stack pointer and the address of the reset handler. The processor loads the first, jumps to the second, and your code is running. Before main is called, a small piece of startup code sets RAM to a known state. In Rust, a runtime crate supplies it, and you will not write it yourself.

Registers are addresses wired to hardware

A register is a memory address that is connected to a peripheral. Write a 1 to one address and a pin goes high. Read another and you get the result of an analogue conversion. To the processor, a pin is just a memory location. You know this idea from Modbus, where a holding register is a number the master writes and something real happens. Here the bus is inside the chip.

0x0000_0000Flashyour program0x2000_0000RAMvariables and the stack0x4000_0000Peripheral registersGPIO, timers, I2C, SPI, PWM0xE000_0000Core peripheralsinterrupt controller, system timer1706051403020110GPIO output register: bit n high means pin n is driven highLayout of the nRF52 family used on the micro:bit v2.Addresses differ by chip. The datasheet has the real map.
The address space. Flash, RAM and the peripheral registers all live in one numbered space, and a register is a number you read or write.

Interrupts are the second path

A PLC runs a scan: read inputs, run logic, write outputs, repeat. A microcontroller can do the same in its main loop. An interrupt adds a second path for events that cannot wait.

The numbers make the case. At 64 MHz, one clock cycle is 15.6 nanoseconds. On a 36-1 crank wheel at 3000 rpm, a tooth passes the sensor every 555 microseconds. That is about 35,000 clock cycles between teeth, which sounds like a lot. A main loop that is busy with something else for longer than that will miss a tooth. An interrupt will not.

Key idea

No operating system will catch you. A bad write to the wrong register can switch a motor on. The language is where you get your safety net.

Chapter 2

Why Rust, and the one rule it enforces

You already know

One master per Modbus line. Two masters talking at once corrupt a frame.

You will be able to

State Rust's one rule, name the four bugs it prevents, and say what it does not protect you from.

Four bugs account for a large share of firmware failures. C lets all four through. Rust stops three at compile time and makes you decide on the fourth.

The bugWhat it looks like on a machineWhat Rust does
Arithmetic wraparoundA throttle counter goes from 255 to 0 and the engine drops to idlePanics in a debug build, and gives you explicit methods to say what you mean (chapter 4)
Using a value before it is setA setpoint nobody initialised, so a motor starts at a random speedDoes not compile
Two things changing one variableThe main loop and an interrupt both write the speed and the result is neitherDoes not compile (chapter 9)
A pointer to something that is goneReading a buffer that was already reusedDoes not compile

The one rule

At any moment, a piece of data has either many readers or one writer, never both. A Modbus network works the same way. One master decides who speaks, and the slaves answer. If two masters talk at once, the frame is corrupted. Rust applies that rule to variables, and it checks it while compiling, before the program ever reaches your hardware.

Many readers, nobody writingthe value&value&value&valueorOne writer, nobody else lookingthe value&mut valueno readerswhile it writes
The rule in two pictures. You can hold many read-only borrows or one mutable borrow. You never hold both at once.
Key idea

Many readers or one writer, never both. Rust checks it before your code runs.

What about unsafe?

Hardware access needs a small amount of code marked unsafe, because the compiler cannot know what an address means. The board crates contain that unsafe code once, and your own code stays safe. You choose which parts of a program carry that risk, and they are easy to find, because the keyword is written next to them.

What Rust will not do

It will not fix your physics. Wire a motor straight to a pin and you will still destroy the pin, in any language. Rust protects the software side of that boundary and leaves the electronics to you.

The honest trade

C is fine in experienced hands. Rust moves a large class of checks to compile time, and the price is that it argues with you for the first few weeks. MicroPython is easier to learn than Rust, and it is slower and gives you less control over timing. If you want a motor spinning this afternoon, use MicroPython. If you want firmware that behaves at 2 am, Rust is the right bet.

The industry has noticed. Ferrous Systems announced an IEC 61508 SIL 2 certification for a subset of Rust's core library in December 2025. IEC 61508 is the functional-safety standard for industrial control. I found no sign in my search of Rust in certified avionics, so treat aviation as early.

Chapter 3

Your first program

You already know

A setpoint register you can read, and a write-protected one you cannot change by accident.

You will be able to

Create, change and print a value, read a compiler error, and know what changes on the chip.

Before any hardware, learn how Rust holds a number.

main.rs
fn main() {
    let setpoint_rpm = 1500; // immutable: it cannot be changed by accident
    let mut actual_rpm = 0; // mutable: this one is allowed to change
    println!("setpoint {setpoint_rpm} rpm, actual {actual_rpm} rpm");
    actual_rpm = 1480;
    println!("setpoint {setpoint_rpm} rpm, actual {actual_rpm} rpm");
    // setpoint_rpm = 2000; // would not compile: setpoint_rpm is not mut
}
Output
setpoint 1500 rpm, actual 0 rpm
setpoint 1500 rpm, actual 1480 rpm

Reading it line by line

  • fn main() is where the program starts on your PC.
  • let makes a variable, and it is immutable by default. setpoint_rpm cannot change. In control work that is a feature: a setpoint that nothing can alter by accident.
  • let mut says this one may change. You have to ask for it, so every variable that can change is visible on the page.
  • // starts a comment.
  • {setpoint_rpm} inside println! prints the variable. The ! marks println! as a macro, which is why it can take a format string.

Rust worked out that both are integers. When it cannot tell, it picks i32, a signed 32-bit number. The next chapter is about choosing the size yourself.

What a project looks like

terminal
motor/
  Cargo.toml      # name, version, dependencies
  src/
    main.rs       # your program

cargo run compiles and runs it. cargo build --release makes the optimised version, which is the one you would flash to a chip.

What changes on the chip

Key idea

On a microcontroller, there is no screen and no operating system, so a few things you used on the PC disappear: println!, files, and growable lists. The program starts with #![no_std] and #![no_main] at the top, and the entry point is marked by the runtime crate instead of being a plain main. The language you learn in chapters 3 to 11 is the same. Only the edges change.

Chapter 4

Numbers that don't lie

You already know

Register widths, 16-bit Modbus words, and a counter that rolls over.

You will be able to

Choose a size, handle the edge, flip single bits, and survive a timer rollover.

Embedded numbers are small, fixed in size and unforgiving. Rust makes you choose the size and say what should happen at the edge.

Why there are limits

A register is a row of bits. Eight bits can make 2 to the power 8, which is 256, different patterns. That is why a u8 holds 0 to 255. Each bit has a weight, doubling as you move left.

128164132116181402110250 = 128 + 64 + 32 + 16 + 8 + 2Add 10 and the answer needs a ninth bit1lost00000100260 needs nine bits. A u8 keeps the low eight, so the answer is 4.
Eight bits with their weights. Add 10 to 250 and the sum needs a ninth bit, which a u8 does not have.

Signed types use two's complement. The top bit has weight minus 128 instead of plus 128, so i8 runs from −128 to 127, and the bit pattern 11111111 reads as 255 for a u8 and as −1 for an i8. The bits are the same. The type tells you how to read them.

TypeBitsRangeTypical use
u880 to 255percent, flags, one byte on a bus
i88−128 to 127small signed offsets
u16160 to 65,535ADC readings, Modbus registers
i1616−32,768 to 32,767accelerometer axes
u32320 to 4,294,967,295microsecond timers
i3232about plus or minus 2.1 billionintermediate maths, errors in a controller

Pick the smallest type that safely holds the value, because every extra byte costs memory on a chip with kilobytes.

Three ways to handle the edge

numbers.rs
fn scale_adc(raw: u16) -> u8 {
    // 12-bit ADC reading (0..=4095) to percent (0..=100)
    ((raw as u32 * 100) / 4095) as u8
}

fn main() {
    let a: u8 = 250;
    println!("checked    {:?}", a.checked_add(10));
    println!("saturating {}", a.saturating_add(10));
    println!("wrapping   {}", a.wrapping_add(10));
    println!("scaled     {}", scale_adc(2048));
    println!("cast trap  {}", 300u16 as u8);

    // A free-running u32 microsecond timer rolls over; wrapping_sub still gives the right gap.
    let before: u32 = u32::MAX - 5;
    let after: u32 = 10;
    println!("timer gap  {} us", after.wrapping_sub(before));
}
Output
checked    None
saturating 255
wrapping   4
scaled     50
cast trap  44
timer gap  16 us
  • Saturating stops at the ceiling. Use it for outputs. A throttle at 100 percent stays at 100 percent.
  • Checked returns None on overflow. Use it when overflow means something has gone wrong.
  • Wrapping rolls over like a counter. Use it for timers and counters, which are meant to roll over.

A plain + panics on overflow in a debug build and wraps silently in a release build. Rust is making you decide, so the processor does not decide for you.

Casts truncate without telling you

300u16 as u8 gives 44. A bad cast on a sensor value is a classic control bug. Scale first, then cast. In scale_adc, the value is widened to u32 before the multiplication, so the intermediate result cannot overflow.

Timer wrap, the line you will use again

A free-running 32-bit microsecond timer rolls over every 232 microseconds, which is about 71.6 minutes. after.wrapping_sub(before) still returns the right gap across the rollover: 10 minus (u32::MAX minus 5) is 16, as the output shows. You will use this exact line in the crank decoder.

Bits are how you talk to registers

A hardware register is a number where each bit means something. To set one pin without disturbing the others, you use the bitwise operators: | to set a bit, & with the inverse mask to clear one, ^ to toggle, and & to test. 1 << pin makes a mask with only that bit on.

bits.rs
// A fake 8-pin output register. Bit n of the register drives pin n.
fn set_pin(reg: u8, pin: u8) -> u8 {
    reg | (1 << pin)
}

fn clear_pin(reg: u8, pin: u8) -> u8 {
    reg & !(1 << pin)
}

fn toggle_pin(reg: u8, pin: u8) -> u8 {
    reg ^ (1 << pin)
}

fn is_set(reg: u8, pin: u8) -> bool {
    reg & (1 << pin) != 0
}

fn main() {
    let mut reg: u8 = 0b0000_0000;
    reg = set_pin(reg, 0);
    println!("set pin 0     {reg:08b}");
    reg = set_pin(reg, 3);
    println!("set pin 3     {reg:08b}");
    reg = toggle_pin(reg, 0);
    println!("toggle pin 0  {reg:08b}");
    reg = set_pin(reg, 7);
    println!("set pin 7     {reg:08b}");
    reg = clear_pin(reg, 3);
    println!("clear pin 3   {reg:08b}");
    println!("pin 7 set? {}   pin 3 set? {}", is_set(reg, 7), is_set(reg, 3));

    // Two's complement: the same eight bits read two ways.
    println!("0xFF as u8 = {}, as i8 = {}", 0xFFu8, 0xFFu8 as i8);
    println!("-1 as u8 = {}", (-1i8) as u8);
}
Output
set pin 0     00000001
set pin 3     00001001
toggle pin 0  00001000
set pin 7     10001000
clear pin 3   10000000
pin 7 set? true   pin 3 set? false
0xFF as u8 = 255, as i8 = -1
-1 as u8 = 255
Key idea

Read a register the way a Modbus word is read: a number whose individual bits carry meaning. Set, clear, toggle and test change one bit and leave the rest alone.

Chapter 5

Decisions, loops and hysteresis

You already know

Thermostats, float switches and the PLC scan cycle.

You will be able to

Write a controller with hysteresis, and structure a program as read, decide, act.

A controller reads, decides, acts and repeats. The decision is where most beginners get it slightly wrong.

The chatter problem

Switch a heater off at 20 degrees and on again at 20 degrees and it will click on and off around that value every time the reading wobbles. The cure is two thresholds. Switch on at 19.5, switch off at 20.5, and do nothing in between. That gap is called hysteresis. Every thermostat, pressure switch and float switch you have met uses it.

thermostat.rs
fn main() {
    // Temperatures in tenths of a degree, so 195 means 19.5 C. No floats needed.
    let low: i32 = 195;
    let high: i32 = 205;
    let mut temp: i32 = 180;
    let mut heater_on = false;

    for minute in 0..24 {
        if temp <= low {
            heater_on = true;
        } else if temp >= high {
            heater_on = false;
        }
        // The room: the heater adds 0.4 C a minute, the walls lose 0.2 C a minute.
        let gain = if heater_on { 4 } else { 0 };
        temp += gain - 2;
        println!(
            "t={minute:2} min  temp={}.{} C  heater={}",
            temp / 10,
            temp % 10,
            if heater_on { "ON" } else { "off" }
        );
    }
}
Output
t= 0 min  temp=18.2 C  heater=ON
t= 1 min  temp=18.4 C  heater=ON
t= 2 min  temp=18.6 C  heater=ON
t= 3 min  temp=18.8 C  heater=ON
t= 4 min  temp=19.0 C  heater=ON
t= 5 min  temp=19.2 C  heater=ON
t= 6 min  temp=19.4 C  heater=ON
t= 7 min  temp=19.6 C  heater=ON
t= 8 min  temp=19.8 C  heater=ON
t= 9 min  temp=20.0 C  heater=ON
t=10 min  temp=20.2 C  heater=ON
t=11 min  temp=20.4 C  heater=ON
t=12 min  temp=20.6 C  heater=ON
t=13 min  temp=20.4 C  heater=off
t=14 min  temp=20.2 C  heater=off
t=15 min  temp=20.0 C  heater=off
t=16 min  temp=19.8 C  heater=off
t=17 min  temp=19.6 C  heater=off
t=18 min  temp=19.4 C  heater=off
t=19 min  temp=19.6 C  heater=ON
t=20 min  temp=19.8 C  heater=ON
t=21 min  temp=20.0 C  heater=ON
t=22 min  temp=20.2 C  heater=ON
t=23 min  temp=20.4 C  heater=ON
1819202106121824minutestemperature, degrees Cswitch off at 20.5switch on at 19.5heater
The same data as the listing. The heater strip at the bottom shows when it was on. Notice the overshoot at both thresholds.

Look at the output. The heater runs until the temperature passes 20.5, switches off, and the room drifts down to 19.4 before it switches on again. The overshoot past each threshold happens because the controller acts on the next reading, and the room keeps moving in the meantime.

What to notice in the code

  • if is an expression: let gain = if heater_on { 4 } else { 0 }; gives gain a value.
  • Temperatures are whole tenths of a degree, so 195 means 19.5. No floating point is needed, which keeps the maths exact and fast on a small chip.
  • for minute in 0..24 runs 24 times. while loops on a condition. loop runs until you break. On a microcontroller the main loop never ends: loop { read; decide; act }.

The scan discipline

A PLC reads all its inputs once at the start of a scan, runs the logic against that frozen picture, and writes all its outputs at the end. That discipline stops a decision from seeing half-updated inputs. The same shape works in Rust.

scan.rs
fn main() {
    // Five scans of (start button, stop button, overload input).
    let inputs = [
        (false, false, false),
        (true, false, false),
        (false, false, false),
        (false, false, true),
        (false, true, false),
    ];
    let mut running = false;
    let mut fault = false;

    for (scan, &(start, stop, overload)) in inputs.iter().enumerate() {
        // 1. Inputs were read into `start`, `stop` and `overload`.
        // 2. Decide.
        if overload {
            fault = true;
            running = false;
        } else if start && !fault {
            running = true;
        } else if stop {
            running = false;
            fault = false;
        }
        // 3. Write outputs.
        println!(
            "scan {scan}: start={start:<5} stop={stop:<5} overload={overload:<5}  motor={:<3} fault={fault}",
            if running { "ON" } else { "off" }
        );
    }
}
Output
scan 0: start=false stop=false overload=false  motor=off fault=false
scan 1: start=true  stop=false overload=false  motor=ON  fault=false
scan 2: start=false stop=false overload=false  motor=ON  fault=false
scan 3: start=false stop=false overload=true   motor=off fault=true
scan 4: start=false stop=true  overload=false  motor=off fault=false

Scan 3 shows the priority order. The overload check comes first, so it wins over a start request, and it clears only when the stop button is pressed in scan 4.

Chapter 6

States: enums and match

You already know

Ladder state bits and a PLC sequence where only one step is active.

You will be able to

Model a machine as an enum, read the compiler's match errors, and classify CAN identifiers.

A machine can be in exactly one state. Events move it between states. Rust lets you write the machine so it cannot be anywhere else.

In ladder logic, state lives in a handful of bits and you hope nobody sets two of them. In Rust you write the states as a type.

StoppedStarting { n }RunningFaultstart buttonn ticks passstopoverload,from any statestop buttonclears it
The motor starter from the listing. Arrows are events. The boxes are the only things the motor can be.
motor.rs
#[derive(Debug, Clone, Copy, PartialEq)]
enum FaultKind {
    Overload,
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum Motor {
    Stopped,
    Starting { ticks_left: u8 },
    Running,
    Fault(FaultKind),
}

#[derive(Debug, Clone, Copy)]
enum Input {
    StartButton,
    StopButton,
    Tick,
    Overload,
}

fn next(state: Motor, input: Input) -> Motor {
    match (state, input) {
        (Motor::Stopped, Input::StartButton) => Motor::Starting { ticks_left: 3 },
        (Motor::Starting { ticks_left: 0 }, Input::Tick) => Motor::Running,
        (Motor::Starting { ticks_left }, Input::Tick) => Motor::Starting { ticks_left: ticks_left - 1 },
        (_, Input::Overload) => Motor::Fault(FaultKind::Overload),
        (_, Input::StopButton) => Motor::Stopped,
        (s, _) => s,
    }
}

fn main() {
    let mut m = Motor::Stopped;
    let script = [
        Input::StartButton,
        Input::Tick,
        Input::Tick,
        Input::Tick,
        Input::Tick,
        Input::Overload,
        Input::StopButton,
    ];
    for i in script {
        m = next(m, i);
        println!("{i:?} -> {m:?}");
    }
}
Output
StartButton -> Starting { ticks_left: 3 }
Tick -> Starting { ticks_left: 2 }
Tick -> Starting { ticks_left: 1 }
Tick -> Starting { ticks_left: 0 }
Tick -> Running
Overload -> Fault(Overload)
StopButton -> Stopped

Motor is exactly one of four things. It cannot be Running and Fault at once. Some states carry data: Starting holds a countdown, and Fault holds which fault it was.

Three things to take from it

  1. Arms are checked in order. The specific case ticks_left: 0 comes before the general ticks_left. Swap them and the motor never reaches Running.
  2. The compiler checks that you covered every case. The last arm, (s, _) => s, means "ignore it and stay put". Delete it and see below.
  3. The function takes the old state and returns the new one. It touches no global variables, so you can test it at your desk with no hardware. This is the shape of almost all good control code.
The compiler says
error[E0004]: non-exhaustive patterns: `(Motor::Running, Input::StartButton)`, `(Motor::Running, Input::Tick)`, `(Motor::Fault(_), Input::StartButton)` and 1 more not covered
  --> c06_fail_nonexhaustive.rs:23:11
   |
23 |     match (state, input) {
   |           ^^^^^^^^^^^^^^ patterns `(Motor::Running, Input::StartButton)`, `(Motor::Running, Input::Tick)`, `(Motor::Fault(_), Input::StartButton)` and 1 more not covered
   |
   = note: the matched value is of type `(Motor, Input)`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern as shown, or multiple match arms
   |
28 ~         (_, Input::StopButton) => Motor::Stopped,
29 ~         _ => todo!(),
   |

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0004`.

That message is Rust refusing to build a machine with undefined behaviour. In a PLC, the same mistake gives you a motor that does something unplanned at night.

match works on ranges too

On a CAN bus the lowest identifier wins arbitration, so the urgent messages get the low numbers. A match with ranges turns an identifier into a meaning, and the compiler makes sure every identifier is covered.

canid.rs
#[derive(Debug, PartialEq)]
enum Message {
    Emergency,
    Motor(u8),
    Sensor(u8),
    Heartbeat,
    Unknown,
}

// Lower CAN identifiers win arbitration, so the urgent messages get the low ranges.
fn classify(id: u16) -> Message {
    match id {
        0x000..=0x0FF => Message::Emergency,
        0x100..=0x1FF => Message::Motor((id - 0x100) as u8),
        0x200..=0x2FF => Message::Sensor((id & 0xFF) as u8),
        0x7FF => Message::Heartbeat,
        _ => Message::Unknown,
    }
}

fn main() {
    for id in [0x001u16, 0x105, 0x23A, 0x7FF, 0x400] {
        println!("0x{id:03X} -> {:?}", classify(id));
    }
}
Output
0x001 -> Emergency
0x105 -> Motor(5)
0x23A -> Sensor(58)
0x7FF -> Heartbeat
0x400 -> Unknown
Key idea

Write the states as a type. The compiler then checks that every state and event pair is handled.

Chapter 7

When things fail: Option and Result

You already know

Modbus exception codes, and CRC checks that reject a corrupted frame.

You will be able to

Return failure as a value, check a Modbus frame, and choose a safe state instead of crashing.

A sensor can be unplugged. A read can time out. In C you return −1 and hope the caller checks. In Rust the failure is part of the type.

If a function returns 0xFFFF to mean "failed", then a real reading of 0xFFFF looks like a failure, and a failure the caller forgets to check looks like a real reading. Rust avoids the problem. Option<T> is either a value or nothing. Result<T, E> is either a value or a reason.

sensor.rs
#[derive(Debug)]
enum SensorError {
    Disconnected,
    OutOfRange(u16),
}

fn read_raw(attempt: u8) -> Result<u16, SensorError> {
    match attempt {
        0 => Err(SensorError::Disconnected),
        1 => Ok(2048),
        2 => Ok(5000),
        _ => Ok(4095),
    }
}

fn to_percent(raw: u16) -> Result<u8, SensorError> {
    if raw > 4095 {
        return Err(SensorError::OutOfRange(raw));
    }
    Ok(((raw as u32 * 100) / 4095) as u8)
}

fn read_percent(attempt: u8) -> Result<u8, SensorError> {
    let raw = read_raw(attempt)?; // on Err, return it from here at once
    to_percent(raw)
}

fn peak(values: &[u16]) -> Option<u16> {
    values.iter().copied().max()
}

fn main() {
    for attempt in 0..4 {
        match read_percent(attempt) {
            Ok(p) => println!("attempt {attempt}: {p}%"),
            Err(SensorError::Disconnected) => println!("attempt {attempt}: sensor disconnected"),
            Err(SensorError::OutOfRange(raw)) => println!("attempt {attempt}: raw {raw} is outside 0..=4095"),
        }
    }
    println!("{:?}", peak(&[120, 340, 90]));
    println!("{:?}", peak(&[]));
    println!("{}", peak(&[]).unwrap_or(0));
}
Output
attempt 0: sensor disconnected
attempt 1: 50%
attempt 2: raw 5000 is outside 0..=4095
attempt 3: 100%
Some(340)
None
0
Ok track: the value keeps flowingread_rawto_percentuse value??caller getsthe ErrErr track: stop here and hand the reason back
The question mark as a railway. Success carries on down the top track. Any failure leaves early on the bottom track and the caller receives it.
  • The ? after read_raw(attempt) means: if this is an error, return it from here immediately. If it is fine, carry on with the value.
  • Both outcomes must be handled in a match. You cannot forget the failure case.
  • peak(&[]) returns None, because an empty list has no peak. unwrap_or(0) supplies a default when you decide that is acceptable.

A real example from your world

Every Modbus RTU frame ends with a 16-bit CRC. The receiver recomputes it and compares. The check is a function that either gives you the frame body or tells you what was wrong, which is exactly a Result. This program computes the standard Modbus CRC over a request for ten registers, then checks a good frame, a noisy one and a frame that is too short.

modbus_crc.rs
fn crc16_modbus(data: &[u8]) -> u16 {
    let mut crc: u16 = 0xFFFF;
    for &byte in data {
        crc ^= byte as u16;
        for _ in 0..8 {
            if crc & 1 != 0 {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    crc
}

#[derive(Debug)]
enum FrameError {
    TooShort(usize),
    BadCrc { expected: u16, got: u16 },
}

// Returns the frame body without its two CRC bytes, or says what is wrong.
fn check_frame(frame: &[u8]) -> Result<&[u8], FrameError> {
    if frame.len() < 4 {
        return Err(FrameError::TooShort(frame.len()));
    }
    let (body, crc_bytes) = frame.split_at(frame.len() - 2);
    let got = u16::from_le_bytes([crc_bytes[0], crc_bytes[1]]); // Modbus sends the low byte first
    let expected = crc16_modbus(body);
    if got == expected {
        Ok(body)
    } else {
        Err(FrameError::BadCrc { expected, got })
    }
}

fn main() {
    // Read 10 holding registers from address 0 on slave 1, then the CRC.
    let good: [u8; 8] = [0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xC5, 0xCD];
    let mut bad = good;
    bad[3] = 0x01; // one bit of noise on the line

    println!("crc of the six body bytes = 0x{:04X}", crc16_modbus(&good[..6]));
    for (name, frame) in [("good", &good[..]), ("noisy", &bad[..]), ("short", &good[..3])] {
        match check_frame(frame) {
            Ok(body) => println!("{name}: ok, {} body bytes", body.len()),
            Err(FrameError::TooShort(n)) => println!("{name}: only {n} bytes, too short to be a frame"),
            Err(FrameError::BadCrc { expected, got }) => {
                println!("{name}: CRC mismatch, expected 0x{expected:04X}, got 0x{got:04X}")
            }
        }
    }
}
Output
crc of the six body bytes = 0xCDC5
good: ok, 6 body bytes
noisy: CRC mismatch, expected 0x0D94, got 0xCDC5
short: only 3 bytes, too short to be a frame

The CRC of the six body bytes is 0xCDC5, and a Modbus frame sends the low byte first, which is why the frame ends in C5 CD. u16::from_le_bytes rebuilds the number from the two bytes. &[u8] is a slice, a read-only view of bytes, and chapter 9 explains why a function can return part of its input.

Do not use unwrap() in firmware you ship. unwrap on a None or an Err panics. A panic usually halts the program or resets the chip, depending on how the project is set up, and neither is a controlled stop. Decide the safe state instead. For a motor, that is almost always "off".
Chapter 8

Types that hold state: structs and impl

You already know

Function blocks with their own memory, and the contact bounce of a mechanical switch.

You will be able to

Write a struct with methods, debounce a button, and compute PWM compare values.

A function block, in Rust. State goes in a struct. Behaviour goes in an impl.

Contact bounce

A mechanical switch does not change cleanly. For a few milliseconds the contacts make and break, and the pin reads 1, 0, 1, 0 before it settles. The fix is to accept a change only after the level has stayed the same for a set time. This is the first real piece of input handling you will write on the micro:bit, because it has buttons.

debounce.rs
#[derive(Debug, Clone, Copy, PartialEq)]
enum Edge {
    Pressed,
    Released,
}

struct Debounce {
    stable: bool,
    candidate: bool,
    since_ms: u32,
    hold_ms: u32,
}

impl Debounce {
    fn new(hold_ms: u32) -> Self {
        Debounce { stable: false, candidate: false, since_ms: 0, hold_ms }
    }

    fn update(&mut self, raw: bool, now_ms: u32) -> Option<Edge> {
        if raw != self.candidate {
            self.candidate = raw; // the pin moved: restart the clock
            self.since_ms = now_ms;
            return None;
        }
        if self.candidate != self.stable && now_ms.wrapping_sub(self.since_ms) >= self.hold_ms {
            self.stable = self.candidate;
            return Some(if self.stable { Edge::Pressed } else { Edge::Released });
        }
        None
    }
}

// A press with contact bounce at 2..6 ms, a release with bounce at 41..43 ms.
fn pin_level(ms: u32) -> bool {
    match ms {
        0..=1 => false,
        2 => true,
        3 => false,
        4 => true,
        5 => false,
        6..=40 => true,
        41 => false,
        42 => true,
        43 => false,
        _ => false,
    }
}

fn main() {
    let mut button = Debounce::new(10);
    for ms in 0..60 {
        if let Some(edge) = button.update(pin_level(ms), ms) {
            println!("{ms:2} ms: {edge:?}");
        }
    }
}
Output
16 ms: Pressed
53 ms: Released
raw pin, bouncingaccepted levelPressed at 16 msReleased at 53 ms10 ms of calm0102030405060millisecondsbounce
Drawn from the same sequence the program uses. The shaded bands are the bounces. The debouncer reports a press ten milliseconds after the pin settles, and a release the same way.

The press bounces between 2 and 6 ms. It settles at 6, and 10 ms later, at 16 ms, the program reports Pressed. The release bounces until 43 ms, and the program reports Released at 53 ms. The bounces in between never reach the rest of your program.

  • struct Debounce groups the four pieces of state that belong together.
  • impl Debounce holds the functions that work on it. &mut self means "this function may change the debouncer".
  • Time is a u32 in milliseconds, and the comparison uses wrapping_sub, so it survives the timer rolling over (chapter 4).
  • It returns Option<Edge>: Some(edge) when something happened, None otherwise (chapter 7).

A PWM channel as a struct

A hardware PWM output is a counter and a comparator. The counter runs from 0 up to a period value and starts again. The output is high while the counter is below the compare value. So the duty cycle is the compare value divided by the period. With an 8 MHz timer and a 20 kHz PWM frequency, the period is 8,000,000 / 20,000 = 400 ticks, and one tick of compare is a quarter of a percent.

pwm.rs
struct Pwm {
    period_ticks: u32,
    compare: u32,
}

impl Pwm {
    fn new(timer_hz: u32, pwm_hz: u32) -> Self {
        Pwm { period_ticks: timer_hz / pwm_hz, compare: 0 }
    }

    fn set_duty_pct(&mut self, pct: u8) {
        let pct = pct.min(100) as u32; // never more than 100 percent
        self.compare = self.period_ticks * pct / 100;
    }

    // The duty cycle the hardware will really produce, in tenths of a percent.
    fn actual_tenths(&self) -> u32 {
        self.compare * 1000 / self.period_ticks
    }
}

fn main() {
    let mut pwm = Pwm::new(8_000_000, 20_000);
    println!("period = {} timer ticks", pwm.period_ticks);
    for pct in [0u8, 25, 50, 73, 100, 130] {
        pwm.set_duty_pct(pct);
        let t = pwm.actual_tenths();
        println!("asked {pct:>3}%  compare {:>3}  actual {}.{}%", pwm.compare, t / 10, t % 10);
    }
}
Output
period = 400 timer ticks
asked   0%  compare   0  actual 0.0%
asked  25%  compare 100  actual 25.0%
asked  50%  compare 200  actual 50.0%
asked  73%  compare 292  actual 73.0%
asked 100%  compare 400  actual 100.0%
asked 130%  compare 400  actual 100.0%

Two details matter. pct.min(100) stops a bad value of 130 from asking for more than the full period. The multiplication is done before the division, so the result keeps its precision, and the numbers are u32 so there is room for it.

Key idea

A struct holds the state that belongs together. An impl holds the functions that work on it. &mut self marks the ones that change it.

Chapter 9

Ownership and borrowing

You already know

One master per bus, and the corrupted frame when two masters talk at once.

You will be able to

Explain moves and borrows with the bus rule, read the two classic errors, and see why interrupts need care.

This is the part of Rust people find hard. If you think of it as a bus, it is not.

On a busIn Rust
One master owns the lineOne owner per value. When the owner goes away, the value goes away
Slaves listen to the master's frame&value: any number of read-only borrows
The master hands the line to exactly one other device&mut value: exactly one writer, and nobody reading meanwhile
Two masters at once corrupt the frameThe compiler refuses to build the program
ownership.rs
struct Motor {
    speed_pct: u8,
}

fn set_speed(m: &mut Motor, pct: u8) {
    m.speed_pct = pct; // &mut: one writer, nobody else looking
}

fn report(m: &Motor) {
    println!("speed {}%", m.speed_pct); // &: any number of readers
}

fn retire(m: Motor) {
    println!("motor retired at {}%", m.speed_pct); // takes ownership; m is gone afterwards
}

fn main() {
    let mut m = Motor { speed_pct: 0 };
    set_speed(&mut m, 40);
    report(&m);
    report(&m);
    retire(m);
    // report(&m); // would not compile: m was moved into retire()
}
Output
speed 40%
speed 40%
motor retired at 40%

set_speed borrows the motor mutably, so it is the only one touching it while it runs. report borrows it read-only, and you may do that as many times as you like. retire takes the motor itself. After that call, m no longer exists in main. Now watch the compiler enforce it.

use after move
struct Motor { speed_pct: u8 }
fn retire(m: Motor) { println!("retired at {}", m.speed_pct); }
fn main() {
    let m = Motor { speed_pct: 40 };
    retire(m);
    println!("{}", m.speed_pct);   // m was moved away on the line above
}
The compiler says
error[E0382]: borrow of moved value: `m`
  --> c09_fail_move.rs:12:20
   |
10 |     let m = Motor { speed_pct: 40 };
   |         - move occurs because `m` has type `Motor`, which does not implement the `Copy` trait
11 |     retire(m);
   |            - value moved here
12 |     println!("{}", m.speed_pct);
   |                    ^^^^^^^^^^^ value borrowed here after move
   |
note: consider changing this parameter type in function `retire` to borrow instead if owning the value isn't necessary
  --> c09_fail_move.rs:5:14
   |
 5 | fn retire(m: Motor) {
   |    ------    ^^^^^ this parameter takes ownership of the value
   |    |
   |    in this function
note: if `Motor` implemented `Clone`, you could clone the value
  --> c09_fail_move.rs:1:1
   |
 1 | struct Motor {
   | ^^^^^^^^^^^^ consider implementing `Clone` for this type
...
11 |     retire(m);
   |            - you could clone this value

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0382`.

Now two writers at once:

two writers
fn main() {
    let mut speed = 10u8;
    let a = &mut speed;
    let b = &mut speed;   // second writer while the first is still in use
    *a += 1;
    *b += 1;
}
The compiler says
error[E0499]: cannot borrow `speed` as mutable more than once at a time
 --> c09_fail_two_writers.rs:4:13
  |
3 |     let a = &mut speed;
  |             ---------- first mutable borrow occurs here
4 |     let b = &mut speed;
  |             ^^^^^^^^^^ second mutable borrow occurs here
5 |     *a += 1;
  |     ------- first borrow later used here

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0499`.

Each message points at the exact line, says what rule you broke, and often suggests the fix.

Borrowing a slice

Most embedded code works on buffers: a row of ADC samples, a CAN payload, a UART line. A slice, written &[u16], is a read-only view of part of a buffer. &mut [u16] is the same view with permission to write. The function lends its caller the right to look, or to write, and nothing is copied.

slices.rs
fn peak(samples: &[u16]) -> u16 {
    let mut p = 0;
    for &s in samples {
        if s > p {
            p = s;
        }
    }
    p
}

// Three-point moving average, done in place. The caller lends the buffer for writing.
fn smooth(samples: &mut [u16]) {
    if samples.len() < 3 {
        return;
    }
    let mut prev = samples[0];
    for i in 1..samples.len() - 1 {
        let current = samples[i];
        samples[i] = ((prev as u32 + current as u32 + samples[i + 1] as u32) / 3) as u16;
        prev = current; // keep the old value so we average real readings, not smoothed ones
    }
}

fn main() {
    let mut readings = [100u16, 900, 120, 130, 880, 140, 150];
    println!("peak before {}", peak(&readings));
    smooth(&mut readings);
    println!("smoothed    {:?}", readings);
    println!("peak after  {}", peak(&readings));
}
Output
peak before 900
smoothed    [100, 373, 383, 376, 383, 390, 150]
peak after  390

peak only looks. smooth rewrites the buffer in place, and it keeps the previous original value in prev, so each average uses real readings and not ones it already smoothed. The peak drops from 900 to 390 because the single noisy spike is averaged against its neighbours.

Why this matters for interrupts

A main loop and an interrupt handler are two masters on one bus. If both can change the same data, a handler can fire halfway through the main loop's update and see a mixed picture.

main loopinterruptwrite speed= 80write direction= reverseinterrupt fires herereads speed = 80direction = forwardA snapshot that never existed:new speed with the old direction.time
The main loop writes two related values in two steps. An interrupt lands in between and reads the new speed with the old direction.

Rust will not let you share data like that unless you wrap it in something that makes the access safe, for example by briefly disabling interrupts around it. That wrapper is called a critical section. The embedded crates provide the wrappers, and we will meet them in chapter 12 when the code needs them.

Key idea

A value has one owner. You can lend it out for reading to many, or for writing to one. The compiler checks every loan.

Chapter 10

Traits: write the controller once

You already know

A Modbus master that works with any drive that answers the same register map.

You will be able to

Write a trait and a generic function, and see why a mock makes hardware code testable.

A Modbus master does not care which brand of drive is on the bus, only that it answers the same register map. A trait is a register map for code.

traits.rs
trait OutputPin {
    fn set(&mut self, high: bool);
}

struct MockPin {
    name: &'static str,
}

impl OutputPin for MockPin {
    fn set(&mut self, high: bool) {
        println!("{} -> {}", self.name, if high { "HIGH" } else { "low" });
    }
}

struct MotorDriver<P: OutputPin> {
    enable: P,
    forward: P,
}

impl<P: OutputPin> MotorDriver<P> {
    fn run_forward(&mut self) {
        self.forward.set(true);
        self.enable.set(true);
    }

    fn stop(&mut self) {
        self.enable.set(false); // remove power first, then the direction
        self.forward.set(false);
    }
}

fn main() {
    let mut driver = MotorDriver {
        enable: MockPin { name: "EN" },
        forward: MockPin { name: "FWD" },
    };
    driver.run_forward();
    driver.stop();
}
Output
FWD -> HIGH
EN -> HIGH
EN -> low
FWD -> low
MotorDriverwritten oncetrait OutputPin: anything with set(high)micro:bit pinreal hardwarePico 2 pinreal hardwareMockPinon your PCeach one implements the trait
One driver, three possible pins. The driver is written against the trait and never names a particular chip.

trait OutputPin says: anything that can set a pin high or low. MockPin is a fake that prints. MotorDriver is written against the trait, not against any particular pin, so it works with any type that implements it.

A generic function

A trait can also bound a function. average<S: Sensor> works for any S that implements Sensor. The compiler makes a specialised copy for each type that uses it, so there is no run-time cost for the flexibility.

sensor_trait.rs
#[derive(Debug)]
enum SensorError {
    Timeout,
}

trait Sensor {
    fn read(&mut self) -> Result<u16, SensorError>;
}

// A pretend sensor that replays a script. None means "the read timed out".
struct FakeSensor {
    script: [Option<u16>; 5],
    next: usize,
}

impl Sensor for FakeSensor {
    fn read(&mut self) -> Result<u16, SensorError> {
        let v = self.script[self.next % self.script.len()];
        self.next += 1;
        v.ok_or(SensorError::Timeout)
    }
}

// Written once, for any type that is a Sensor.
fn average<S: Sensor>(sensor: &mut S, n: u32) -> Result<u16, SensorError> {
    let mut total: u32 = 0;
    for _ in 0..n {
        total += sensor.read()? as u32;
    }
    Ok((total / n) as u16)
}

fn main() {
    let mut steady = FakeSensor { script: [Some(100), Some(110), Some(90), Some(100), Some(100)], next: 0 };
    println!("steady: {:?}", average(&mut steady, 5));

    let mut flaky = FakeSensor { script: [Some(100), Some(110), None, Some(100), Some(100)], next: 0 };
    println!("flaky:  {:?}", average(&mut flaky, 5));
}
Output
steady: Ok(100)
flaky:  Err(Timeout)

The flaky sensor times out on its third read. The ? from chapter 7 returns the error at once, so average gives up and reports Err(Timeout), with no half-computed average. You tested the whole behaviour on a PC with no sensor attached.

This is how the real crates work

The Rust embedded community defined this idea once, in a crate called embedded-hal. It has traits for output pins (OutputPin, with set_high and set_low), for I2C, SPI, PWM and delays. A driver written against those traits runs on the micro:bit, on the Pico, or against a mock on your PC. The CAN chip crates I found, mcp2515 and mcp25xx, are written this way. It is also why the crank decoder runs on your desk before it ever sees a Pico.

Key idea

Write the logic against a trait and run it on a mock. When the real hardware arrives, only the edge changes.

A habit worth copying. In stop, the code removes the enable first and the direction second. Write the safe-shutdown order into the driver once, and every caller gets it.
Chapter 11

Time and the control loop

You already know

PID loops, scan times, and loop gain that you tune by watching a trend.

You will be able to

Build and read an integer PI controller, tune it against a model, and schedule tasks at fixed rates.

Control is measure, compare, correct, repeat, at a fixed rate. Every motor controller you will build is a variation on that.

setpointΣ+−errorPIcontrollerdutyMotorthe plantspeedsensor measures speed and feeds it backMeasure, compare, correct, repeat.
A closed loop. The sensor measures the speed, the sum takes it away from the setpoint, and the controller works on what is left.

Proportional on its own is not enough

A proportional controller sets the output in proportion to the error. When the error reaches zero, the output is zero, and a motor at zero output slows down. So it settles where the output is just enough to hold a speed below the target. That leftover is the steady-state error. An integral term adds up the error over time, so it keeps pushing the output up for as long as any error remains.

controller.rs
// Integer PI controller. Gains are scaled: output = (kp*error + ki*integral) / 10_000.
struct Pi {
    kp: i32,
    ki: i32,
    integral: i32,
    out_min: i32,
    out_max: i32,
}

impl Pi {
    fn new(kp: i32, ki: i32) -> Self {
        Pi { kp, ki, integral: 0, out_min: 0, out_max: 100 }
    }

    fn update(&mut self, setpoint: i32, measured: i32) -> i32 {
        let error = setpoint - measured;
        let trial = self.integral + error;
        let out = (self.kp * error + self.ki * trial) / 10_000;
        if out >= self.out_min && out <= self.out_max {
            self.integral = trial; // anti-windup: only integrate while not saturated
        }
        out.clamp(self.out_min, self.out_max)
    }
}

// A motor as a first-order lag: full duty gives 4000 rpm, time constant 0.5 s.
fn simulate(kp: i32, ki: i32) -> Vec<(f32, i32, i32)> {
    let dt = 0.01_f32;
    let tau = 0.5_f32;
    let mut speed = 0.0_f32;
    let mut pi = Pi::new(kp, ki);
    let mut log = Vec::new();
    for step in 0..=400 {
        let duty = pi.update(2000, speed as i32);
        speed += (duty as f32 * 40.0 - speed) * (dt / tau);
        if step % 5 == 0 {
            log.push((step as f32 * dt, speed as i32, duty));
        }
    }
    log
}

fn main() {
    for (label, kp, ki) in [("P", 200, 0), ("PI", 200, 5)] {
        for (t, speed, duty) in simulate(kp, ki) {
            println!("{label},{t:.2},{speed},{duty}");
        }
    }
}

Gains are whole numbers. The result is divided by 10,000 at the end, which gives you fractional gains without floating point on the controller side. clamp limits the output to 0 to 100 percent. The check if out >= self.out_min && out <= self.out_max is anti-windup: while the output is saturated, the integral stops growing, so it does not store a huge value that then overshoots later.

050010001500200001234time, secondsmotor speed, rpmsetpoint 2000P only settles at 879P + I reaches 2000
Simulated motor, setpoint 2000 rpm, controller running at 100 Hz. The data comes from the program above.
Time (s)P only, rpmP + I, rpm
0.57421208
1.08551733
2.08761999
4.08792000

The numbers describe the run. P alone settles at 879 rpm, which is 1,121 rpm short of the target. P plus I passes 1,900 rpm, 95 percent of the target, at 1.40 seconds, peaks at 2006 rpm and ends at 2000.

Judging a step response

Four numbers describe a controller. Rise time is how fast it gets there. Overshoot is how far it goes past the target. Settling time is how long it takes to stay near the target. Steady-state error is what is left at the end. P alone fails on the last one. P plus I fixes it.

What this does and does not show. The motor here is a simulation: a first-order lag with a 0.5 second time constant and a top speed of 4000 rpm. I chose the gains by calculating the damping of that model, which came to about 0.9. A real motor has friction, inertia and noise, and its gains need tuning on the bench.

Running things at fixed rates

Not everything needs to run at the control rate. A common pattern is one timer tick per millisecond, with each task running on its own multiple of that tick: the sensor every millisecond, the control loop every 10, the display every 100.

ticker.rs
fn control_step(count: &mut u32) {
    *count += 1; // would read the sensor and update the motor
}

fn update_display(count: &mut u32) {
    *count += 1;
}

fn main() {
    let mut control_runs = 0;
    let mut display_runs = 0;
    let mut sensor_runs = 0;

    // One loop pass per millisecond, like a 1 kHz hardware timer tick.
    for tick_ms in 1..=1000u32 {
        sensor_runs += 1; // every 1 ms
        if tick_ms % 10 == 0 {
            control_step(&mut control_runs); // every 10 ms: 100 Hz control loop
        }
        if tick_ms % 100 == 0 {
            update_display(&mut display_runs); // every 100 ms
        }
        if tick_ms % 250 == 0 {
            println!("t={tick_ms:4} ms  sensor={sensor_runs:4}  control={control_runs:3}  display={display_runs:2}");
        }
    }
}
Output
t= 250 ms  sensor= 250  control= 25  display= 2
t= 500 ms  sensor= 500  control= 50  display= 5
t= 750 ms  sensor= 750  control= 75  display= 7
t=1000 ms  sensor=1000  control=100  display=10

After 1000 ms the control loop has run exactly 100 times, a clean 100 Hz, because it is driven by the tick count and not by how long the other work took. That determinism is what lets you reason about a controller.

Chapter 12

The micro:bit v2

You already know

Reading a datasheet pinout and knowing which pins can drive what.

You will be able to

Know what the board gives you, what it cannot do, how the toolchain fits together, and your first four stages.

You do not need to build a circuit to start. The board already carries the parts for the first four stages.

Fronttouch logoAB5 by 5 LED matrix0123VGNDedge connector: the pins you can useBackUSBnRF52833the processoraccelerometer+ magnetometerspeakermicbatterySchematic. Positions simplified, not to scale.
The v2 board. Everything on the front and the sensors on the back are ready to use with no wiring.

What is on the board

  • A 5 by 5 LED matrix, two buttons and a touch-sensitive logo.
  • An accelerometer and magnetometer on an I2C bus inside the board, so there is nothing to wire.
  • A microphone, a speaker and a Bluetooth radio.
  • An edge connector with a handful of pins, and a built-in programmer, so you flash it over USB.

What it cannot do on its own

  • It has no CAN controller. For CAN you need the Pico 2 and a CAN module.
  • Its pins supply a few milliamps. Never connect a motor to a pin. A motor needs a driver stage between the pin and the motor, either on a breadboard or on a driver board that clips onto the edge connector.

How the toolchain fits together

Your PC compiles code for a different processor. That is called cross-compiling. The compiler needs a target, which names the processor family. For the micro:bit v2's Cortex-M4F it is thumbv7em-none-eabihf. The Pico 2's Cortex-M33 cores use a different one, and the original micro:bit v1, which has a smaller processor, uses a third. Confirm the target in the guide for your board.

A small linker file, usually called memory.x, tells the linker where flash and RAM are in the address space, using the map from chapter 1. A tool called probe-rs then sends the compiled program through the board's built-in programmer and lets you debug it. A board-support crate gives pins readable names. Names and versions change, so follow a current guide. Three I found in my search, which I have not read in full: a getting-started guide, a list of free embedded Rust books, and Rust with Embassy on the Pico.

When the code runs on the board there is no screen, so you log through the debug probe instead. The guides above cover how.

The four stages

StageWhat you buildDone when
1Blink the LED matrixOne LED turns on and off at a rate you chose
2Read the buttons with the debouncer from chapter 8One press gives exactly one event, however hard you bounce it
3Read the accelerometer and show tiltTilting the board left and right changes which LED lights
4Run the motor-starter state machine from chapter 6 from the buttonsThe LEDs show Stopped, Starting, Running and Fault, and the Fault state needs the stop button to clear
This chapter has no code, on purpose. I cannot flash a board from here, and I will not hand you firmware I have not run. When the board is in front of you, tell me what the toolchain and the board say, and we build these stages together.
Chapter 13

The Pico 2, the motor and the CAN bus

You already know

Variable-speed drives, H-bridges and 120 ohm CAN termination.

You will be able to

Explain PWM and the H-bridge from first principles, wire CAN with a 3.3 volt chip safely, and plan the capstone.

Now the real hardware, and the real safety rules.

PWM from first principles

A pin is either on or off. To make a motor run at half speed, switch the pin on and off quickly and let the motor's own inductance average it. The fraction of time the pin is on is the duty cycle. At 50 percent duty the motor sees about half the supply. The frequency matters too. Around 20 kHz is above what most people hear, so the motor does not whine.

25%avg50%avg75%avgone period50 us at 20 kHzthe motor averages the pulses
Three duty cycles over three periods. The dashed line is the average voltage the motor responds to.

The driver stage

The pin never touches the motor. It drives the logic inputs of an H-bridge driver, a set of four switches that can reverse the current. A motor is an inductor. Switch it off suddenly and it produces a voltage spike, so the driver needs a path for that current, usually flyback diodes across each switch. Buy a driver module that has them, and size its current rating well above the motor's stall current.

+V supplygroundS1closedS2openS3openS4closedMcurrent flowsleft to right
An H-bridge running forward. S1 and S4 are closed, so current flows through the motor from left to right.
Switches closedWhat the motor does
S1 and S4Runs forward
S2 and S3Runs in reverse
S3 and S4, or S1 and S2Brakes, because both motor terminals are tied to the same rail
NoneCoasts
S1 and S3, or S2 and S4Never. That connects supply to ground through one side of the bridge and destroys the switches
Key idea

Never close both switches on the same side of the bridge. Good driver chips make that impossible. If you build one from parts, you must make it impossible in software and in hardware.

CAN with a 3.3 volt chip

Level warning. Many cheap MCP2515 modules run on 5 volts. The Pico's pins are 3.3 volts. A 5 volt signal into a Pico pin can destroy the chip. Use a level shifter, or a module that runs its logic at 3.3 volts. Check the module before you wire it.

You know CAN termination already: 120 ohms at each end, so 60 ohms measured across the bus. Many modules have a jumper for the resistor. Two other things catch people out. The MCP2515 needs its bit timing set to match the bus speed and the module's crystal, which is commonly 8 or 16 MHz. All the nodes must share a ground reference, and the motor's high-current return should not run through the same wire as the logic ground.

HeaderSOF1Identifier11 bitsRTR1IDE1r01DLC4arbitration happens here:a 0 beats a 1, so the lowest ID winsPayload and checksData0 to 8 bytesCRC15 bitsD1ACK2EOF7D is the CRC delimiter. Any node that heard the frame correctlypulls the ACK slot dominant. Segment widths are not to scale.
A standard CAN frame. Arbitration happens on the identifier. The bit timing, the termination and the transceiver are what you set up on hardware.

To see traffic you need two nodes. A second Pico and second module is the cheap route. The MCP2515 also has a loopback mode for a first test, which is worth checking in its datasheet.

AccelerometerI2CNode A: Pico 2SPIMCP2515 + CAN transceiverCAN-H / CAN-L120 ohm at each endMCP2515 + CAN transceiverSPINode B: second PicoPWM + directionH-bridge motor drivermotor supplyMotor
The capstone plan. The accelerometer reading goes into a CAN frame on node A. Node B reads it, turns it into a PWM duty, and drives the motor.

Both nodes use the traits from chapter 10, so most of the logic can be tested on your PC first.

Chapter 14

The crank decoder

You already know

A 36-1 crank wheel, and what a gap in the pulse train means.

You will be able to

Work out the timing from first principles, read the decoder's states, and pass nine tests.

The project that ties it together. It is also the beginning of the engine controller you described.

The exercise is in the chat as crank_decoder.zip. It contains a Rust crate with nine failing tests and three todo!() functions for you to fill in.

36 slots35 teethgap: the missing toothtooth 0, first afterthe gaptooth 34,last before itcrank turnsthis wayEach interval between teeth is 1 slot, and across the gap it is 2 slots.
The wheel. 36 slots with the tooth in one slot missing, so 35 teeth. Tooth 0 is the first tooth to arrive after the gap.

The gap shows up as time

The sensor cannot see the missing tooth. It only sees the interval between edges. Between ordinary teeth the interval is one slot. Across the gap it is two.

TTTTT2Ttooth 0The gap is found when an interval is more than 1.5 timesthe interval before it: 2T against T.tooth edges arriving at the sensor, left to right
Edges arriving over time. The long interval is the only way the decoder can tell where the wheel is.

The decoder compares each interval with the one before it. If the new one is more than 1.5 times longer, that is the gap. With some noise on each edge, ordinary intervals never reach a ratio of 1.5, and the gap interval never falls below it. The exercise tests that with 8 percent jitter.

The maths, run

One slot is one thirty-sixth of a revolution, so the revolution time is the slot time multiplied by 36. This program works the numbers for four speeds, from cranking to redline.

rpm_maths.rs
const SLOTS: u64 = 36;

fn rpm_from_slot_us(slot_us: u64) -> u64 {
    60_000_000 / (slot_us * SLOTS)
}

fn slot_us_from_rpm(rpm: u64) -> u64 {
    60_000_000 / (rpm * SLOTS)
}

fn main() {
    println!(" rpm   slot (us)   gap (us)   rpm recovered");
    for rpm in [150u64, 800, 3000, 6500] {
        let slot = slot_us_from_rpm(rpm);
        println!("{rpm:>4}   {slot:>9}   {:>8}   {:>13}", slot * 2, rpm_from_slot_us(slot));
    }
}
Output
 rpm   slot (us)   gap (us)   rpm recovered
 150       11111      22222             150
 800        2083       4166             800
3000         555       1110            3003
6500         256        512            6510

The last column is the rpm recovered from a whole-microsecond slot time. At 6500 rpm it reads 6510, an error of 0.15 percent. Whole microseconds are the timer's resolution, so there is a floor to how precisely you can measure a fast engine. That is a limit of the measurement, and the decoder cannot improve on it.

HuntingSyncedtooth indexpower ongap foundRevolutiongap in the wrong place,or no gap where one is due:SyncLostno tooth for longer than the timeout:Stalleach ordinary tooth:Tooth { index, rpm }
The decoder's states. It hunts until it finds a gap, then stays synced while the gaps arrive when expected.

What it uses from the book

  • Chapter 4: wrapping_sub on the microsecond timer, and widening before you multiply.
  • Chapter 6: two states, hunting and synced, which suit an enum.
  • Chapter 7: Option for "no event yet" and for "never seen a tooth".
  • Chapter 8: a Decoder struct with an impl.
  • Chapter 10: the decoder takes a timestamp, not a pin, so it runs unchanged on a PC, a micro:bit and a Pico.

My own solution is sealed. Its SHA-256 hash is 974839d869008b702a9bf012835334bd8d3fce0aa042920740a8b84030f27a83, and I will show it to you after you have passed the tests, so you can check it is the file I wrote before you started.

Chapter 15

Checkpoints and what comes next

You already know

Exams you did not enjoy, and tests of a machine that you did.

You will be able to

Know exactly what the first checkpoint covers, and look up any term in the glossary.

The test is not a surprise. This is exactly what it covers.

Checkpoint A: 15 questions, one mark each

ChapterQuestions
3 Your first program1
4 Numbers3
5 Decisions and hysteresis2
6 States3
7 Option and Result2
8 Structs2
9 Ownership2

Short answers. One or two lines each. No lookups and no pasting. I seal the answer key and show you its hash before you start, and I reveal the file after, so you can check I did not move it. The "Check yourself" boxes in each chapter are the same style of question.

Chapters 10 and 11 and the board work come up in Checkpoint B, once you have the micro:bit running.

A Rust cheat sheet, in your terms

In control workIn Rust
Scan cycleloop { read; decide; act }
Holding registerA variable, or a hardware register wrapped in a type
State bitsAn enum
Fault codeResult with an error type
Function blockstruct plus impl
Vendor-neutral driverA trait
One master on the busOne owner or one &mut
InterlockA rule the compiler enforces before the program runs

Glossary

TermMeaning here
ADCAnalogue to digital converter. Turns a voltage into a number
BorrowLending a value for reading (&) or writing (&mut) without giving it away
CargoRust's build tool and package manager
CrateA Rust package, a library or a program
Critical sectionA short stretch of code that runs with interrupts disabled
Duty cycleThe fraction of a PWM period the output is high
GPIOGeneral-purpose input and output pins
HALHardware abstraction layer. Traits that hide the chip behind a common interface
I2C, SPIShort-distance serial buses between chips on a board
InterruptA hardware signal that pauses the main program to run a short handler
MovePassing a value so the original owner can no longer use it
no_stdA program that does not use the standard library, because there is no operating system
PWMPulse width modulation. Switching an output fast so its average sets a level
TargetThe processor family the compiler is producing code for
TraitA set of behaviours a type promises to provide

Sources

Found in my searches and not read in full: Ferrous Systems IEC 61508 SIL 2 announcement, Ferrocene automotive certification, Rust for safety-critical systems in space, mcp2515 crate, mcp25xx crate.