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

By demiplus (@demiplus.com)
Published:

日本語

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:


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 | TIM4CH1 | Red | PWM output | | PB7 | TIM4CH2 | Green | PWM output | | PB8 | TIM4CH3 | Blue | PWM output | | GND | - | - | LED common cathode |

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.


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();

4.3 HSV→RGB Conversion

By changing only the hue little by little, the color smoothly cycles through a rainbow.

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)
}

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;
}


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:


6. Summary

What I'd Like to Try Next


References


Related Articles