コンテンツにスキップ

組み込みRust入門:マイコン・no_std・Embassy

組み込みRust(Embedded Rust)とは、身の回りの物の中に入っている小さなコンピュータ向けにRustを書くことです。サーモスタット、キーボード、ドローン、植物の水やりセンサーなどです。こうしたコンピュータはマイコン(マイクロコントローラ、microcontroller)と呼ばれます。1つのチップの中に、プロセッサ、数百キロバイトのメモリ、そしてLED・ボタン・センサーとやり取りするピンが入っています。たいていOSはまったくありません。電源を入れてから切るまで、動いているのはあなたのプログラムだけです。

難しそうに聞こえるかもしれませんが、とても楽しい世界でもあります。数行書くだけで、机の上の本物のライトが点滅するのです。このページでは、何ができるのか、どのボードとクレートを選べばいいのか、そして最初のプログラムをチップに書き込む方法を紹介します。その途中で、ブラウザでそのまま動かせる例を使って、「組み込みならではの考え方」も練習していきます。

Rustが組み込みにぴったりな理由

Section titled “Rustが組み込みにぴったりな理由”

何十年もの間、組み込みのコードは主にCで書かれてきました。Rustは、小さなチップの上でとても大切になることをいくつも持ち込んでくれます。

  • ガベージコレクタがない。 メモリを片付けるためにプログラムが止まることがないので、タイミングを予測できます。信号に数マイクロ秒以内に反応しなければならないときには、これが重要です。
  • ランタイムなしのメモリ安全性。 所有権と借用のルールはコンパイルのときにチェックされるので、チップの上では何のコストもかかりません。いくつもの種類のクラッシュ(ダングリングポインタや、割り込みとメインループの間のデータ競合など)が、書き込む前に見つかります。
  • ゼロコスト抽象化。 HALを使うとメモリのアドレスを直接いじる代わりに led.set_high() と書けて、しかもそれは手で書いたときと同じ、たった1回のレジスタへの書き込みにコンパイルされます。
  • 所有権がハードウェアを表す。 ピンは値です。いったん Output にムーブされたら、ほかの誰かがそれを横取りして、間違えて入力として使うことはできません。以前はCのファイルのコメントに書くしかなかったことを、コンパイラが守らせてくれます。
  • すばらしいツール。 cargo、rustup のターゲット、probe-rs が、メーカーのIDEやmakefileの多くを置き換えてくれます。

まだ荒削りなところも正直にお伝えしておきます。チップのメーカーは今でもSDKとサンプルをまずCで出すので、チップによってはRustの対応が手薄だったり、コミュニティだけが支えていたりします。データシートを読む必要もあります。そして一部のクレートは1.0より前で、リリースの間にAPIが変わるので、いつも自分が使っているバージョンのサンプルに従いましょう。

  • センサーとガジェット: 気象ステーション、土の水分のアラーム、歩数計。
  • USBデバイス: 自作キーボード、ゲームコントローラー、MIDIコントローラー。
  • ネットにつながる(IoT)デバイス: ESP32を使って、測った値をサーバーに送るWi-FiやBluetoothのセンサー。
  • ロボットとモーター: ライントレーサー、ステッピングモーターやサーボの制御、LEDテープやディスプレイ。
  • 本格的なシステム: Oxide Computerは、マイコン向けの小さなOSであるHubris(英語)を、すべてRustで書きました。ESP32を作っているEspressif社は、自社でRustの対応をメンテナンスし、The Rust on ESP Book(英語)を公開しています。

最初のボードは、どれを選んでも大丈夫です。迷ったらRaspberry Pi Picoにしましょう。安くて、ドキュメントがとても充実していて、下のEmbassyの例もPico向けに書かれています。

ボード チップ Rustのターゲット よいところ 始めるときに使うもの
Raspberry Pi Pico / Pico W RP2040(Arm Cortex-M0+) thumbv6m-none-eabi 安くて人気があり、Embassyの対応がすばらしい embassy-rp、または rp2040-hal + rp-pico
Raspberry Pi Pico 2 RP2350(Arm Cortex-M33、またはRISC-V) thumbv8m.main-none-eabihf 新しくて速く、浮動小数点演算ユニットがある embassy-rp(フィーチャー rp235xa)、rp235x-hal
ESP32-C3 / ESP32-C6 のボード EspressifのRISC-Vチップ riscv32imc-unknown-none-elf / riscv32imac-unknown-none-elf Wi-FiとBluetoothを内蔵し、公式にRustに対応 esp-hal と esp-generate ツール
STM32 Nucleo、「Black Pill」 STM32(たとえばSTM32F4) thumbv7em-none-eabihf 産業界で使われている巨大なチップファミリー。Nucleoボードにはデバッグプローブが内蔵されている embassy-stm32、stm32f4xx-hal
BBC micro:bit v2 nRF52833(Arm Cortex-M4F) thumbv7em-none-eabihf デバッグプローブ、LED、ボタン、センサーがボードに載っている microbit-v2、そしてDiscoveryの本

