embassy-rs

Activity

Stars and mentions · Last 30 days, Oct 7 – Nov 5

GitHub stars0
Mentions3
01234Oct 7Oct 21Nov 5
UTC days · repositories embassy-rs owns0 repositories

Top repositories

embassy-rs’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside embassy-rs’s account where embassy-rs opened or reviewed pull requests in the past 365 days.

Who noticed embassy-rs

Everyone who starred or forked embassy-rs’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

3posts
Nov 60 posts
  1. It would be really great to see how it fits in embedded systems, MCUs in particular! Embassy (embassy-rs/embassy) uses this concept of no heap allocation and I find it really solid. Even if you write in C, most of the times you don't use heap. MISRA, the C coding standard for automotives, doesn't allow dynamic allocation!

  2. サーバーの非同期 Rust アプリのasync/await の考え方で組み込みソフトが書けるフレームワーク Embassy ~概要編~ 対象版:embassy-rs/embassy commit f2aaadfff1cea990cada90c31ba6203c063e9645(2026-09-27)。主な crate の版は embassy-executor 0.10.0、embassy-time 0.5.1、embassy-sync 0.8.0、embassy-rp 0.10.0 です。ビルドには、この commit の rust-toolchain.toml が指定する Rust 1.97 を使いました。1 2 比較に使う FreeRTOS は FreeRTOS-Kernel V11.3.1(commit 3a22924e0a9ddbbc8b0758881c33b3422a5cc20d)で、Cortex-M33 用の port portable/GCC/ARM_CM33_NTZ を読みました。FreeRTOS はソースを読んだだけで、ビルド・実行はしていません。 はじめに Embassy は、Rust の async/await を組み込み開発の第一の選択肢にすることを目指すプロジェクトです。3 その魅力は、サーバーなどの非同期 Rust アプリで使う async/await の考え方で、マイコンのアプリも書けることです。ネットワークの受信を .await で待つように、ボタン入力や時間の経過、ハードウェアの処理完了も .await で待てます。待ちの前後を 1 つの関数に上から順に書けて、待っている間は別のタスクを進められます。3 この書き方を、限られたメモリや省電力の要求と両立できる点も特徴です。Embassy では、タスクごとのスタックを予約せずに並行処理でき、この記事で使う Pico 2 向けの実行基盤は、進める仕事がなければ CPU を休止させます。仕組みと成立条件は後で確認します。 普段の非同期 Rust アプリと共通するのは、待ちを含む処理の組み立て方です。この記事のマイコン向けプログラムでは、標準ライブラリ std を使わない no_std の指定や、ピンなどのハードウェア設定が必要です。3 この記事の問いは、「非同期 Rust の書き方で組み込みアプリを作るために、Embassy は待ちと再開をどう支え、少ないメモリや省電力の要求をどう満たすのか」です。ボタンの押下を待つタスクと、LED を点滅させるタスクを最後まで追いながら、その仕組みを担う部品とソースの位置を確認します。同じ並行処理を実現できる FreeRTOS のような RTOS(リアルタイム OS)と比べ、書き方を支える仕組みの違いと得失も見ます。読み終えると、待ちに入ってから割り込みで再開するまでの 1 往復と、その分担、利用者が設計する必要のある点を説明できるようになります。 背景と要求 Embassy Book の「From bare metal to async Rust」の節は、「ボタンを押すと LED が変わる」プログラムを、レジスタを直接操作する版から async 版まで書き換えながら、各段階の問題を挙げています。3 これに、従来型 RTOS のタスクが使うメモリを加えると、Embassy が応えようとする要求は次の 5 つにまとまります。 ボタンの状態を繰り返し読み続けると、CPU を休ませられない:Book は、ループの中でボタンの状態を読み続ける版について、マイコンが休止状態を使えず、必要以上に電力を使うと指摘しています。3 ここから、何も進められないときは CPU を休ませたいという要求(R2)が出てきます。 割り込みで知らせてもらう版は、処理が 2 か所に分かれる:休止状態に入るには、ボタンの押下を割り込みで知らせてもらう必要があります。Book の割り込み版では、ボタンと LED をメインループと割り込みハンドラの両方から触れる大域変数に置き、排他(mutex)で守り、触れる間は割り込みを止めています。そのため、簡単だった処理が再び複雑になったと書かれています。3 この版からは 3 つの要求が出てきます。待ちの前後の処理を 1 か所に上から順に書きたい(R3)、同じペリフェラルを 2 か所から操作できてしまう状態を避けたい(R4)、処理と割り込みの間で値を安全に渡したい(R5)です。 従来型 RTOS のタスクは、タスクごとにスタックを持つ:割り込み版の複雑さは、RTOS のタスクに分ければ避けられます。ただし RTOS は処理の途中でも切り替えるので、どこで止められても再開できるよう、タスクごとにスタックを持ちます。スタックは、関数呼び出し中の引数や局所変数などを置く作業領域です。最大使用量に備えて予約する容量(深さ)は、アプリがタスクごとに決めます。4 5 ここから、限られた RAM で、タスクごとのスタックを予約せず、ヒープも使わずに複数の処理を並行させたいという要求(R1)が出てきます。 静的確保は領域をあらかじめ用意する方法、ヒープは実行中に領域を確保・解放する仕組みです。領域の取り方と、タスク専用のスタックが要るかは別の話で、FreeRTOS も静的確保ならヒープを使わずに動かせます。 背景から出てきた要求を、次の 5 つに整理します。冒頭の「待ちの前後を上から順に書ける」という魅力は R3 に当たります。その書き方を少ないメモリと省電力の要求に合わせて支えるのが R1・R2、安全にハードウェアを操作し、値を渡すための要求が R4・R5 です。実現方法と得失は、用語を説明した後の「FreeRTOS と何が違うか」と、各機能の説明で扱います。 要求 実現したいこと R1 限られた RAM で、タスクごとのスタックを予約せず、ヒープも使わずに複数の処理を並行させたい R2 何も進められないときは CPU を休止させたい R3 HW の完了待ちの前後を、CPU を占有せず、上から順に読めるコードで書きたい R4 同じペリフェラルを、意図せず複数の処理から同時に操作する誤りを防ぎたい R5 処理同士、処理と割り込みの間で安全に値を渡したい async/await と Future(この記事で必要な分だけ) ボタンを待つ間、処理はどこに残り、誰が続きを動かすのでしょうか。まず Future を進める操作を、次に再開に必要なデータと通知先を見ます。 async・.await は Rust 言語の機能です。Future と poll は標準ライブラリの core::future、返り値の Poll と通知に使う Waker は core::task にあります。6 7 core は OS のないマイコンでも使える土台です。8 Embassy はその上に、Future を進める Executor と、待ちを Future として表すドライバ・キュー・タイマーを提供します。 普通の Rust の関数は、呼ぶと本体を実行して結果を返します。関数に async を付けると、呼んだ時点では本体を実行せず、処理を進めるための値を返します。この値を Future と呼びます。Future は厳密には「poll という操作で進められる」という約束を表すトレイトの名前です。9 次は、ボタンの押下回数をキューで LED のタスクへ渡す疑似コードです。(a)〜(d) は待つ場所を表します。ピンの引数と、押下直後に接点の揺れ(チャタリング)を読み飛ばす時間待ちは省略しています。実際の型とサイズを調べる対象は、後の「通し例のプログラム」です。 // 疑似コード:タスク 1(ボタン)とタスク 2(LED) async fn button_task() { let mut count = 0; loop { wait_button_press().await; // (a) ボタンのピンが変わるのを待つ count += 1; queue.send(count).await; // (b) キューに空きができるのを待つ } } async fn led_task() { loop { let count = queue.receive().await; // (c) キューに値が入るのを待つ for _ in 0..3 { led_on(); sleep_ms(100).await; // (d) 指定時間が過ぎるのを待つ led_off(); sleep_ms(100).await; } } } Future は poll で進む Future は置いてあるだけでは進みません。保存された状態から処理を進める操作を poll と呼びます。Future トレイトの poll メソッドを呼ぶことです。poll は、待ちのために戻るか、処理が完了するまで実行します。返り値は標準ライブラリの Poll 型で、次の 2 つです。7 返り値 意味 Pending まだ待つ必要がある。状態を残して、今回の呼び出しから戻る Ready(結果) この Future の処理が完了した。結果を返す (a) の .await では、外側の button_task の Future が、内側のボタン待ち Future を poll します。 button_task を poll └─ (a) のボタン待ち Future を poll ├─ Pending → button_task も Pending で戻る └─ Ready → count += 1 へ進む 次に外側が poll されると、(a) の待ちをもう一度確認します。内側のボタン待ち Future がその場で Ready を返せば、外側は次の行へ進みます。.await を書くたびに他のタスクへ切り替わるわけではありません。9 ボタン待ちが完了しても、ループする button_task 自体は終了せず、送信や次のボタン待ちへ進みます。 poll は処理を一定時間だけ動かす操作ではありません。長い計算や、即完了する await が続けば、その間は poll も戻りません。 Future に何が残るか 待ちの前後で押下回数が残るのは、コンパイラが作る Future が、進行位置と続きに必要な値を持つためです。実際の通し例は button_task(mut button: Input<'static>) として入力ピンを受け取り、(a)(b) の後にチャタリングを読み飛ばす時間待ちを加えています。 この実コードを Raspberry Pi Pico 2(RP2350)向けに Rust 1.97.1・release でビルドし、-Zprint-type-sizes でメンバーを調べました。図の値は、押下回数が 2 でボタン待ち中という場面を例にしています。Input は入力ピンを操作する HAL の型です。__awaitee はコンパイラが付けた、await 中の子 Future を置くメンバー名です。 button_task の Future(24 バイト) ├─ discriminant(1 バイト):進行位置。今は (a) の待ち ├─ count: u32(4 バイト):押下回数 2 ├─ button: Input(1 バイト):引数で受け取った入力ピン └─ __awaitee:Input::wait_for_falling_edge() の Future(16 バイト) もう一度 poll してボタン待ちが Ready を返すと、保存した count を使って count += 1 へ進みます。送信待ちや時間待ちでは __awaitee の型が替わります。各状態は同じ領域を重ねて使うので、大きさをすべて足すわけではありません。並びとサイズはコンパイラ・最適化設定に依存する、このビルドでの値です。再現コマンドは動作確認 2 に載せます。 Future トレイト、全状態、子 Future の内部と型サイズの出力 Future トレイトは完了時の型と poll の形を定め、データのメンバーは具体的な実装型が持ちます。以下が標準ライブラリの定義です。Pin は Future を扱う際に位置を固定するための標準の型で、GPIO のピンとは別です。 // core::future::Future の定義(属性とドキュメントコメントを除く) pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } トレイトが決めるのは、完了したときの結果の型 Output と、poll メソッドの形だけで、フィールド(データのメンバー)は持ちません。6 データを持つのは、このトレイトを実装する具体的な型です。async 関数の場合、その型はコンパイラが生成し、ソースには型の名前が出てきません。 以下は疑似コードではなく、入力ピンとチャタリング待ちを含む通し例の測定結果です。内部の木に出る Flex は入出力のモードを切り替えられる GPIO ドライバ、AnyPin は GPIO 番号を実行時の値として保持する型です。InputFuture はエッジ待ちの末端にある Future で、Peri<AnyPin> はそのピンの所有者を表します。InputFuture の pin メンバーはソースでも確認できます。10 この型は、Rust の enum(いくつかの形のうち 1 つを取る型)と同じ作りです。今どの形かを表す番号(discriminant、1 バイト)が「どこまで進んだか」に当たり、形は次の 6 つです。 形 いつこの形か Unresumed まだ一度も poll されていない Suspend0 (a) のボタン待ちの await で止まっている Suspend1 (b) のキューへの送信の await で止まっている Suspend2 チャタリングを読み飛ばす時間待ちの await で止まっている Returned 完了した(この例は loop なので来ない) Panicked poll 中に panic した Suspend0〜Suspend2 の形では、await をまたいで使う値をメンバーとして持ちます。(a) で止まっているとき(Suspend0)の中身を、子の Future までたどると次のようになります。 button_task の Future(24 バイト) ├─ discriminant:Suspend0((a) で止まっている) ├─ count:u32(4 バイト)。押下回数=2 ├─ button:Input(1 バイト)。引数で受け取ったピン └─ __awaitee:いま await している子 Future └─ Input::wait_for_falling_edge() の Future(16 バイト) ├─ self:&mut Input(4 バイト)。button を指す └─ __awaitee:Flex::wait_for_falling_edge() の Future(8 バイト) ├─ self:&mut Flex(4 バイト) └─ __awaitee:InputFuture(1 バイト) └─ pin:Peri<AnyPin>(1 バイト)。待っているピンの番号 __awaitee の型は形ごとに違い、Suspend1 ではキューへの送信の Future(SendFuture、12 バイト)、Suspend2 では時間待ちの Future(Timer、16 バイト)です。Timer のメンバーは、期限の時刻 expires_at(8 バイト)と、一度 poll されたかを表す yielded_once(1 バイト)です。形どうしは同じ領域を重ねて使うので、Future 全体の大きさは各形の合計ではなく、最も大きい形に合わせた 24 バイトになります。 -Zprint-type-sizes の出力(button_task の Future の部分) upvar は async 関数が呼び出し時に受け取った引数、local は本体の中の変数です。引数 button は、本体の中の同名の変数としても並びます。padding は整列のための空きです。{async fn body of …} がコンパイラの生成した Future の型で、__button_task_task_inner_function は #[embassy_executor::task] マクロが元の関数を包むときに付けた名前です。 print-type-size type: {async fn body of __button_task_task::__button_task_task_inner_function()}: 24 bytes, alignment: 8 bytes print-type-size discriminant: 1 bytes print-type-size variant Unresumed: 5 bytes print-type-size padding: 3 bytes print-type-size upvar .button: 1 bytes, alignment: 1 bytes print-type-size variant Suspend0: 23 bytes print-type-size local .count: 4 bytes, offset: 0 bytes, alignment: 4 bytes print-type-size upvar .button: 1 bytes print-type-size local .button: 1 bytes print-type-size padding: 2 bytes print-type-size local .__awaitee: 16 bytes, alignment: 4 bytes, type: {async fn body of Input<'_>::wait_for_falling_edge()} print-type-size variant Suspend1: 19 bytes print-type-size local .count: 4 bytes, offset: 0 bytes, alignment: 4 bytes print-type-size upvar .button: 1 bytes print-type-size local .button: 1 bytes print-type-size padding: 2 bytes print-type-size local .__awaitee: 12 bytes, alignment: 4 bytes, type: embassy_sync::channel::SendFuture<'_, ThreadModeRawMutex, u32, 1> print-type-size variant Suspend2: 23 bytes print-type-size local .count: 4 bytes, offset: 0 bytes, alignment: 4 bytes print-type-size upvar .button: 1 bytes print-type-size local .button: 1 bytes print-type-size padding: 2 bytes print-type-size local .__awaitee: 16 bytes, alignment: 8 bytes, type: embassy_time::Timer print-type-size variant Returned: 5 bytes print-type-size padding: 3 bytes print-type-size upvar .button: 1 bytes, alignment: 1 bytes print-type-size variant Panicked: 5 bytes print-type-size padding: 3 bytes print-type-size upvar .button: 1 bytes, alignment: 1 bytes 子の Future と、末端の型のメンバーは次の行です(同じ出力からの抜粋。… は省略した行)。 print-type-size type: {async fn body of embassy_rp::gpio::Input<'_>::wait_for_falling_edge()}: 16 bytes, alignment: 4 bytes … print-type-size variant Suspend0: 12 bytes print-type-size upvar .self: 4 bytes, offset: 0 bytes, alignment: 4 bytes print-type-size local .__awaitee: 8 bytes, type: {async fn body of Flex<'_>::wait_for_falling_edge()} … print-type-size type: {async fn body of embassy_rp::gpio::Flex<'_>::wait_for_falling_edge()}: 8 bytes, alignment: 4 bytes … print-type-size variant Suspend0: 5 bytes print-type-size upvar .self: 4 bytes, offset: 0 bytes, alignment: 4 bytes print-type-size local .__awaitee: 1 bytes, type: gpio::InputFuture<'_> … print-type-size type: embassy_rp::gpio::InputFuture<'_>: 1 bytes, alignment: 1 bytes print-type-size field .pin: 1 bytes … print-type-size type: embassy_time::Timer: 16 bytes, alignment: 8 bytes print-type-size field .expires_at: 8 bytes print-type-size field .yielded_once: 1 bytes print-type-size end padding: 7 bytes Executor と Waker は何を担当するか Future の poll を呼ぶ実行基盤を Executor と呼びます。Executor は Rust の標準ライブラリになく、Embassy では embassy-executor という crate が提供します。Executor に登録して進める Future をタスク、登録する操作を spawn と呼びます。タスク・spawn も Embassy 側の仕組みです。Executor は、次に poll するタスクへの参照を実行待ち列に持ちます。11 待ちに入ったタスクを後で列に戻すために、「このタスクをもう一度 poll してほしい」と知らせる通知先を使います。これが Waker です。型は標準ライブラリの core::task にあり、通知時の処理は Executor が用意します。12 Executor はタスクの Waker を Context(poll に通知先などを渡す引数)に入れ、待つ Future がドライバなどへ預けます。預かる相手(ピン・キュー・タイマーなど)を、この記事では待ち先と呼びます。13 14 名前 提供元 役割 Future トレイトは Rust の core。async 関数の型はコンパイラが生成 進行位置、変数、await 中の子 Future を保持する Executor Embassy の実行基盤 実行待ち列からタスクを選び、poll する Waker 型は Rust の core、通知時の処理は Embassy 対象タスクの再 poll を依頼する ボタン待ちでは、データの保持先が次のように分かれます。タスクの管理情報と Future を一緒に置くのが、Embassy のタスクの保存領域です。15 Executor └─ 実行待ち列 └─ タスク 1 への参照 └─ タスク 1 の保存領域 ├─ 管理情報:所属 Executor、登録状態など └─ Future:進行位置、count、button、子 Future(__awaitee) GPIO ドライバ └─ 通知先の保存欄 └─ タスク 1 の Waker ├─ 対象:タスク 1 └─ 操作:タスク 1 を実行待ち列へ戻す Future 本体はタスクの保存領域に残ります。実行待ち列に入れたり、列から取り出したりするのは、そのタスクへの参照です。この例では、外側からボタン待ち Future へ同じタスクの Waker が渡ります。内側の Future を await するたびに別タスクが作られるわけではありません。 待ちから再開までの 4 手順 以後、待ちから再開までを S1〜S4 で表します。ボタン待ち・時間待ち・キューの待ちで共通です。部品の番号①〜④とは区別します。 S1 待って制御を返す:Executor がタスクを poll する。待ちが終わっていなければ、待ち先が Waker を預かり、タスクは Pending で戻る。続きに必要な値は Future に残る。 S2 進める仕事がなければ休む:Executor は他のタスクを進め、進められるものがなくなったら CPU を休止させる。他の仕事がある間はこの段を通らない。 S3 通知して列へ戻す:待ち先が Waker を呼び、タスクが実行待ち列へ戻る。通知元は GPIO・タイマーの割り込みや、キューへ値を入れた別タスクなど。CPU が休止中なら、通知に伴って起こされる。16 17 S4 もう一度 poll する:Executor がそのタスクを poll する。待ち先が Ready を返せば続きへ進み、まだ待つなら S1 へ戻る。14 ボタンの例なら、S1 で count = 2 を残して待ち、S3 で GPIO の割り込みハンドラが Waker を呼びます。S4 でボタン待ちが Ready を返して初めて count += 1 が実行され、3 になります。 Waker を呼んだ段階では、Future の続きはまだ実行されていません。 Waker はタスクを実行待ち列へ戻し、Executor が後で poll を呼びます。また、Waker が知らせるのは「もう一度確認してほしい」ということです。完了や結果の値を返すのは poll で、再び Pending になる場合もあります。 タスク専用スタックが不要になる理由 待っている間は poll の呼び出しから戻っています。続きに必要な値を Future に残せるので、呼び出し途中のスタック全体をタスクごとに保存する必要がありません。Future の大きさはコンパイル時に決まります。 poll 中の関数呼び出しにはスタックを使います。この記事の単一 Executor の例では、各タスクが同じスタックを順に使い、Pending で戻るときに明け渡します。R1 の「タスクごとのスタックを予約しない」は、この性質に支えられています。3 枠組みと説明の分け方 Embassy の中心は、先ほどの S1〜S4(待って戻る → 仕事がなければ休む → 通知 → 再 poll)の往復です。 この往復を、4 つの部品が分担します。説明は、①タスク実行 → ②ペリフェラル所有権と割り込み連携 → ③時間 → ④同期・タスク間通信の順に進めます。②〜④はどれも「待ち先に Waker を預かり、起きたら①の列へ戻す」という①の約束に従う部品なので、①を先に置きます。HAL は、チップごとのハードウェア操作をまとめた層です。③の時刻取得とアラーム設定を②の HAL が実装するため、②を③より前にします。④はハードウェアに直接触れず、同じ起床の仕組みをタスク同士の受け渡しに使うので最後にします。 この分け方は、部品ごとに「何を待ち、誰が Waker を呼ぶか」が異なるので、部品ごとに 1 本の記事で範囲を閉じられます。①〜④は、第 2 回『タスク実行編』、第 3 回『ペリフェラル所有権と割り込み連携編』、第 4 回『時間編』、第 5 回『同期・タスク間通信編』で 1 つずつ詳しく扱います。どの回でも、その部品に当たる FreeRTOS の仕組みを並べて、得失を確かめます。 FreeRTOS と何が違うか FreeRTOS の仕組みを、比較に要る分だけ先に説明します。FreeRTOS のタスクは、タスクの情報をまとめた構造体(TCB:Task Control Block)と、タスク専用のスタックを 1 つずつ持ちます。4 処理の途中でも別のタスクへ切り替えることを、プリエンプションと呼びます。切り替えるときは、今のタスクのレジスタをそのタスクのスタックに積み、次に動かすタスクのスタックから戻します。18 動かすのは、実行できるタスクの中で優先度が最も高いものです。19 時間は一定周期のタイマー割り込み(tick)で数えます。20 実行できるタスクがないときはカーネルが作るアイドルタスクが動きますが、既定では CPU を休止させません。休止させるには、利用者がアイドル時の処理か設定を足す必要があります。21 22 Embassy 側も 1 点補います。Executor の poll を割り込みハンドラの中で回すと、その Executor は割り込みの優先度で動き、優先度の低い処理の途中に割り込めます。これを割り込みで動く Executor と呼びます。23 この 2 つの方式を、問いの形で並べます。Embassy 列の休止は、この Pico 2 向け Executor の動作です。安全な HAL API は、unsafe による迂回やレジスタの直接操作を使わない範囲を指します。 問い FreeRTOS Embassy タスクごとのスタックは要るか(R1) 要る。静的確保でヒープなしにもできるが、深さはアプリがタスクごとに決める5 要らない。タスクの状態の大きさはコンパイラが決める。この記事の単一 Executor ではスタックを共有する 処理の途中で別のタスクに切り替わるか 有効にしたプリエンプションで切り替わる24 同じ Executor 内では切り替わらない。高優先度の割り込みで動く別の Executor は割り込める 何もすることがないとき、CPU は既定で休止するか(R2) 休止しない。休止させるには、利用者がアイドル時の処理か設定を足す 休止する。進める仕事がなければ Executor の休止命令で待つ HW の完了待ちの前後をどう書くか(R3) タスク内で待つ API を呼び、前後を上から順に書ける。待つ間は別のタスクが動く タスク内で非同期ドライバを await し、前後を上から順に書ける。Pending で制御を返した間は別のタスクが動く ペリフェラルの競合をどう防ぐか(R4) アプリやドライバが mutex で操作を直列化するか、専用タスクに要求をキューで送る。使い方は利用者が設計する25 安全な HAL API では、ペリフェラルは 1 つだけ存在する値で、二重に渡すとコンパイルエラー。共有する場合は、mutex や専用タスクへのキューを明示的に使う(後の②で説明) 割り込みハンドラから値を渡すとき、何を選び分けるか(R5) 割り込み用の FromISR 版の関数を選ぶ(ISR は割り込みハンドラ)。キューはタスクと同じものを使う26 呼ぶ関数を、待たずにすぐ戻る版(try_send・signal)にする(割り込みハンドラは await できないため)。キューの内部操作を守る排他方式も選ぶ(実装の④で説明)27 28 待ちの前後を上から順に書ける点は、FreeRTOS のタスクでも共通です。Embassy は、それを Rust の async/await と非同期ドライバで実現します。そのうえで、表から読み取れる Embassy の利点は次の 3 つです。 RAM:タスクごとのスタックを予約しないので、タスクの領域はタスクが await をまたいで保持する値の分だけになります。この記事のタスク保存領域は、管理情報 40 バイト+Future 24〜32 バイトで、計 64〜72 バイトでした(動作確認 2)。共有スタックはこの外にあります。比べると、FreeRTOS の Cortex-M33 の port は、切り替え用のレジスタの保存だけでタスクごとに 72 バイトをスタックに積み、その上に関数の呼び出しに要る深さが加わります。29 既定で休止する:待ちの間に CPU を休ませるための追加の設定が要りません。 ペリフェラルの所有権の二重渡しがコンパイル時に分かる:FreeRTOS でアプリやドライバが管理する「誰が操作してよいか」のうち、安全な HAL API を通じた所有権の重複を、実行前に型で検査します。複数のタスクで共有するときの操作の直列化は、別に必要です。 代償も 3 つあります。 協調して制御を返すコードが要る:時間のかかる計算や、即完了する await を繰り返すタスクは、同じ Executor の他のタスクを待たせます。自動的に処理時間を区切ってくれる仕組みはありません。 ブロックする待ちの呼び出し場所に制約がある:完了まで呼び出しから戻らないことを、ここではブロックと呼びます。その間は同じ Executor の他タスクを poll できません。3 HW の完了を待ちながら他タスクも進めるには、待ちを await で書けるドライバを使うか、実行場所を分ける必要があります。 残る見積もりと実行時の検査:タスクごとのスタックはなくなりますが、全タスクが共有する 1 本のスタックの最大使用量は見積もる必要があります。また、キューの共有条件の誤りを、コンパイル時だけですべては検出できません(実装の④)。 一方、FreeRTOS は、プリエンプションを有効にし、割り込みやスケジューラが抑止されていなければ、時間のかかるタスクが混ざっても優先度の高いタスクへ切り替えられます。xQueueReceive() のような待ち API も、待つタスクを実行対象から外して他のタスクへ CPU を渡します。その代わり、利用者はタスクごとのスタック容量と休止の設定を決めます。スタック容量の検査・保護の比較は第 2 回『タスク実行編』で扱います。 対象読者 Rust の基礎文法と、マイコン開発(GPIO や割り込みを扱った経験)がある方を対象にします。async/await は「はじめに」の章の「async/await と Future」の節で、FreeRTOS の仕組みは「FreeRTOS と何が違うか」の節で、必要な分だけ説明しています。FreeRTOS を使った経験は前提にしません。 想定する環境は次のとおりです。 項目 内容 ホスト Ubuntu 26.04.1 LTS(WSL2) Rust 1.97.1(対象 commit の rust-toolchain.toml の指定)、ターゲット thumbv8m.main-none-eabihf ELF の調査 GNU binutils 2.45.50(arm-none-eabi-size・arm-none-eabi-nm) ボード Raspberry Pi Pico 2(RP2350、Arm Cortex-M33)。GPIO16 と GND の間にボタン、LED は基板上の GPIO25 書き込み・ログ Raspberry Pi Debug Probe と probe-rs 0.32.0 で書き込み、ログを受け取る。Embassy の RP2350 用サンプルと同じ構成です30。「Pico 2 で動かす手順」の節に実行コマンドを載せます PC 上での確認 同じ例を Embassy の std 向け Executor で動かす版(動作確認 1) 比較に使う FreeRTOS FreeRTOS-Kernel V11.3.1 のソース。Cortex-M33 用の port portable/GCC/ARM_CM33_NTZ を読む。RP2350 向けには FreeRTOS-Kernel-Community-Supported-Ports に Pico SDK 用の SMP port もありますが、この記事で比較するのは前述の Cortex-M33 用 port です31。FreeRTOS はビルド・実行していません 動作確認用コード 記事中に載せます。Pico 2 用は pico2/、PC 用は host/ の 2 つの crate で、各動作確認のコマンドの前に実行するディレクトリを書きます。どちらの Cargo.toml も、Embassy を対象 commit の git 依存で指定します。pico2/ の memory.x・build.rs・.cargo/config.toml は、Embassy の examples/rp235x の同名ファイルをそのまま使います32 用語集 本文で繰り返し使う語だけをまとめます。「はじめに」の章で説明した語も、読み返すときのためにここに並べます。前提になる語から並べます。 用語 意味 Future async 関数を呼ぶと返る値。どこまで進んだかの番号と、await をまたぐ変数・await 中の子 Future を持つ。Future トレイトは Rust の標準ライブラリにある poll Future トレイトのメソッド。Future を進め、待つ必要があれば Pending、完了したら Ready(結果) を返す タスク Executor に登録して並行に進める Future。Embassy では #[embassy_executor::task] を付けた async 関数 spawn タスクを Executor に登録する操作 Executor タスクを poll して進める実行基盤。Rust の標準ライブラリにはなく、Embassy が提供する。1 つのスタックの上で全タスクを順に進める 待ち先 タスクが待っている相手(ピン・キュー・タイマーなど)。待っている間、そのタスクの Waker を預かる Waker 待ち先に預ける「進められるようになったら知らせる先」。呼ぶと、そのタスクが実行待ち列へ戻る 実行待ち列 登録時や Waker による通知で入り、次に poll されるのを待つタスクへの参照の列 HAL Hardware Abstraction Layer。チップごとのペリフェラル操作をまとめた層。この記事では RP2350 用の embassy-rp 要求をどういう風に実現するか この章では、ファイル名や関数名を使わずに、4 つの部品の役割と 1 往復の流れを説明します。表と図では、CPU を介さずにメモリとペリフェラルの間でデータを転送する機能を DMA、タスクを①に登録する操作を spawn と書きます。 機能一覧 番号 機能 応える要求 概要 ① タスク実行 R1・R2 タスクを静的な領域に置き、実行待ち列にあるタスクだけを poll する。列が空なら CPU を休止させる ② ペリフェラル所有権と割り込み連携 R3・R4 ペリフェラルを所有者 1 つの値として配り、割り込みや DMA の完了で待ち中のタスクを起こす ③ 時間 R2・R3 「指定時刻に起こす」予約を受け付け、タイマーの割り込みでタスクを起こす ④ 同期・タスク間通信 R4・R5 容量を固定したキューや通知で、受け渡しの「満杯」「空」を待ちとして表す 全体図 上がアプリケーション、下がハードウェアです。矢印の横に、部品の間で受け渡すもの(インターフェース)を書きました。タスクは②③④のどれかを await して、その待ち先に Waker を預けます。待ち先は条件がそろうと Waker を呼び、タスクへの参照を①の実行待ち列へ戻します。time driver は、③が②に求める「現在時刻を返す」「指定時刻に起こす」の約束です。休止命令は、①が CPU に出す合図です。休止した CPU は、割り込みで起きます。 ■ アプリ タスク1:ボタン待ち → 押下回数を送る タスク2:押下回数を受け取る → LED を点滅させる │ spawn ▲ poll │ await(待ち先を選ぶ) ▼ │ ├─→ ④ 同期・タスク間通信:キュー ■ ① タスク実行 ├─→ ③ 時間:指定時刻まで待つ 実行待ち列 └─→ ② ペリフェラル所有権と割り込み連携 ▲ ③ ─→ ② time driver └────────── Waker ────────────── ②・③・④の待ち先 │ 休止命令 │ レジスタ操作 ▲ 割り込み ▼ ▼ │ ■ HW:CPU コア / 割り込みコントローラ / タイマー / GPIO 図の解説 先に、具体例を時系列で示します。S1〜S4 は導入で定めた待ちから再開の手順、①〜④は担当する部品です。図の右側は②〜④への待ちの分岐、左向きの Waker は①の実行待ち列へ戻す通知を表します。CPU の休止も割り込みによる再開も HW が担います。 起動時に、タスク 1 とタスク 2 を①へ登録(spawn)する。 タスク 2 は、キューが空なので④のキューに Waker を預けて待つ(S1)。 タスク 1 は、ボタンのピンが変わるまで②に Waker を預けて待つ(S1)。 ほかに進めるタスクがなくなり、①が CPU を休止させる(S2)。 ボタンが押されると GPIO の割り込みで CPU が休止から起きる。②のハンドラがタスク 1 の Waker を呼んで実行待ち列へ戻し、ハンドラから戻った①の Executor がタスク 1 を poll する(S3・S4)。 タスク 1 がキューに値を入れると、④がタスク 2 の Waker を呼び、タスク 2 が実行待ち列へ戻る(S3)。タスク 1 が次の待ちに入って制御を返すと、タスク 2 が poll される(S4)。 タスク 2 は LED を点け、点滅間隔の分だけ③に起床を予約して待つ(S1)。時刻が来るとタイマーの割り込みで③が Waker を呼び、タスク 2 が再開する(S3・S4)。 次に、図の要素ごとの役割と、FreeRTOS で同じ役割を担う仕組みを示します。 アプリケーション層:タスク 1 とタスク 2 は、どちらも async 関数です。起動時に①へ登録(spawn)され、以後は①から poll されて少しずつ進みます。 ① タスク実行:実行待ち列にあるタスクだけを poll し、列が空なら CPU に休止命令を出して、割り込みが来るまで止まります。タスクは 1 つのスタックの上で順に poll されるので、タスクごとのスタックは要りません。FreeRTOS では、優先度の最も高いタスクを選び、レジスタをタスクごとのスタックへ積み替えて切り替えます。 ④ 同期・タスク間通信:キューは、空のときは受信するタスクの Waker を預かり、値が入るとそれを呼びます。ハードウェアには直接触れません。FreeRTOS のキューは、Waker の代わりに、待つタスクそのものを「このキューを待つタスクの一覧」に載せます。33 34 ② ペリフェラル所有権と割り込み連携:ピンは、起動時に HAL の初期化が 1 度だけ返す値で、タスク 1 に move で渡します。ピンの待ち先が Waker を預かり、GPIO の割り込みで HAL の割り込み処理がそれを呼ぶので、アプリは割り込みハンドラにピンを配る必要がありません。FreeRTOS では、割り込みハンドラが割り込み用の API で、待っているタスクに通知します。 ③ 時間:③は「この時刻に起こして」という予約を受け、②の time driver を通じてタイマーのアラームを設定し、その割り込みで Waker を呼びます。FreeRTOS では、一定周期の tick 割り込みで時刻を進め、待ち時間を過ぎたタスクを実行できる状態に戻します。 HW:CPU コアは休止命令で止まり、割り込みで動き出します。割り込みコントローラが、タイマーと GPIO の割り込みを CPU へ届けます。 Executor の置き方ごとの違い 1 つの Executor の中では、タスクは自分から制御を返すまで他のタスクに切り替わりません(協調的マルチタスク)。Embassy Book も、タスクが無期限にブロックすると Executor が制御を取り戻せないと注意しています。3 この制約を緩めるために、Executor の置き方には 2 つのパターンがあります。この記事の例は、単一の Executor だけを使います。 パターン 内容 向いている場面 単一の Executor 全タスクを 1 つの実行待ち列で順に進める。待ちの間は CPU を休止させる この記事の例のように、各タスクがすぐ待ちに入る場合 優先度の異なる複数の Executor 割り込みハンドラの中でタスクを poll する Executor(割り込みで動く Executor)を優先度ごとに置く。優先度の高い Executor のタスクが、低い方のタスクの途中に割り込んで進む 応答を急ぐタスクと、時間のかかるタスクが混在する場合 協調実行は、長い計算や既存のブロッキング API をそのまま混ぜたい場合には制約になります。同じ Executor で進めるなら、計算を短い単位に分割し、区切りで明示的に制御を返す方法があります。具体的な関数は、後の実装の①で示します。 ただし、この方法は計算を自動で分割せず、HW の完了を省電力で待つ方法でもありません。 優先度の異なる複数の Executor は、「急ぐタスクだけ高優先度の割り込みで動く Executor に置く」ことで、時間のかかるタスクが制御を返すのを待たずに済むようにします。23 割り込み禁止中には高優先度側も割り込めません。同じ高優先度 Executor のタスク同士も、互いが制御を返すまで待ちます。FreeRTOS では、プリエンプションを有効にした上でタスクごとに優先度を付けます。Embassy では、それに加えて Executor の配置も利用者が決めます。具体的な対処と限界は、第 2 回『タスク実行編』で扱います。 設計をどういう風に実装したか? 前の章の①〜④が、Embassy のどの crate・ファイルにあるかを対応づけます。関数単位の詳しい処理は、第 2 回以降で扱います。 Embassy は部品ごとに crate(Rust のパッケージ)を分けています。①のタスク領域・実行待ち列は embassy-executor、Pico 2 で poll と休止を繰り返す外側の Executor は embassy-rp が提供します。②は embassy-rp と HAL 共通の embassy-hal-internal、③は embassy-time、④は embassy-sync が中心です。 対応表 番号 機能 実現方法 ソースの場所(crate) ① タスク実行 #[embassy_executor::task] がタスクの静的な領域を作り、Spawner::spawn で登録する。Executor が実行待ち列のタスクを poll し、Pico 2 では embassy-rp の Executor が休止命令を扱う embassy-executor、embassy-rp 15 35 36 37 38 ② ペリフェラル所有権と割り込み連携 embassy_rp::init が全ペリフェラルを 1 度だけ値(Peri)として返す。GPIO の割り込みハンドラが、ピンごとに預かった Waker を呼ぶ embassy-hal-internal、embassy-rp 39 40 41 42 16 ③ 時間 Timer が期限と Waker を time driver に渡す。embassy-rp の time driver が、タイマー TIMER0 のアラームで起こす embassy-time、embassy-time-driver、embassy-rp 43 44 45 ④ 同期・タスク間通信 Channel が固定長のキューと、送信側・受信側の Waker の登録先を持つ embassy-sync 46 47 コラム:表と図の名前は何をするか ①:Spawner はタスクを Executor に登録する窓口です。wfe は CPU を休止させる命令です。 ②:Peri<'d, T> は、ペリフェラル T の所有者を表す値です。'd はライフタイムで、この値が有効な期間を表します。AtomicWaker は Waker を 1 つ預かる入れ物です。型の定義元は④の embassy-sync ですが、②の GPIO がピンごとに保持して使います。48 ③:Timer は時間待ちの Future です。time driver は embassy-time-driver が定める「時刻取得」「起床予約」の約束で、embassy-rp がタイマー TIMER0 を使って実装します。 ④:Channel はタスク間で値を渡すキューです。 関係:②の AtomicWaker、③の time driver、④の Channel は、どれも預かった Waker を呼びます。Waker を呼ぶと、①がそのタスクを実行待ち列に戻します。つまり②〜④はそれぞれ違う出来事を待ちますが、①への戻し方は同じです。 全体図 関係図 「要求をどういう風に実現するか」の章の全体図と同じ配置で、各部品に crate 名、境界に実装上の名前を書きました。HW の行には、RP2350 での名前を書きます。NVIC は Cortex-M の割り込みコントローラ、wfe(Wait For Event)は割り込みか起床イベント(割り込みを使わずに休止中のコアを起こす合図)が来るまでコアを休止させる命令、sev(Send Event)は起床イベントを発行する命令、TIMER0 は RP2350 のタイマー、IO_BANK0 は RP2350 の GPIO の制御ブロックです。 ■ アプリ(#[embassy_executor::task] の async fn) タスク1:button_task タスク2:led_task │ spawn ▲ poll │ await(待ち先を選ぶ) ▼ │ ├─→ ④ embassy-sync:Channel ■ ① タスク実行 ├─→ ③ embassy-time:Timer 実行待ち列 └─→ ② embassy-rp:GPIO ▲ ③ ─→ ② Driver └────────── Waker ────────────── ②・③・④の待ち先 │ wfe │ レジスタ操作 ▲ 割り込み ▼ ▼ │ ■ HW:Cortex-M33 / NVIC / TIMER0 / IO_BANK0 ソースツリー 関係図に出てきた crate のうち、通し例の 1 往復が通るフォルダとファイルだけを示します。CPU ごとの Executor を置く embassy-executor/src/platform/(この例は代わりに embassy-rp/src/executor.rs を使います)、DMA などのドライバ、ほかの crate は省略し、第 2〜5 回の各回の全体像で示します。 embassy/ ├── embassy-executor/ │ ├── src/spawner.rs │ └── src/raw/ │ ├── mod.rs │ ├── run_queue.rs │ └── waker.rs ├── embassy-executor-macros/ ├── embassy-time/ │ └── src/timer.rs ├── embassy-time-driver/ ├── embassy-sync/ │ ├── src/channel.rs │ ├── src/blocking_mutex/ │ └── src/waitqueue/ ├── embassy-hal-internal/ └── embassy-rp/ ├── src/lib.rs ├── src/executor.rs ├── src/gpio.rs └── src/time_driver.rs 各フォルダ・ファイルの意味合いと責務は、次の表にまとめました。 ソースツリーの各フォルダ・ファイルの責務(表) フォルダ・ファイル 意味合い 実装しているもの 責務 機能 embassy/ リポジトリの最上位 crate ごとのフォルダ 部品ごとに crate を分けて置く 全体 embassy-executor/ どの HAL でも使う実行基盤 タスク領域・実行待ち列・Waker・Spawner タスクを進め、待ちを休止につなぐ ① embassy-executor/src/spawner.rs タスク登録の入口 Spawner 静的な領域のタスクを Executor に渡す ① embassy-executor/src/raw/ Executor の内部実装 HAL や待ち先が直接使う低レベルの部品 タスク領域・列・Waker の実体を持つ ① embassy-executor/src/raw/mod.rs Executor の中核 TaskStorage・TaskPool・poll タスクの状態を持ち、poll を駆動する ① embassy-executor/src/raw/run_queue.rs 実行待ち列 起こされたタスクを積む連結リストと、一括取り出し 起こされたタスクだけを poll に回す ① embassy-executor/src/raw/waker.rs Waker の実体 タスクへのポインタから Waker を作る処理 待ち先からの起床をタスクへ戻す ①(②〜④との境界) embassy-executor-macros/ タスクと main を作るマクロ #[embassy_executor::main]・#[embassy_executor::task] タスク領域の静的な生成と、Executor の起動 ① embassy-time/ 時間の API 時刻・期間の型と時間待ちの Future アプリに時間を提供する ③ embassy-time/src/timer.rs 時間待ちの API Timer 指定時刻まで待つ Future ③ embassy-time-driver/ 時間の下層との境界 Driver トレイト HAL が実装する「時刻取得」「起床予約」の約束 ③(②との境界) embassy-sync/ 同期・通信の部品 キュー・通知・排他・待ち先の登録 タスク同士、タスクと割り込みの受け渡し ④ embassy-sync/src/channel.rs タスク間の受け渡し 固定長キューと送受信の Future 満杯・空を待ちとして表す ④ embassy-sync/src/blocking_mutex/ 排他の強さの選択 RawMutex とその実装 共有相手に合う排他方式を型で選ぶ ④ embassy-sync/src/waitqueue/ 待ち先の登録 AtomicWaker Waker を預かり、呼ぶ部品(②でも使う) ④・② embassy-hal-internal/ HAL 共通の土台 Peri・割り込みの型 所有権と割り込みの結び付けの共通部分 ② embassy-rp/ RP2040・RP235x 用の HAL ペリフェラルのドライバ・Executor・time driver チップ固有の操作を①〜③の約束に合わせる ①〜③ embassy-rp/src/lib.rs RP 用 HAL の入口 init・bind_interrupts! ペリフェラルの一括取得と初期化、割り込みの登録 ② embassy-rp/src/executor.rs RP 用の Executor wfe で休止する Executor Pico 2 での①の実体 ① embassy-rp/src/gpio.rs GPIO ドライバ エッジ待ちの Future と割り込みハンドラ ピンの変化を起床へつなぐ ② embassy-rp/src/time_driver.rs RP 用 time driver TIMER0 のアラームとタイマー待ち列 ③の下層の実体 ③ 図の解説 各項目の後に、FreeRTOS で同じ役割を担うコード → Embassy のコードの順に抜粋を並べます。抜粋の中の … は省略した行です。 アプリ:#[embassy_executor::task] を付けた async 関数がタスクになります。タスク 1・タスク 2 は、この記事の通し例(「動かして確かめる」の節の冒頭に全文)の button_task・led_task です。 ① タスク実行(embassy-executor と embassy-rp):#[embassy_executor::task] を付けた関数ごとに、マクロが static POOL を作ります。49 POOL は、タスクの管理情報と Future を組にした TaskStorage の配列です。15 Executor は実行待ち列のタスクを poll します。計算を区切って明示的に制御を返すには embassy_futures::yield_now().await を使えます。50 列の取り出し順・公平性と優先度は、第 2 回『タスク実行編』で扱います。 FreeRTOS では、タスクを静的に作るときに、アプリがスタックの領域と TCB の領域を渡し、スタックの深さを決めます(下の宣言の最後の 2 引数と uxStackDepth)。51 切り替えは、切り替え用の例外 PendSV の中で行い、今のタスクのスタックにレジスタを積んでから、次のタスクを選ぶ vTaskSwitchContext を呼びます。18 比較 port のレジスタ保存とスタック保護 この port(メモリ保護・セキュリティ分離の追加機能を使わない構成(MPU・TrustZone なし))は、タスクを作るときに切り替え用の保存領域として 18 ワード(72 バイト)をスタックに積みます。29 積む値にはスタックの下限(psplim)も含まれ、切り替えのたびに下限も切り替えて、スタックの溢れを CPU が検出できるようにしています。52 TCB の大きさはビルドしていないので確かめていません。 // FreeRTOS-Kernel: tasks.c:1320-1326 TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode, const char * const pcName, const configSTACK_DEPTH_TYPE uxStackDepth, void * const pvParameters, UBaseType_t uxPriority, StackType_t * const puxStackBuffer, StaticTask_t * const pxTaskBuffer ) PendSV_Handler のアセンブリ(読みたい人向け) 読むのは stmdb(レジスタを今のタスクのスタックへ積む)と bl vTaskSwitchContext(次のタスクを選ぶ)の 2 行です。 // FreeRTOS-Kernel: portable/GCC/ARM_CM33_NTZ/non_secure/portasm.c:424・432-434・469(PendSV_Handler、MPU なしの構成) " mrs r0, psp \n" /* Read PSP in r0. */ … " mrs r2, psplim \n" /* r2 = PSPLIM. */ " mov r3, lr \n" /* r3 = LR/EXC_RETURN. */ " stmdb r0!, {r2-r11} \n" /* Store on the stack - PSPLIM, LR and registers that are not automatically saved. */ … " bl vTaskSwitchContext \n" Embassy のタスクの領域は、タスクの情報(TaskHeader)と Future だけで、スタックの領域を持ちません。15 下の抜粋で見るのは、フィールドが raw(タスクの情報)と future(Future 本体)の 2 つだけで、スタック用のフィールドがないことです。 // embassy-executor/src/raw/mod.rs:220-224 #[repr(C)] pub struct TaskStorage<F: Future + 'static> { raw: TaskHeader, future: UninitCell<F>, // Valid if STATE_SPAWNED } 「Executor と Waker は何を担当するか」の図を実装名へ対応づけると、次の配置になります。TaskHeader の executor は所属先、poll_fn はそのタスクを poll する関数、run_queue_item は列へつなぐための情報です。53 TaskStorage ├─ raw: TaskHeader │ ├─ executor:所属 Executor へのポインタ │ ├─ poll_fn:このタスクを poll する関数 │ └─ state・run_queue_item など:登録状態と列の管理情報 └─ future:async 関数の状態を保持する値 GPIO ドライバの通知先保存欄(AtomicWaker) └─ Waker:このタスクを実行待ち列へ戻す通知先 Executor はタスクを poll する際、そのタスクの Waker を作って Context(poll に渡す通知先などをまとめた引数)へ入れます。ボタン待ち Future は cx.waker() で通知先を取り出し、GPIO の AtomicWaker に登録します。13 14 count や進行位置は Future 側に残り、GPIO ドライバの通知先保存欄には Waker が置かれます。 Waker の実データ Waker は、対象のデータを指す data ポインタと、通知などを行う関数の表 vtable を組にした値です。12 Embassy では data はタスクの管理情報へのポインタで、関数の表にはタスクを実行待ち列へ戻す処理が入ります。37 17 GPIO の通知先保存欄は Waker を預かるもので、Future 本体を移す場所ではありません。 休止命令と起床イベント(wfe/sev):この例の embassy-rp の Executor は「poll → wfe」を繰り返します。54 sev は CPU にイベントの印を残します。wfe は印があれば消して直ちに進み、なければイベントや割り込みを待ちます。命令を実行することと、実際に休止することは別です。 poll 中に仕事が追加され、先に sev が出た場合も、次の wfe は眠らずに次の poll へ進めます。55 空だった列にタスクが積まれたときに sev を出す仕組みは、第 2 回『タスク実行編』で詳しく扱います。56 57 FreeRTOS では、アイドル時に呼ぶ利用者の関数(idle hook)か、tickless idle(待つ間の周期 tick 割り込みを止める方式)を設定します。tickless idle は、有効でも予想休止時間などの条件を満たしたときだけ休止へ進みます。21 Cortex-M33 の比較 port は、その中で割り込みを待つ休止命令 wfi を使います。58 下の #if は、条件が偽ならその区間をビルドから外す C の指定です。読むのは vApplicationIdleHook() と portSUPPRESS_TICKS_AND_SLEEP(...) の 2 つの呼び出しです。両設定を無効にすると、この 2 つの処理は組み込まれません。アイドルタスク自体には、それ以外の処理もあります。 // FreeRTOS-Kernel: tasks.c:5969-5974・5980-6024(抜粋) #if ( configUSE_IDLE_HOOK == 1 ) { vApplicationIdleHook(); } #endif … #if ( configUSE_TICKLESS_IDLE != 0 ) { … if( xExpectedIdleTime >= ( TickType_t ) configEXPECTED_IDLE_TIME_BEFORE_SLEEP ) { … portSUPPRESS_TICKS_AND_SLEEP( xExpectedIdleTime ); … } … } #endif Embassy のこの Executor は、休止を有効にする設定なしで wfe を実行します。読むのは loop の self.inner.poll() と asm!("wfe") の 2 行です。trace_idle() は計測用で、-> ! は戻らない関数を表します。より深い省電力状態に追加の処理が要るチップでは、どちらの方式でもその処理が必要です。59 // embassy-rp/src/executor.rs:127-137 pub fn run(&'static mut self, init: impl FnOnce(Spawner)) -> ! { init(self.inner.spawner()); loop { unsafe { self.inner.poll(); embassy_executor::trace_idle(); asm!("wfe"); }; } } ② embassy-hal-internal と embassy-rp:embassy_rp::init は、最初に全ペリフェラルを 1 度だけ取り出し、2 回目の呼び出しでは panic します。42 39 取り出したペリフェラルは Peri<'d, T> という所有者付きの値で、コピーできません。40 GPIO のエッジ待ちは、ピンごとの AtomicWaker に Waker を登録して割り込みを有効にし14、GPIO の割り込み IO_IRQ_BANK0 のハンドラが該当ピンの Waker を呼びます。16 GPIO 以外の割り込み接続 DMA や UART のように HAL が自動で割り込みを結ばないペリフェラルは、bind_interrupts! で割り込みとハンドラの組を型として宣言します。41 60 タスクに分けても所有権を検査できる理由:Embassy のタスクは、同じファームウェアの中でコンパイルする Future です。タスク関数の引数に渡した値は Future が保持するので、init が返した p.PIN_25 をタスク 2(LED)に渡すと、所有権はその Future に移り、別のタスクにも渡そうとするとコンパイルエラーになります。49 15 これは実行時のロックではなく、「最初の取得は 1 度だけ」(実行時に検査)と「取得した値はコピーできず二重に渡せない」(コンパイル時に検査)の組み合わせです。unsafe の入口やレジスタの直接操作で迂回した場合までは守りません。61 62 一時的に貸す方法と迂回の詳細は、第 3 回『ペリフェラル所有権と割り込み連携編』で扱います。 共有が必要なときの排他:複数タスクで同じ I2C バスなどを使うなら、1 つのドライバの操作を、使用権が空くまで await できる非同期 Mutex で守るか、ドライバを所有する専用タスクへ Channel で要求を送ります。非同期 Mutex は、後述するキュー内部の短い操作を守る RawMutex とは別の部品です。63 FreeRTOS でも、mutex と専用タスク+キュー(gatekeeper)で同じ直列化をします。25 共有する場合の設計と排他は、第 5 回『同期・タスク間通信編』で詳しく扱います。 メモリ保護と操作の直列化の違い FreeRTOS の MPU 版 port はタスクごとのアクセス権を制限できますが、2 つのタスクに同じ領域を許せば両方が触れるので、操作を順番に行わせる mutex やキューの代わりにはなりません。64 65 割り込みからの起床では、FreeRTOS の割り込みハンドラ(アプリかドライバが書く)が、割り込み用の API で待っているタスクに通知します。起こしたタスクの優先度が今のタスクより高ければ、ハンドラの最後に portYIELD_FROM_ISR() で PendSV の発生を予約して切り替えます。66 67 68 69 portYIELD_FROM_ISR の定義(読みたい人向け) 読むのは portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; の行です。割り込みコントローラの制御レジスタに PENDSVSET ビットを書くことが、PendSV の保留に当たります。 // FreeRTOS-Kernel: portmacrocommon.h:424-437(抜粋) … portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; … #define portYIELD_FROM_ISR( x ) portEND_SWITCHING_ISR( x ) Embassy の GPIO の割り込みハンドラは、変化のあったピンの割り込みを止めてから(抜粋の write_clear の部分)、そのピンの AtomicWaker を呼ぶ(wakers[pin].wake())だけです。切り替えの要求は書きません。Waker がタスクを実行待ち列に戻し、割り込みから戻った Executor がそのタスクを poll します。 // embassy-rp/src/gpio.rs:256-264(irq_handler の中) if event != 0 { proc_intx.inte(pin / 8).write_clear(|w| { … }); wakers[pin].wake(); } ③ embassy-time と embassy-time-driver の Driver:Timer は、最初の poll か期限前の poll で schedule_wake に期限と Waker を渡して Pending を返し、期限を過ぎてから poll されると完了します。43 schedule_wake は embassy-time-driver の Driver が定める操作です。embassy-rp の time driver は、起床予約(期限と Waker)を並べたタイマー待ち列から、最も早い時刻を TIMER0 のアラームに設定し、TIMER0_IRQ_0 割り込みでタイマー待ち列を進めます。45 time driver の結合方法 embassy-time は time driver の中身を知らずにビルドでき、最後にプログラムをつなぎ合わせる段階(リンク)で HAL の実装と結びつきます。44 FreeRTOS では、SysTick(Cortex-M の周期タイマー)が configTICK_RATE_HZ の周期で割り込み70、ハンドラが xTaskIncrementTick() で時刻を 1 tick 進めます。待ち時間を過ぎたタスクが実行できるようになり、切り替えが要ると判断したら PendSV を保留させます。20 tickless idle を有効にしない限り、この割り込みは待つタスクがなくても毎周期起きます。下では xTaskIncrementTick() と PENDSVSET への書き込みを見ます。!= pdFALSE は「切り替えが必要」という判定です。 // FreeRTOS-Kernel: portable/GCC/ARM_CM33_NTZ/non_secure/port.c:1088-1094(SysTick_Handler の中) /* Increment the RTOS tick. */ if( xTaskIncrementTick() != pdFALSE ) { traceISR_EXIT_TO_SCHEDULER(); /* Pend a context switch. */ portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; } Embassy の Timer は、期限前に poll されると、期限の時刻と Waker を time driver に渡すだけです。周期的な割り込みではなく、予約された最も早い時刻にアラームを設定します。43 45 下の抜粋で読むのは、else 側の schedule_wake(期限, cx.waker()) の行です。cx は poll に渡される引数で、ここから自分のタスクの Waker を取り出せます。Pin<&mut Self>・Context は poll の決まった引数の形です。as_ticks() の tick は時刻の単位で、この time driver では 1 tick が 1 µs です。周期的な tick 割り込みを発生させるという意味ではありません。 // embassy-time/src/timer.rs:304-315 impl Future for Timer { type Output = (); fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { if self.yielded_once && self.expires_at <= Instant::now() { Poll::Ready(()) } else { embassy_time_driver::schedule_wake(self.expires_at.as_ticks(), cx.waker()); self.yielded_once = true; Poll::Pending } } } ④ embassy-sync:Channel は固定長キューと送受信側の Waker の登録先を持ちます。46 通し例の型は次のとおりです。 Channel<ThreadModeRawMutex, u32, 1> // M(内部操作の排他) T(値) N(容量) Channel<M, T, N> の M は、キュー内部の短い操作をどう守るかを選びます。この例では単一コアの通常実行で、同じ Executor の 2 タスクだけが共有するため、ThreadModeRawMutex を使います。47 割り込みハンドラとも共有するなら CriticalSectionRawMutex など、その共有範囲を守れる方式へ変える必要があります。型名だけで共有範囲の誤りをすべて検出できるわけではありません。 RawMutex の検査と共有範囲 通常実行とは、割り込みハンドラの外のスレッドモードです。ThreadModeRawMutex はスレッドモードかを実行時に検査し、割り込みハンドラから触ると停止します。一方、2 コアの通常実行を区別する検査ではありません。71 72 CriticalSectionRawMutex は、割り込みなどに中断されずに行う区間(クリティカルセクション)で内部操作を守ります。複数コア間でも守れるかはその実装に依存します。型の選択と実行環境の組み合わせは第 5 回『同期・タスク間通信編』で扱います。 FreeRTOS の xQueueReceive() は、キューが空で待ち時間が残っていれば、受信するタスクをキューの「受信待ちの一覧」に載せ、実行対象から外して他のタスクへ切り替えます。33 待ちの間、そのタスクの続きはタスクのスタックに残ります。下では受信待ちの一覧へ登録する vTaskPlaceOnEventList の行を見ます。== pdFALSE は「まだ時間切れでない」、!= pdFALSE は「まだ空である」という条件です。 // FreeRTOS-Kernel: queue.c:1609-1616(xQueueReceive の中) if( xTaskCheckForTimeOut( &xTimeOut, &xTicksToWait ) == pdFALSE ) { /* The timeout has not expired. If the queue is still empty place - the task on the list of tasks waiting to receive from the queue. */ if( prvIsQueueEmpty( pxQueue ) != pdFALSE ) { … vTaskPlaceOnEventList( &( pxQueue->xTasksWaitingToReceive ), xTicksToWait ); Embassy の Channel は、空なら受信側の Waker を登録して Pending を返します。タスクの続きは Future の中に残して待ち、poll の呼び出しは Executor へ戻ります。Executor はその間に別のタスクを進められます。下では else 側の receiver_waker.register(cx.waker()) と Poll::Pending を見ます。 // embassy-sync/src/channel.rs:746-757 fn poll_receive(&mut self, cx: &mut Context<'_>) -> Poll<T> { … // 取り出す前の送信側への通知は省略 if let Some(message) = self.queue.pop_front() { Poll::Ready(message) } else { self.receiver_waker.register(cx.waker()); Poll::Pending } } HW:Cortex-M33 は wfe で休止し、sev か割り込みで起きます。NVIC が TIMER0(③のアラーム)・IO_BANK0(②の GPIO)の割り込みを CPU へ届け、各割り込みのハンドラが②③の Waker を呼びます。 動かして確かめる 動作確認には、Pico 2 で動かす通し例を使います。先にプログラムを示します。動作確認 1 では同じ 2 タスクを PC 上で動かし、動作確認 2・3 では Pico 2 用の crate をビルドします。 通し例のプログラム 「はじめに」の章の疑似コードを、Embassy で書いたものです。ログを出す行(info!)を除き、疑似コードと同じ待ちに (a)〜(d) を付けました。読むのは 2 つのタスク関数と、main の spawn です。実際のファイルの全文は、このすぐ後の折りたたみにあります。 // pico2/src/bin/button_led.rs(ログの行を除いたもの) #![no_std] #![no_main] use defmt::unwrap; use embassy_executor::Spawner; use embassy_rp::gpio::{Input, Level, Output, Pull}; use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex; use embassy_sync::channel::Channel; use embassy_time::Timer; use {defmt_rtt as _, panic_probe as _}; /// タスク 1 からタスク 2 へ押下回数を渡すキュー(容量 1) static PRESSED: Channel<ThreadModeRawMutex, u32, 1> = Channel::new(); const DEBOUNCE_MS: u64 = 50; const BLINK_MS: u64 = 100; const BLINK_COUNT: u32 = 3; #[embassy_executor::task] async fn button_task(mut button: Input<'static>) { let mut count = 0; loop { button.wait_for_falling_edge().await; // (a) count += 1; PRESSED.send(count).await; // (b) Timer::after_millis(DEBOUNCE_MS).await; } } #[embassy_executor::task] async fn led_task(mut led: Output<'static>) { loop { let count = PRESSED.receive().await; // (c) for _ in 0..BLINK_COUNT { led.set_high(); Timer::after_millis(BLINK_MS).await; // (d) led.set_low(); Timer::after_millis(BLINK_MS).await; // (d) } } } #[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()); let button = Input::new(p.PIN_16, Pull::Up); let led = Output::new(p.PIN_25, Level::Low); spawner.spawn(unwrap!(button_task(button))); spawner.spawn(unwrap!(led_task(led))); } pico2/src/bin/button_led.rs の全文(ログの行を含む)// pico2/src/bin/button_led.rs //! この記事の通し例:ボタン押下を待つタスクと、通知を受けて LED を点滅させるタスク。 //! GPIO16 と GND の間にボタンをつなぐ(内部プルアップで、押すと Low になる)。LED は Pico 2 の GPIO25。 #![no_std] #![no_main] use de

  3. @50riv_ いいえ、メジャーな実装な上にRust界隈ではこれをもってunsafeによるダーティなドライバを否定しているようです。 embassy-rs/embassy

    1 like · 110 views

Achievements

Awards earned by the repositories embassy-rs owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.