A first-principles course

Embedded Rust
for Control

For anyone who wants to understand how a small computer controls a machine, starting from nothing. 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
  • 21 diagrams
  • every program compiled and run
sensor
Contents
  1. 0Before we start
  2. 1What a microcontroller really is
  3. 2Why Rust, and the one rule
  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

Before we start

You already know

How a good teacher shows you the thing first and names it second.

You will be able to

Know how this book teaches, so nothing in it ambushes you.

Most programming books hand you rules. This book hands you a picture first, then the idea, then the code. It starts from nothing, so you do not need to know anything about electronics or programming to begin.

Every chapter works the same way, in the same order:

  1. A picture first. Something you could touch: a gearbox, an odometer, a set of keys. You will never meet a new word before you have met the thing it names.
  2. Then the idea, in one or two sentences, with a name on it.
  3. Then the Rust, small enough to hold in your head. I tell you what each line is doing before I ask you to trust it.
  4. Then you do something. A small ungraded exercise, then a few questions to answer in your own words.

If a paragraph is hard, that is my fault and not yours. Tell me which one and I will redraw it.

What you need on your computer

One thing: Rust. Install it from rustup.rs. Then you can compile and run a file like this:

terminal
rustc file.rs      # turn the file into a program
./file             # run it (on Windows: file.exe)

Chapters 3 to 11 teach the language on your computer, with no board plugged in. Chapters 12 to 14 are the hardware and the crank decoder. Everything that ran in this book was really run, and the output you see is the real output.

The book is written for the micro:bit v2 and the Pico 2. To tell which micro:bit you have, turn it over. The v2 has a small speaker on the back and a gold oval touch logo on the front. The v1 has neither.

Chapter 1

What a microcontroller really is

You already know

Giving an instruction to one person who passes it on, and a warning light on a car dashboard.

You will be able to

Explain what is inside the chip using a workshop, and why a crank tooth needs more than a loop.

Picture this

Imagine a tiny workshop with one mechanic in it. He is very fast, but he can only do one thing at a time.

On a shelf he has a workshop manual. It is printed, so it is still there when the lights go off. On the wall he has a whiteboard for working things out. When the lights go off, the whiteboard is wiped.

Along another wall there is a row of switches and gauges. Each one is labelled with a number. Flick switch 5 and a lamp outside comes on. Read gauge 9 and it tells you a temperature.

The mechanic never leaves the room. He reads one line of the manual, does it, reads the next. That is all a computer ever does.

Now put names on it. A microcontroller is that workshop on a single chip:

In the workshopOn the chip
The mechanicThe CPU core. It runs one instruction at a time
The printed manualFlash memory. Your program lives here and survives power-off
The whiteboardRAM. Your variables live here and vanish at power-off
The switches and gaugesPeripherals. They are the parts that touch the real world: pins, timers, converters
The wall the lamps are wired toPins. The only part the outside world touches
CPU core: the mechanicFlashthe manualRAMthe whiteboardthe bus: the village of numbered doorsGPIOpins in and outTimerskeep timeADCvolts to numberLinkstalk to chipsPins: where the outside world connectsAn interrupt is a peripheral tapping the CPU on the shoulder:stop, run one short job, carry on.
The workshop drawn as a chip. Everything the program does to the world goes through a peripheral.

Why there is no operating system

On your laptop, a manager called an operating system decides who gets the CPU, stops programs touching each other's memory and cleans up after crashes. On a microcontroller there is no manager. Power arrives, the mechanic reads page one of the manual, and from then on he does exactly what the manual says. That is freedom, and it is also danger: nothing stops you doing something stupid.

It is small

The micro:bit v2's chip is an nRF52833. Its mechanic is a design called Arm Cortex-M4, running at 64 MHz, which means his clock ticks 64 million times a second. It has 512 KB of flash and 128 KB of RAM, where a KB is a thousand bytes and a byte is one row of eight wheels. The Pico 2's RP2350 has two mechanics at up to 150 MHz and 520 KB of RAM. I took those figures from the makers' published specifications, so check them against the datasheets (the maker's own reference documents for the chip) before you rely on them. Notice the scale: thousands of bytes, where your laptop has billions. That is why embedded Rust avoids asking for memory while it is running.

A register is like a mother to her children

Think of a family compound with eight children. When you want something done with the children, you do not run round and speak to each child. You go to their mother and say one thing: “Children 0 and 2 stay up. The rest go to bed.” She passes it on and the children do it. Later you ask her, “Who is awake?” and she tells you, without you looking into a single bedroom.

Now the same thing with numbers. Give each child a number from 0 to 7. Awake is 1 and asleep is 0. Your instruction is eight yes-or-no answers in a row: 00000101. Children 0 and 2 are awake. The other six are asleep. One short sentence to one person, and eight children respond.

The mother is a register. A register is the one place you speak to when you want real things to happen, or want to know how they are, without dealing with each thing yourself. You write to her to make things move, and you read from her to learn how things are.

In another home it is the father you speak to, and what he looks after is the whole home, the mother and the children included. “Quiet hour.” He passes it on and the whole house goes quiet. It is the same idea: one person you speak to, who is connected to many things.

On the chip, the children are pins: tiny metal legs that go out to the real world, to a lamp, a button, a motor. A register is the mother of a group of pins. Write to her and eight pins change at once. Read from her and you learn what eight pins are doing.

The chip is a village of such houses, and every house has a number on its door. That number is the address. The mechanic does the same thing at every door: he walks up and says “put this here”, or asks “what is there?”. That is the only way he talks to anything.

Not every house has children in it. The manual and the whiteboard live in the same village, in plain storehouses. You put a sack in and it stays, and you come back and find the same sack. The mechanic cannot tell a storehouse from a mother's house by the way he walks up to it. Only what is behind the door is different.

A quick word on hex

The picture below shows door numbers like 0x2000_0000. That is hex, and you need to read it, because every datasheet is written in it.

On the odometer you count in tens, because you have ten fingers. A chip counts in twos, one wheel with two positions. A long row of wheels like 01001010 is miserable to write down. Hex is the shortcut: take the wheels four at a time. Four wheels can make exactly 16 patterns, from 0 to 15. Give each pattern one character: 0 to 9 as usual, then A = 10, B = 11, C = 12, D = 13, E = 14, F = 15. That is hex: counting in sixteens, one character for every four wheels.

