0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

STM32 + Embassy でRGB LEDコントロール

0
Last updated at Posted at 2026-06-27

概要

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

STM32F411 Blackpill: カラー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共通カソード
  • ソフトウェア側は アクティブハイ(デューティ比が高いほど明るい)として扱う

STM32F411 Blackpill: カラー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でモーター制御をやりたいと思います

参考にしたサイト


関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?