概要
前回はDocker上にEmbassy開発環境を構築してLED点滅プログラムを動かしました。今回はハードウェアPWMを使ってRGB LEDのコントロールを実装してみます。
前提:
- 前回の記事で環境構築済み(Ubuntu 24.04 + Docker)
- STM32F411 Black Pill + picoprobe(Raspberry Pi Pico)
1. ハードウェア準備
1.1 使った部品
| 部品 | 数量 | 備考 |
|---|---|---|
| RGB LED(コモンカソード) | 1 | 5mm砲弾型 |
| 抵抗 | 3 | 各 1kΩ |
| ブレッドボード | 1 | |
| ジャンパワイヤ | 適量 |
抵抗値は実験を簡単にするためRGBで同一の値を使用しました。実用的には各色成分の輝度を調整するのがよいと思われます(赤は順方向電圧が低いため大きめの値にするなど)。
1.2 配線
コモンカソードタイプのRGB LEDを使い、各色のアノードをTIM4の3チャンネル(PB6/PB7/PB8)に接続します。共通カソードはGNDへ。
| GPIO | タイマー | 色 | 備考 |
|---|---|---|---|
| PB6 | TIM4_CH1 | Red | PWM出力 |
| PB7 | TIM4_CH2 | Green | PWM出力 |
| PB8 | TIM4_CH3 | Blue | PWM出力 |
| GND | - | - | 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.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();
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_dutyはu16::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でモーター制御をやりたいと思います