1280641320160814021100100 = 41010 = 8 + 2 = 104A0x4A= 64 + 8 + 2 = 74
One byte read in hex. The wheels split into two groups of four. 0100 is 4, 1010 is 10 which is A, so the byte is 0x4A, which is 74.

Three small facts finish it. The 0x at the front just means "what follows is hex", so 0x10 is sixteen, not ten. Underscores like 0x2000_0000 are only to help your eye, like the commas in 1,000,000. And two hex digits make exactly one byte, so 0xFF is 1111 1111, which is 255.

Nobody reads a hex door number as a quantity. You read it as a place, the way you read a postcode. It tells you which part of the village you are in.

door number (in hex)0x0000_0000Flashyour program0x2000_0000RAMvariables and the stack0x4000_0000Peripheral registersthe mothers of the pins0xE000_0000Core registersinterrupts and the clock1706051403020110One mother opened up: 8 children, one per wheel.A 1 means awake (that pin is on). Say one byte, eight pins obey.This is the nRF52 family used on the micro:bit v2.Exact numbers differ by chip. The datasheet has the real map.
The village of numbered doors, grouped by what is behind them. One of the mothers is opened up to show her eight children, one per wheel.

Why a loop on its own is not enough

Think of driving. You are watching the road, and that is your main job. But the oil-pressure light comes on. You do not wait until the next junction to notice. The light grabs your attention right now, you deal with it, and then you carry on driving.

A microcontroller does the same. Its main loop is the driving. An interrupt is the oil-pressure light: a signal from a peripheral that makes the mechanic put his pencil down, run one short job, and then pick the pencil up exactly where he left it.

Here is why that matters on an engine. On a 36-1 crank wheel at 3000 rpm, a tooth passes the sensor every 555 microseconds. The chip runs at 64 MHz, so that gap is about 35,000 ticks of the mechanic's clock. It sounds like plenty. But if the main loop is busy with something else for longer than that, the tooth goes by and nobody saw it. The interrupt is how you promise not to miss it.

Key idea

A microcontroller is a workshop with one mechanic, a manual, a whiteboard and a wall of numbered switches. An interrupt is the oil-pressure light.

Say it back
  • Flash is the printed manual and it stays. RAM is the whiteboard and it is wiped.
  • A register is like a mother to her children: speak to her once and eight pins respond, ask her and she tells you how they are.
  • An interrupt is the oil-pressure light: put the pencil down, do one short job, carry on.
  • A crank tooth every 555 microseconds can slip past a busy loop. An interrupt will not miss it.
Chapter 2

Why Rust, and the one rule

You already know

A village meeting: one person speaks and the rest listen.

You will be able to

Say Rust's one rule in a sentence and know what it saves you from.

Picture this

A job card is pinned on the workshop wall. Three mechanics can read it at the same time with no problem. Now one mechanic takes it down to rub out the old torque value and write a new one.

If the other two read it while he is halfway through rubbing, they see half the old number and half the new one. Somebody tightens a head bolt to a number that never existed.

The safe rule is simple: either many people may look at the card, or one person may write on it. Never both at once.

You have seen this rule in everyday life. In a village meeting, one person speaks and the rest listen. If two people shout at once, nobody understands either. Rust takes that rule and applies it to every piece of data in your program, and it checks it before the program ever runs.

Many readers, nobody writingthe value&value&value&valueorOne writer, nobody else lookingthe value&mut valueno readerswhile it writes
The rule in two pictures. Many readers at once is fine. A writer has to be alone.
Key idea

Many readers, or one writer, never both. Rust checks it while compiling, so the mistake never reaches the chip.

What this saves you from

Four bugs cause a large share of firmware disasters. A language called C lets all four through. Rust stops three of them at compile time and forces you to decide on the fourth.

The bugOn a machine it looks likeWhat Rust does
A number rolls overA throttle counter goes from 255 to 0 and the engine drops to idleMakes you say what you want to happen (chapter 4)
Using something you never setA setpoint nobody filled in, so the 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)
Pointing at something that is goneReading a buffer that has already been reusedDoes not compile

What it will not do

It will not fix your physics. Wire a motor straight to a pin and you will burn the pin, in any language. Rust protects the software side. The electronics are still on you.

A word about unsafe

Talking to hardware means telling the compiler "trust me, this address is a switch." That is one place where Rust lets you say unsafe. The board libraries do it once, carefully, and your own code stays safe. The keyword is easy to search for, so the dangerous bits can always be found.

The honest trade

Rust is harder to learn than the others. For the first few weeks it will argue with you. MicroPython is gentler, but it is slower and it gives you less control of timing. C is fine in expert hands. 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. That 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.

Say it back
  • Many may read the job card, or one may write on it, never both.
  • Rust checks that rule before the program runs.
  • Rust does not fix your electronics. A motor on a pin still burns the pin.
Chapter 3

Your first program

You already know

A labelled tin on a shelf, with its lid glued shut.

You will be able to

Make a named value, change one and not the other, and read what the compiler tells you.

Picture this

On your workshop shelf there are tins with labels on them. A tin marked SETPOINT, the speed you want, has the number 1500 inside. A tin marked ACTUAL, the speed you really have, has 0. The speed is in rpm, revolutions per minute: how many times a shaft turns in one minute.

The SETPOINT tin has its lid glued shut. Nobody can change it by accident. The ACTUAL tin has a normal lid, because the actual speed is supposed to change.

That is a variable in Rust: a labelled tin with a number inside. And Rust glues the lid shut unless you say otherwise.

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

Read it like a recipe

