前書き
Rustでのマイコンの開発環境が整っているものも多くなってきました。
ESP32は、わりと安価な割に、これ一つでWiFiまでサポートする高機能なマイコンで、結構メジャーなチップです。が、CPUチップの内部情報が若干少ないこと、おそらく無線通信の許可・免許諸々の関係でどうしても、公式のライブラリを直接使う以外のコントロールができない部分があることなどが原因しているのか、Rustの開発環境は困難な状態にあると思ってました。
がどうやら、Espressif Systemsが公式で、Rustの開発ツールを整えてくれたらしいということで、過去に一度挫折しているのですが、もう一度やってみました。
これは、その経過のメモ書きです。
OS環境
使用した開発に使うホストの環境は、
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.4 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=jammy
な感じです。
Rustの開発環境
基本的なRustの開発環境は整っているものとします。
流石に、ここから説明が必要となると、いずれにせよ挫折します。言語にもクセがありますし・・・。
参考までに、基本的な情報源として、 Rustの日本語ドキュメント/Japanese Docs for Rust をあげておきます。とくに、「プログラミング言語Rust日本語版」は、入門書として非常に良くできています。
今回使用したRustのバージョンは、
$ rustup show
Default host: x86_64-unknown-linux-gnu
【略】
active toolchain
----------------
stable-x86_64-unknown-linux-gnu (default)
rustc 1.76.0 (07dca489a 2024-02-04)
となります。
ESP32開発環境の構築
さて、公式の情報のスタートとして、githubのesp-rs/esp-idf-templateリポジトリを挙げておきます。基本的な情報は、ほぼここにありました。
ひととおり眺めてみると、どうやら、std環境でのプログラムまでできるみたい。というわけで、今回は、std環境でのプログラムを一つの目標としてみます。
まずは、上記のページの説明どおりにツール群をインストールしていきます。
OSに必要なツール群のインストール
まず、OS側に必要なツール・ライブラリ群をインストールします。開発環境で使っているパソコンであれば、目にすることも多い子達なのですでに入っているものも多いでしょう。
$ sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0
Rustの追加ツール群のインストール
次に、Rust側での追加ツール群のインストールです。
$ cargo install cargo-generate
$ cargo install ldproxy
$ cargo install espup
$ cargo install espflash
$ cargo install cargo-espflash
上から順に、cargo-generateは、cargo newのテンプレート版です。ESP32プロジェクトのスケルトンを生成するために使用します。
espupは、rustupのESP32版です。ESP32ツールチェーンの生成・維持を行います。
espflashとcargo-espflashは、プログラムのローダー及びモニターとなります。
ESP32ツールチェーンの構築
最後に、ESP32ツールチェーンを構築します。
$ espup install
このコマンドは、少し時間がかかります。実行が終わると、rustup showで、espというツールチェーンが追加されているのが見られます。
$ rustup show
Default host: x86_64-unknown-linux-gnu
rustup home: /home/aaaa/.rustup
installed toolchains
--------------------
stable-x86_64-unknown-linux-gnu (default)
nightly-x86_64-unknown-linux-gnu
esp
【後略】
そうしてもう一つ。ホームディレクトリに export-esp.sh
というファイルが作られます。中身はPATH
の追加とLIBLANG_PATH
の設定です。.bashrc
に追記しても良さそうな内容ですが、公式には、他の開発環境への影響が懸念されるので推奨しないとされています。
コンソールでの作業の初めに、
$ . ~/export-esp.sh
を実行するのが吉のようです。忘れてプログラムのコンパイルに入ると、途中で異常になります。気がついたときに上記を実行してもう一度コンパイルすれば復帰できるので重症ではありません。
まずは、単純にLチカ
プロジェクトの生成
まずは、プロジェクトを作ります。普通だと、cargo new aaa
ですが、ESP32の場合は、cargo generate
を使用します。ESP32のクロスコンパイルをするためにツールチェーンの設定などわりといろいろな設定が必要ですが、これをまとめてやってくれて非常に便利です。
また、stdを使用する場合と、no-stdの場合では、cargo generate
の引数が違います。今回は、stdライブラリを使用するパターンで生成することにします。
まず最初に、ターミナルを起動した最初に、必ず、. ~/export-esp.sh
の実行を忘れないでください。このプロジェクト生成のときにこれを忘れると、このプロジェクトがコンパイルできなくなります。(やっちゃったときは、コンパイルのときに謎の大量のエラーが出ます。リカバリは、プロジェクトの作り直しです。)
今後の作業でも同じで、まぁ、忘れたことに気がついたときに. ~/export-esp.sh
を実行すれば済むことが多いのですが、ここだけは忘れるとちょっと手間なので。
プロジェクトを生成するディレクトリに移動したあとで、
$ . ~/export-esp.sh
$ cargo generate esp-rs/esp-idf-template cargo
⚠️ Favorite `esp-rs/esp-idf-template` not found in config, using it as a git repository: https://github.com/esp-rs/esp-idf-template.git
🤷 Project Name: test0
🔧 Destination: /home/develop/m5paper/rust/test/test0 ...
🔧 project-name: test0 ...
🔧 Generating template ...
✔ 🤷 Which MCU to target? · esp32
✔ 🤷 Configure advanced template options? · false
🔧 Moving generated files into: `/home/develop/m5paper/rust/test/test0`...
🔧 Initializing a fresh Git repository
✨ Done! New project created /home/develop/m5paper/rust/test/test0
途中で、人のアイコンのところは、選択肢が出てきます。それに答えていくと、カレントディレクトリにプロジェクト名のディレクトリが生成されています。
デフォルトプロジェクトの中身
では移動してみます。
$ cd test0
$ tree -a
.
├── .cargo
│ └── config.toml
【.git内部 省略】
├── .gitignore
├── Cargo.toml
├── build.rs
├── rust-toolchain.toml
├── sdkconfig.defaults
└── src
└── main.rs
.cargo/config.toml
にクロスコンパイルのために必要な設定事項がすでに記載されています。
[build]
target = "xtensa-esp32-espidf"
[target.xtensa-esp32-espidf]
linker = "ldproxy"
# runner = "espflash --monitor" # Select this runner for espflash v1.x.x
runner = "espflash flash --monitor" # Select this runner for espflash v2.x.x
rustflags = [ "--cfg", "espidf_time64"] # Extending time_t for ESP IDF 5: https://github.com/esp-rs/rust/issues/110
[unstable]
build-std = ["std", "panic_abort"]
[env]
MCU="esp32"
# Note: this variable is not used by the pio builder (`cargo build --features pio`)
ESP_IDF_VERSION = "v5.1.2"
また、Cargo.toml
には、ESP32を開発する上で必須の依存が最初から書かれています。
[package]
name = "test0"
version = "0.1.0"
authors =
edition = "2021"
resolver = "2"
rust-version = "1.71"
[profile.release]
opt-level = "s"
[profile.dev]
debug = true # Symbols are nice and they don't increase the size on Flash
opt-level = "z"
[features]
default = ["std", "embassy", "esp-idf-svc/native"]
pio = ["esp-idf-svc/pio"]
std = ["alloc", "esp-idf-svc/binstart", "esp-idf-svc/std"]
alloc = ["esp-idf-svc/alloc"]
nightly = ["esp-idf-svc/nightly"]
experimental = ["esp-idf-svc/experimental"]
embassy = ["esp-idf-svc/embassy-sync", "esp-idf-svc/critical-section", "esp-idf-svc/embassy-time-driver"]
[dependencies]
log = { version = "0.4", default-features = false }
esp-idf-svc = { version = "0.48", default-features = false }
[build-dependencies]
embuild = "0.31.3"
この時点でのスケルトンのmain.rs
は次のような感じです。所謂、Hello World!
です。コメントを削除すると
fn main() {
esp_idf_svc::sys::link_patches();
// Bind the log crate to the ESP Logging facilities
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Hello, world!");
}
冒頭のesp_idf_svc::sys::link_patches();
はおまじないとして必要な模様。
次の、ESPLogger::initialize_default();
はロガーの初期化です。これで、次の行のlog::info!
が使用可能になります。このlog
クレートは汎用のロガーで、通常のプログラムで使用しているものと全く同じクレートです。
動作としては、モニターターミナルにHello, world!
をログ表示して終わります。
コンパイル・ロード・実行・モニター
この時点で、コンパイル・ロード・実行がすでに可能です。
実機があれば、USBで接続したあと、
$ cargo run --release
を実行すれば、いつものコンパイル画面が流れたあと(結構待たされます。)、ロード画面に移行します。この時点で、複数の機器が接続されている場合は、その選択が可能です。
その後、ターミナルは、自動的に、ESP32のモニター回線に接続し、ESP32の起動ログが流れます。最後の方で、ログの一部として、Hello, world!
の表示が確認できるはずです。
モニターの終了は、ctrl-c
で出来ます。
このプログラムでは、Hello,worldの表示にlog::info!
を使用しています。このプロジェクトはstdに対応するはずでした。というわけで、これをprintln!("Hello, world!")
に書き換えてみましょう。実行すると、今度は、ログの一部としてではなく、ヘッダのない状態で、Hello world!が表示されます。stdoutは、どうやら、モニターターミナルに接続されているようです。これで、println!デバッグが出来ますね。
Lチカ ソース
では、Lチカです。
ハードの構成ですが、M5Paperの短辺の側面Grove端子から、GPIOをもらいます。端子番号は、goio25とgpio32です。ここに、電流制限抵抗とともに、LEDを2本接続します。
M5Paperの画面の表示は、前に使っていたプログラムが表示した画面がそのまま残っています。電子ペーパーって消さないとずっと残るんですよね。というわけで、今回作っているプログラムとは全く関係がありません。
ソースを触る前に、プロジェクト構成直後の状態で、
$ cargo doc --open
を実行しておくことをおすすめします。これをやっておくと、ブラウザに使用しているクレートのドキュメントが表示されるはずです。crates.ioから使用しているクレートを探しに行ってもよいのですが、こうしておけば、いつでもローカルで使っているクレートすべてが一覧できるので便利です。
特に、今回は、esp-idf-svc
クレートのドキュメントがとても参考になるはずです。
今回、anyhowクレートを使用するので、Cargo.toml
に
[dependencies]
【略】
anyhow = "1.0.79"
を追加しておいてください。これは、エラー処理を便利に書くことができるようにするためのクレートです。
では、Lチカのソースです。
use anyhow::Result;
use esp_idf_svc::{
hal::{gpio::PinDriver, peripherals::Peripherals},
sys::link_patches,
};
use std::{thread::sleep, time::Duration};
fn main() -> Result<()> {
link_patches();
// Bind the log crate to the ESP Logging facilities
esp_idf_svc::log::EspLogger::initialize_default();
let peripherals = Peripherals::take()?;
// ESP32 Battery回路 Power ON
let mut power = PinDriver::output(peripherals.pins.gpio2)?;
power.set_high()?;
let mut led1 = PinDriver::output(peripherals.pins.gpio25)?;
let mut led2 = PinDriver::output(peripherals.pins.gpio32)?;
loop {
led1.set_high()?;
led2.set_low()?;
sleep(Duration::from_secs(2));
led1.set_low()?;
led2.set_high()?;
sleep(Duration::from_secs(2));
}
}
一般的なembeddedのプログラムとほとんど同じ感じです。
GPIOの使用はperipherals
構造体の取得から始まります。これは、ESP32のハードウェアのすべてを表現しています。esp_idf_svc::hal::peripherals::Peripherals::take()
関数は、Peripherals構造体を取得してきます。この関数は、プログラム実行中に一回のみの実行が必要です。複数回の実行は出来ないので取得した変数のスコープに注意が必要です。
ちなみにmain()
の返り値をanyhow::Result<()>
としてあるので、どんなエラーでも、?
演算子で返して異常終了できます。(まぁ、Peripheral構造体の取得やgpioの取得でエラーが出たら、所詮プログラムのできることはログに盛大にエラーを吐き出して自沈するくらいのことしかできませんから。正直な話、禁断のunwrap()でもよいくらい。)
次の2行は、M5Paper特有の処理です。M5Paperの電源管理機構は、バッテリーで実行中は、gpio2がonの間のみ電源を通電します。USB給電の場合は、常時通電です。このため、プログラム実行開始後、速やかにgpio2をonにする必要があります。
GPIOの操作は、まず、PinDriverの構築から始まります。esp_idf_svc::hal::gpio::PinDriver
構造体のoutput(ピン番号)
を呼び出せば、gpioのピンを出力状態に設定して操作用の構造体を返してくれます。(ちなみに、input(ピン番号)
関数を使えば、入力用に設定されます。)
あとは、返された構造体の、set_high
,set_low
関数を呼べば、GPIOのオン・オフができます。
次に、同じく、PinDriver構造体を、gpio25とgpio32に対して、取得します。
あとは、無限ループで、オン・オフを繰り返しているだけです。
std::thread::sleep()
関数が使えているところは注目です。いつもの関数で遅延も出来ます。
ちょっと欲張って
この、esp_idf_svc
クレートですが、中を見てみると、標準的に使われるC言語のidfライブラリをembeddedインターフェース仕様にラップしてくれています。もとは、素直に、C言語のときと同じidfライブラリです。というわけで、このベースには、同じくFreeRTOSが鎮座しています。
であるならばと・・・。
Lチカマルチスレッド版
マルチスレッドにしてみただけのソースです。(えぇ。「だけ」というくらい簡単に出来ました。)
動作としては、スレッドを2本起こして、2つのLEDを違うタイミングで点滅させ、モニターに点滅回数を送信する形にします。モニターへの送信は、メインスレッドで管轄します。そのために、スレッドとの通信にメッセージキューを使用することにします。
use anyhow::Result;
use esp_idf_svc::hal::{
delay,
gpio::{Output, Pin, PinDriver},
peripherals::Peripherals,
};
use std::{sync::mpsc, thread::sleep, time::Duration};
fn main() -> Result<()> {
esp_idf_svc::sys::link_patches();
// Bind the log crate to the ESP Logging facilities
esp_idf_svc::log::EspLogger::initialize_default();
let peripherals = Peripherals::take()?;
// battery pawer on
let mut power = PinDriver::output(peripherals.pins.gpio2)?;
power.set_high()?;
// leds gpio
let mut ledy = PinDriver::output(peripherals.pins.gpio25)?;
let mut ledr = PinDriver::output(peripherals.pins.gpio32)?;
// 各スレッドから点滅回数を受信するためのメッセージボックス
let (ledy_tx, ledy_rx) = mpsc::channel();
let (ledr_tx, ledr_rx) = mpsc::channel();
// yelow led flashing
std::thread::spawn(move || led_flash(&mut ledy, &ledy_tx, Duration::from_millis(400)));
// red led flashing
std::thread::spawn(move || led_flash(&mut ledr, &ledr_tx, Duration::from_millis(300)));
loop {
match ledy_rx.try_recv() {
Ok(i) => {
println!("黄色点灯回数:{}", i);
}
Err(mpsc::TryRecvError::Empty) => { /* 未受信の場合何もしない */ }
Err(e) => {
Err(e)?;
}
}
match ledr_rx.try_recv() {
Ok(i) => {
println!("赤色点灯回数:{}", i);
}
Err(mpsc::TryRecvError::Empty) => { /* 未受信の場合なにもしない */ }
Err(e) => {
Err(e)?;
}
}
delay::FreeRtos::delay_ms(1);
}
}
fn led_flash<T: Pin>(led: &mut PinDriver<T, Output>, tx: &mpsc::Sender<u32>, ms: Duration) {
let mut count = 0;
loop {
tx.send(count).unwrap();
led.set_high().unwrap();
count += 1;
sleep(ms);
led.set_low().unwrap();
sleep(ms);
}
}
以上です。
正直、特筆することがないところがびっくりなところまで、stdの標準通りに作ることが出来ました。
メッセージキューには、標準のstd::sync::mpsc::channel
を使用しています。
スレッドの生成には、同じく標準のstd::thread::spawn
を使用しました。
先のプログラムとの変更点は、channelを確保して、spawnで点滅関数を起動しているところくらいです。
普通に、普通のRustのマルチスレッドですねぇ。
関数に、PinDriver
を渡すときの宣言だけが少々面倒です。ただし、自分では考えてません(笑)。最初に&mut PinDriver
と書いてコンパイルをしてみます。すると、ちゃんとジェネリック宣言が必要だとコンパイラに怒られます。あとは、コンパイラの仰せのとおりに順次直していくだけです。数回直せば通ります。このあたり、Rustのコンパイラのエラーメッセージはものすごく親切です。最初は、ジェネリックの海で溺れるのを覚悟していたのですが・・・。
一つだけ微妙なのは、メインスレッドのメインループ内で、メッセージキューの受信処理と、印字をひたすらやっているわけですが、esp_idf_svc::hal::delay
モジュールから、FreeRtos::delay_ms()
関数を挟んでいます。先からの流れなら、std::thread::sleep()
でも良いはずなんですが、そうすると、ESP32のウォッチドグタイマーが叫び始めます。どうやら、sleep
では、タイマーリセットが出来ていないようです。delayモジュール内の関数たちは、ちゃんとタイマーリセットをしている旨、ドキュメントにも記載があります。 ちょっとだけ残念でした。
実行すると、LEDが点滅すると同時に、モニターに各LEDの点灯回数がひたすら流れ続けます。