0x04 PWM Rust Driver
This repository contains a Bare-Metal Rust driver for Pulse Width Modulation (PWM) on the RP2350 (and RP2040) microcontrollers.
It includes:
- A thin demo (
src/main.rs) that slowly breathes an LED (fades it in and out) and prints the duty cycle percentage over UART. - A reusable library module (
src/pwm.rs) providing a hardware-agnosticpwm_libwith helper math to calculate clock dividers and level targets. - Board initialization logic (
src/board.rs).
🚀 Getting Started from Scratch
If you're starting with a fresh machine, follow these exact steps to install the toolchain, build the code, and flash it to your microcontroller.
1. Install Rust
First, install rustup (the Rust toolchain installer) if you haven't already:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Note: Restart your terminal or run source $HOME/.cargo/env after this finishes.
Ensure your Rust compiler is up to date:
rustup update
2. Install the Target Architecture
This project is configured for the RP2350 (ARM Cortex-M33). We need to install the cross-compilation target for it:
rustup target add thumbv8m.main-none-eabihf
(If you were targeting the RP2040, you would use thumbv6m-none-eabi instead).
3. Install Build Tools
You will need a few extra tools to help link and format the firmware for the RP-series chips.
Install flip-link (adds zero-cost stack overflow protection):
cargo install flip-link
Install picotool (used by cargo run to flash the chip):
- macOS:
brew install picotool - Linux/Windows: Follow the official Raspberry Pi documentation to install
picotoolor build it from source.
4. Building the Code
To compile the code for the microcontroller, simply run:
cargo build
To build a highly optimized release version (smaller and faster):
cargo build --release
5. Flashing to the Microcontroller
This project is pre-configured in .cargo/config.toml to use picotool as the custom runner.
To flash the code:
- Hold down the BOOTSEL button on your RP2350 board.
- Plug it into your computer via USB (or press the RUN/RESET button while holding BOOTSEL).
- Run the following command:
cargo run --release
cargo will compile the code and automatically use picotool to upload the .elf file directly to your board and start executing it!
6. Testing on the Host
Because the PWM math logic is separated into a reusable library, you can run the unit tests natively on your computer (no microcontroller required!).
However, because this project sets a default bare-metal target (thumbv8m.main-none-eabihf) in .cargo/config.toml, running a plain cargo test will fail because the standard library doesn't exist on the microcontroller. You must explicitly tell Cargo to compile the tests for your host computer's processor architecture:
Mac (Apple Silicon):
cargo test --lib --target aarch64-apple-darwin
Linux (Intel/AMD 64-bit):
cargo test --lib --target x86_64-unknown-linux-gnu
Windows (64-bit):
cargo test --lib --target x86_64-pc-windows-msvc
🧠 Code Walkthrough
This section explains exactly how the code works, where the entry point is, and traces the flow of execution as if you were stepping through it line-by-line.
1. The Entry Point (src/main.rs)
Unlike a standard computer program, bare-metal microcontrollers do not have an operating system to call main(). Instead, we use the #[entry] macro from the HAL (Hardware Abstraction Layer) to define the very first function that runs after the chip boots up.
main() -> !: This is the absolute start of our code. It takes ownership of all the hardware peripherals (hal::pac::Peripherals::take().unwrap()) and immediately passes them intoboard::run(...). The-> !means this function never returns (because embedded devices run in an infinite loop).
2. Board Initialization (src/board.rs)
Once execution enters board.rs, we need to wake up the specific hardware subsystems we want to use (Clocks, Pins, UART for printing, SysTick for delay, and PWM).
run(...): The master setup function. It sequentially calls the helper initialization functions below, and then kicks off the PWM sweep loop.init_clocks(...): Wakes up the external 12 MHz crystal (XOSC) and configures the PLLs (Phase-Locked Loops) to drive the system clock at its maximum speed.init_pins(...): Takes control of physical pins acrossIO_BANK0.init_uart(...): Configures the hardwareUART0peripheral to operate at a standard115200baud rate with an8N1configuration for debug printing.init_delay(...): Captures the ARM Cortex-MSYST(SysTick) peripheral to create a blocking delay timer.init_pwm(...): ObtainsPWM Slice 4and itschannel_bmapped toGPIO 25(the onboard LED). Sets up the clock divider and TOP value (wrap) to give us a 1 kHz frequency.pwm_loop(...): Loops infinitely, sweeping the LED duty cycle up and then down, simulating a breathing effect.sweep_up(...)/sweep_down(...): Walks through duty cycle percentages0to100and back down in steps of 5%.apply_duty(...): Computes the hardware register level usingpwm_lib, sets the new duty cycle, formats aDuty: XX%string without heap allocation, transmits it, and delays 50ms.
3. The Reusable PWM Math Library (src/pwm.rs)
Instead of burying math inside initialization functions, src/pwm.rs extracts pure mathematical logic that's decoupled from hardware structs so we can thoroughly unit-test it natively.
calc_clk_div(...): Calculates the precise floating-point clock divider required to step down the fast system clock so the counter overflows exactlyfreq_hztimes per second.clamp_percent(...): Constrains duty cycle percentages between0and100.duty_to_level(...): Translates a high-level percentage (e.g.,50%) into the raw integer counter compare-value (e.g.,5000out of10000) for the hardware registers.