Take the lines one at a time.

  • fn main() { is "the program starts here". Everything between the curly braces is the job.
  • let setpoint_rpm = 1500; says: make a tin, label it setpoint_rpm, put 1500 in it. The lid is glued shut.
  • let mut actual_rpm = 0; says the same but mut (short for mutable, "may change") means this lid opens.
  • println!(...) prints a line. Whatever you put in curly braces, like {actual_rpm}, is replaced with what is in that tin. The ! is only a mark saying this is a macro, a helper that can take a template like this.
  • actual_rpm = 1480; opens the lid and puts a new number in.
  • The last line is a comment, because it starts with //. The computer ignores it. It shows what would fail.
Key idea

A variable is a labelled tin. Rust glues the lid shut unless you write mut. Every tin you can change is visible on the page.

Why does this matter on a machine? Because in a control program the dangerous values are the ones that change without anyone noticing. Here, if a line of code tries to change the setpoint by accident, the compiler stops you.

What the chip changes

On a microcontroller there is no screen, so println! does not exist the same way, and the program starts a little differently. The tins, the lids and everything in chapters 3 to 11 are identical. Only the edges change. One word you will meet soon: in Rust a library is called a crate.

Say it back
  • A variable is a labelled tin with a number inside.
  • Plain let glues the lid shut. let mut lets it open.
  • Compiler messages name the line and tell you the fix. Read them.
Chapter 4

Numbers that don't lie

You already know

A car's odometer rolling over.

You will be able to

Say why a number has a limit, pick the right behaviour at the edge, and flip one bit without disturbing the rest.

Picture this

Think of an old mechanical odometer with five little wheels. It reads 99,999. Drive one more kilometre and every wheel rolls over to 00000. The car did not lose its history. The odometer just ran out of wheels.

A number inside a chip is exactly that. It has a fixed number of wheels. The only difference is that each wheel has two positions, 0 or 1, not ten. A wheel with two positions is called a bit.

Eight bits make a number called a u8. How many different readings can eight two-position wheels show? Each wheel doubles the count, so 2 times 2, eight times, which is 256. That is why a u8 runs from 0 to 255 and then rolls over.

The weights on the balance

How does a row of bits become a number? Imagine a set of brass weights on a balance: 1, 2, 4, 8, 16, 32, 64, 128. Each bit says "put this weight on the pan" or "leave it off." Switch on the bits for 64, 8 and 2, and the pan holds 74. Any number up to 255 can be built from that one set.

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, each with its weight. Add 10 to 250 and the answer needs a ninth wheel, which a u8 does not have.

Signed numbers borrow one trick: the top weight is counted as negative. So a signed byte, called i8, runs from −128 to 127. The bit pattern 11111111 is 255 as a u8 and −1 as an i8. Same wheels. Different way of reading them.

NameWheelsRangeWhere you will meet it
u880 to 255a percentage, one byte on a bus
u16160 to 65,535a sensor reading, a position
i1616−32,768 to 32,767accelerometer axes
u32320 to 4,294,967,295a microsecond timer
i3232about plus or minus 2.1 billionerrors and sums inside a controller

An ADC (analogue-to-digital converter) is the part of the chip that turns a voltage into a number. A 12-wheel ADC turns 0 to 3.3 volts into a number from 0 to 4095. An accelerometer's three axes are its three directions of feel: left-right, forward-back and up-down.

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

Three ways to treat the edge

So what should happen when 250 plus 10 will not fit? It depends on the job, and Rust makes you choose, because the choice is physical.

  • Saturating: stop at the maximum, like a fuel gauge that cannot go past full. Use it for outputs. A throttle at 100 percent should stay at 100 percent.
  • Checked: hand back a warning instead of an answer, like a dashboard light. Use it when overflow means something has gone wrong.
  • Wrapping: roll over like the odometer. Use it for timers, which are supposed to roll.
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

Read the output top to bottom. Starting from 250: checked returns None, which means "no answer", saturating stops at 255, and wrapping rolls round to 4. A plain + does something different depending on how you build the program, which is exactly why Rust makes you pick.

The cast trap

Squeezing a big number into a small one with as chops off the extra wheels. 300u16 as u8 gives 44, because 300 minus 256 is 44. A sensor value silently turning into 44 is a classic control bug. Look at scale_adc above: it widens the number to u32 before multiplying by 100, so the middle of the sum has room, and only then shrinks the answer, which is safe because it is at most 100.

The timer that rolls over, and the one line to remember

A microsecond timer counts up on a u32. After about 71.6 minutes it rolls over. If your program measures "how long since that tooth?" by subtracting, a rollover in the middle makes the answer enormous.

after.wrapping_sub(before) fixes it, because wrapping subtraction is odometer arithmetic. In the output, a timer that went from just below the top to 10 gives a gap of 16 microseconds, which is right. You will use this exact line in the crank decoder.

Talking to a register one bit at a time

You will see these numbers written in hex, as in chapter 1: 0x4A is 0100 1010, which is 74.

Remember the mother and her eight children? Your instruction to her was one byte, and each child was one wheel. Often you want to change one child without disturbing the other seven. Four small tools do everything:

You want toYou writeThe picture
Turn bit n onreg |= 1 << nSwitch it up. The others are left alone
Turn bit n offreg &= !(1 << n)Switch it down
Flip bit nreg ^= 1 << nToggle it
Is bit n on?reg & (1 << n) != 0Look at it

1 << n means "start with a 1 and slide it n places left", so it makes a stencil with just one hole, at position n. You lay the stencil over the register and only that one switch is affected.

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

A number is a fixed row of two-position wheels. At the edge, you choose: stop (saturate), warn (check) or roll (wrap). To change one switch in a register, use a one-hole stencil.

Say it back
  • A number is a fixed row of two-position wheels. Eight wheels give 256 readings, 0 to 255.
  • At the edge you choose: saturate (stop), check (warn) or wrap (roll). Outputs saturate. Timers wrap.
  • 300u16 as u8 is 44. Widen before you multiply, shrink at the end.
  • To change one bit in a register, use a stencil with one hole.
Chapter 5

Decisions, loops and hysteresis

You already know

A float switch in a water tank.

You will be able to

Stop a controller flickering, and shape a program as read, decide, act.

Picture this

A water tank on the roof has a float switch. You tell the pump: "start at the half-way mark." Now the tank fills a little, the water ripples, and the float bobs up and down across the mark. The pump starts, stops, starts, stops, many times a minute. The contactor burns out in a week.

The fix every plant uses is to give the switch two marks. Start the pump when the water falls to the low mark. Stop it only when the water reaches the high mark. In between, do nothing. The ripples cannot reach both marks.

That gap between the two marks is called hysteresis. Thermostats, pressure switches and the float in your fuel tank all use it. Here is a heater doing exactly that: on below 19.5 degrees, off above 20.5, left alone in between.

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 strip at the bottom shows when the heater was on.

Look at the graph before the code. The heater runs until the temperature passes 20.5, switches off, and the room slides down to about 19.4 before the heater comes back. The overshoot beyond each mark is real. The controller only acts on the next reading, and the room keeps moving in between.

Reading the code

  • Temperatures are whole tenths of a degree, so 195 means 19.5. No decimals means the sums are exact and fast, which matters on a small chip.
  • for minute in 0..24 means "do this 24 times", with minute counting 0, 1, 2 and so on up to 23.
  • if heater_on { 4 } else { 0 } is a question that gives back a number. In Rust an if can hand you a value.
  • On a real controller the main loop never ends. It looks like loop { read; decide; act }, and the loop only stops when the power does.

The scan: read, then decide, then act

Think of a cashier closing the till at the end of the day. She counts everything first and writes the totals down. Then she works out what they mean from that frozen list. Only then does she write the receipt. She never changes the numbers halfway through counting, because then the answer would be wrong. A control program works in the same rhythm: read everything, decide, then act. It stops a decision from seeing a half-updated world.

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

Watch scan 3. An overload and a start request arrive together, and the overload check is written first, so it wins. The fault stays until the stop button is pressed in scan 4. The order of the if lines is the order of priority, so the most important check goes first.

Key idea

When a decision flips on a single threshold, noise makes it chatter. Use two thresholds and a quiet gap between them.

Say it back
  • One threshold makes a controller chatter. Two thresholds with a quiet gap stop it.
  • A scan reads everything, then decides, then acts.
  • The order of the if lines is the order of priority.
Chapter 6

States: enums and match

You already know

A gear selector: P, R, N, D. It can only ever be in one.

You will be able to

Describe a machine as a list of allowed states, and let the compiler catch the case you forgot.

Picture this

Look at the selector in your automatic gearbox. It is in Park, Reverse, Neutral or Drive. It cannot be in Drive and Reverse at the same moment. The lever has exactly one position at a time, and a mechanic can list every position it has.

Some positions carry a little extra information. If the box is in Drive, you also want to know which gear it has chosen.

Now imagine a different gearbox where the position is stored as a bunch of separate yes/no flags. Nothing stops two of them being on at once. That is how many machine controllers end up, and why a motor sometimes does something nobody planned at night.

Rust has a tool that is the gear selector: an enum (short for enumeration, a list of choices). You write the positions down once, and the value can only ever be one of them.

StoppedStarting { n }RunningFaultstart buttonn ticks passstopoverload,from any statestop buttonclears it
A motor starter drawn as positions on a selector. The arrows are the events that move it.
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

What you are looking at

The top of the file lists the positions: Stopped, Starting (which carries a countdown), Running and Fault (which carries which fault). Then it lists the things that can happen: StartButton, StopButton, Tick and Overload.

The function next is the heart. You give it the current position and what just happened, and it hands back the new position. Inside it is a match, which works like reading a table: "if the position is this and the event is that, the answer is this."

Take the output line by line. The start button moves Stopped to Starting with 3 ticks left. Each tick counts down, and the tick after it reaches 0 makes it Running. Then the overload slams it into Fault, and the stop button clears it.

Three things worth noticing

  1. The rows are read from the top, and the first row that fits wins. The special case "countdown is zero" must sit above the general case "countdown is something". Swap them and the motor would never reach Running.
  2. The last row (s, _) => s means "anything else: stay as you are". The underscore is "I do not care."
  3. The function touches nothing outside itself. It takes a state and returns a state. That means you can test every move at your desk with no hardware. Almost all good control code is built this way.

The compiler checks you did not forget a position

Here is the best part. Delete that last catch-all row and try to build it.

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 is the compiler saying: "you described what happens in some situations, and you left a hole." In a machine the same hole is a motor doing something unplanned at 3 am. In Rust it is a message on your screen before the program exists.

match works on ranges, which suits CAN

CAN is the network that lets a car's computers talk: the engine, the gearbox and the brakes share one pair of wires. Every message carries an ID number. If two want to speak at once, the lower number goes first, so important messages get small numbers. A match that sorts an ID into a meaning is natural, and the compiler makes sure no ID is left unclassified.

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

An enum is a gear selector: a short list of positions, exactly one at a time. A match is a table that must cover every position, and the compiler checks it.

Say it back
  • An enum is a gear selector: a short list of positions, one at a time.
  • A match is a table that must cover every position. The compiler checks.
  • The first row that fits wins, so specific rows go above general ones.
Chapter 7

When things fail: Option and Result

You already know

Sending someone to the store: they come back with the part, empty-handed, or with a note.

You will be able to

Return "nothing" or "here is why not" in the type, and use ? to pass trouble up.

Picture this

You send a junior to the stores for a bearing. He can come back three ways:

  • with the bearing in his hand;
  • with empty hands, because there is none;
  • with a note: "the storeroom is locked, the key is with the supervisor."

Now think about a fuel gauge whose sender wire has fallen off. The needle drops to zero. Is the tank empty or the wire broken? The gauge cannot tell you, because it only has one way to say something. Programs have the same trap. Say a sensor reports 65,535, written 0xFFFF in hex. It might be a perfectly real reading, or it might be the device's way of saying “I could not read”, and the number alone cannot tell you which. That is the bug in the old style of programming: returning a number like 0 or −1 to mean "it failed", and hoping every caller remembers to check.

Rust fixes it by making the answer carry its own warning label. There are two boxes:

  • Option is a box that might be empty: Some(bearing) or None.
  • Result is a box with either the bearing or a note: Ok(bearing) or Err(reason).

You cannot use the bearing without opening the box, and opening the box makes you deal with the other case. A forgotten check becomes a compile error.

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

Walk through it

First the program names the ways a sensor can fail: Disconnected, or OutOfRange carrying the bad reading. read_raw pretends to be a sensor that fails on the first try, works on the second, gives a nonsense 5000 on the third, and works again.

Now the key line, in read_percent:

the question mark
let raw = read_raw(attempt)?;

The ? means: open the box. If it holds the bearing, carry on. If it holds a note, stop here and hand the note up to whoever called me. One character replaces five lines of checking.

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 stays on the top track. Any failure drops onto the bottom track and rides straight to the caller.

In main, a match forces you to say what happens for each outcome. The output shows all four attempts, each treated properly. At the bottom, peak(&[]) asks for the biggest number in an empty list and gets None, because there is no biggest number. unwrap_or(0) says "if the box is empty, use 0", and that is a decision you make on purpose.

The same idea on a real Modbus frame

Machines talk to each other in messages, and wires pick up noise. So each message ends with a small number called a checksum, worked out from the rest of the message. It is like counting the sacks loaded on a truck and writing the count on the delivery note: the receiver counts again, and if the two numbers differ, something was damaged on the way. Modbus is a plain language that factory machines and motor drives use over a wire, and it ends every message with a two-byte checksum of this kind, called a CRC. The check either gives you the message body or tells you what was wrong, which is precisely a Result. This program computes the standard Modbus CRC over a request for ten registers, then checks a clean message, a noisy one and one 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. A Modbus frame sends the low byte first, which is why the frame ends in C5 CD. The noisy frame is caught because its CRC does not match. The &[u8] you see is a slice, a read-only window onto a row of bytes, and the next chapters explain how it is safe to pass one around.

Do not use unwrap() in firmware you ship. unwrap means "I promise this box is full", and if it is not, the program panics. On a chip that usually means a halt or a reset, which is not a controlled stop. Decide the safe state instead. For a motor it is almost always off.
Key idea

Do not use a number to say "it failed." Use a box that is either the answer or the reason. The question mark passes trouble up the line for you.

Say it back
  • Do not use a number to mean “it failed”. Use a box that is the answer or the reason.
  • Option is a box that may be empty. Result is a box with the answer or a note.
  • The question mark hands the note up to the caller.
Chapter 8

Types that hold state: structs and impl

You already know

A bouncing ball coming to rest, and a toolbox with labelled compartments.

You will be able to

Bundle related facts into one thing, give it actions, and debounce a button.

Picture this

Drop a rubber ball on the floor. It does not land once. It hits, jumps, hits, jumps again, and each bounce is smaller until it rests.

A mechanical switch does the same. When you press a button, the two metal contacts bounce against each other for a few milliseconds. The pin reads 1, 0, 1, 0, 1, 0, and then settles at 1. To a fast chip, one push looks like six.

How do you know when the ball has really landed? You watch it for a while. If it has stayed still for ten milliseconds, it has landed.

To do that, the program must remember things between one look and the next: the last level it saw, and when that level started. You need a place to keep those facts together. In Rust it is called a struct.

Think of a toolbox with labelled compartments. A struct is the box and its compartments. An impl (short for implementation) is the list of things you can do with that box.

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
The same sequence the program uses. The shaded bands are the bounces. The debouncer only reports a change after the pin has stayed steady for ten milliseconds.

Walk through it

  • struct Debounce lists its compartments: the level it has accepted, the raw level it saw last, when that raw level began, and the hold time.
  • impl Debounce holds the actions. new builds a fresh box. update is called once per look at the pin.
  • &mut self means "this action may change the box." Remember the job card: writing needs exclusive access.
  • Time is a number in milliseconds, and the comparison uses wrapping_sub, the odometer trick from chapter 4, so a timer roll-over cannot confuse it.
  • It returns an Option, from chapter 7. On almost every look nothing has happened, so it needs a way to say “nothing to report”: that is None. Some(edge) means “the ball has really landed”. A bare answer would have no way to say nothing.

Now read the output with the picture. The press bounces from 2 to 6 ms, then settles. Ten milliseconds of steadiness later, at 16 ms, the program says Pressed. The release bounces until 43 ms and is reported at 53 ms. All those flickers in between never reach the rest of your program.

A PWM channel is a struct too

Imagine filling a bath by flicking the tap on and off very fast. If the tap is on half the time, the bath fills as if the tap were half open. That is PWM, pulse width modulation, and how a pin that is only on or off can run a motor at half speed.

The hardware does it with a counter that runs 0, 1, 2 and so on up to a limit, then starts again, and a compare value. The pin is high while the counter is below the compare value. With an 8 MHz timer and a 20 kHz PWM frequency, one cycle is 8,000,000 / 20,000 = 400 ticks. So a compare value of 100 means 25 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 small details are worth seeing. pct.min(100) stops a bad request of 130 percent from asking for more than a full cycle. And the multiplication happens before the division, so the answer keeps its precision.

Key idea

A struct is a toolbox with labelled compartments. An impl is what you can do with it. &mut self marks the actions that change the box.

Say it back
  • A struct is a toolbox with compartments. An impl is what you can do with it.
  • &mut self marks the actions that change the box.
  • The debouncer says Some(edge) only when the ball has really landed, and None the rest of the time.
Chapter 9

Ownership and borrowing

You already know

A vehicle logbook: only one person owns the Land Rover at a time.

You will be able to

Explain move, borrow and the single-writer rule with keys and logbooks, and read the two classic errors.

Picture this

You own a Land Rover. The logbook has your name on it, and you hold the keys. There is exactly one owner.

You can let a friend look under the bonnet. Five friends can look at once. Nobody is changing anything, so nobody is in danger.

You can hand the keys to one mechanic to work on it. While he has the engine half apart, nobody else should be peering in, because they would see a mess that is not a real engine.

Or you can sell it. After you sell it, it is not yours. If you try to drive it, that is not a bug in the car. It is a bug in you.

That is the whole of Rust's memory idea. Put the words next to it:

The Land RoverIn Rust
One person owns itOne owner per value
Let friends look at the engine&value: a read-only borrow. Any number at once
Hand the keys to one mechanic&mut value: a writable borrow. Exactly one, and nobody else looking
Sell itA move. The old owner can no longer use it
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%

Read it with the pictures. set_speed takes a &mut: it is the one mechanic with the keys. report takes a &: anyone may look, as often as they like. retire takes the motor itself: it is a sale, and after it, m is gone from main.

Watch the compiler enforce it

First, try to drive a vehicle you have sold:

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 sold 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 try handing the keys to two mechanics at once:

two writers
fn main() {
    let mut speed = 10u8;
    let a = &mut speed;
    let b = &mut speed;   // a second key 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 to the exact line, names the rule you broke, and usually suggests the fix. Learn to love those messages. They are the cheapest mechanic you will ever hire.

Borrowing a window onto a buffer

Most embedded work is about rows of numbers: a run of ADC readings (numbers from the voltage-to-number card), a CAN payload (the data bytes of a CAN message), a UART line (plain serial text coming down a wire, the way a keyboard or a GPS talks to a computer). A slice, written &[u16], is a read-only window onto part of a row. Nothing is copied, and the owner still has the row. &mut [u16] is the same window with permission to write.

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, so it gets the read-only window. smooth rewrites the row in place, so it gets the writable one. It also keeps the previous original value in prev, so each average uses real readings and not ones it has already smoothed. The peak falls from 900 to 390 because one noisy spike is averaged against its neighbours.

Why this matters for interrupts

Go back to the oil-pressure light. The interrupt handler and the main loop are two mechanics who might reach for the same job card. If the main loop is halfway through changing the speed and the direction, and the handler fires in between, it sees the new speed with the old direction, a state that never existed.

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 updates two related values in two steps. An interrupt lands between them and reads a snapshot that never existed.

Rust refuses to let both touch the same data unless you wrap it in something that makes the access safe, such as briefly switching interrupts off around it. That wrapper is called a critical section, and the board libraries provide it. We will meet it in chapter 12 when the code first needs it.

Key idea

One owner. Many may look, or one may work, never both. A sale means the old owner cannot touch it again. The compiler checks every handover.

Say it back
  • One owner. Many may look, or one may work, never both.
  • Selling it (a move) means the old owner cannot touch it again.
  • The main loop and an interrupt are two mechanics reaching for one job card.
Chapter 10

Traits: write the controller once

You already know

A socket set: any socket fits any ratchet with a half-inch square drive.

You will be able to

Say what a trait is, write code against it, and test hardware logic with a pretend pin.

Picture this

You own a ratchet with a square drive. It does not care whether the socket on the end is a 10 mm, a 13 mm or a spark-plug socket, as long as the socket has the standard square hole. The ratchet and the sockets agree on a shape, and that is all they need to agree on.

A phone charger works the same way. Any charger with the right plug will charge any phone with that socket. The charger does not care which brand of phone it is, as long as the plug fits.

A trait is that agreed shape, for code. It lists the actions a thing must be able to do. Anything that can do them fits.

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 shape and never names a particular chip.

Walk through it

  • trait OutputPin says: "anything that can be set high or low." That is the square drive.
  • MockPin is a pretend socket. When it is set, it just prints a line. It lets us run the whole thing on a laptop.
  • MotorDriver is the ratchet. It is written against the shape, not against a chip. The <P: OutputPin> reads "for any P that fits the OutputPin shape."
  • Read the output. Running forward turns the direction on and then the enable. Stopping removes the enable first and the direction second. That is a safe-shutdown order, written once, in the driver, and every caller gets it for free.

The same trick on a sensor

A trait can also let you write one function that works for any sensor:

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 fails on its third reading. The question mark from chapter 7 returns the failure at once, so average gives up and reports the error, with no half-finished average. You tested the whole behaviour on a laptop with no sensor attached.

This is how the real libraries work

The Rust embedded community agreed these shapes once, in a library called embedded-hal. It has shapes for output pins, I2C and SPI (the short links between chips on one board), PWM and delays. A driver written against them runs on the micro:bit, on the Pico, or against a mock on your laptop. The CAN-chip libraries (crates) I found, mcp2515 and mcp25xx, are built this way, though I have not read them closely.

Key idea

A trait is the square drive: an agreed shape. Write the logic against the shape and test it with a pretend part. When the real hardware arrives, only the edge changes.

Say it back
  • A trait is the square drive: an agreed shape.
  • Write the logic against the shape and test it with a pretend part.
Chapter 11

Time and the control loop

You already know

Holding a speed up a hill with your foot on the pedal.

You will be able to

Explain proportional and integral control with a foot on the pedal, and read the graph of a step response.

Picture this

You want to hold 80 km/h on a long hill, using only your foot. You look at the speedometer. You are doing 70. You press the pedal in proportion to how far you are below: a lot when you are far, a little when you are close.

The car climbs to 78 and stays there, no matter how long you wait. Why? Because at 78 you are only 2 below, so you only press a little, and a little is exactly what is needed to hold 78 on this hill. You have run out of reasons to press harder. You are stuck short.

A real driver notices: "I have been below for ages." So you push a bit further, and the longer you have been short, the harder you push. You pass 78, reach 80, and now the push eases off by itself.

That is the whole of the most popular controller in industry. The first move, pressing in proportion to the error, is P, proportional. The second, pressing more the longer you have been wrong, is I, integral, because it adds up the error over time.

setpointΣ+−errorPIcontrollerdutyMotorthe plantspeedsensor measures speed and feeds it backMeasure, compare, correct, repeat.
A closed loop. The sensor measures speed, the sum subtracts it from the setpoint, and the controller works on what is left.
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}");
        }
    }
}

