はじめに
前回の記事では、Embassyの基本的な使い方として、LEDの点滅(Lチカ)と並行処理を扱いました。
今回は、PWM(Pulse Width Modulation: パルス幅変調)を使った制御について解説します。PWMは、LEDの明るさ調整やモーターの速度制御など、組み込み開発において非常に重要な技術です。
使用したマイコンはNucleo STM32 F446REです。
今回使用したコードはembassy-introductionリポジトリにまとめておきました。
また、Embassyに関する情報がまだ少ないためか、AIに聞いても間違った解答が返ってくることが多いです。そこは注意してください。
参考資料
The Rust Programming Language 日本語版
Rustの日本語翻訳版ドキュメントです。一連の記事では3, 4, 5, 6, 9, 10, 18章の内容を扱います。
記事内でも少しは解説しますが、ドキュメントの完成度が非常に高いので読むことをおすすめします。
Embassy Book
全部英語で読むのが辛いですが、From bare metal to async Rust、System description
は読んでみることをおすすめします。
embassy
EmbassyのGitHubリポジトリ。examplesが非常に役に立ちました。
NUCLEO-F446RE
MbedによるNucleo F446REのページです。
STM32F446xC/E DataSheet
STMicroelectronicsによるNucleo F446xC/Eのデータシートです。
PWM
PWMは、デジタル信号のHigh/Lowの比率(デューティ比)を変えることで、疑似的にアナログ出力を実現する技術です。
例えば、LEDの明るさを調整する場合、
- デューティ比 0%: 常にLow → 消灯
- デューティ比 50%: High/Lowが半々 → 中間の明るさ
- デューティ比 100%: 常にHigh → 最大輝度
PWM信号の周波数が十分に高ければ、人間の目には滑らかな明るさの変化として認識されます。
コード全体
#![no_std]
#![no_main]
use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::{
gpio::OutputType,
peripherals::TIM2,
time::khz,
timer::simple_pwm::{PwmPin, SimplePwm, SimplePwmChannel},
};
use embassy_time::Timer;
// Panic handler. Don't remove.
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let simple_pwm = SimplePwm::new(
p.TIM2,
Some(PwmPin::new(p.PA5, OutputType::PushPull)),
None,
None,
None,
khz(10),
Default::default(),
);
let chs = simple_pwm.split();
spawner.spawn(pwm(chs.ch1)).unwrap();
}
#[embassy_executor::task]
async fn pwm(mut ch: SimplePwmChannel<'static, TIM2>) {
ch.enable();
let max_duty_cycle = ch.max_duty_cycle();
let step = (max_duty_cycle / 5) as usize;
info!("PWM max duty {}", max_duty_cycle);
loop {
for duty_cycle in (0..=max_duty_cycle).step_by(step) {
ch.set_duty_cycle(duty_cycle);
info!("{}", ch.current_duty_cycle());
Timer::after_millis(300).await;
}
}
}
ビルドと実行
前回と同様に、以下のコマンドまたはrust-analyzerのRunボタンで実行してください。
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Running `probe-rs run --chip STM32F446RE --connect-under-reset target/thumbv7em-none-eabihf/debug/pwm`
Erasing ✔ 100% [####################] 128.00 KiB @ 51.89 KiB/s (took 2s)
Programming ✔ 100% [####################] 89.00 KiB @ 38.73 KiB/s (took 2s) Finished in 4.77s
0.000000 [TRACE] BDCR ok: 00008200 (embassy_stm32 src/rcc/bd.rs:221)
0.000000 [DEBUG] flash: latency=0 (embassy_stm32 src/rcc/f247.rs:264)
0.000000 [DEBUG] rcc: Clocks { hclk1: MaybeHertz(16000000), hclk2: MaybeHertz(16000000), hclk3: MaybeHertz(16000000), hse: MaybeHertz(0), hsi: MaybeHertz(16000000), lse: MaybeHertz(0), lsi: MaybeHertz(0), pclk1: MaybeHertz(16000000), pclk1_tim: MaybeHertz(16000000), pclk2: MaybeHertz(16000000), pclk2_tim: MaybeHertz(16000000), pll1_q: MaybeHertz(0), pll1_r: MaybeHertz(0), plli2s1_p: MaybeHertz(0), plli2s1_q: MaybeHertz(0), plli2s1_r: MaybeHertz(0), pllsai1_q: MaybeHertz(0), rtc: MaybeHertz(32000), sys: MaybeHertz(16000000) } (embassy_stm32 src/rcc/mod.rs:71)
0.001220 [INFO ] PWM max duty 1600 (pwm src/bin/pwm.rs:42)
0.002288 [INFO ] 0 (pwm src/bin/pwm.rs:47)
0.303619 [INFO ] 320 (pwm src/bin/pwm.rs:47)
0.605163 [INFO ] 640 (pwm src/bin/pwm.rs:47)
0.906707 [INFO ] 960 (pwm src/bin/pwm.rs:47)
1.208251 [INFO ] 1280 (pwm src/bin/pwm.rs:47)
1.509735 [INFO ] 1600 (pwm src/bin/pwm.rs:47)
1.811309 [INFO ] 0 (pwm src/bin/pwm.rs:47)
このように出力された上で、LEDが0.3秒ごとに段階的に明るくなり、最大輝度に達したらまた暗くなるのを繰り返せば成功です。
コードの解説
Rust初学者向け解説
Option<T>型とSome/None
Some(PwmPin::new(p.PA5, OutputType::PushPull)),
None,
None,
None,
Option<T>は、「値が存在するかしないか」を表現する型です。
-
Some(value): 値が存在する -
None: 値が存在しない
C++では、値の不在を表現するためにnullptrや特殊な値(-1など)を使うことがありますが、これは型システムで保護されていないため、バグの原因になりがちです。RustではOption型を使うことで、「値が存在しないかもしれない」ことを明示的に表せます。
let value: Option<i32> = Some(42);
// ❌ コンパイルエラー: Option<i32>はi32として直接使えない
// let x: i32 = value;
// ✅ OK: unwrapで取り出す
let x: i32 = value.unwrap();
info!("Value is {}", x);
// ✅ OK: パターンマッチで取り出す
let x: i32 = match value {
Some(v) => v,
None => defmt::panic!("No value"),
};
info!("Value is {}", x);
PWMの初期化では、4つのチャンネル全てに対してピンを指定する必要がありますが、使わないチャンネルにはNoneを指定します。
ジェネリクスと型パラメータ
async fn pwm(mut ch: SimplePwmChannel<'static, TIM2>) {
SimplePwmChannel<'static, TIM2>の<>内にある記述は、型パラメータです。C++のテンプレートに似た機能ですが、Rustではより厳密に型チェックが行われます。
-
'static: ライフタイム(前回解説) -
TIM2: 使用するタイマーペリフェラルの型
このように型パラメータを使うことで、コンパイル時に正しいタイマーとピンの組み合わせかどうかをチェックできます。例えば、TIM2のチャンネル1として定義したピンを、誤ってTIM3で使おうとするとコンパイルエラーになります。
rangeとstep_by
for duty_cycle in (0..=max_duty_cycle).step_by(step) {
-
0..=max_duty_cycle: 0からmax_duty_cycleまでの範囲(両端を含む) -
.step_by(step): 指定したステップ数ずつ進む
例えばmax_duty_cycleが65535の場合、stepが13107になり、
0 → 13107 → 26214 → 39321 → 52428 → 65535
のように、5段階でデューティ比が変化します。
SimplePwmの初期化
let simple_pwm = SimplePwm::new(
p.TIM2, // タイマー2を使用
Some(PwmPin::new(p.PA5, OutputType::PushPull)), // チャンネル1: PA5
None, // チャンネル2: 未使用
None, // チャンネル3: 未使用
None, // チャンネル4: 未使用
khz(10), // PWM周波数: 10kHz
Default::default(), // その他の設定はデフォルト
);
SimplePwm::newは、PWMタイマーを初期化します。引数は以下の通りです。
- タイマーペリフェラル: ここでは
TIM2を使用 - チャンネル1〜4のピン設定: 使用するチャンネルには
Some(PwmPin::new(...))、使わないチャンネルにはNoneを指定 - PWM周波数:
khz(10)で10kHzに設定。人間の目には見えない速さで点滅するため、滑らかな明るさ調整が可能 - 追加設定: ここではデフォルトを使用
ピンとタイマーの対応関係
前回の記事でも述べた通りEmbassyでは、タイマーのチャンネルとピンの対応関係が型システムで保証されています。
例えば、TIM2のチャンネル1はPA5ピンで使えますが、もし誤って別のタイマー用のピンを指定すると、コンパイル時にエラーが発生します。
// ✅ OK: TIM2のチャンネル1とPA5は対応している
SimplePwm::new(
p.TIM2,
Some(PwmPin::new(p.PA5, OutputType::PushPull)),
...
);
// ❌ コンパイルエラー: PA6はTIM2のチャンネル1として使えない
SimplePwm::new(
p.TIM2,
Some(PwmPin::new(p.PA6, OutputType::PushPull)), // PA6はTIM3用
...
);
チャンネルの分割: split()メソッド
let chs = simple_pwm.split();
spawner.spawn(pwm(chs.ch1)).unwrap();
split()メソッドは、SimplePwmを4つの独立したチャンネル(ch1〜ch4)に分割します。
なぜsplit()が必要なのか?
Rustの所有権システムでは、可変参照は同時に1つしか存在できません。そのため、simple_pwmの可変参照を使ってチャンネルを操作している間は、他のチャンネルを操作できません。
内部のコードを見てみましょう。EmbassyのSimplePwmには、チャンネルを取得するための2つの方法があります。
(VSCodeの場合、F12もしくはctrl + 左クリックでメソッドの定義に飛べます)
ch1()メソッド: 可変参照を使用
/// Channel 1
///
/// This is just a convenience wrapper around [`Self::channel`].
///
/// If you need to use multiple channels, use [`Self::split`].
pub fn ch1(&mut self) -> SimplePwmChannel<'_, T> {
self.channel(Channel::Ch1)
}
Rust初学者向け解説: `self`、`&self`、`&mut self`
Rustのメソッドは、第一引数として以下の3つのいずれかを取ります。
-
self(所有権の移動)- メソッド呼び出し後、元の変数は使用不可になる
- オブジェクトを消費して変換する場合などに使用
pub fn split(self) -> SimplePwmChannels<'static, T> { ... } // 呼び出し後、simple_pwmは使用不可 -
&self(不変参照)- 読み取り専用アクセス
- 複数の箇所から同時にアクセス可能
pub fn max_duty_cycle(&self) -> u16 { ... } // 何度でも呼び出せる -
&mut self(可変参照)- 読み書き可能だが、同時に1つしか存在できない
- 状態を変更するメソッドに使用
pub fn ch1(&mut self) -> SimplePwmChannel<'_, T> { ... } // 呼び出し中は他の可変参照を作れない
この制約により、データ競合をコンパイル時に防ぐことができます。
ch1()が&mut selfを取るため、他のチャンネルは同時に取得できません。
let mut simple_pwm = SimplePwm::new(...);
let mut ch1 = simple_pwm.ch1(); // simple_pwmの可変参照を借用
// ❌ コンパイルエラー: すでに可変参照が存在する
// let mut ch2 = simple_pwm.ch2();
もちろんch2()以外にもsimple_pwmの可変参照が必要なメソッドは使用できません。
let mut ch1 = simple_pwm.ch1();
// ❌ コンパイルエラー: すでに可変参照が存在する
// simple_pwm.set_frequency(khz(20));
ch1.enable();
// ch1のスコープが終わり次第、使えるようになる
// simple_pwm.set_frequency(khz(20));
タイマー全体の設定と周波数変更
&mut selfが必要な理由は、もう1つあります。SimplePwmにはタイマー全体に影響する設定を変更するメソッドがあり、これらも&mut selfを取ります。
/// Set PWM frequency.
///
/// Note: when you call this, the max duty value changes, so you will have to
/// call `set_duty` on all channels with the duty calculated based on the new max duty.
pub fn set_frequency(&mut self, freq: Hertz) {
// タイマーの周波数を変更
// これにより全チャンネルのmax_duty_cycleが変わる
}
周波数を変更すると、全てのチャンネルのデューティ比の基準値が変わります。例えば、周波数を10kHzから20kHzに変更すると、max_duty_cycleの値も変わるため、全チャンネルでset_dutyを呼び直す必要があります。
もし複数のチャンネルが同時にアクセス可能だと、以下のような問題が発生します。
// もし&mut selfなしで複数チャンネルにアクセスできたら...
let mut ch1 = simple_pwm.ch1();
let mut ch2 = simple_pwm.ch2();
ch1.set_duty_cycle(800); // 50%に設定(max_duty = 1600として)
// 別のスレッドやタスクで
simple_pwm.set_frequency(khz(20)); // 周波数変更 → max_dutyが変わる
// ❌ ch1のデューティ比が意図しない値になる!
&mut selfの制約により、周波数を変更している間は他のチャンネルへのアクセスが一切できないため、このような不整合を防げます。
split()による所有権の移動
一方、split()メソッドはself(所有権)を引数に取ります。
/// Splits a [`SimplePwm`] into four pwm channels.
///
/// This returns all four channels, including channels that
/// aren't configured with a [`PwmPin`].
// TODO: I hate the name "split"
pub fn split(self) -> SimplePwmChannels<'static, T>
where
// must be static because the timer will never be dropped/disabled
'd: 'static,
{
// without this, the timer would be disabled at the end of this function
let timer = ManuallyDrop::new(self.inner);
let ch = |channel| SimplePwmChannel {
timer: unsafe { timer.clone_unchecked() },
channel,
};
SimplePwmChannels {
ch1: ch(Channel::Ch1),
ch2: ch(Channel::Ch2),
ch3: ch(Channel::Ch3),
ch4: ch(Channel::Ch4),
}
}
// TODO: I hate the name "split" ![]()
![]()
![]()
Rust初学者向け解説
where句とトレイト境界
pub fn split(self) -> SimplePwmChannels<'static, T>
where
'd: 'static,
{
where句は、ジェネリクス型に対するトレイト境界を指定します。詳しくは次の記事で説明します。
'd: 'staticは、「ライフタイム'dは'staticでなければならない」という制約です。これにより、SimplePwmに渡されたペリフェラルが、プログラムの実行中ずっと有効であることを保証します。
where句を使わずに書くこともできますが、複雑な制約の場合はwhere句を使う方が読みやすくなります。
// where句なし(シンプルな場合)
pub fn new<T: Clone>(x: T) -> Self { ... }
// where句あり(複雑な場合)
pub fn complex<T, U>(x: T, y: U) -> Self
where
T: Display + Debug,
U: Clone + Send + 'static,
{ ... }
unsafeとは
Rustはメモリ安全性を保証する言語ですが、それには一定のコストがあります。特に、低レベルなハードウェア制御では、コンパイラの安全性チェックが邪魔になることがあります。
unsafeキーワードは、「ここからはプログラマが責任を持って安全性を保証する」ことを示します。unsafeブロック内では、通常は禁止されている以下の操作が可能になります。
- 生ポインタの参照外し
-
unsafe関数の呼び出し - 可変静的変数へのアクセス
-
unsafeトレイトの実装
timer: unsafe { timer.clone_unchecked() },
ここではclone_unchecked()というunsafe関数を呼び出しています。これは、通常のclone()とは異なり、所有権や借用規則のチェックをスキップして複製します。
なぜunsafeが必要かというと、ハードウェアレジスタへの複数の参照を作る必要があるからです。通常のRustの規則では、可変参照は1つしか存在できませんが、PWMの4つのチャンネルは全て同じタイマーハードウェアを参照します。
Embassyの開発者は、この操作が安全であることを確認した上でunsafeを使用しています。つまり、ライブラリ内部ではunsafeを使うが、ユーザーコードでは安全なAPIだけを使えるように設計されているのです。
`ManuallyDrop`とは
let timer = ManuallyDrop::new(self.inner);
通常、Rustでは変数がスコープを抜けると自動的にメモリが解放されます(デストラクタが呼ばれます)。しかし、ManuallyDropでラップすると、自動的な解放を防ぐことができます。
なぜこれが必要かというと、split()はSimplePwmの所有権を消費しますが、内部のタイマーハードウェアは4つのチャンネル全てで共有される必要があるからです。
もしManuallyDropを使わないと、split()関数の終わりでタイマーが解放され、返された4つのチャンネルが全て無効になってしまいます。
ManuallyDropを使うことで、「タイマーハードウェアはプログラムが終了するまで有効」という状態を維持できます。組み込み開発では、ハードウェアは通常プログラムの実行中ずっと使い続けるため、この動作は理にかなっています。
split()はSimplePwmの所有権を消費するため、呼び出し後はsimple_pwmという変数にアクセスできません。
let simple_pwm = SimplePwm::new(...);
let chs = simple_pwm.split();
// ✅ OK: 各チャンネルは独立している
let mut ch1 = chs.ch1;
let mut ch2 = chs.ch2;
// ❌ コンパイルエラー: simple_pwmは既にmoveされた。
// simple_pwm.set_frequency(khz(20));
split()のトレードオフ
split()を使うと、各チャンネルを独立して操作できるようになる一方で、タイマー全体に影響する設定(周波数の変更など)はできなくなります。
これは、split()がSimplePwmの所有権を消費し、タイマー全体の設定をするためのインターフェース(set_frequencyなど)が失われるためです。
let mut simple_pwm = SimplePwm::new(...);
let chs = simple_pwm.split();
// ❌ コンパイルエラー: simple_pwmは既にmoveされた。
// simple_pwm.set_frequency(khz(30));
この設計により、チャンネル全体に影響するタイマーの操作がsplit()後には行われないことが保証されるため、全てのチャンネルを独立に扱うことが安全にできるようになります。
複数のチャンネルを並行して制御したい場合はsplit()を使い、タイマー全体の設定を変更する必要がある場合はsplit()前に行う、という使い分けが重要です。
実際の使用例
let mut simple_pwm = SimplePwm::new(p.TIM2, ...);
// タイマー全体の設定を行う
simple_pwm.set_frequency(khz(10));
// 設定が完了したらsplitして各チャンネルを独立させる
let chs = simple_pwm.split();
// 各チャンネルを別々のタスクで並行処理
spawner.spawn(control_led(chs.ch1)).unwrap();
spawner.spawn(control_motor(chs.ch2)).unwrap();
チャンネルの有効化
ch.enable();
PWM出力を開始する前に、チャンネルを有効化する必要があります。
デューティ比の設定
let max_duty_cycle = ch.max_duty_cycle(); // 最大デューティ比を取得
let step = (max_duty_cycle / 5) as usize; // 5段階に分割
デューティ比は0〜max_duty_cycleの範囲で指定します。max_duty_cycleはタイマーの設定によって異なる場合があるため、max_duty_cycle()メソッドで取得するのが安全です。
ch.set_duty_cycle(duty_cycle); // デューティ比を設定
set_duty_cycle()でデューティ比を変更すると、PWM出力に反映されます。
段階的な明るさ変化
for duty_cycle in (0..=max_duty_cycle).step_by(step) {
ch.set_duty_cycle(duty_cycle);
info!("{}", ch.current_duty_cycle());
Timer::after_millis(300).await;
}
0から最大値まで、step刻みでデューティ比を変化させます。これにより、LEDが段階的に明るくなります。
Timer::after_millis(300).awaitにより、各段階で300ms待機します。
まとめ
この記事では、EmbassyでのPWM制御について解説しました。
次回は、タスク間で変数を共有する方法について解説します。