# Rust on Robots ## Hands-on Embedded Rust on STM32 **Duration:** 120 minutes **Participants:** ~20 --- # Today is about robot firmware architecture We use Rust to understand how robot software is structured: ```text hardware signal | software type | robot event | robot state | decision | actuator command ``` --- # We will not build a full robot in two hours We do not have: - full chassis - motors - motor drivers - camera module - ultrasonic hardware - playing field --- # We will build a robot framework We do have: - STM32 Blue Pill - ST-Link programmer - RGB LED - 5-way button - Rust - a robot control model => The STM32 board is our small, reduced robot. --- # The workshop target By the end, we want this model: ```text button / sensor input | RobotEvent | RobotMode | MotionCommand | LED status or wheel-speed model ``` This is the same shape as a real robot controller. Different hardware. Same architecture. --- # Room calibration 1. Who has written Rust before? 2. Who has programmed a microcontroller before? 3. Who has built, programmed or broken a robot before? --- # Get the Repo Code along and try out yourself `git.wieerwill.dev/wieerwill/rust-stm32` 1. Clone it 2. install RustUp or Nix shell 3. try it out (more in Chapter 0) --- # My RoboCup Junior Soccer journey A soccer robot starts simple: ```text turn on | show status | wait for start | find the ball | drive toward the ball | avoid problems | recover and continue ``` --- # A (soccer) robot is a loop with consequences ```text observe | interpret | decide | move | observe again ``` The hard part is keeping this loop understandable when reality becomes messy. --- # Robot software touches the real world A robot has messy inputs: - camera frame - ball position - line / field detection - ultrasonic distance - start button - battery state - motor feedback And physical outputs: - wheel speeds - LED status - logs - stop behaviour - recovery behaviour --- # The board is our reduced robot ```text RGB LED -> actuator / status output 5-way button -> input device / fake sensor rig STM32 loop -> robot control loop Rust types -> domain model ``` Small hardware. Real architecture. --- # Chapter map ```text 00_check toolchain check 01_rust_basics values, functions, mutation 02_ownership ownership and borrowing 03_types domain types, enums, Result 04_first_firmware minimal STM32 firmware 05_rgb_output LED as actuator 06_button_events button as sensor 07_events raw input to robot event 08_state_machine robot behaviour 09_sensor_pipeline camera and ultrasonic model 10_motor_commands motion intent to wheel speeds 11_kinematics geometry and inverse kinematics 12_embassy async Embassy loop and timers ``` --- # Chapter 0: Toolchain --- # Chapter 0: Toolchain check Open: `src/bin/00_check.rs` Run: `run-00` Expected result: ```text The host program prints the workshop toolchain check. ``` --- # Why Rust for this? - Who owns this peripheral? - Who may mutate robot state? - What happens when a sensor fails? - Which commands are valid? - Which states should be impossible? - Where does hardware end and behaviour begin? Rust makes many of those boundaries visible. --- # Rust is strict in useful places - memory safety without a garbage collector - explicit ownership - controlled mutation - strong enums and pattern matching - `Option` and `Result` - small `no_std` programs for microcontrollers --- # But Rust does not fix everything - wrong wiring - noisy sensors - weak batteries - bad control logic - missing calibration --- # Chapter 01: Values --- # Chapter 01: Values are explicit ```rust let speed = 42; let mut target_speed = 0; target_speed = speed; ``` Default: values are immutable. Mutation must be requested with `mut`. For robot code, that is a good default. --- # Robot translation: Mutation should have a home Bad: ```text everything can change everything ``` Better: ```text sensor reading | event | state transition | command ``` The robot state changes in one obvious place. --- # Chapter 01 exercise: Change behaviour without changing structure Open: ```text src/bin/01_rust_basics.rs ``` Tasks: 1. Change a constant. 2. Change a function return value. 3. Add a new status value. 4. Run the program. Checkpoint: ```text run-01 ``` --- # Chapter 02: Ownership --- # Chapter 02: Ownership means responsibility ```rust let command = String::from("forward"); let next_command = command; // command is no longer usable here ``` One value has one owner. When ownership moves, responsibility moves. --- # Robot translation: Peripherals are resources A GPIO pin is not just an integer. It represents access to real hardware. ```text LED pin owner | configure output | set high / low ``` Ownership makes accidental sharing harder. --- # Borrowing means temporary access ```rust fn print_command(command: &str) { println!("command = {command}"); } let command = String::from("forward"); print_command(&command); // command is still usable here ``` A borrow can inspect without taking ownership. --- # Mutable borrowing: One writer at a time ```rust fn stop(command: &mut MotionCommand) { command.forward = 0.0; command.strafe = 0.0; command.rotate = 0.0; } ``` Rust does not like hidden shared mutation. For robot code, that is useful. --- # Robot translation: Who may change the robot state? A robot state should not be mutated from everywhere. Better: ```text sensor module -> creates observations event module -> creates RobotEvent state machine -> changes RobotMode command module -> creates MotionCommand hardware module -> applies output ``` Each layer has a job. --- # Chapter 02 exercise: Fix the ownership error Open: ```text src/bin/02_ownership.rs ``` Tasks: 1. Run it. 2. Read the compiler error. 3. Fix it with a borrow. 4. Add a mutable command update. Checkpoint: ```text run-02 ``` --- # Chapter 03: Types --- # Chapter 03: Numbers are not enough ```rust let value = 42; ``` What is it? - centimetres? - percent? - PWM duty cycle? - motor speed? - battery voltage? - camera pixel coordinate? Robot code needs meaning, not just numbers. --- # Types are safety rails ```rust struct DistanceCm(u16); struct MotorPower(f32); struct BallXPixel(u16); struct BatteryMillivolts(u16); ``` A number alone does not tell us what it means. Types carry meaning. --- # Enums model robot reality better than booleans ```rust enum BallObservation { NotSeen, Left, Center, Right, TooClose, } ``` This is clearer than: ```rust ball_seen: bool ball_left: bool ball_right: bool ``` Because impossible combinations disappear. --- # Failure is part of the API ```rust enum SensorError { Timeout, OutOfRange, NotCalibrated, } fn read_distance() -> Result { todo!() } ``` A sensor may fail. The type should admit that. --- # Robot translation: Sensor data is evidence A sensor reading is not behaviour. ```text raw echo time | distance | classification | RobotEvent | state transition ``` The robot should not directly react to raw numbers. --- # Chapter 03 exercise: Replace loose values with domain types Open: ```text src/bin/03_types.rs ``` Tasks: 1. Replace a raw distance number with `DistanceCm`. 2. Replace booleans with `BallObservation`. 3. Return `Result` from a fake sensor function. 4. Match on the result. Checkpoint: ```text run-03 ``` --- # Chapter 04: Embedded Rust --- # Chapter 04: First embedded Rust program On the STM32 we usually do not have: - an operating system - heap by default - files - a normal terminal - `std` We write `no_std` firmware. --- # Bare metal means we own the loop ```text configure hardware | enter loop | read input | update state | write output | repeat ``` That loop is the beginning of a robot controller. --- # Firmware is not an app with a main window On a microcontroller: - startup code prepares the chip - firmware configures peripherals - the loop runs forever - timing is explicit - debugging is limited - output may be one LED --- # Tooling checkpoint Open: ```text src/bin/04_first_firmware.rs ``` Build: ```text build-04 ``` Flash: ```text run-04 ``` Expected result: The board boots and reaches the firmware loop. --- # Hardware silence is a valid embedded state If nothing happens, debug in layers: 1. Is the board powered? 2. Is the ST-Link visible? 3. Is the chip target correct? 4. Did the firmware flash? 5. Is the pin correct? 6. Is the LED orientation correct? Do not randomly change five things at once! --- # Embedded debugging is layered 1. host build works? 2. target build works? 3. probe visible? 4. flash succeeds? 5. firmware boots? 6. pin toggles? 7. external wiring works? Debug from the outside in. --- # Chapter 05: Output --- # Chapter 05: Output - LED as actuator An LED is a tiny actuator. It gives us visible robot state: ```text Off -> idle Blue blink -> searching Green -> driving Red blink -> obstacle / error ``` The LED becomes the robot dashboard. --- # Robot translation: Status output matters A robot needs visible state because: - it may not have a screen - logs may be unavailable - behaviour may look ambiguous - debugging happens on the floor - humans need to know if it is safe A status LED is not decoration. --- # Chapter 05 exercise: Status output Open: ```text src/bin/05_rgb_output.rs ``` Tasks: 1. Make the LED blink. 2. Change the colour. 3. Add a status pattern. 4. Use one pattern for `Idle`. 5. Use one pattern for `Searching`. Checkpoint: ```text run-05 ``` --- # Chapter 06: Input --- # Chapter 06: Input - button as sensor The 5-way button becomes our fake sensor rig. ```text Center -> start / stop Left -> ball left Right -> ball right Up -> ball centered Down -> obstacle detected ``` This lets us simulate camera or ultrasonic input. --- # Raw input is not robot meaning Button press: ```text raw electrical state ``` Robot event: ```text BallLeft ObstacleDetected StartStop ``` We separate hardware reading from robot meaning. --- # Robot translation: Perception is a boundary The controller should not care whether `BallLeft` came from: - a camera - a test file - a simulator - a button - a future ML model It only needs the event. --- # Chapter 06 exercise: Button to event Open: ```text src/bin/06_button_events.rs ``` Tasks: 1. Read the 5-way button. 2. Convert direction to `RobotEvent`. 3. Show the event with LED status or logging. 4. Add a default `NoEvent` case. Checkpoint: ```text run-06 ``` --- # Chapter 07: Events --- # Chapter 07: Events ```rust enum RobotEvent { NoEvent, StartStop, BallLeft, BallRight, BallCentered, BallLost, ObstacleDetected, Timeout, } ``` Events are interpreted facts. They are not raw hardware. --- # Event priority matters What should win? ```text BallCentered + ObstacleDetected ``` Probably not: ```text drive forward ``` Some events are safety-critical. --- # Chapter 07 exercise: Add event priority Open: ```text src/bin/07_events.rs ``` Tasks: 1. Convert button direction to `RobotEvent`. 2. Add a function `prioritize_event`. 3. Ensure `ObstacleDetected` wins. 4. Keep `NoEvent` harmless. Checkpoint: ```text test-07 ``` --- # Chapter 08: State Machine --- # Chapter 08: Robot state machine The robot needs memory. ```rust enum RobotMode { Idle, SearchBall, AlignToBall, DriveToBall, AvoidObstacle, Error, } ``` It must know what it is currently trying to do. --- # State is not the same as event Event: ```text BallLeft ``` State: ```text AlignToBall ``` Command: ```text RotateLeft ``` Keep them separate. --- # State produces command ```rust enum MotionCommand { Stop, Forward, RotateLeft, RotateRight, StrafeLeft, StrafeRight, Avoid, } ``` The robot should think in intent first. --- # Decision function ```rust fn decide( mode: RobotMode, event: RobotEvent, ) -> (RobotMode, MotionCommand) { match (mode, event) { (_, RobotEvent::ObstacleDetected) => (RobotMode::AvoidObstacle, MotionCommand::Stop), (RobotMode::Idle, RobotEvent::StartStop) => (RobotMode::SearchBall, MotionCommand::RotateLeft), (RobotMode::SearchBall, RobotEvent::BallLeft) => (RobotMode::AlignToBall, MotionCommand::RotateLeft), (RobotMode::SearchBall, RobotEvent::BallRight) => (RobotMode::AlignToBall, MotionCommand::RotateRight), (RobotMode::SearchBall, RobotEvent::BallCentered) => (RobotMode::DriveToBall, MotionCommand::Forward), (mode, _) => (mode, MotionCommand::Stop), } } ``` One place. One transition table. Less mystery. --- # Chapter 08 exercise: Make the robot behave Open: ```text src/bin/08_state_machine.rs ``` Tasks: 1. Add `BallLost -> SearchBall`. 2. Add `StartStop -> Idle`. 3. Ensure `ObstacleDetected` always wins. 4. Map `RobotMode` to LED status. 5. Add one new mode or command. Checkpoint: ```text run-08 ``` --- # We can test robot behaviour without a robot ```rust #[test] fn obstacle_wins_over_ball() { let (mode, command) = decide( RobotMode::DriveToBall, RobotEvent::ObstacleDetected, ); assert_eq!(mode, RobotMode::AvoidObstacle); assert_eq!(command, MotionCommand::Stop); } ``` Robot logic can be tested on the laptop. --- # Chapter 09: Sensors --- # Chapter 09: Camera model Real camera pipeline: ```text camera frame | color / blob / ML detection | BallObservation | RobotEvent ``` --- # Camera model -> Workshop model ```rust enum BallObservation { NotSeen, Left, Center, Right, TooClose, } ``` The controller should not care *how* the ball was detected. --- # Camera data is not clean A camera may give: - no ball - multiple candidates - wrong colour - motion blur - partial visibility - bad lighting - stale frame So convert pixels into a small domain model (later). --- # Chapter 09: Ultrasonic model ```rust struct RawEchoMicros(u32); struct DistanceCm(u16); enum ObstacleObservation { Clear, Warning, TooClose, } ``` Pipeline: ```text raw echo -> distance -> classification -> event ``` A distance value is not behaviour. --- # Sensor fusion starts small For a soccer robot with vision and Ultrasonic: ```text BallCentered + Clear -> DriveToBall BallCentered + TooClose -> Stop or AvoidObstacle BallLost + Clear -> SearchBall BallLeft + Warning -> Align carefully ``` Multiple inputs must become one decision. --- # Chapter 09 exercise: Sensor pipeline Open: ```text src/bin/09_sensor_pipeline.rs ``` Tasks: 1. Convert fake camera input to `BallObservation`. 2. Convert fake ultrasonic distance to `ObstacleObservation`. 3. Convert observations to `RobotEvent`. 4. Feed the event into `decide()`. 5. Add one test for obstacle priority. Checkpoint: ```text test-09 ``` --- # Chapter 10: Motion --- # Chapter 10: Motion commands The robot does not think in PWM first. It thinks in motion intent: ```rust struct MotionVector { forward: f32, strafe: f32, rotate: f32, } ``` Then a lower layer translates intent into wheel speeds. --- # From symbolic command to vector ```rust fn command_to_vector(command: MotionCommand) -> MotionVector { match command { MotionCommand::Stop => MotionVector { forward: 0.0, strafe: 0.0, rotate: 0.0, }, MotionCommand::Forward => MotionVector { forward: 0.6, strafe: 0.0, rotate: 0.0, }, MotionCommand::RotateLeft => MotionVector { forward: 0.0, strafe: 0.0, rotate: -0.4, }, MotionCommand::RotateRight => MotionVector { forward: 0.0, strafe: 0.0, rotate: 0.4, }, _ => MotionVector { forward: 0.0, strafe: 0.0, rotate: 0.0, }, } } ``` Intent becomes a numeric movement request. --- # Four-wheel omni drive: Command to wheels ```rust struct WheelSpeeds { front_left: f32, front_right: f32, rear_left: f32, rear_right: f32, } fn mix(cmd: MotionVector) -> WheelSpeeds { WheelSpeeds { front_left: cmd.forward + cmd.strafe + cmd.rotate, front_right: cmd.forward - cmd.strafe - cmd.rotate, rear_left: cmd.forward - cmd.strafe + cmd.rotate, rear_right: cmd.forward + cmd.strafe - cmd.rotate, } } ``` The formula is simple. The real robot is not. --- # Real motors add boring but important details A real motor layer needs: - speed normalization - motor orientation calibration - PWM generation - motor driver limits - battery compensation - emergency stop behaviour - testing on the floor Rust helps keep the layers honest. --- # Chapter 10 exercise: From robot command to wheel speeds Open: ```text src/bin/10_motor_commands.rs ``` Tasks: 1. Convert `MotionCommand` to `MotionVector`. 2. Convert `MotionVector` to `WheelSpeeds`. 3. Normalize speeds to `-1.0..=1.0`. 4. Add a test for `Stop`. 5. Add a test for `RotateLeft`. Checkpoint: ```text test-10 ``` --- # Chapter 11: Kinematics --- # Chapter 11: Kinematics enters when geometry matters For a mobile base: ```text robot motion intent -> wheel speeds ``` For a robot arm: ```text target position -> joint angles -> motor commands ``` That second step is inverse kinematics. --- # Forward kinematics asks: Where did we end up? ```text joint angles | robot geometry | tool position ``` Example: ```text base angle + shoulder angle + elbow angle => x / y / z position ``` Good for checking and simulation. --- # Inverse kinematics asks: How do we get there? ```text target position | robot geometry | joint angles ``` Example: ```rust struct ArmTarget { x_mm: f32, y_mm: f32, z_mm: f32, } struct JointAngles { base_deg: f32, shoulder_deg: f32, elbow_deg: f32, } ``` --- # Invalid movement should fail before hardware moves ```rust enum KinematicsError { TargetOutOfReach, JointLimitExceeded, CollisionRisk, } fn inverse_kinematics( target: ArmTarget, ) -> Result { todo!() } ``` The safest motor command is the one you never send. --- # Chapter 11 exercise: Reject invalid motion Open: ```text src/bin/11_kinematics.rs ``` Tasks: 1. Run the tests. 2. Change one reachable target. 3. Add one rejected target. 4. Keep unsafe motion out of the command layer. Checkpoint: ```text test-11 ``` --- # Chapter 12: Embassy --- # Chapter 12: Why Embassy? So far, we wrote the loop ourselves: ```text configure hardware ↓ loop forever ↓ check time ↓ read input ↓ update state ↓ write output ``` But it becomes noisy when several things must happen at different rates. --- # Chapter 12: Robots rarely do only one thing A robot may need to: * blink status LED every 500 ms * read buttons or sensors often * print diagnostics occasionally * update behaviour state * send motor commands regularly * wait without blocking everything else A single hand-written loop can do this. It just gets ugly. --- # Chapter 12: Reuse established embedded crates We do not need to build every firmware primitive ourselves. Embassy gives us reusable embedded building blocks: * async executor * task spawning * timers * tickers * async waiting * embedded-focused scheduling model The setup stays explicit. The waiting and task structure get cleaner. --- # Chapter 12: What Embassy is Embassy is an embedded Rust framework built around async Rust. It provides an async executor designed for embedded systems. Important embedded properties: * no heap required * tasks are statically allocated * async/await works on microcontrollers * timers can express waiting without busy-loop plumbing This is not a desktop runtime. --- # Chapter 12: From one loop to tasks Manual loop model: ```text main loop ↓ do everything ↓ manually track time ↓ repeat ``` Embassy model: ```text init hardware ↓ spawn tasks ↓ await timers ↓ executor resumes work ``` The firmware still owns the hardware. The structure is cleaner. --- # Chapter 12: Timer-driven waiting Instead of manual delay plumbing: ```rust loop { do_work(); busy_wait(); } ``` Embassy lets us express waiting directly: ```rs loop { do_work(); Timer::after(Duration::from_millis(500)).await; } ``` The code says what it means: `do work, then wait` --- # Chapter 12: Periodic work with Ticker For periodic work: ```rs let mut ticker = Ticker::every(Duration::from_millis(500)); loop { ticker.next().await; hprintln!("heartbeat"); } ``` A ticker is useful for: * heartbeat output * periodic sensor sampling * regular status updates * control-loop prototypes --- # Chapter 12: Async task shape ```rs #[embassy_executor::task] async fn heartbeat_task() { let mut ticker = Ticker::every(Duration::from_millis(500)); loop { ticker.next().await; hprintln!("heartbeat"); } } ``` The task looks sequential. The executor handles waiting and resuming. --- # Chapter 12: Main task stays explicit ```rs #[embassy_executor::main] async fn main(spawner: Spawner) { spawner.spawn(heartbeat_task()).unwrap(); loop { hprintln!("main task alive"); Timer::after(Duration::from_secs(2)).await; } } ``` The structure is still visible: ```text start executor ↓ spawn heartbeat ↓ main task keeps running ``` --- # Chapter 12: Embassy example Open: `src/bin/12_embassy.rs` Run: `run-12` Tasks: 1. Run the baseline. 2. Change the heartbeat text. 3. Change the timer period. 4. Add a second async task. 5. Compare this with chapter 04. 6. Decide which version is easier to extend. --- # Chapter 12: What Embassy does not solve Embassy does not remove: * hardware setup * ownership rules * bad wiring * wrong pin mapping * sensor noise * control logic bugs * the need for testing on real hardware It helps structure waiting and concurrent tasks. It does not make the robot correct by itself. --- # The full architecture we built toward ```text camera / ultrasonic / button (Sensors) | raw reading | domain observation | RobotEvent | RobotMode | MotionCommand | MotionVector | WheelSpeeds | motor driver (Output) ``` --- # Hardware and software work together in layers ```text Hardware layer GPIO, timers, PWM, ADC, buses Driver layer LED, button, motor driver, sensor Domain layer BallObservation, ObstacleObservation Behaviour layer RobotMode, state machine Control layer MotionCommand, wheel speeds ``` Do not mix this into one loop! --- # What Rust helped with - explicit ownership of resources - visible mutation - clear domain types - enums for valid states - `Result` for sensor failure - testable robot logic - separation between hardware and behaviour - small embedded programs without `std` Rust did not remove the need for calibration, physics and debugging. --- # What robotics taught us - Raw input is not meaning - Meaning is not behaviour - Behaviour is not motor output - Motor output is not guaranteed motion - Real systems need feedback - Safety should win over ambition The robot is a loop, not a script --- # Where to continue Next useful steps: 0. Get yourself a microcontroller 1. Add real ultrasonic hardware 2. Add motor driver output 3. Add a camera module or external vision process 4. Add logs and host-side tests 5. Move repeated tasks to Embassy async 6. Put the board onto a small chassis 7. Test on the floor Build one layer at a time --- # Useful references - The Rust Book: ownership and borrowing - The Embedded Rust Book: `no_std` and bare-metal concepts - probe-rs: flashing and embedded debugging tooling - RoboCupJunior Soccer rules and description