Walk through it

  • A Pi struct, from chapter 8, remembers integral, the running total of how wrong you have been.
  • update takes the setpoint and what you measured. The error is the gap between them.
  • The output is kp times the error, plus ki times the running total, divided by 10,000. The gains are whole numbers and the division at the end gives fractions without decimals.
  • clamp(0, 100) stops the output going past the pedal's range.
  • The if that guards the integral is anti-windup. If your foot is already on the floor, there is no point storing up even more "push". It would only come back later as a nasty overshoot. So the total only grows while the output is inside its range.

The motor in this program is a simulation, and it behaves like a heavy flywheel. Open the throttle and it does not jump to speed. It climbs towards it. Full throttle would take it towards 4000 rpm, and it covers about two thirds of any gap in half a second (engineers call that half-second the time constant). The controller looks at it 100 times a second, written 100 Hz, where Hz just means "times per second". The graph below is that run.

050010001500200001234time, secondsmotor speed, rpmsetpoint 2000P only settles at 879P + I reaches 2000
Setpoint 2000 rpm. The grey line, P alone, stalls short. The magenta line, P plus I, finds the target.
Time (s)P only, rpmP + I, rpm
0.57421208
1.08551733
2.08761999
4.08792000

P alone settles at 879 rpm and stays there, which is 1121 rpm short of the target. P plus I passes 1,900 rpm, 95 percent of the target, at 1.40 seconds, peaks at 2006, and ends at 2000.