micro:bitは、公式の Embedded Rust Discovery book(英語)で使われているボードです。初心者向けの、無料で1ステップずつ進められるコースです。初代のESP32とESP32-S3は、RISC-Vではなく、Xtensaのコアを使っています。それらには、Espressifの espup ツールが専用のRustツールチェーンをインストールしてくれます。Pico WのLEDはふつうのピンではなくWi-Fiチップにつながっているので、別のLチカのプログラムが必要です(Embassyにはそのための wifi_blinky というサンプルがあります)。

ふつうのRustのプログラムは標準ライブラリ(std)を使います。std はOSがあることを前提にしています。ファイル、スレッド、ヒープ、println! のためのターミナルなどです。マイコンにはそのどれもありません。そこで組み込みのプログラムは、次の2行から始まります。

#![no_std] // 標準ライブラリをリンクせず、`core` だけを使う
#![no_main] // いつもの `fn main()` のエントリーポイントを使わない
  • #![no_std] を付けると、使えるのはcore(英語)だけになります。整数、Option、Result、イテレータ、スライス、トレイト、フォーマットなどです。Vec、String、Box、ファイル、スレッドはありません(アロケータを用意すれば alloc クレートを通して Vec などをまた使えるようになりますが、それが必要ないプロジェクトもたくさんあります)。
  • #![no_main] が必要なのは、チップの上には main を呼んでくれるOSがないからです。cortex-m-rt のようなランタイムのクレートが、リセットのあとでメモリを準備し、それからあなたの関数にジャンプします。その関数には #[cortex_m_rt::entry] や、Embassyなら #[embassy_executor::main] のような属性を付けます。
  • パニックしたときに何が起きるかも決めておく必要があります。出力先のターミナルがないからです。panic-probe や panic-halt のようなクレートがこれを用意してくれます。use panic_probe as _; のようにして追加します。

Vec がないなら、リストはどうやって保存すればいいのでしょう? heapless(英語)クレートが、コンパイル時に決まる固定の容量を持つ Vec、String、キューを用意してくれます。これらはスタックや static の中に置かれるので、ヒープは使わず、メモリ不足で驚くこともありません。いっぱいになったバッファは、入れようとした値をそのまま返してくれます。

use core::fmt::Write;
use heapless::{String, Vec};
fn main() {
// ちょうど4つの測定値が入る場所。メモリは変数そのものの一部です。
// ヒープもアロケータも使わず、大きさはコンパイル時にわかります。
let mut readings: Vec<i32, 4> = Vec::new();
for value in [215, 218, 221, 224, 227] {
match readings.push(value) {
Ok(()) => println!("stored {value}"),
Err(rejected) => println!("buffer full, dropped {rejected}"),
}
}
// 容量が16バイトに固定された String
let mut line: String<16> = String::new();
write!(line, "avg={}", readings.iter().sum::<i32>() / readings.len() as i32).unwrap();
println!("{line} (capacity {})", line.capacity());
}

heapless はパソコンでも動くので、cargo add [email protected] と cargo run で試せます(Playgroundには入っていません)。

stored 215
stored 218
stored 221
stored 224
buffer full, dropped 227
avg=219 (capacity 16)

組み込みRustのクレートは、何層にも積み重なっています。ふだん作業するのはたいていHALかフレームワークの層ですが、その下に何があるのかを知っておくと役に立ちます。

