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

@demiplus.com

English

概要

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

STM32F411 Black Pill: カラーLED制御

前提:

  • 前回の記事で環境構築済み(Ubuntu 24.04 + Docker)
  • STM32F411 Black Pill + picoprobe(Raspberry Pi Pico)

1. ハードウェア準備

1.1 使った部品

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

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

1.2 配線

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

GPIOタイマー備考
PB6TIM4_CH1RedPWM出力
PB7TIM4_CH2GreenPWM出力
PB8TIM4_CH3BluePWM出力
GND--LED共通カソード
  • ソフトウェア側は アクティブハイ(デューティ比が高いほど明るい)として扱う

STM32F411 Black Pill: カラーLED配線図


2. ピン設定 (PWM)

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

  • TIM4の選択理由: STM32F411のTIM4は CH1〜CH4 を持つ汎用タイマーで、PB6/PB7/PB8 がそれぞれ TIM4_CH1/CH2/CH3 の代替機能に割り当てられている。3色を1タイマーにまとめられるため配線・コードがシンプルになる。
  • GPIO Alternate Function(代替機能): STM32のGPIOは「汎用入出力」のほか、タイマーやUARTなどのペリフェラル信号を出力する代替機能を持つ。どのピンがどの機能に対応するかはデータシートの Alternate Function マッピングで決まっている。embassy-stm32 では PwmPin::new(p.PB6, ...) のようにピンを SimplePwm に渡すと、HALが内部で対応するAF設定を行ってくれるため、レジスタを直接触る必要はない。

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.tomlrust-toolchain.tomlled-blink のものをそのまま流用できます(picoprobeランナー・flip-linkthumbv7em-none-eabihf ターゲット指定など。前回記事「5. プロジェクトの構成」を参照)。

3.2 Cargo.toml

依存クレートは前回の led-blink と同一で、namecolor-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();
  • PwmPin::new(...): チャンネルごとに new_ch1/new_ch2 のような別関数ではなく、共通の new でピンを生成し、SimplePwm::new の引数位置(第2〜5引数)でCH1〜CH4が決まる
  • SimplePwm::new(...): タイマー(TIM4)、各チャンネルのピン、PWM周波数(1kHz)、カウントモード(EdgeAlignedUp)を指定
  • pwm.split(): まとめて初期化したPWMをチャンネル単位(ch1/ch2/ch3/ch4)に分割。以降は各チャンネルを独立に enable() / set_duty_cycle() できる
  • max_duty_cycle(): そのタイマー設定での最大デューティ値(=100%相当)。デューティ比の計算に使う

4.3 HSV→RGB変換

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

  • HSV色空間について
    • H: 色相(0-359度)
    • S: 彩度(0-255)
    • V: 明度(0-255)
  • ここでは浮動小数点を使わず、整数演算だけで実装(FPUを持たないマイコンへ移植しても軽量に動くようにするため)。色相を60度ごとの6領域に分け、領域内の補間を 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),
    }
}

備考: 浮動小数点版

上記は非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)
}
  • 整数版では 255倍・255除算でスケーリングしていた部分が、0.0〜1.0 の正規化された値での素直な計算になる
  • 一方、FPUを持たない Cortex-M0/M0+(RP2040 など)では f32 演算がソフトウェアエミュレーションとなり、コード量・実行時間ともに増える。そうしたターゲットへ移植する場合は整数版が有利
  • sin/cos などの超越関数を使う場合は別途 libm(または micromath)クレートが必要になるが、ここでは四則演算のみなので不要

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;
}
  • 色相 hue を1ずつインクリメント(% 360 で一周)
  • HSVから得た 0-255 のRGB値を、PWMの max_duty に合わせてデューティ比へスケール
    • 中間積 r(最大255) * max_dutyu16::MAX を超えうるため u32 で計算する(例: 255 × 65535 = 16,711,425)
    • set_duty_cycle()u32 を受け取るので、u16 へのキャストは不要
  • ステータスLED(PC13)は20ループ(約1秒)ごとにトグルし、defmtログにHueとRGB値を出力
  • 50msごとに更新することで滑らかな色変化になる

5. ビルドと実行

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

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

動作結果:

  • RGB LEDが虹色に滑らかに変化
  • PC13のステータスLEDが1秒ごとに点滅
  • ログにRGB値が出力される

6. まとめ

  • ハードウェアPWMでRGB LEDを制御できた
  • TIM4の3チャンネルを同時に使用
  • HSV色空間を使うと滑らかな色変化が簡単に実装できる

今後やってみたいこと

  • 次回はドライバICを回路に追加してPWMでモーター制御をやりたいと思います

参考にしたサイト


関連記事

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)