Four numbers to judge any controller

  • Rise time: how fast it gets close.
  • Overshoot: how far it goes past.
  • Settling time: how long until it stays near.
  • Steady-state error: what is left at the end. P alone fails here. P plus I fixes it.
What this does and does not show. The motor here is a simulation, not a real one. I picked gains that give a smooth settle on this simulated motor. A real motor has friction, inertia and noise, and will need tuning on the bench.

Doing several jobs at different rates

Not everything needs the same rhythm. In an engine bay, the oil-pressure gauge can be read slowly, but the crank signal must be watched constantly. The usual pattern is one heartbeat every millisecond, and each job runs on its own multiple of it: the sensor every 1 ms, the controller every 10 ms, the display every 100 ms.

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 controller has run exactly 100 times, a clean 100 Hz, because it is driven by the heartbeat and not by how long the other jobs took. That predictability is what lets you reason about a controller and trust it.

Key idea

P presses in proportion to the error. I presses harder the longer you are wrong. Anti-windup stops you saving up push you cannot use.

Say it back
  • P presses in proportion to the error, and stops short of the target.
  • I presses harder the longer you have been wrong, so it reaches the target.
  • Anti-windup: do not store push you cannot use.
Chapter 12

The micro:bit v2

You already know

Choosing the right plug for the right socket.

