PWM, Motor Drive
& Encoder Capstone
Pulse Width Modulation is the fundamental technique for controlling power to actuators: motors, LEDs, heaters, speakers. You cannot vary voltage on a microcontroller output — it is either 3.3V or 0V — but you can vary the fraction of time it spends high. A motor receiving a signal that is high 75% of the time receives 75% of its theoretical power. The motor's mechanical inertia smooths the switching into effective analogue control.
The RP2350 has 12 PWM slices (slice 0–11). Each slice has two channels: A and B. Each channel maps to a specific GPIO pin — you cannot choose freely. GPIO8 maps to PWM slice 4 channel A, GPIO9 to slice 4B, and GPIO10 to slice 5A. For the L298N Enable pin (ENA on GPIO10) we want slice 5A.
RP2350 PWM SLICE ARCHITECTURE ──────────────────────────────────────────────────────────── Each slice has: ┌──────────────────────────────────────────────┐ │ 16-bit counter (counts 0 → TOP, then wraps) │ │ TOP register (sets period = frequency) │ │ Compare A / B (sets duty cycle per channel) │ │ Clock divider (slows counter clock) │ └──────────────────────────────────────────────┘ Frequency formula: f_pwm = f_sys / (divider × (TOP + 1)) f_sys = 150 MHz (RP2350 default) For 20 kHz PWM (above human hearing, below FET switching loss): divider = 1, TOP = 7499 f = 150_000_000 / (1 × 7500) = 20_000 Hz ✓ Duty cycle: compare_value = (duty_percent / 100.0) × (TOP + 1) 75% duty → compare = 0.75 × 7500 = 5625 GPIO → Slice map (partial): GPIO8 → Slice 4 Channel A GPIO9 → Slice 4 Channel B GPIO10 → Slice 5 Channel A ← ENA pin (motor speed) GPIO12 → Slice 6 Channel A GPIO14 → Slice 7 Channel A
Embassy exposes PWM through the embassy_rp::pwm module. The Pwm struct takes ownership of the slice — consistent with the peripheral singleton model you studied in Chapter 6. You configure a Config with top, compare values, and divider, then call set_config to update live.
use embassy_rp::pwm::{Config as PwmConfig, Pwm}; // Configure PWM slice 5 for 20 kHz let mut pwm_cfg = PwmConfig::default(); pwm_cfg.top = 7499; // sets period → 20 kHz at 150 MHz sysclk pwm_cfg.compare_a = 0; // start at 0% duty (motor stopped) pwm_cfg.divider = 1u8.into(); // no division let pwm = Pwm::new_output_a( p.PWM_SLICE5, // hardware singleton — compile error if used twice p.PIN_10, // GPIO10 = ENA on L298N pwm_cfg.clone(), ); // To change duty cycle at runtime: fn set_duty(pwm: &mut Pwm<'_, PWM_SLICE5>, percent: f32) { let mut cfg = PwmConfig::default(); cfg.top = 7499; cfg.compare_a = (percent / 100.0 * 7500.0) as u16; pwm.set_config(&cfg); }
A microcontroller GPIO can source perhaps 12mA at 3.3V. A DC motor wants hundreds of milliamps at 12V. The L298N is an H-bridge — four transistors arranged in an H pattern — that switches motor current from an external supply (up to 46V, 2A) under control of low-power logic signals. It is named after the four transistors forming the letter H with the motor as the crossbar.
L298N H-BRIDGE WIRING ────────────────────────────────────────────────── L298N Module Pins RP2350 GPIO ───────────────────── ────────────── IN1 ←── GPIO8 IN2 ←── GPIO9 ENA ←── GPIO10 (PWM) GND ←── GND +5V (logic) ←── 3.3V (works fine) 12V (motor power) ←── External 12V supply Direction truth table: ┌──────┬──────┬───────────────────────┐ │ IN1 │ IN2 │ Motor │ ├──────┼──────┼───────────────────────┤ │ HIGH │ LOW │ Forward │ │ LOW │ HIGH │ Reverse │ │ LOW │ LOW │ Coast (free spin) │ │ HIGH │ HIGH │ Brake (active short) │ └──────┴──────┴───────────────────────┘ ENA = PWM signal → controls speed (0-100% duty) ENA = LOW → motor always off regardless of IN1/IN2
Never set IN1 and IN2 both HIGH for extended periods. This creates a shoot-through condition: both high-side and low-side transistors conduct simultaneously, creating a short across your motor supply. The L298N has internal protection but it generates significant heat. Always coast or brake by setting one or both LOW.
use embassy_rp::gpio::{Level, Output}; struct Motor<'d> { in1: Output<'d>, in2: Output<'d>, } impl<'d> Motor<'d> { fn new(in1: Output<'d>, in2: Output<'d>) -> Self { Self { in1, in2 } } fn forward(&mut self) { self.in1.set_high(); self.in2.set_low(); } fn reverse(&mut self) { self.in1.set_low(); self.in2.set_high(); } fn coast(&mut self) { self.in1.set_low(); self.in2.set_low(); } fn brake(&mut self) { self.in1.set_high(); self.in2.set_high(); } }
Your rotary encoder outputs two signals: CLK (GPIO6) and DT (GPIO7). These are not synchronised to any clock — they are purely mechanical contacts that open and close as the shaft rotates. The two channels are offset by 90 degrees (one quarter period) — hence quadrature. This phase relationship tells you both speed and direction.
QUADRATURE ENCODER SIGNAL DIAGRAM ────────────────────────────────────────────────────────── Clockwise rotation: ↓ CLK falls first CLK: ‾‾‾‾|____|‾‾‾‾|____|‾‾‾‾ DT: ‾‾|____|‾‾‾‾|____|‾‾‾‾|‾‾ ↑ DT falls AFTER CLK When CLK falls (↓): read DT DT = HIGH → clockwise (+1) DT = LOW → counter-CW (-1) Counter-clockwise rotation: CLK: ‾‾‾‾|____|‾‾‾‾|____|‾‾‾‾ DT: ‾‾‾‾‾‾|____|‾‾‾‾|____|‾‾ ↑ DT falls BEFORE CLK When CLK falls (↓): read DT DT = LOW → counter-clockwise (-1) Position = cumulative sum of steps Speed = steps per unit time
In Embassy, we use ExtiInput to receive async interrupts on GPIO6. Every falling edge on CLK wakes our encoder task which immediately samples GPIO7 for direction. The position is stored in a Mutex<CriticalSectionRawMutex, i32> so multiple tasks can safely read it.
use embassy_rp::gpio::{Input, Pull}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; // Global shared position counter static POSITION: Mutex<CriticalSectionRawMutex, i32> = Mutex::new(0); #[embassy_executor::task] async fn encoder_task( mut clk: Input<'static>, dt: Input<'static>, ) { loop { // Sleep until CLK falls — zero CPU usage while waiting clk.wait_for_falling_edge().await; // Sample DT immediately — phase window is microseconds let direction = if dt.is_high() { 1i32 } else { -1i32 }; let mut pos = POSITION.lock().await; *pos += direction; // Optional: log every 20 steps (~one revolution) if *pos % 20 == 0 { defmt::info!("Encoder position: {}", *pos); } } }
Three tasks need to share data: the encoder task writes position, the display task reads position to show on TM1637, and the motor controller task reads position for closed-loop control. In a threaded OS you might reach for a global variable protected by a mutex — and that is exactly what we do, but Embassy's Mutex is async-aware and works with the single-core cooperative scheduler.
Which primitive for which job?
Mutex<CriticalSectionRawMutex, T> — for shared mutable state accessed briefly. CriticalSection disables interrupts during the lock. Use for primitive types (i32, f32, structs) that fit in a few words. Our position counter is ideal.
Channel<M, T, N> — for passing messages between tasks. Producer sends, consumer receives. MPMC (multi-producer multi-consumer) or SPSC. Use when one task produces events another consumes — button presses, sensor readings.
Signal<M, T> — like a channel of capacity 1. Latest value wins, older values are discarded. Use for continuously-updated values where only the most recent matters — sensor readings, setpoints.
Avoid: RefCell across await points, unsafe static muts, anything that blocks (like std::sync::Mutex).
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; use embassy_sync::signal::Signal; // Position: written by encoder, read by display + controller static POSITION: Mutex<CriticalSectionRawMutex, i32> = Mutex::new(0); // Speed setpoint: written by controller, read by PWM driver static SPEED_SETPOINT: Signal<CriticalSectionRawMutex, f32> = Signal::new(); // Display task — reads position every 100ms, shows on TM1637 #[embassy_executor::task] async fn display_task(/* TM1637 pins */) { loop { Timer::after_millis(100).await; let pos = *POSITION.lock().await; // display pos on TM1637 (Chapter 8 driver) tm1637_show(pos.abs() as u16).await; } } // Motor controller — simple proportional controller #[embassy_executor::task] async fn motor_task(/* motor + pwm handles */) { let target = 200i32; // target encoder position loop { Timer::after_millis(50).await; let pos = *POSITION.lock().await; let error = target - pos; // P-controller: gain 0.5, clamp 0-100% let duty = (error as f32 * 0.5).clamp(0.0, 100.0); SPEED_SETPOINT.signal(duty); } }
This is the complete three-task capstone. Copy this in full into your project's src/main.rs. You will need to add the dependencies to Cargo.toml and the Embassy executor macro. The system runs four concurrent Embassy tasks: the encoder interrupt handler, the display refresher, the motor P-controller, and main's async block which handles the startup sequence.
[package]
name = "motor-capstone"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m", "executor-thread", "defmt", "integrated-timers"] }
embassy-rp = { version = "0.2", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl"] }
embassy-time = { version = "0.3", features = ["defmt"] }
embassy-sync = { version = "0.6", features = ["defmt"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
static-cell = "2"
portable-atomic = { version = "1", features = ["critical-section"] }
[profile.release]
opt-level = 's' # optimise for size on embedded
debug = 2 # keep debug info for probe-rs
lto = true # link-time optimisation — smaller binary
[[bin]]
name = "motor-capstone"
test = false
bench = false#![no_std] #![no_main] use embassy_executor::Spawner; use embassy_rp::gpio::{Input, Level, Output, Pull}; use embassy_rp::pwm::{Config as PwmConfig, Pwm}; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::mutex::Mutex; use embassy_sync::signal::Signal; use embassy_time::Timer; use {defmt_rtt as _, panic_probe as _}; // ─── Shared state ──────────────────────────────────────────── static POSITION: Mutex<CriticalSectionRawMutex, i32> = Mutex::new(0); static SPEED_PCT: Signal<CriticalSectionRawMutex, f32> = Signal::new(); // ─── Encoder task ──────────────────────────────────────────── #[embassy_executor::task] async fn encoder_task( mut clk: Input<'static>, dt: Input<'static>, ) { loop { clk.wait_for_falling_edge().await; let dir = if dt.is_high() { 1i32 } else { -1i32 }; let mut pos = POSITION.lock().await; *pos += dir; defmt::trace!("enc pos={}", *pos); } } // ─── Display task (TM1637 simplified) ──────────────────────── #[embassy_executor::task] async fn display_task() { loop { Timer::after_millis(100).await; let pos = *POSITION.lock().await; // In a real build: call your Chapter 8 TM1637 driver here defmt::info!("DISPLAY pos={}", pos); } } // ─── Motor controller task (P-controller) ──────────────────── #[embassy_executor::task] async fn motor_task() { // This task only signals — actual PWM write happens in main loop // to avoid passing the Pwm handle across task boundaries let target = 120i32; // target encoder steps from start loop { Timer::after_millis(50).await; let pos = *POSITION.lock().await; let error = (target - pos) as f32; // Proportional gain = 0.8; saturate to [0, 100] let duty = (error * 0.8).abs().min(100.0); SPEED_PCT.signal(duty); } } // ─── Entry point ───────────────────────────────────────────── #[embassy_executor::main] async fn main(spawner: Spawner) { let p = embassy_rp::init(Default::default()); defmt::info!("Motor capstone starting — SprintTZ NOC demo"); // Motor direction pins let in1 = Output::new(p.PIN_8, Level::Low); let in2 = Output::new(p.PIN_9, Level::Low); let mut motor = Motor::new(in1, in2); // PWM for speed (ENA) let mut cfg = PwmConfig::default(); cfg.top = 7499; cfg.compare_a = 0; let mut pwm = Pwm::new_output_a(p.PWM_SLICE5, p.PIN_10, cfg); // Encoder pins — pull-up, CLK on GPIO6, DT on GPIO7 let clk = Input::new(p.PIN_6, Pull::Up); let dt = Input::new(p.PIN_7, Pull::Up); // Spawn tasks spawner.spawn(encoder_task(clk, dt)).unwrap(); spawner.spawn(display_task()).unwrap(); spawner.spawn(motor_task()).unwrap(); // Main loop: apply whatever speed the controller signals motor.forward(); loop { let duty_pct = SPEED_PCT.wait().await; let compare = (duty_pct / 100.0 * 7500.0) as u16; let mut cfg2 = PwmConfig::default(); cfg2.top = 7499; cfg2.compare_a = compare; pwm.set_config(&cfg2); if duty_pct < 2.0 { motor.coast(); defmt::info!("Target reached — coasting"); } } }
Three Embassy tasks running concurrently on a single core. The encoder task sleeps in hardware until CLK falls — consuming zero cycles. The display task wakes every 100ms. The motor controller wakes every 50ms. The main loop only runs when a new speed signal arrives. Embassy's cooperative scheduler handles all of this with no RTOS, no threads, no heap allocation — just futures.
Add a Velocity Estimate
The encoder task currently tracks position. Extend it to also compute velocity: the number of steps accumulated in the last 100ms. Store it in a second Signal<CriticalSectionRawMutex, i32> called VELOCITY. Display the velocity alongside position on the TM1637 by alternating every 500ms.
PD Controller
The current motor task is a P-controller (proportional only). Add a derivative term: measure the rate of change of error between controller cycles. D-term = (error - prev_error) / dt. Tune Kp and Kd so the motor reaches target with minimal overshoot. Log the error to RTT and plot it with probe-rs's defmt graph view.
Sprint NOC Demo — Network Latency Visualiser
Replace the encoder position concept with a simulated "network latency" value (0–999ms). Drive the motor speed proportionally to simulated latency: low latency = motor slow, high latency = motor fast. Display the value on TM1637. This is the kind of physical dashboard Sprint's NOC team could use to feel network health viscerally rather than watching a Zabbix graph. Implement a ramp function that cycles simulated latency from 10→500→10ms over 10 seconds.