層 どんなものか 例
PAC(Peripheral Access Crate) チップのレジスタを記述したファイルから自動生成される。レジスタやフィールドごとに、ほぼ安全な関数が1つずつある。とても低レベル。 rp2040-pac、stm32f4、esp32c3
HAL(Hardware Abstraction Layer) 1つのチップファミリー向けの使いやすい型:Output、Uart、I2c、タイマー。PACの上に作られている。 rp2040-hal、esp-hal、stm32f4xx-hal、embassy-rp、embassy-stm32
embedded-hal のトレイト OutputPin、I2c、SpiDevice、DelayNs のような共通のトレイト。HALが実装し、ドライバが使う。バージョン1.0で安定している。 embedded-hal、embedded-hal-async
BSP(Board Support Package) HALに、特定の1つのボードのピンや部品の名前(「LED」「ボタンA」)を加えたもの。 rp-pico、microbit-v2
RTIC 割り込み駆動のアプリのためのフレームワーク。タスクは割り込みと優先度に結びつけられ、共有データはビルド時にコンパイラがチェックする。 rtic
Embassy asyncのフレームワーク。エグゼキュータ、タイマー、ネットワーク、USB、そして独自のHALがある。async fn のタスクを書き、ハードウェアのイベントを .await する。 embassy-executor、embassy-time、embassy-rp

魔法は真ん中の行にあります。embedded-hal の I2c トレイトに対して書かれた温度センサーのドライバは、Pico、ESP32、STM32のどれでも、変更なしで動きます。どのHALも同じトレイトを実装しているからです。このページの後半で、そういうドライバを自分で書いてみます。

ターゲットとprobe-rsのインストール

Section titled “ターゲットとprobe-rsのインストール”

あなたのパソコンのプロセッサはx86かArmのデスクトップ向けのものですが、PicoのものはCortex-M0+です。そこでクロスコンパイル(cross-compile)をします。あるマシンの上で、別のマシン向けにビルドすることです。rustup が、そのチップ向けにコンパイル済みの core ライブラリをダウンロードしてくれます。

Terminal window
rustup target add thumbv6m-none-eabi # RP2040 (Pico)
rustup target add thumbv8m.main-none-eabihf # RP2350 (Pico 2)
rustup target add thumbv7em-none-eabihf # micro:bit v2, STM32F4

プログラムをチップに書き込み、そのログを見るには、probe-rs(英語)とデバッグプローブが必要です。デバッグプローブは、チップのデバッグ用のピンにつなぐ小さなUSBデバイスです。micro:bitとSTM32 Nucleoのボードには、最初から内蔵されています。Picoなら、Raspberry Pi Debug Probeか、無料の debugprobe ファームウェアを書き込んだ2台目のPicoを使います。probe-rsは、インストーラーのスクリプト(Windowsについてはインストールのページ(英語)を参照)か、cargoでインストールします。

Terminal window
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
# または、時間はかかるがソースからビルド:
cargo install probe-rs-tools --locked
probe-rs list # 見つかったプローブを表示する

まだプローブがない? PicoのBOOTSELボタンを押したままUSBにつなぐと、USBドライブとして認識されます。picotool や elf2uf2-rs のようなツールを使えば、その方法でプログラムをコピーできます。ただし、プローブがないと defmt のログは見られません。

ESP32-C3やC6なら、ふつうはプローブはまったく必要ありません。チップにUSBが内蔵されていて、espflash ツールがふつうのUSBケーブルで書き込んでくれるからです。cargo install esp-generate espflash --locked を実行し、次に esp-generate でチップとオプションを選びます。あとは cargo run --release で、ビルド、書き込み、ログの表示まで全部やってくれます。

「Lチカ」(blinky)は、組み込みの世界の「Hello, world!」です。LEDをつけて、待って、消して、待って、それを永遠にくり返します。これは公式のEmbassy RP2040 blinkyのサンプル(英語)です(コメントを追加しています)。本物のボードが必要で、Playgroundでは動きません。

#![no_std]
#![no_main]
use defmt::*;
use defmt_rtt as _; // defmt のログをプローブに送る
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::Timer;
use gpio::{Level, Output};
use panic_probe as _; // パニックしたら、メッセージをプローブ経由で出力して止まる
#[embassy_executor::main(executor = "embassy_rp::executor::Executor", entry = "cortex_m_rt::entry")]
async fn main(_spawner: Spawner) {
// チップのすべての周辺機能の所有権を、ちょうど一度だけ受け取る
let p = embassy_rp::init(Default::default());
// PicoのLEDはピン25にある。PIN_25 は `led` にムーブされる
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on!");
led.set_high();
Timer::after_millis(500).await;
info!("led off!");
led.set_low();
Timer::after_millis(500).await;
}
}

