STM32 + Embassy(Rust) でRGB LEDをPWM制御する

By demiplus (@demiplus.com)
Published:

English

概要

前回はDocker上にEmbassy開発環境を構築してLED点滅プログラムを動かしました。今回はハードウェアPWMを使ってRGB LEDのコントロールを実装してみます。

STM32F411 Black Pill: カラーLED制御

前提:


1. ハードウェア準備

1.1 使った部品

| 部品 | 数量 | 備考 | |------|------|------| | RGB LED(コモンカソード) | 1 | 5mm砲弾型 | | 抵抗 | 3 | 各 1kΩ | | ブレッドボード | 1 | | | ジャンパワイヤ | 適量 | |

抵抗値は実験を簡単にするためRGBで同一の値を使用しました。実用的には各色成分の輝度を調整するのがよいと思われます(赤は順方向電圧が低いため大きめの値にするなど)。

1.2 配線

コモンカソードタイプのRGB LEDを使い、各色のアノードをTIM4の3チャンネル(PB6/PB7/PB8)に接続します。共通カソードはGNDへ。

| GPIO | タイマー | 色 | 備考 | |------|---------|-----|------| | PB6 | TIM4CH1 | Red | PWM出力 | | PB7 | TIM4CH2 | Green | PWM出力 | | PB8 | TIM4CH3 | Blue | PWM出力 | | GND | - | - | LED共通カソード |

STM32F411 Black Pill: カラーLED配線図


2. ピン設定 (PWM)

RGBの3色をそれぞれ独立にPWM制御したいので、1つのタイマーで3チャンネル以上を出力できるものを選びます。


3. プロジェクト作成

3.1 ディレクトリ構成

前回の led-blink と同じ構成で、projects/ の下に color-led/ を新規作成します。

projects/
├── led-blink/      # 前回
└── color-led/      # 今回作成
    ├── .cargo/
    │   └── config.toml
    ├── Cargo.toml
    ├── rust-toolchain.toml
    └── src/
        └── main.rs

.cargo/config.toml と rust-toolchain.toml は led-blink のものをそのまま流用できます(picoprobeランナー・flip-link・thumbv7em-none-eabihf ターゲット指定など。前回記事「5. プロジェクトの構成」を参照)。

3.2 Cargo.toml

依存クレートは前回の led-blink と同一で、name を color-led に変えるだけです。PWMは embassy-stm32 のHALに含まれるため、追加の依存は不要です。

[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 で最大限の最適化


4. コード実装

4.1 コード全文

//! 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初期化部分

PwmPin::new(...) で各ピンをPWM出力ピンとして用意し、SimplePwm::new(...) でTIM4に4チャンネル分(使用チャンネルを Some、未使用は None)まとめて渡します。

// 各色のPWMピンを作成(PushPull出力)
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: 未使用
    Hertz::khz(1),   // PWM周波数 1kHz
    CountingMode::EdgeAlignedUp,
);

// チャンネルごとに分割して操作する
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変換

色相(Hue)だけを少しずつ変えていくと、色が滑らかに虹色に変化します。

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

備考: 浮動小数点版

上記は非FPU環境への移植も見据えて整数演算だけで書いていますが、STM32F411 は Cortex-M4F でハードウェアFPUを内蔵しており、f32 の四則演算は単サイクルで実行できます。Black Pill のフラッシュ容量(512KB)にも余裕があるため、F411 上で動かす限りは可読性を優先して f32 で書いても、容量・パフォーマンス上のコストはほとんど問題になりません。

参考として、同じ変換を浮動小数点で書くと以下のようになります(整数版と機能的に等価)。HSVの定義式(p = v(1-s)、q = v(1-fs)、t = v(1-(1-f)s))をそのまま書き下せるため、整数版よりも可読性が良くなります。

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 メインループ

loop {
    let (r, g, b) = hsv_to_rgb(hue, 255, 255);

    // 0-255 を 0-max_duty にスケール。中間積でのオーバーフローを避けるため u32 で計算
    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;

    // 約1秒ごと(20 * 50ms)にステータスLEDをトグル
    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. ビルドと実行

# コンテナ起動
docker compose run --rm embassy-dev

# ビルド&実行(picoprobe接続時は一発でフラッシュまで完了)
cd color-led
cargo run --release

動作結果:


6. まとめ

今後やってみたいこと


参考にしたサイト


関連記事