Overview
In the previous article, I built an Embassy development environment on Docker and ran an LED blink program. This time, I'll implement RGB LED control using hardware PWM.
Prerequisites:
- Environment already set up in the previous article (Ubuntu 24.04 + Docker)
- STM32F411 Black Pill + picoprobe (Raspberry Pi Pico)
1. Hardware Preparation
1.1 Parts Used
| Part | Quantity | Notes |
|---|---|---|
| RGB LED (common cathode) | 1 | 5mm through-hole |
| Resistor | 3 | 1kΩ each |
| Breadboard | 1 | |
| Jumper wires | as needed |
To keep the experiment simple, I used the same resistor value for R, G, and B. In practice, it's better to tune the value per color channel (e.g. a larger value for red, since it has a lower forward voltage).
1.2 Wiring
I used a common-cathode RGB LED, connecting each color's anode to one of TIM4's three channels (PB6/PB7/PB8). The shared cathode goes to GND.
| GPIO | Timer | Color | Notes |
|---|---|---|---|
| PB6 | TIM4_CH1 | Red | PWM output |
| PB7 | TIM4_CH2 | Green | PWM output |
| PB8 | TIM4_CH3 | Blue | PWM output |
| GND | - | - | LED common cathode |
- On the software side, it's treated as active high (higher duty cycle = brighter)
2. Pin Configuration (PWM)
Since I want to control the three RGB colors independently with PWM, I chose a timer that can output three or more channels from a single timer.
- Why TIM4: STM32F411's TIM4 is a general-purpose timer with CH1–CH4, and
PB6/PB7/PB8are each mapped to the alternate functionTIM4_CH1/CH2/CH3. Grouping all three colors into one timer keeps the wiring and code simple. - GPIO Alternate Function: In addition to general-purpose I/O, STM32 GPIOs have alternate functions that output peripheral signals such as timers or UART. Which pin maps to which function is defined by the Alternate Function mapping in the datasheet. With embassy-stm32, when you pass a pin to
SimplePwmviaPwmPin::new(p.PB6, ...), the HAL configures the corresponding AF setting internally, so there's no need to touch registers directly.
3. Creating the Project
3.1 Directory Structure
Using the same structure as the previous led-blink, I created a new color-led/ under projects/.
projects/
├── led-blink/ # previous
└── color-led/ # created this time
├── .cargo/
│ └── config.toml
├── Cargo.toml
├── rust-toolchain.toml
└── src/
└── main.rs
.cargo/config.toml and rust-toolchain.toml can be reused as-is from led-blink (picoprobe runner, flip-link, thumbv7em-none-eabihf target, etc. — see "5. Project Structure" in the previous article).
3.2 Cargo.toml
The dependency crates are identical to the previous led-blink; you only change name to color-led. PWM is included in the embassy-stm32 HAL, so no additional dependencies are needed.
[package]
name = "color-led"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread"] }
embassy-time = { version = "0.5", features = ["tick-hz-32_768"] }
embassy-stm32 = { version = "0.5", features = [
"stm32f411ce",
"time-driver-any",
"memory-x",
]}
defmt = "1.0"
defmt-rtt = "1.0"
panic-probe = { version = "1.0", features = ["print-defmt"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
[profile.dev]
opt-level = 1
[profile.release]
debug = 2
lto = true
opt-level = "s"
codegen-units = 1 # single unit + LTO for maximum optimization
4. Implementation
4.1 Full Code
//! RGB LED Rainbow Effect using Embassy on STM32F411CE (Black Pill)
//!
//! GPIO Configuration (active high, common cathode RGB LED):
//! - PB6 = Red (TIM4_CH1)
//! - PB7 = Green (TIM4_CH2)
//! - PB8 = Blue (TIM4_CH3)
//! - PC13 = Status LED (active low)
#![no_std]
#![no_main]
use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_stm32::time::Hertz;
use embassy_stm32::timer::low_level::CountingMode;
use embassy_stm32::timer::simple_pwm::{PwmPin, SimplePwm};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
/// Convert HSV to RGB
///
/// # Arguments
/// * `hue` - Hue value (0-359)
/// * `sat` - Saturation (0-255)
/// * `val` - Value/Brightness (0-255)
///
/// # Returns
/// (r, g, b) tuple with values 0-255
fn hsv_to_rgb(hue: u16, sat: u8, val: u8) -> (u8, u8, u8) {
if sat == 0 {
return (val, val, val);
}
let region = hue / 60;
let remainder = ((hue % 60) as u16 * 255 / 60) as u8;
let p = ((val as u16 * (255 - sat as u16)) / 255) as u8;
let q = ((val as u16 * (255 - (sat as u16 * remainder as u16) / 255)) / 255) as u8;
let t = ((val as u16 * (255 - (sat as u16 * (255 - remainder as u16)) / 255)) / 255) as u8;
match region {
0 => (val, t, p),
1 => (q, val, p),
2 => (p, val, t),
3 => (p, q, val),
4 => (t, p, val),
_ => (val, p, q),
}
}
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let config = embassy_stm32::Config::default();
let p = embassy_stm32::init(config);
info!("Embassy STM32F4 Color LED Rainbow started!");
// PC13: onboard status LED (active low)
let mut status_led = Output::new(p.PC13, Level::High, Speed::Low);
// Setup TIM4 PWM for RGB LED
// PB6 = TIM4_CH1 (Red)
// PB7 = TIM4_CH2 (Green)
// PB8 = TIM4_CH3 (Blue)
let ch1_pin = PwmPin::new(p.PB6, embassy_stm32::gpio::OutputType::PushPull);
let ch2_pin = PwmPin::new(p.PB7, embassy_stm32::gpio::OutputType::PushPull);
let ch3_pin = PwmPin::new(p.PB8, embassy_stm32::gpio::OutputType::PushPull);
let pwm = SimplePwm::new(
p.TIM4,
Some(ch1_pin),
Some(ch2_pin),
Some(ch3_pin),
None,
Hertz::khz(1), // 1kHz PWM frequency
CountingMode::EdgeAlignedUp,
);
// Split PWM into individual channels
let mut channels = pwm.split();
let max_duty = channels.ch1.max_duty_cycle();
info!("PWM max duty cycle: {}", max_duty);
// Enable PWM channels
channels.ch1.enable();
channels.ch2.enable();
channels.ch3.enable();
let mut hue: u16 = 0;
let mut toggle_counter: u8 = 0;
loop {
// Convert HSV to RGB (full saturation and brightness)
let (r, g, b) = hsv_to_rgb(hue, 255, 255);
// Scale 0-255 to 0-max_duty. Compute in u32 to avoid overflow in the
// intermediate product (255 * 65535 = 16_711_425 > u16::MAX)
let duty_r = r as u32 * max_duty as u32 / 255;
let duty_g = g as u32 * max_duty as u32 / 255;
let duty_b = b as u32 * max_duty as u32 / 255;
channels.ch1.set_duty_cycle(duty_r);
channels.ch2.set_duty_cycle(duty_g);
channels.ch3.set_duty_cycle(duty_b);
// Increment hue for rainbow effect
hue = (hue + 1) % 360;
// Toggle status LED every ~1 second (20 * 50ms)
toggle_counter += 1;
if toggle_counter >= 20 {
status_led.toggle();
toggle_counter = 0;
info!("Hue: {}, RGB: ({}, {}, {})", hue, r, g, b);
}
// Update every 50ms for smooth color transition
Timer::after_millis(50).await;
}
}
4.2 PWM Initialization
PwmPin::new(...) prepares each pin as a PWM output pin, and SimplePwm::new(...) passes all four channels to TIM4 at once (used channels as Some, unused as None).
// Create a PWM pin for each color (PushPull output)
let ch1_pin = PwmPin::new(p.PB6, embassy_stm32::gpio::OutputType::PushPull);
let ch2_pin = PwmPin::new(p.PB7, embassy_stm32::gpio::OutputType::PushPull);
let ch3_pin = PwmPin::new(p.PB8, embassy_stm32::gpio::OutputType::PushPull);
let pwm = SimplePwm::new(
p.TIM4,
Some(ch1_pin), // CH1: Red
Some(ch2_pin), // CH2: Green
Some(ch3_pin), // CH3: Blue
None, // CH4: unused
Hertz::khz(1), // PWM frequency 1kHz
CountingMode::EdgeAlignedUp,
);
// Split into channels to operate them individually
let mut channels = pwm.split();
let max_duty = channels.ch1.max_duty_cycle();
channels.ch1.enable();
channels.ch2.enable();
channels.ch3.enable();
PwmPin::new(...): Rather than separate per-channel functions likenew_ch1/new_ch2, a singlenewcreates the pin, and the argument position inSimplePwm::new(2nd–5th arguments) determines CH1–CH4.SimplePwm::new(...): Specifies the timer (TIM4), each channel's pin, the PWM frequency (1kHz), and the counting mode (EdgeAlignedUp).pwm.split(): Splits the jointly-initialized PWM into individual channels (ch1/ch2/ch3/ch4). From there, each channel can beenable()d /set_duty_cycle()d independently.max_duty_cycle(): The maximum duty value for that timer configuration (= 100%). Used in the duty-cycle calculation.
4.3 HSV→RGB Conversion
By changing only the hue little by little, the color smoothly cycles through a rainbow.
- About the HSV color space:
- H: Hue (0-359 degrees)
- S: Saturation (0-255)
- V: Value/Brightness (0-255)
- Here it's implemented with integer arithmetic only, without floating point (so it stays lightweight even when ported to MCUs without an FPU). The hue is divided into six regions of 60 degrees each, and the interpolation within a region is computed via
p/q/t.
fn hsv_to_rgb(hue: u16, sat: u8, val: u8) -> (u8, u8, u8) {
if sat == 0 {
return (val, val, val);
}
let region = hue / 60;
let remainder = ((hue % 60) as u16 * 255 / 60) as u8;
let p = ((val as u16 * (255 - sat as u16)) / 255) as u8;
let q = ((val as u16 * (255 - (sat as u16 * remainder as u16) / 255)) / 255) as u8;
let t = ((val as u16 * (255 - (sat as u16 * (255 - remainder as u16)) / 255)) / 255) as u8;
match region {
0 => (val, t, p),
1 => (q, val, p),
2 => (p, val, t),
3 => (p, q, val),
4 => (t, p, val),
_ => (val, p, q),
}
}
Note: Floating-Point Version
The version above is written with integer arithmetic only, anticipating portability to non-FPU environments. However, the STM32F411 is a Cortex-M4F with a built-in hardware FPU, so f32 arithmetic executes in a single cycle. The Black Pill's flash capacity (512KB) also has plenty of headroom, so as long as you run on the F411, writing it in f32 for readability poses almost no cost in size or performance.
For reference, here's the same conversion written in floating point (functionally equivalent to the integer version). Since it can express the HSV definitions (p = v(1-s), q = v(1-fs), t = v(1-(1-f)s)) directly, it's more readable than the integer version.
fn hsv_to_rgb_f32(hue: u16, sat: u8, val: u8) -> (u8, u8, u8) {
let s = sat as f32 / 255.0;
let v = val as f32 / 255.0;
let region = (hue / 60) as u8;
let f = (hue % 60) as f32 / 60.0;
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
let (r, g, b) = match region {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}
- The parts that were scaled by ×255 / ÷255 in the integer version become straightforward calculations on normalized
0.0–1.0values. - On the other hand, on FPU-less Cortex-M0/M0+ (such as the RP2040),
f32operations are emulated in software, increasing both code size and execution time. When porting to such targets, the integer version is advantageous. - If you use transcendental functions like
sin/cos, you'd need thelibm(ormicromath) crate separately, but here only basic arithmetic is used, so it's not needed.
4.4 Main Loop
loop {
let (r, g, b) = hsv_to_rgb(hue, 255, 255);
// Scale 0-255 to 0-max_duty. Compute in u32 to avoid overflow in the intermediate product
let duty_r = r as u32 * max_duty as u32 / 255;
let duty_g = g as u32 * max_duty as u32 / 255;
let duty_b = b as u32 * max_duty as u32 / 255;
channels.ch1.set_duty_cycle(duty_r);
channels.ch2.set_duty_cycle(duty_g);
channels.ch3.set_duty_cycle(duty_b);
hue = (hue + 1) % 360;
// Toggle the status LED every ~1 second (20 * 50ms)
toggle_counter += 1;
if toggle_counter >= 20 {
status_led.toggle();
toggle_counter = 0;
info!("Hue: {}, RGB: ({}, {}, {})", hue, r, g, b);
}
Timer::after_millis(50).await;
}
- Increment the hue
hueby 1 (% 360to wrap around). - Scale the 0-255 RGB values obtained from HSV to the duty cycle according to the PWM's
max_duty.- The intermediate product
r(max 255) * max_dutycan exceedu16::MAX, so it's computed inu32(e.g. 255 × 65535 = 16,711,425). set_duty_cycle()takes au32, so no cast tou16is needed.
- The intermediate product
- The status LED (PC13) toggles every 20 loops (~1 second), outputting the hue and RGB values to the defmt log.
- Updating every 50ms produces a smooth color transition.
5. Build and Run
# Start the container
docker compose run --rm embassy-dev
# Build & run (flashing completes in one shot when picoprobe is connected)
cd color-led
cargo run --release
Result:
- The RGB LED smoothly cycles through rainbow colors
- The PC13 status LED blinks every second
- RGB values are printed to the log
6. Summary
- Controlled an RGB LED with hardware PWM
- Used three TIM4 channels simultaneously
- Using the HSV color space makes smooth color transitions easy to implement
What I'd Like to Try Next
- Next time, I want to add a driver IC to the circuit and do motor control with PWM