いくつか注目してほしいところがあります。

  • main は async fn です。Timer::after_millis(500).await は、ビジーループでぐるぐる回って待つわけではありません。タイマーが鳴るまで、エグゼキュータがチップを眠らせます。タスクが複数あれば(Spawner で起動します)、このタスクが待っている間にほかのタスクが動きます。async/awaitがはじめてでも大丈夫です。考え方はパソコンでTokioを使うときと同じで、OSがないだけです。
  • p.PIN_25 は Output::new にムーブされます。p.PIN_25 をもう一度使おうとすると、コンパイラが止めてくれます。所有権をハードウェアに当てはめたものです。
  • use defmt_rtt as _; と use panic_probe as _; は、名前を使うためではなく、そのクレートが準備してくれるもの(ログのチャンネルとパニックハンドラ)のためだけにクレートをインポートしています。

点滅するところをいちばん早く見るには、公式のサンプルフォルダそのものを使いましょう。Embassyのリポジトリをクローンし、examples/rp の中で cargo run --bin blinky --release を実行します。自分のプロジェクトでは、依存クレートをそのサンプルの Cargo.toml から持ってきます。

[package]
name = "blinky"
version = "0.1.0"
edition = "2024"
[dependencies]
embassy-executor = { version = "0.10.0", features = ["defmt"] }
embassy-time = { version = "0.5.1", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.10.0", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl", "rp2040", "executor-thread"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
[profile.release]
debug = true # probe-rs のためにデバッグ情報を残す(チップには書き込まれない)

あと2つのファイルで、cargoに何をどうビルドして、どう実行するかを伝えます。.cargo/config.toml はターゲットを設定し、cargo run がprobe-rsで書き込むようにします。

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"
[build]
target = "thumbv6m-none-eabi"
[env]
DEFMT_LOG = "debug"

最後に、同じ examples/rp フォルダから memory.x(チップのどこにフラッシュとRAMがあるか)と build.rs(リンカスクリプトを渡す)をコピーします。それからプローブをつないで、次のコマンドを実行します。

Terminal window
cargo run --release

cargoが thumbv6m-none-eabi 向けにビルドし、probe-rsがチップに書き込み、LEDが点滅し始めます。そして led on! / led off! のメッセージが、タイムスタンプ付きでターミナルに流れてきます。

マイコンには画面がありませんし、core::fmt で文字列をフォーマットするとフラッシュの容量をたくさん使います。defmt(英語)(「deferred formatting」、遅延フォーマット)は、その両方を解決します。フォーマット文字列はチップではなくパソコンの側に保存されます。チップはプローブを通して小さなIDと生の値だけを送り、probe-rsがそれを読める文字列に戻してくれます。

use defmt::{info, warn, Format};
#[derive(Format)] // #[derive(Debug)] のようなもの。ただし defmt 用
pub struct Reading {
pub sensor_id: u8,
pub milli_celsius: i32,
}
pub fn report(r: &Reading) {
info!("new reading: {}", r);
if r.milli_celsius > 60_000 {
// `=u8` と `=i32` で defmt に正確な型を伝えると、さらに容量を節約できる
warn!("sensor {=u8} is too hot: {=i32} mC", r.sensor_id, r.milli_celsius);
}
}

レベルは trace!、debug!、info!、warn!、error! で、log クレートとまったく同じです。.cargo/config.toml の DEFMT_LOG の設定で、どのレベルをそもそもコンパイルに含めるかを選びます。それより下のレベルは0バイトしか使いません。

レジスタで考える:ビットとマスク

Section titled “レジスタで考える:ビットとマスク”

どのHALの下でも、ハードウェアを操作するというのは、レジスタ(register)に数値を書き込むことです。レジスタは特別なメモリのアドレスで、それぞれのビットがチップの何かをオン・オフします。よくあるチップでは、32ビットのGPIO出力レジスタに、ピン1本につき1ビットが割り当てられています。ここではふつうの u32 にそのレジスタの役をしてもらい、組み込みの開発者なら誰もが使う4つの操作を練習しましょう。

const LED: u32 = 25; // PicoのボードにあるLEDはピン25
fn main() {
// この u32 をチップのGPIO出力レジスタだと思ってください。
// ビット0がピン0を、ビット1がピン1を……という具合に操作します。
let mut gpio_out: u32 = 0b1000; // ピン3はもうオンになっている
// 1ビットを立てる:マスクとOR
gpio_out |= 1 << LED;
println!("LED on: {gpio_out:#034b}");
// 1ビットを読む:マスクとANDして、0と比べる
let led_is_on = (gpio_out & (1 << LED)) != 0;
let pin2_is_on = (gpio_out & (1 << 2)) != 0;
println!("LED on? {led_is_on}, pin 2 on? {pin2_is_on}");
// 1ビットを反転する:マスクとXOR
gpio_out ^= 1 << LED;
println!("toggled: {gpio_out:#034b}");
// 1ビットを下ろす:反転したマスクとAND
gpio_out |= 1 << LED;
gpio_out &= !(1 << 3);
println!("pin 3 off: {gpio_out:#034b}");
println!("as hex: {gpio_out:#010x}");
}

実行結果:

LED on: 0b00000010000000000000000000001000
LED on? true, pin 2 on? false
toggled: 0b00000000000000000000000000001000
pin 3 off: 0b00000010000000000000000000000000
as hex: 0x02000000
▶ 実際に試してみよう

出力のラベルは、スペースで桁をそろえているので英語のままにしています。

Rustでは、整数に ! を使うとすべてのビットが反転します(Cではこれに ~ を使います)。{:#034b} は値を 0b の接頭辞付きの2進数で、32桁にそろえて表示するので、ビット25が現れたり消えたりする様子が見えます。ビット演算子の一覧は演算子を見てください。

多くのレジスタは、1つの数値の中に複数のフィールドを詰め込んでいます。速さの設定に数ビット、「有効」に1ビット、といった具合です。1つのフィールドだけを変えるには、リード・モディファイ・ライト(read-modify-write)を行います。そのフィールドのビットを0にしてから新しい値をORで入れ、ほかのビットには一切触れません。

// 架空のUART制御レジスタ:
// ビット0 = 有効
// ビット4..=6 = 速さの設定(3ビットの数値で、0から7)
const ENABLE: u32 = 1 << 0;
const SPEED_SHIFT: u32 = 4;
const SPEED_MASK: u32 = 0b111 << SPEED_SHIFT;
fn read_speed(reg: u32) -> u32 {
(reg & SPEED_MASK) >> SPEED_SHIFT
}
fn write_speed(reg: u32, speed: u32) -> u32 {
// 古いフィールドを0にしてから、新しい値を入れる。
// ほかのビットは、元のままぴったり残る。
(reg & !SPEED_MASK) | ((speed << SPEED_SHIFT) & SPEED_MASK)
}
fn main() {
let mut ctrl: u32 = ENABLE | (2 << SPEED_SHIFT);
println!("start: {ctrl:#010b}, speed = {}", read_speed(ctrl));
ctrl = write_speed(ctrl, 5);
println!("speed 5: {ctrl:#010b}, speed = {}", read_speed(ctrl));
// 9 は3ビットに収まらない。マスクが値を切り詰めるので、
// となりのビットにはみ出すことはない。
ctrl = write_speed(ctrl, 9);
println!("speed 9?: {ctrl:#010b}, speed = {}", read_speed(ctrl));
let still_enabled = (ctrl & ENABLE) != 0;
println!("still enabled: {still_enabled}");
}

実行結果:

start: 0b00100001, speed = 2
speed 5: 0b01010001, speed = 5
speed 9?: 0b00010001, speed = 1
still enabled: true
▶ 実際に試してみよう

9 が黙って 1 になってしまったことに気づきましたか? これこそ、PACやHALが守ってくれる種類のバグです。PACやHALはフィールドごとに名前の付いたメソッドを用意してくれる(多くの場合、名前の付いた値も)ので、マスクを手で書くことはめったにありません。本物のハードウェアでは、レジスタは決まったメモリのアドレスで、unsafeなコードの中で core::ptr::write_volatile を使って書き込みます。その部分はPACがやってくれます。

パソコンでテストできるドライバを書く

Section titled “パソコンでテストできるドライバを書く”

身につけておきたい、組み込みのいちばんよい習慣があります。ロジックを特定の1つのチップに対してではなく、トレイトに対して書くことです。そうすれば、同じコードがボードの上では本物のHALで動き、パソコンの上ではふつうのテストの中で偽物(「モック」、mock)の部品を使って動きます。ここでは StatusLed が必要とするのは、「オン・オフできる何か」と「待つことができる何か」だけです。

use std::cell::RefCell;
use std::rc::Rc;
// Lチカのドライバに必要な2つの能力。embedded-hal 1.0 の
// OutputPin と DelayNs トレイトをまねて、Playground で動くように簡単にしたもの
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
}
trait Delay {
fn delay_ms(&mut self, ms: u32);
}
// ドライバはどのチップのことも知らない。トレイトだけを使う
struct StatusLed<P: OutputPin, D: Delay> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: Delay> StatusLed<P, D> {
// `times` 回点滅する:`on_ms` の間つけて、`off_ms` の間消す
fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) {
for _ in 0..times {
self.pin.set_high();
self.delay.delay_ms(on_ms);
self.pin.set_low();
self.delay.delay_ms(off_ms);
}
}
}
// --- パソコンでテストするための偽物 ---
// どちらの偽物も同じ共有のログに書き込むので、順番がわかる
type Log = Rc<RefCell<Vec<String>>>;
struct MockPin {
log: Log,
}
impl OutputPin for MockPin {
fn set_high(&mut self) {
self.log.borrow_mut().push("on".to_string());
}
fn set_low(&mut self) {
self.log.borrow_mut().push("off".to_string());
}
}
struct MockDelay {
log: Log,
}
impl Delay for MockDelay {
fn delay_ms(&mut self, ms: u32) {
self.log.borrow_mut().push(format!("wait {ms}"));
}
}
fn main() {
let log: Log = Rc::new(RefCell::new(Vec::new()));
let pin = MockPin { log: Rc::clone(&log) };
let delay = MockDelay { log: Rc::clone(&log) };
let mut led = StatusLed { pin, delay };
led.blink(2, 100, 400);
let events = log.borrow();
println!("{}", events.join(", "));
assert_eq!(events.len(), 8);
assert_eq!(events[0], "on");
assert_eq!(events[1], "wait 100");
println!("all checks passed");
}