You will be able to

Know what the board gives you, what it cannot do, how your code gets onto it, 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.

Picture this

Think of a car you have just bought that already has lights, a horn, a gauge and a handbrake fitted. You do not wire anything to drive it around the yard. The micro:bit is that car: buttons, lamps, a motion sensor and a speaker are already on the board, so your first four lessons need no soldering.

But it has no engine for a heavy load. The pins give a few milliamps, like the horn button on a steering wheel. They tell something bigger what to do. They never do the heavy work themselves.

What is already on the board

  • A 5 by 5 grid of LEDs. Twenty-five tiny lamps you can switch one by one. It is your screen.
  • Two buttons and a touch logo. Real mechanical buttons, so everything from chapter 8 about bounce applies.
  • An accelerometer. A sensor that feels tilt and shake. Inside the chip is a microscopic weight on tiny springs, and when you tilt the board the weight leans, and the chip measures how much. It talks to the processor over an I2C link, which is two wires inside the board: one carries the beat (the clock) and one carries the data, and the two chips take turns, like two people passing notes down a corridor. It is already wired, so you do nothing.
  • A microphone, a speaker and a radio. We will not need them for now.
  • A built-in programmer. You plug in a USB cable and the board can be loaded with your program and debugged. No extra tool is needed to put code in.

