STM32 + Embassy(Rust): Controlling an RGB LED with Hardware PWM

@demiplus.com

日本語

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.

STM32F411 Black Pill: RGB LED control

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

PartQuantityNotes
RGB LED (common cathode)15mm through-hole
Resistor31kΩ each
Breadboard1
Jumper wiresas 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.

GPIOTimerColorNotes
PB6TIM4_CH1RedPWM output
PB7TIM4_CH2GreenPWM output
PB8TIM4_CH3BluePWM output
GND--LED common cathode
  • On the software side, it's treated as active high (higher duty cycle = brighter)

STM32F411 Black Pill: RGB LED wiring diagram


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/PB8 are each mapped to the alternate function TIM4_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 SimplePwm via PwmPin::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 like new_ch1/new_ch2, a single new creates the pin, and the argument position in SimplePwm::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 be enable()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.0 values.
  • On the other hand, on FPU-less Cortex-M0/M0+ (such as the RP2040), f32 operations 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 the libm (or micromath) 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 hue by 1 (% 360 to 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_duty can exceed u16::MAX, so it's computed in u32 (e.g. 255 × 65535 = 16,711,425).
    • set_duty_cycle() takes a u32, so no cast to u16 is needed.
  • 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

References


Related Articles

demiplus.com
demiplus

@demiplus.com

Robot, Art, Games
white wind blog -> https://whtwnd.com/demiplus.com

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)