実行結果:

on, wait 100, off, wait 400, on, wait 100, off, wait 400
all checks passed
▶ 実際に試してみよう

2つのモックは Rc<RefCell<...>> を通して1つのログを共有しているので(スマートポインタを参照)、イベントの正確な順番を確かめられます。本物のプロジェクトでは、assert_eq! の行を #[test] 関数に入れて、パソコンの上で cargo test で実行します。ボードは必要ありません(テストと一歩進んだテストを参照)。

ボードの上では、同じドライバを本物の embedded-hal 1.0 のトレイトに対して書きます。変わるのは2点だけです。ピンのメソッドが Result を返す(I/Oエキスパンダのチップにつながったピンは失敗することがあるからです)ので、エラーは ? で呼び出し元に渡します。そして、このコードは std をまったく必要としません。

#![no_std]
use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin;
pub struct StatusLed<P, D> {
pin: P,
delay: D,
}
impl<P: OutputPin, D: DelayNs> StatusLed<P, D> {
pub fn new(pin: P, delay: D) -> Self {
StatusLed { pin, delay }
}
pub fn blink(&mut self, times: u32, on_ms: u32, off_ms: u32) -> Result<(), P::Error> {
for _ in 0..times {
self.pin.set_high()?;
self.delay.delay_ms(on_ms);
self.pin.set_low()?;
self.delay.delay_ms(off_ms);
}
Ok(())
}
}