What it cannot do on its own

  • It has no CAN controller. For CAN you use the Pico 2 with a CAN module.
  • Its pins give a few milliamps. Never connect a motor to a pin. A motor needs a driver stage in between, which is chapter 13.
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.

How your code gets onto the board

You write the program on your laptop in Rust. The chip on the board reads a completely different language of instructions. So a translator, the compiler, turns your Rust into the chip's own language. Because the translator runs on your laptop but produces code for another processor, this is called cross-compiling.

You must tell the translator which language to produce. That is the target. For the micro:bit v2 it is named thumbv7em-none-eabihf. You do not need to decode it, but it is not random. thumbv7em is the family of instructions this Arm processor reads, none says there is no operating system, and eabihf says the chip has hardware for numbers with a decimal point. You add it once:

terminal
rustup target add thumbv7em-none-eabihf

The Pico 2 and the older micro:bit v1 use different targets, so confirm the name in the guide for your board.

Three more pieces complete the picture:

  • memory.x is a one-page map that tells the tool gluing your program together where the manual (flash) and the whiteboard (RAM) sit in the village of numbered doors from chapter 1. That glueing tool is called the linker.
  • probe-rs is the tool that pushes the program through the board's built-in programmer and lets you look inside while it runs. Think of the plug you put into a car to read its fault codes, but one that can also load a new program.
  • A board-support crate. A crate is Rust's word for a library. This one gives the pins readable names, such as button_a, so you do not memorise door numbers.

Names and versions change, so follow a current guide when you set this up. Three I found in my search and have not read in full: a getting-started guide, a list of free embedded Rust books, and Rust with Embassy on the Pico.

The board has no screen to print to, so when the program runs you log messages through the same probe cable. The guides above show how.

Your first four stages

StageWhat you buildYou are done when
1Blink one LEDIt 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 from chapter 6 with the buttonsThe LEDs show Stopped, Starting, Running and Fault, and Fault 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 exactly what the toolchain and the board say, and we build these stages together.
Say it back
  • The board already has lamps, buttons and a motion sensor, so the first stages need no wiring.
  • A pin gives a few milliamps, like a horn button. Never wire a motor to it.
  • The target is the name of the language the chip reads. The compiler translates into it.
Chapter 13

The Pico 2, the motor and the CAN bus

You already know

Your foot on the pedal is not the engine's power.

You will be able to

Say why a pin cannot drive a motor, how an H-bridge reverses one, and how to wire CAN safely to a 3.3 volt chip.

Now the real hardware, and the real safety rules.

Picture this

Your foot on the accelerator does not push the throttle plate with its own strength. It moves a cable, or tells a motor, and the engine's own power does the work. A pin on a chip is your foot. The motor is the engine. Between them you need something that can carry the load.

Speed control: flick the tap

Remember the bath from chapter 8. A pin is only on or off, but if you flick it on and off fast, the motor feels the average. On half the time, it behaves as if it had half the supply. The fraction of time that the pin is on is the duty cycle. Around 20 kHz (twenty thousand flicks a second) is above what people can 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 the motor responds to.

The driver stage: a relay done with transistors

A relay is a small switch that a weak signal can close, and that then switches a heavy current. The starter solenoid in a car is one: a light turn of the key clicks a heavy switch. A motor driver does the same job with transistors, and it is the thing sitting between the pin and the motor.

To make the motor turn either way, drivers use four switches arranged like the letter H, with the motor as the crossbar. This is an H-bridge. Close the top-left and bottom-right switches and current runs through the motor one way. Close the other diagonal and it runs the other way.

+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. This joins supply to ground through one side of the bridge
Key idea

Never close both switches on the same side of the bridge. It is the same as dropping a spanner across a battery. Good driver chips make it impossible. If you build one from parts, you must make it impossible in software and in hardware.

One more thing about motors. A motor winding is a big coil, and a spinning coil does not want to stop. Cut the current suddenly and it throws a voltage spike, like the bang in a pipe when you slam a tap shut on moving water. A flyback diode across each switch is the pressure-relief path that lets that spike die safely. Buy a driver module that already has them, and choose one rated well above the motor's stall current, which is the current it draws when it is held still.

