PWM and controlling brightness
Step 6 of 10, and the step that changes what the LED is. Step 5 left the firmware switching LD2: on, off, on again, one accepted press per toggle. This step dims it instead. The pin is handed to a timer, the timer’s compare register decides how much of each period the pin spends high, and the firmware’s only job is to load that register. It starts from tag stm32-ch05 and ends at stm32-ch06.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against, with stm32f4xx-hal 0.23.0 and embedded-hal 1.0.0.
What the timer does
Section titled “What the timer does”A PWM signal is a square wave with a fixed period and a variable duty cycle: the fraction of the period the pin spends high. At 50% the pin is high half the time, and an LED fed that way looks half as bright as one fed 100% — not because the voltage is lower, but because it is on less of the time. The eye averages.
TIM2 is the timer that makes the wave. It counts up from zero to the value in its auto-reload register and starts over, so one period is ARR plus one ticks of the timer’s clock. With the clock configuration step 3 froze — the 16 MHz internal oscillator, no PLL — a tick is 62.5 ns, and at one kilohertz a period is 16000 ticks, so ARR holds 15999. A second register, the channel’s capture/compare register, holds the duty: while the counter is below it the output is high, and below it the output is low. Divide one by the other and the duty is CCR1 / (ARR + 1). That denominator is what the HAL hands back as max_duty_cycle(): 16000 here.
Two choices in that sentence are worth a word each. One kilohertz is fast enough that no one sees the switching — past roughly a hundred hertz an LED is a steady light to the eye — and it makes the period exactly one millisecond, the same length as the loop’s tick, so each duty write lands once per period instead of mid-period. And the frequency is not a number the firmware has to keep true by hand: the HAL reads the timer’s input clock from the frozen RCC and works out the prescaler and auto-reload from it, so a later change to the clock configuration moves the resolution, not the frequency.
The pin’s second job
Section titled “The pin’s second job”PA5 carries a second function. On the F401 the pin’s alternate function 1 is TIM2’s channel 1, so the same pad that step 3 made a push-pull output becomes a timer output — the GPIO block disconnects and the timer drives the line. That is a mode change, not a rewiring, and it is where the pin’s own methods stop applying: from here on the firmware writes a compare register, and the timer does the switching.
The setup, in main():
let (_, channels) = dp.TIM2.pwm_hz(1.kHz(), &mut rcc);let mut led = channels.0.with(gpioa.pa5.into_alternate::<1>());led.enable();pwm_hz returns a pair: the manager, which owns the period and can change it later, and the timer’s channel handles, one per output compare channel. channels.0 is channel 1 — the tuple is zero-based even though the hardware counts channels from one — and with does two things: it moves the pin into alternate mode and connects it to the channel. The manager is dropped on the spot, which is safe because the channel holds its own handle on the timer and the counter keeps running without it.
The last line is not optional. A channel starts with its output disabled: the counter runs, the compare matches, the waveform exists — and none of it reaches the pin until enable() sets the channel’s output-enable bit. A firmware that forgets it writes dutys faithfully into a register nothing listens to, and the LED stays dark no matter what the code says.
The Result, again
Section titled “The Result, again”set_duty_cycle is a trait method, and the trait’s methods return a Result — the third time in this track a trait method has surprised someone who expected a bare value (step 3 hit it on set_high, step 5 on is_low). The block needs embedded-hal, the crate the firmware already links against:
[dependencies]embedded-hal = "1.0.0"use embedded_hal::pwm::SetDutyCycle;
/// The firmware's write, minus the error handling: the trait method/// returns a `Result`, and a `()` return type does not accept one.fn dim<P: SetDutyCycle>(led: &mut P) { led.set_duty_cycle_percent(50)}
fn main() {}error[E0308]: mismatched types --> src/main.rs:6:5 |6 | led.set_duty_cycle_percent(50) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `()`, found `Result<(), <P as ErrorType>::Error>` | = note: expected unit type `()` found enum `Result<(), <P as embedded_hal::pwm::ErrorType>::Error>`help: consider using a semicolon here |6 | led.set_duty_cycle_percent(50); | +help: try adding a return type |5 | fn dim<P: SetDutyCycle>(led: &mut P) -> Result<(), <P as embedded_hal::pwm::ErrorType>::Error> { | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++help: consider using `Result::expect` to unwrap the `Result<(), <P as embedded_hal::pwm::ErrorType>::Error>` value, panicking if the value is a `Result::Err` |6 | led.set_duty_cycle_percent(50).expect("REASON") | +++++++++++++++++
For more information about this error, try `rustc --explain E0308`.error: could not compile `verify` (bin "verify") due to 1 previous errorThe fix is one call. On this chip the error type is Infallible, so the unwrap cannot fire; the Result is there because the trait is written for chips whose timer writes can fail, and this firmware inherits the shape without the risk.
The curve
Section titled “The curve”Dimming an LED is easy; making it breathe is the same write in a loop with a curve behind it. The firmware’s curve is a triangle: the duty climbs from 0 to full over half ticks, falls back over another half, and repeats. Half a second each way makes a breath that takes a second, and the button from step 5 pauses and resumes it — each accepted press restarts the ramp from dark, which is simpler to reason about than resuming mid-slope.
The curve lives in its own struct, with no chip types in it, so the same code runs against a mock channel on a host. This is the complete program — the Button struct from step 5, the curve, two mocks and all:
use embedded_hal::digital::{ErrorType as DigitalErrorType, InputPin};use embedded_hal::pwm::{ErrorType, SetDutyCycle};
/// A button that reports one event per accepted press.struct Button<I: InputPin> { pin: I, /// The level the debouncer currently believes the line is at: /// `true` while the button is held down. pressed: bool, /// How many consecutive ticks have disagreed with `pressed`. held: u8,}
/// A level change has to hold for this many consecutive 1 ms ticks before/// it counts. Ten milliseconds is a starting point: long enough that/// chatter cannot reach it, short enough that a deliberate press/// registers on the first attempt.const DEBOUNCE_TICKS: u8 = 10;
impl<I: InputPin> Button<I> { fn new(pin: I) -> Self { // A board starts with the button released; if it is held at // power-on, the first accepted level corrects this on release. Button { pin, pressed: false, held: 0, } }
/// One tick of the poll loop. Returns `true` on the tick a press is /// accepted — not on every tick the button is held. fn tick(&mut self) -> bool { // Active low: the pin reads low while the button is pressed. let last = self.pressed; let raw = self.pin.is_low().unwrap_or(last); if raw == self.pressed { self.held = 0; false } else { self.held += 1; if self.held >= DEBOUNCE_TICKS { self.pressed = raw; self.held = 0; // `raw` is `true` exactly when the new level is pressed; // a release is accepted the same way but not reported. raw } else { false } } }}
/// The breathing curve, kept free of chip types so the same struct runs/// against a mock PWM channel on a host (that is how it was tested) and/// against TIM2's channel 1 on the board.struct Breath { /// Ticks since the ramp started, 0..=2*half. phase: u16, /// Ticks in half a cycle: the ramp up, or the ramp down. The duty /// climbs from 0 to full and back inside 2*half ticks. half: u16, /// Whether the breath runs at all; the button flips this. running: bool,}
impl Breath { fn new(half: u16) -> Self { Breath { phase: 0, half, running: true, } }
/// A press switches the breathing off or on, and either way the next /// breath starts from dark. fn toggle(&mut self) { self.running = !self.running; self.phase = 0; }
/// The duty the LED should hold right now, in percent. The curve is /// a triangle: straight up to full brightness, straight back down, /// one tick wide at each turnaround. fn duty(&self) -> u8 { if !self.running { return 0; } // Integer arithmetic only — no float in sight, and none needed // for a straight line. The percent is the numerator over `half`. let percent = if self.phase < self.half { self.phase as u32 * 100 } else { (2 * self.half as u32 - self.phase as u32) * 100 } / self.half as u32; percent as u8 }
/// One tick of the loop. A paused breath does not advance. fn advance(&mut self) { if !self.running { return; } self.phase += 1; if self.phase > 2 * self.half { self.phase = 0; } }}
/// A pin that plays back a recorded trace of levels instead of reading/// one: `true` means the line reads low.struct MockPin { trace: [bool; 66], tick: usize,}
// The error type lives on its own trait, and the digital and pwm// families each define one — a mock pin and a mock channel each// implement the one their trait requires.impl DigitalErrorType for MockPin { type Error = core::convert::Infallible;}
impl InputPin for MockPin { fn is_high(&mut self) -> Result<bool, Self::Error> { Ok(!self.is_low()?) }
fn is_low(&mut self) -> Result<bool, Self::Error> { let low = self.trace.get(self.tick).copied().unwrap_or(false); self.tick += 1; Ok(low) }}
/// A PWM channel that records the duty it was given instead of driving/// a pin. Full scale is 1000 counts, so the raw number a percent turns/// into is visible next to the percent itself.struct MockPwm { /// The last duty written, in the channel's own counts. duty: u16, /// The highest duty written so far. peak: u16, /// How many writes the channel has taken. writes: u32,}
impl ErrorType for MockPwm { type Error = core::convert::Infallible;}
impl SetDutyCycle for MockPwm { fn max_duty_cycle(&self) -> u16 { 1000 }
fn set_duty_cycle(&mut self, duty: u16) -> Result<(), Self::Error> { self.duty = duty; self.peak = self.peak.max(duty); self.writes += 1; Ok(()) }}
/// The pin levels the mock plays back, one per tick. The mock's own/// counter and the loop's index advance together: one read per tick.const TRACE: [bool; 66] = [ // A full breath first, untouched: dark to bright and back. false, false, false, false, false, false, false, false, false, false, false, // 1 false, false, false, false, false, false, false, false, false, false, // 12 // The next ramp has started when the contact arrives. false, false, false, false, // 22 true, false, // 26: the contact chatters true, true, true, true, true, true, true, true, true, true, // 28: held, press accepted on the tenth true, true, true, true, true, true, true, true, // 38: still held, breathing paused false, false, false, false, false, false, false, false, false, false, // 46: released, accepted silently true, true, true, true, true, true, true, true, true, true, // 56: pressed again, accepted on the tenth true, // 66: still held, the breath is climbing again];
fn main() { // A short half-cycle: ten ticks, so a whole breath is 21 lines. let mut breath = Breath::new(10); let mut button = Button::new(MockPin { trace: TRACE, tick: 0, }); let mut pwm = MockPwm { duty: 0, peak: 0, writes: 0, };
// The loop body is the firmware's loop body, tick for tick: poll // the button, write the duty, count the millisecond, advance. for tick in 0..TRACE.len() { let mut event = ""; if button.tick() { breath.toggle(); event = if breath.running { "B1: breathing resumed" } else { "B1: breathing paused" }; } pwm.set_duty_cycle_percent(breath.duty()).unwrap(); let note = if event.is_empty() { String::new() } else { format!(" {event}") }; println!( "tick {:>2}: pin={:<5} duty {:>3}% (raw {:>4}){note}", tick + 1, if TRACE[tick] { "low" } else { "high" }, breath.duty(), pwm.duty, ); breath.advance(); } println!( "duty writes: {writes}, peak {peak} of {max} full scale", writes = pwm.writes, peak = pwm.peak, max = pwm.max_duty_cycle(), );}tick 1: pin=high duty 0% (raw 0)tick 2: pin=high duty 10% (raw 100)tick 3: pin=high duty 20% (raw 200)tick 4: pin=high duty 30% (raw 300)tick 5: pin=high duty 40% (raw 400)tick 6: pin=high duty 50% (raw 500)tick 7: pin=high duty 60% (raw 600)tick 8: pin=high duty 70% (raw 700)tick 9: pin=high duty 80% (raw 800)tick 10: pin=high duty 90% (raw 900)tick 11: pin=high duty 100% (raw 1000)tick 12: pin=high duty 90% (raw 900)tick 13: pin=high duty 80% (raw 800)tick 14: pin=high duty 70% (raw 700)tick 15: pin=high duty 60% (raw 600)tick 16: pin=high duty 50% (raw 500)tick 17: pin=high duty 40% (raw 400)tick 18: pin=high duty 30% (raw 300)tick 19: pin=high duty 20% (raw 200)tick 20: pin=high duty 10% (raw 100)tick 21: pin=high duty 0% (raw 0)tick 22: pin=high duty 0% (raw 0)tick 23: pin=high duty 10% (raw 100)tick 24: pin=high duty 20% (raw 200)tick 25: pin=high duty 30% (raw 300)tick 26: pin=low duty 40% (raw 400)tick 27: pin=high duty 50% (raw 500)tick 28: pin=low duty 60% (raw 600)tick 29: pin=low duty 70% (raw 700)tick 30: pin=low duty 80% (raw 800)tick 31: pin=low duty 90% (raw 900)tick 32: pin=low duty 100% (raw 1000)tick 33: pin=low duty 90% (raw 900)tick 34: pin=low duty 80% (raw 800)tick 35: pin=low duty 70% (raw 700)tick 36: pin=low duty 60% (raw 600)tick 37: pin=low duty 0% (raw 0) B1: breathing pausedtick 38: pin=low duty 0% (raw 0)tick 39: pin=low duty 0% (raw 0)tick 40: pin=low duty 0% (raw 0)tick 41: pin=low duty 0% (raw 0)tick 42: pin=low duty 0% (raw 0)tick 43: pin=low duty 0% (raw 0)tick 44: pin=low duty 0% (raw 0)tick 45: pin=low duty 0% (raw 0)tick 46: pin=high duty 0% (raw 0)tick 47: pin=high duty 0% (raw 0)tick 48: pin=high duty 0% (raw 0)tick 49: pin=high duty 0% (raw 0)tick 50: pin=high duty 0% (raw 0)tick 51: pin=high duty 0% (raw 0)tick 52: pin=high duty 0% (raw 0)tick 53: pin=high duty 0% (raw 0)tick 54: pin=high duty 0% (raw 0)tick 55: pin=high duty 0% (raw 0)tick 56: pin=low duty 0% (raw 0)tick 57: pin=low duty 0% (raw 0)tick 58: pin=low duty 0% (raw 0)tick 59: pin=low duty 0% (raw 0)tick 60: pin=low duty 0% (raw 0)tick 61: pin=low duty 0% (raw 0)tick 62: pin=low duty 0% (raw 0)tick 63: pin=low duty 0% (raw 0)tick 64: pin=low duty 0% (raw 0)tick 65: pin=low duty 0% (raw 0) B1: breathing resumedtick 66: pin=low duty 10% (raw 100)duty writes: 66, peak 1000 of 1000 full scaleThe trace is the whole story. Ticks 1 to 21 are one untouched breath: the duty climbs ten points a tick to 100%, turns, and falls back to zero. Tick 26 reads low and tick 27 reads high, and that one flicker resets the debouncer’s counter — nothing is accepted, and the breath does not notice. The press is accepted at tick 37, after ten consecutive low readings, and the duty goes to zero and stays there while the button is held. The release at tick 55 is accepted the same way and reported by nothing, because tick reports presses. The second press at tick 65 resumes the breath from dark.
The raw column is the percent becoming counts: the mock’s full scale is 1000, so set_duty_cycle_percent(25) would land 250 in the compare register. On the board the same call divides by max_duty_cycle() instead, which is 16000 — the trait’s provided method does that arithmetic once, in one place, instead of every firmware that wants a percentage.
The firmware
Section titled “The firmware”src/main.rs in full — this is the tag’s file, byte for byte:
#![no_std]#![no_main]
use cortex_m::delay::Delay;use cortex_m_rt::entry;use embedded_hal::delay::DelayNs;use embedded_hal::digital::InputPin;use embedded_hal::pwm::SetDutyCycle;use stm32f4xx_hal::pac;use stm32f4xx_hal::prelude::*;use stm32f4xx_hal::rcc::Config;
#[entry]fn main() -> ! { let dp = pac::Peripherals::take().unwrap(); let cp = cortex_m::peripheral::Peripherals::take().unwrap();
// Constrain the RCC and apply the default clock configuration: the // chip's 16 MHz internal oscillator, no PLL. let mut rcc = dp.RCC.constrain().freeze(Config::default()); let clocks = rcc.clocks;
let gpioa = dp.GPIOA.split(&mut rcc); let gpioc = dp.GPIOC.split(&mut rcc);
// PC13 is the board's user button, wired as step 5 left it. let button_pin = gpioc.pc13.into_pull_up_input(); let mut button = Button::new(button_pin);
// TIM2's channel 1 is muxed onto PA5, the pin LD2 is wired to. The // pin changes jobs here: no longer an output the firmware writes // outright, but a timer output the firmware only loads. One // kilohertz makes a period of 1 ms — the same length as the loop's // tick, so the duty is written once per period and never lands // mid-period. let (_, channels) = dp.TIM2.pwm_hz(1.kHz(), &mut rcc); let mut led = channels.0.with(gpioa.pa5.into_alternate::<1>()); // The channel starts with its output disabled: the counter runs and // the waveform is generated, but nothing reaches the pin until this // call sets the channel's output-enable bit. led.enable();
// The delay counts SysTick ticks against the AHB frequency. let mut delay = Delay::new(cp.SYST, clocks.hclk().to_Hz());
breathe(&mut led, &mut button, &mut delay)}
/// A button that reports one event per accepted press.////// A mechanical contact does not close once: it bounces, and a raw read/// of the pin flickers between levels around each transition. This/// struct swallows that by only accepting a level that has held still/// for `DEBOUNCE_TICKS` consecutive ticks. The pin type is generic, so/// the same struct runs against a recorded trace on the host (that is/// how it was tested) and against PC13 on the board.struct Button<I: InputPin> { pin: I, /// The level the debouncer currently believes the line is at: /// `true` while the button is held down. pressed: bool, /// How many consecutive ticks have disagreed with `pressed`. held: u8,}
/// A level change has to hold for this many consecutive 1 ms ticks before/// it counts. Ten milliseconds is a starting point: long enough that/// chatter cannot reach it, short enough that a deliberate press/// registers on the first attempt.const DEBOUNCE_TICKS: u8 = 10;
impl<I: InputPin> Button<I> { fn new(pin: I) -> Self { // A board starts with the button released; if it is held at // power-on, the first accepted level corrects this on release. Button { pin, pressed: false, held: 0, } }
/// One tick of the poll loop. Returns `true` on the tick a press is /// accepted — not on every tick the button is held. fn tick(&mut self) -> bool { // Active low: the pin reads low while the button is pressed. An // error can't happen on this chip (`Infallible`), so a read that // did fail would keep the last accepted level. let last = self.pressed; let raw = self.pin.is_low().unwrap_or(last); if raw == self.pressed { self.held = 0; false } else { self.held += 1; if self.held >= DEBOUNCE_TICKS { self.pressed = raw; self.held = 0; // `raw` is `true` exactly when the new level is pressed; // a release is accepted the same way but not reported. raw } else { false } } }}
/// The breathing curve, kept free of chip types so the same struct runs/// against a mock PWM channel on a host (that is how it was tested) and/// against TIM2's channel 1 on the board.struct Breath { /// Ticks since the ramp started, 0..=2*half. phase: u16, /// Ticks in half a cycle: the ramp up, or the ramp down. The duty /// climbs from 0 to full and back inside 2*half ticks. half: u16, /// Whether the breath runs at all; the button flips this. running: bool,}
impl Breath { fn new(half: u16) -> Self { Breath { phase: 0, half, running: true, } }
/// A press switches the breathing off or on, and either way the next /// breath starts from dark. fn toggle(&mut self) { self.running = !self.running; self.phase = 0; }
/// The duty the LED should hold right now, in percent. The curve is /// a triangle: straight up to full brightness, straight back down, /// one tick wide at each turnaround. fn duty(&self) -> u8 { if !self.running { return 0; } // Integer arithmetic only — no float in sight, and none needed // for a straight line. The percent is the numerator over `half`. let percent = if self.phase < self.half { self.phase as u32 * 100 } else { (2 * self.half as u32 - self.phase as u32) * 100 } / self.half as u32; percent as u8 }
/// One tick of the loop. A paused breath does not advance. fn advance(&mut self) { if !self.running { return; } self.phase += 1; if self.phase > 2 * self.half { self.phase = 0; } }}
/// The loop, written against the traits instead of the chip's channel/// and pin types, as `react` was: `P` carries the duty, `I` reads the/// button, `D` counts the time.////// The LED is no longer switched, it is dimmed: one duty write per/// millisecond, and each accepted press pauses or resumes the breath.fn breathe<P: SetDutyCycle, I: InputPin, D: DelayNs>( led: &mut P, button: &mut Button<I>, delay: &mut D,) -> ! { // Half a second up, half a second down: one breath takes a second. let mut breath = Breath::new(500);
loop { if button.tick() { breath.toggle(); } // One write per tick, running or paused. On this chip the error // type is `Infallible`, so the unwrap cannot fire. led.set_duty_cycle_percent(breath.duty()).unwrap(); delay.delay_ms(1); breath.advance(); }}
/// What runs when the firmware panics. Step 2 filled this slot with the/// `panic-halt` crate; step 4 replaced that with the handler below.////// Step 6 gave PA5 to the timer, and a pin in alternate mode is driven/// by the peripheral: a BSRR write alone would no longer reach it. The/// mode bits are the handler's to rewrite, so the pin goes back to a/// plain output first and the LED is the BSRR's again — a panic still/// freezes the board with LD2 lit, which tells it apart from a breath/// that merely stopped.#[panic_handler]fn panic(_: &core::panic::PanicInfo) -> ! { // Safety: `ptr()` returns the register block's address without // checking ownership. GPIOA exists at that address from reset // onward, and nothing else runs once a panic has fired. unsafe { let gpioa = pac::GPIOA::ptr(); (*gpioa).moder().modify(|_, w| w.moder5().output()); (*gpioa).bsrr().write(|w| w.bs5().set_()); } loop {}}What changed since step 5 is the LED and the loop. OutputPin is gone from the imports: nothing in the firmware switches the pin any more, so nothing needs the trait. react is gone, replaced by breathe, which is the same shape — generic over the LED, the button and the delay, so the curve and the debouncer stay testable on a host — with one duty write where the toggle used to be. The Button struct is untouched, byte for byte. The panic handler gained one line, and that line is the next section.
The panic handler takes the pin back
Section titled “The panic handler takes the pin back”Steps 4 and 5 left the handler doing one thing: write bit 5 of GPIOA’s bit set/reset register, which lights LD2 and leaves it lit. That write works because PA5 is a push-pull output, and in that mode the port’s output register is what drives the pad.
Step 6 changed the mode, and in alternate mode the pad belongs to the peripheral. The BSRR write still lands in the register — the register does not know the mode changed — but nothing carries it to the pin, and a panic would freeze the board with the LED at whatever brightness the breath happened to be at, or dark. So the handler rewrites the mode bits first: PA5 back to a plain output, then the BSRR write that has worked since step 4. Mode bits are the handler’s to rewrite. Nothing else is running, and the pin is still the handler’s to claim.
Build it and run it
Section titled “Build it and run it”cargo build Compiling time v0.3.55 Compiling syn v2.0.119 Compiling syn v3.0.9 Compiling cortex-m-rt-macros v0.7.7 Compiling enumflags2_derive v0.7.12 Compiling enumflags2 v0.7.12 Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32) Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.40sThat is a cold build’s tail: the dependency closure, then the firmware itself. Cargo compiles dependencies in parallel, so the order of the middle lines varies between runs. No --target — .cargo/config.toml pins it, as it has since step 2. The path after the package name is this machine’s project directory, and the build time is this machine’s too.
With the board plugged in:
cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`Error: No connected probes were found.The last line is this machine again: no board attached, the same error a loose cable gives. Plug the Nucleo in and the same command writes the image, resets the chip and starts it. What the board does then: LD2 fades up over half a second, fades back down, and repeats. B1 pauses the breath mid-ramp and holds the LED dark; the next press starts the ramp again from dark.
Pitfalls
Section titled “Pitfalls”The channel nobody enabled
Section titled “The channel nobody enabled”The channel’s output starts disabled, and with() does not change that: it connects the pin and sets the compare mode, but the output-enable bit is enable()’s job. Forget it and every symptom points at the wrong place — the duty writes succeed, the compare register holds the right number, the timer’s counter is running — and the LED stays dark. If a PWM pin does nothing, check the enable before the maths.
The duty scale is ARR plus one, not ARR
Section titled “The duty scale is ARR plus one, not ARR”max_duty_cycle() returns the auto-reload value plus one — 16000 at one kilohertz on 16 MHz — because the counter runs from 0 to ARR inclusive. Write that number and the pin is high for the whole period. The percent method divides by it, which is the easy way to be right; the raw way needs the number from max_duty_cycle(), not from a formula. The HAL also documents a wrap in that getter: a full scale of exactly 65536 comes back as 0. The default clock configuration keeps this firmware at 16000, comfortably inside a u16, but a faster clock with a low PWM frequency can walk into the wrap.
A clock change moves the resolution, not the frequency
Section titled “A clock change moves the resolution, not the frequency”The HAL works the prescaler and auto-reload out from the timer’s real input clock, so the one-kilohertz period survives a change to the clock configuration. What changes is how many counts full scale is: move the core to a PLL-derived 84 MHz and the same kilohertz needs 84000 ticks, which is more than a u16 holds — and the trait’s duty type is u16. The firmware’s percent calls hide that, right up to the point where the hardware has more resolution than the trait can carry.
Where the code lives
Section titled “Where the code lives”The project lives in embedded-stm32/ in the examples repository, one commit and one tag per article. This article’s end state is stm32-ch06:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch06cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
Step 7, Talking to an I2C sensor, reads a BMP280 over I2C — what changes when the thing on the other end of the bus has an address of its own. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.