Embassyの Output は OutputPin を実装していて、embassy_time::Delay は DelayNs を実装しているので、Picoの上では StatusLed::new(led, embassy_time::Delay) がそのまま動きます。これらの本物のトレイトに対してパソコンでテストしたいなら、embedded-hal-mock(英語)クレートが、すぐに使える偽物のピン、I2CやSPIのバスを用意してくれます。

浮動小数点を使わないセンサーの計算

Section titled “浮動小数点を使わないセンサーの計算”

RP2040のCortex-M0+のように、多くの小さなチップには浮動小数点演算ユニットがありません。浮動小数点数も使えますが、ソフトウェアで計算されるので遅く、プログラムも大きくなります。そこで組み込みのコードでは、よく固定小数点(fixed-point)の数を使います。もっと小さな単位の整数のことです。25.25 度の代わりに、25250 ミリ度として保存します。

TMP102タイプの温度センサーは2バイトを送ってきます。上位の12ビットが、1/16 °C刻みの符号付きの数値です。

// 上位12ビットが、1/16 °C(0.0625 °C)刻みの温度。
// 符号付き(2の補数)の数値です。
fn to_millicelsius(bytes: [u8; 2]) -> i32 {
// i16 なので符号が残る。符号付きの数値への `>> 4` でも符号は残る
let raw = i16::from_be_bytes(bytes) >> 4;
// 1/16 °C = 62.5 m°C なので、625 を掛けて 10 で割る
raw as i32 * 625 / 10
}
// 浮動小数点数に一度も触れずに、25250 を "25.250" と表示する
fn format_milli(m: i32) -> String {
let sign = if m < 0 { "-" } else { "" };
let m = m.abs();
format!("{sign}{}.{:03}", m / 1000, m % 1000)
}
fn main() {
let samples = [
[0x19, 0x40], // 暖かい部屋
[0x00, 0x10], // 0 より上の、いちばん小さな1刻み
[0xFF, 0x80], // 氷点下ちょうど手前
[0xE7, 0x00], // 冷凍庫
];
for bytes in samples {
let m = to_millicelsius(bytes);
println!("{bytes:02X?} -> {m:>6} m°C = {} °C", format_milli(m));
}
}