CAN with a 3.3 volt chip

Level warning. Many cheap MCP2515 modules run on 5 volts. The Pico's pins run on 3.3 volts. Feeding 5 volts into a Pico pin is like jump-starting a 12 volt circuit from a 24 volt truck battery: you 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.

A CAN bus is one pair of wires that runs past every device. At each of the two ends you fit a 120 ohm termination resistor, so the signal does not bounce back like an echo down a tunnel. Measure across the pair with both fitted and you read about 60 ohms, which is two 120s side by side. Some modules have a jumper for the resistor. Three more things catch people out:

  • Everyone must agree on how long one bit lasts, like a band agreeing the tempo. The MCP2515 is told the bus speed and the speed of its crystal, which is commonly 8 or 16 MHz, and works out its bit timing from them.
  • All nodes share a ground. And the motor's heavy return current should not travel down the same wire as the logic ground.
  • One node cannot test itself on a real bus. You need two nodes, or the chip's loopback mode, which sends a frame to itself and is worth checking in its datasheet.

The frame in the picture below is what each node puts on the wire:

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 segment widths are not to scale.
PartWhat it does
SOFStart of frame. One bit that says "listen, a message is coming"
Identifier (11 bits)What the message is about. A lower number wins when two nodes talk at once
RTR, IDE, r0Small flags that say what kind of frame this is
DLCHow many data bytes follow, from 0 to 8
DataThe payload itself
CRCThe checksum, as in chapter 7
ACKAny node that heard it correctly pulls this bit down to say "received"
EOFEnd of frame

The capstone

This is the project you said you wanted: an accelerometer, a CAN link and a motor.

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 laptop first.

Say it back
  • A pin is your foot. The driver stage is the throttle linkage that carries the load.
  • An H-bridge reverses a motor with four switches. Never close both switches on one side.
  • A flyback diode lets the motor's voltage spike die safely, like a relief path for a slammed tap.
  • A 5 volt CAN module on a 3.3 volt pin can destroy the chip. Shift the level.
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, say how the decoder finds the gap, and pass nine tests.

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

Picture this

A stranger is blindfolded in a room, and a metronome clicks steadily: tick, tick, tick. Every so often one click is missing and there is a longer silence. If he counts the clicks between long silences, he knows exactly where in the cycle he is, even though he can see nothing. He is the decoder, and the long silence is the missing tooth.

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. A test is a small program that feeds in a made-up tooth pattern and checks what the decoder says. Your job is to make all nine pass.

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 decoder cannot see the missing tooth

This is the key idea. The sensor does not see a hole. It only produces a pulse when a tooth passes. So the only evidence of the gap is time: between ordinary teeth the wait is one slot, and 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 remembers the last interval and compares each new one with it. If the new one is more than 1.5 times longer, that is the gap. Why 1.5? Ordinary gaps wobble a little because teeth and engine speed are never perfect, and that wobble is called jitter. A real gap is about twice as long, so 1.5 sits comfortably between the two. If you waited for exactly 2, a real gap that measured 1.9 times because of jitter would be missed. The exercise tests this with up to 8 percent jitter.

The maths, run

One slot is a thirty-sixth of a turn, so the time for a whole turn is the slot time multiplied by 36. This program works the numbers at 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 slot time measured in whole microseconds. At 6500 rpm it reads 6510, an error of 0.15 percent. A microsecond is the smallest tick the timer has, 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 laptop, a micro:bit and a Pico.

My answer is sealed

I wrote my own solution and passed all nine tests. Then I took its hash. A hash is a fingerprint of a file: a long code (this one is written in hex, as in chapter 1) that changes completely if even one letter of the file changes. Here is mine:

974839d869008b702a9bf012835334bd8d3fce0aa042920740a8b84030f27a83

When you pass the tests I will show you the file, and you can work out its fingerprint yourself. If it matches, I did not change my answer after seeing yours.

Say it back
  • The decoder cannot see the missing tooth. It sees only time between edges.
  • A gap is an interval about twice the normal one, so 1.5 times is the line, with room for jitter.
  • A hash is a fingerprint. It proves I did not change my answer after seeing yours.
Chapter 15

Checkpoints and what comes next

You already know

Exams, and what a fair test feels like.

You will be able to

Know exactly what the first checkpoint covers, and look up any word 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 (the fingerprint from chapter 14) 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, and the "Say it back" boxes hold the answers in short form.

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 pictures

The pictureIn Rust
A tin with its lid glued shutlet
A tin whose lid openslet mut
A gear selector, one position at a timeenum, read with match
Fetch the part, or come back with nothingOption
Fetch the part, or come back with a note why notResult
Hand the note up the line?
A toolbox with labelled compartmentsstruct
What you can do with the toolboximpl
A logbook with one owner; selling itOwnership; a move
Letting friends look at the engine&
Handing the keys to one mechanic&mut
The square drive that fits any sockettrait
The cashier: count, decide, write the receiptloop { read; decide; act }

Glossary

TermMeaning here
AddressThe number on a door in the village of the chip. The CPU reaches everything by address
ADCAnalogue to digital converter. The part that turns a voltage into a number
BitOne two-position wheel: 0 or 1
BorrowLending a value for looking (&) or for working on (&mut) without giving it away
ByteEight bits
CargoRust's build tool and package manager
CrateRust's word for a library or a program
Critical sectionA short stretch of code that runs with interrupts switched off
Duty cycleThe fraction of a PWM period the output is on
GPIOGeneral-purpose input and output: the pins you control
HashA fingerprint of a file that changes completely if the file changes by one letter
HexCounting in sixteens, one character for each four bits. 0x in front marks it
I2C, SPIShort links between chips on one board
InterruptA signal that makes the CPU stop, run one short job, and carry on
JitterThe small wobble in when things arrive
MovePassing a value on so the old 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. Flicking an output fast so its average sets a level
RegisterLike a mother to her children: the one place you speak to make many real things happen, or to learn how they are. Write it to act, read it to sense
TargetThe processor family the compiler is producing code for
TraitAn agreed shape of actions that a type promises to provide
UARTA plain serial line, the way a keyboard or a GPS talks to a computer

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.