How this book works
Buses, state machines, fault handling, timing, PLC scan cycles.
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:
rustc file.rs # compile one file
./file # run it (on Windows: file.exe)
cargo new motor # or start a project
cd motor
cargo runThis 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.
Next, chapter 1What a microcontroller really isWhat a microcontroller really is
A PLC scan cycle, and a Modbus register map where writing a number makes something happen.
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.
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.
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.
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.
Why Rust, and the one rule it enforces
One master per Modbus line. Two masters talking at once corrupt a frame.
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 bug | What it looks like on a machine | What Rust does |
|---|---|---|
| Arithmetic wraparound | A throttle counter goes from 255 to 0 and the engine drops to idle | Panics in a debug build, and gives you explicit methods to say what you mean (chapter 4) |
| Using a value before it is set | A setpoint nobody initialised, so a motor starts at a random speed | Does not compile |
| Two things changing one variable | The main loop and an interrupt both write the speed and the result is neither | Does not compile (chapter 9) |
| A pointer to something that is gone | Reading a buffer that was already reused | Does 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 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.
Next, chapter 3Your first programYour first program
A setpoint register you can read, and a write-protected one you cannot change by accident.
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.
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
}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.letmakes a variable, and it is immutable by default.setpoint_rpmcannot change. In control work that is a feature: a setpoint that nothing can alter by accident.let mutsays 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}insideprintln!prints the variable. The!marksprintln!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
motor/
Cargo.toml # name, version, dependencies
src/
main.rs # your programcargo 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
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.
Numbers that don't lie
Register widths, 16-bit Modbus words, and a counter that rolls over.
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.
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.
| Type | Bits | Range | Typical use |
|---|---|---|---|
u8 | 8 | 0 to 255 | percent, flags, one byte on a bus |
i8 | 8 | −128 to 127 | small signed offsets |
u16 | 16 | 0 to 65,535 | ADC readings, Modbus registers |
i16 | 16 | −32,768 to 32,767 | accelerometer axes |
u32 | 32 | 0 to 4,294,967,295 | microsecond timers |
i32 | 32 | about plus or minus 2.1 billion | intermediate 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
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));
}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
Noneon 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.
// 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);
}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
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.
Decisions, loops and hysteresis
Thermostats, float switches and the PLC scan cycle.
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.
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" }
);
}
}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
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
ifis an expression:let gain = if heater_on { 4 } else { 0 };givesgaina 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..24runs 24 times.whileloops on a condition.loopruns until youbreak. 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.
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" }
);
}
}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.
Next, chapter 6States: enums and matchStates: enums and match
Ladder state bits and a PLC sequence where only one step is active.
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.
#[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:?}");
}
}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 -> StoppedMotor 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
- Arms are checked in order. The specific case
ticks_left: 0comes before the generalticks_left. Swap them and the motor never reaches Running. - The compiler checks that you covered every case. The last arm,
(s, _) => s, means "ignore it and stay put". Delete it and see below. - 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.
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.
#[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));
}
}0x001 -> Emergency 0x105 -> Motor(5) 0x23A -> Sensor(58) 0x7FF -> Heartbeat 0x400 -> Unknown
Write the states as a type. The compiler then checks that every state and event pair is handled.
When things fail: Option and Result
Modbus exception codes, and CRC checks that reject a corrupted frame.
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.
#[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));
}attempt 0: sensor disconnected attempt 1: 50% attempt 2: raw 5000 is outside 0..=4095 attempt 3: 100% Some(340) None 0
- The
?afterread_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(&[])returnsNone, 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.
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}")
}
}
}
}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.
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".Types that hold state: structs and impl
Function blocks with their own memory, and the contact bounce of a mechanical switch.
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.
#[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:?}");
}
}
}16 ms: Pressed 53 ms: Released
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 Debouncegroups the four pieces of state that belong together.impl Debounceholds the functions that work on it.&mut selfmeans "this function may change the debouncer".- Time is a
u32in milliseconds, and the comparison useswrapping_sub, so it survives the timer rolling over (chapter 4). - It returns
Option<Edge>:Some(edge)when something happened,Noneotherwise (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.
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);
}
}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.
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.
Ownership and borrowing
One master per bus, and the corrupted frame when two masters talk at once.
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 bus | In Rust |
|---|---|
| One master owns the line | One 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 frame | The compiler refuses to build the program |
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()
}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.
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
}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:
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;
}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.
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));
}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.
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.
A value has one owner. You can lend it out for reading to many, or for writing to one. The compiler checks every loan.
Traits: write the controller once
A Modbus master that works with any drive that answers the same register map.
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.
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();
}FWD -> HIGH EN -> HIGH EN -> low FWD -> low
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.
#[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));
}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.
Write the logic against a trait and run it on a mock. When the real hardware arrives, only the edge changes.
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.Time and the control loop
PID loops, scan times, and loop gain that you tune by watching a trend.
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.
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.
// 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.
| Time (s) | P only, rpm | P + I, rpm |
|---|---|---|
| 0.5 | 742 | 1208 |
| 1.0 | 855 | 1733 |
| 2.0 | 876 | 1999 |
| 4.0 | 879 | 2000 |
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.
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.
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}");
}
}
}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.
Next, chapter 12The micro:bit v2The micro:bit v2
Reading a datasheet pinout and knowing which pins can drive what.
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.
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
| Stage | What you build | Done when |
|---|---|---|
| 1 | Blink the LED matrix | One LED turns on and off at a rate you chose |
| 2 | Read the buttons with the debouncer from chapter 8 | One press gives exactly one event, however hard you bounce it |
| 3 | Read the accelerometer and show tilt | Tilting the board left and right changes which LED lights |
| 4 | Run the motor-starter state machine from chapter 6 from the buttons | The LEDs show Stopped, Starting, Running and Fault, and the Fault state needs the stop button to clear |
The Pico 2, the motor and the CAN bus
Variable-speed drives, H-bridges and 120 ohm CAN termination.
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.
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.
| Switches closed | What the motor does |
|---|---|
| S1 and S4 | Runs forward |
| S2 and S3 | Runs in reverse |
| S3 and S4, or S1 and S2 | Brakes, because both motor terminals are tied to the same rail |
| None | Coasts |
| S1 and S3, or S2 and S4 | Never. That connects supply to ground through one side of the bridge and destroys the switches |
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
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.
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.
Both nodes use the traits from chapter 10, so most of the logic can be tested on your PC first.
Next, chapter 14The crank decoderThe crank decoder
A 36-1 crank wheel, and what a gap in the pulse train means.
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.
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.
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.
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));
}
}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.
What it uses from the book
- Chapter 4:
wrapping_subon the microsecond timer, and widening before you multiply. - Chapter 6: two states, hunting and synced, which suit an enum.
- Chapter 7:
Optionfor "no event yet" and for "never seen a tooth". - Chapter 8: a
Decoderstruct with animpl. - 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.
Checkpoints and what comes next
Exams you did not enjoy, and tests of a machine that you did.
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
| Chapter | Questions |
|---|---|
| 3 Your first program | 1 |
| 4 Numbers | 3 |
| 5 Decisions and hysteresis | 2 |
| 6 States | 3 |
| 7 Option and Result | 2 |
| 8 Structs | 2 |
| 9 Ownership | 2 |
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 work | In Rust |
|---|---|
| Scan cycle | loop { read; decide; act } |
| Holding register | A variable, or a hardware register wrapped in a type |
| State bits | An enum |
| Fault code | Result with an error type |
| Function block | struct plus impl |
| Vendor-neutral driver | A trait |
| One master on the bus | One owner or one &mut |
| Interlock | A rule the compiler enforces before the program runs |
Glossary
| Term | Meaning here |
|---|---|
| ADC | Analogue to digital converter. Turns a voltage into a number |
| Borrow | Lending a value for reading (&) or writing (&mut) without giving it away |
| Cargo | Rust's build tool and package manager |
| Crate | A Rust package, a library or a program |
| Critical section | A short stretch of code that runs with interrupts disabled |
| Duty cycle | The fraction of a PWM period the output is high |
| GPIO | General-purpose input and output pins |
| HAL | Hardware abstraction layer. Traits that hide the chip behind a common interface |
| I2C, SPI | Short-distance serial buses between chips on a board |
| Interrupt | A hardware signal that pauses the main program to run a short handler |
| Move | Passing a value so the original owner can no longer use it |
no_std | A program that does not use the standard library, because there is no operating system |
| PWM | Pulse width modulation. Switching an output fast so its average sets a level |
| Target | The processor family the compiler is producing code for |
| Trait | A 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.