実行結果:

[19, 40] -> 25250 m°C = 25.250 °C
[00, 10] -> 62 m°C = 0.062 °C
[FF, 80] -> -500 m°C = -0.500 °C
[E7, 00] -> -25000 m°C = -25.000 °C
▶ 実際に試してみよう

これがうまくいくのは、3つの細かい工夫のおかげです。i16::from_be_bytes は、2バイトを最上位バイトが先の順番(「ビッグエンディアン」、big-endian)で1つの符号付きの数値として読みます。ほとんどのセンサーはこの順番でデータを送ってきます。符号付きの整数への >> 4 は符号を残すので、マイナスの温度も正しく出ます。そして format_milli は符号を別に扱っています。整数の計算では -500 / 1000 は 0 なので、この手順がないと -0.5 °C が 0.500 と表示されてしまいます。

整数の割り算は小数点以下を切り捨てることにも注意しましょう。62.5 m°Cは62になりました。部屋の温度センサーなら0.5ミリ度は問題になりませんが、その単位でどれくらいの精度が得られるのかは、いつも意識しておきましょう。範囲にも気をつけてください。大きな測定値に大きな係数を掛けると、i32 がオーバーフローすることがあります(整数オーバーフローを参照)。パソコンなら String で問題ありませんが、チップの上では defmt で出力するか、heapless::String に書き込みます。

no_std のプログラムで std のものを使う。 println!、Vec、String は std から来ているので、単純に存在しません。

error: cannot find macro `println` in this scope
--> src/main.rs:4:5
|
4 | println!("hello");
| ^^^^^^^

ログには defmt::info! を、コレクションには heapless(またはアロケータと alloc クレート)を使いましょう。

パニックハンドラを忘れる。 no_std のバイナリは、パニックしたときに何をするかを決めておく必要があります。panic-probe のようなクレートがないと、次のエラーになります。

error: `#[panic_handler]` function required, but not found

Cargo.toml に panic-probe(または panic-halt)を追加し、main.rs に use panic_probe as _; を書きましょう。

レジスタ全体を上書きしてしまう。 reg = 1 << 3 はピン3をオンにしますが、ほかのピンを全部オフにしてしまいます。1ビットだけを変えるには reg |= 1 << 3 を使います。フィールドなら、上で見たリード・モディファイ・ライトを行いましょう。

古いチュートリアルどおりに書く。 組み込みのクレートは変化が速く、数年前のブログ記事は embedded-hal 0.2や古いEmbassyのAPIを使っていることがよくあります。実際に依存しているクレートのバージョンのexamplesフォルダから始めましょう。

1. どのピンがオンになっている?

Section titled “1. どのピンがオンになっている?”

1つのビットを反転する toggle(reg: u32, pin: u32) -> u32 と、ビットが1になっているすべてのピンを並べる pins_on(reg: u32) -> Vec<u32> を書いてください。ピン0、5、25がオンの状態から始めて、リストとオンになっているピンの数を表示し、それからピン5と7を反転して、もう一度表示しましょう。ヒント: 0から31までのピンをマスクで1つずつ調べます。また、u32 には count_ones() メソッドがあります。

解答を見る
fn toggle(reg: u32, pin: u32) -> u32 {
reg ^ (1 << pin)
}
fn pins_on(reg: u32) -> Vec<u32> {
(0..32).filter(|pin| reg & (1 << pin) != 0).collect()
}
fn main() {
let mut reg: u32 = (1 << 0) | (1 << 5) | (1 << 25);
println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());
reg = toggle(reg, 5);
reg = toggle(reg, 7);
println!("on: {:?} ({} pins)", pins_on(reg), reg.count_ones());
}

実行結果:

on: [0, 5, 25] (3 pins)
on: [0, 7, 25] (3 pins)
▶ 実際に試してみよう

XORは、ビットが元々どんな値でも反転させます。そのため、ピン5を反転するとオフになり、ピン7を反転するとオンになりました。count_ones() が1のビットを数えてくれるので、その部分にループは必要ありません。

2. 浮動小数点を使わずに電池の残量を出す

Section titled “2. 浮動小数点を使わずに電池の残量を出す”

12ビットのADC(アナログ・デジタル変換器)は、0から3300 mVに対して0から4095の読み取り値を返します。電池は電圧を半分にする分圧回路を通してつながっているので、本当の電圧は測った電圧の2倍です。battery_mv(raw: u16) -> i32 と battery_percent(mv: i32) -> i32 を書いてください。3000 mVが0%、4200 mVが100%で、0より小さくも100より大きくもなりません。読み取り値2606、2482、2234、1861で試しましょう。ヒント: 精度を失わないように、割る前に掛けましょう。それから clamp を調べてみてください。

解答を見る
const EMPTY_MV: i32 = 3000;
const FULL_MV: i32 = 4200;
fn battery_mv(raw: u16) -> i32 {
// 12ビットのADC(0..=4095)で 0..=3300 mV を測る。前に電圧を半分にする分圧回路がある
raw as i32 * 3300 * 2 / 4095
}
fn battery_percent(mv: i32) -> i32 {
let percent = (mv - EMPTY_MV) * 100 / (FULL_MV - EMPTY_MV);
percent.clamp(0, 100)
}
fn main() {
for raw in [2606, 2482, 2234, 1861] {
let mv = battery_mv(raw);
println!("raw {raw} -> {mv} mV -> {}%", battery_percent(mv));
}
}

実行結果:

raw 2606 -> 4200 mV -> 100%
raw 2482 -> 4000 mV -> 83%
raw 2234 -> 3600 mV -> 50%
raw 1861 -> 2999 mV -> 0%
▶ 実際に試してみよう

4095で割るのを最後にすることで、精度を保っています。raw / 4095 * 6600 だと、4095より小さい読み取り値はすべて0になってしまいます。いちばん大きな積の 4095 * 6600 は約2700万で、i32 に余裕で収まります。

  • 組み込みRustは、OSのないマイコンの上で動きます。ガベージコレクタがないこと、コンパイル時の安全性、ゼロコスト抽象化のおかげで、Rustはぴったりの言語です。
  • #![no_std] を付けると使えるのは core だけになり、#![no_main] を付けるとランタイムのクレートがあなたのエントリー関数を呼ぶようになります。パニックハンドラも選ぶ必要があります。heapless は容量が固定されたコレクションを用意してくれます。
  • 層は、PAC(レジスタ)、HAL(1つのチップファミリー)、embedded-hal のトレイト(共通)、BSP(1つのボード)で、その上にRTICやEmbassyのようなフレームワークがあります。
  • rustup target add でチップのターゲットを追加し、probe-rsとデバッグプローブで書き込んでログを見ます(ESP32なら espflash)。defmt はログをとても小さく保ってくれます。
  • パソコンの上で練習しましょう。レジスタのためのビットマスク、モックの部品を使ってトレイトに対して書いたドライバ、浮動小数点数の代わりの固定小数点の整数です。

理解度チェック

1. #![no_std] は何をしますか?

2. 1つのセンサーのドライバを、Pico、ESP32、STM32で変更なしに動かせるのは、どの層のおかげですか?

3. ほかのビットを変えずに、reg のビット3をオンにするにはどうしますか?

4. 組み込みのコードで、25.25 °Cをよく 25250 ミリ度として保存するのはなぜですか?

スコア: 0 / 4

  • The Embedded Rust Book(英語):no_std、周辺機能、レジスタ、組み込みのエコシステムについての公式の入門書です。
  • Discovery (micro:bit v2)(英語):BBC micro:bitを使って、LEDの点滅からセンサーまで1ステップずつ進む、初心者向けの無料のコースです。
  • The Embassy Book(英語):Embassyの始め方、エグゼキュータとHALのしくみ、公式サンプルの動かし方。
  • The Rust on ESP Book(英語):esp-hal、esp-generate、espflash、ESP32のツールチェーンについての、Espressif自身によるガイドです。
  • probe-rs installation(英語):すべてのOSでのprobe-rsのインストール方法と、デバッグプローブの準備のしかた。