はじめに
ハードウェアアクセラレーションを備えた Rust RTOS
リアルタイムシステムを構築するための並行性フレームワーク
はじめに
この本には、Real-Time Interrupt-driven Concurrency (RTIC) フレームワークのユーザー向けドキュメントが含まれています。API リファレンスはこちらで利用できます。
これは RTIC v2.x のドキュメントです。
以前のリリース: RTIC v1.x | RTIC v0.5.x(非サポート) | RTFM v0.4.x(非サポート)
RTIC は RTOS ですか?
RTIC が RTOS かどうかはよくある質問であり、あなたのバックグラウンドによって答えは異なる場合があります。RTIC の開発者の観点では、RTIC は、より古典的なソフトウェアカーネルではなく、Cortex-M MCU 上の NVIC や RISC-V 上の CLIC などのハードウェアを利用してスケジューリングを実行する、ハードウェアアクセラレーションされた RTOS です。
コミュニティにおけるもう 1 つの一般的な見方では、RTIC にはソフトウェアカーネルが存在せず、外部 HAL に依存していることから、RTIC は並行性フレームワークであるとされています。
RTIC - 過去、現在、そして未来
このセクションでは、RTIC モデルの背景を説明します。要点だけ知りたい場合は、セクション RTIC モデル に進んでください。
RTIC フレームワークは、スウェーデンの Luleå University of Technology (LTU) におけるリアルタイムシステム研究を出発点としています。RTIC は、Timber 言語の並行モデル、RTFM-SRP ベースのスケジューラ、RTFM-core 言語、および Abstract Timer 実装から着想を得ています。関連研究の完全な一覧については、RTFM および RTIC の出版物を参照してください。
Stack Resource Policy ベースのスケジューリング
Stack Resource Policy (SRP) ベースの並行性とリソース管理は、RTIC フレームワークの中核にあります。SRP モデル自体は Priority Inheritance Protocols を拡張したものであり、シングルコアスケジューリングに対して優れた特性をいくつも提供します。たとえば次のとおりです。
- プリエンプティブで、デッドロックや競合状態のないスケジューリング
- リソース効率
- タスクは単一の共有スタック上で実行される
- タスクは共有リソースに wait-free にアクセスしつつ、run-to-completion で実行される
- 単一の(名前付き)クリティカルセクションによって優先度逆転が有界となる、予測可能なスケジューリング
- 静的解析に適した理論的基盤(例: タスクの応答時間やシステム全体のスケジューラビリティ)
SRP には、システム全体に関わる一連の要件があります:
- 各タスクには静的優先度が関連付けられていること、
- タスクはシングルコア上で実行されること、
- タスクは run-to-completion でなければならないこと、および
- リソースは LIFO 順で取得/ロックされなければならないこと。
SRP の解析
SRP ベースのスケジューリングでは、各リソースの静的な 天井値 (𝝅) を計算するために、静的優先度タスクの集合と、それらの共有リソースへのアクセスが既知である必要があります。静的リソース 天井値 𝝅(r) は、リソース r にアクセスする任意のタスクの静的優先度の最大値を表します。
例
共有リソース R にアクセスする 2 つのタスク A(優先度 p(A) = 2)と B(優先度 p(B) = 4)を仮定します。R の静的天井値は 4 です(𝝅(R) = max(p(A) = 2, p(B) = 4) = 4 から計算されます)。
この例をグラフで表すと次のようになります:
graph LR
A["p(A) = 2"] --> R
B["p(B) = 4"] --> R
R["𝝅(R) = 4"]
RTIC: ハードウェアアクセラレーションされたリアルタイムスケジューラ
SRP 自体は、動的優先度スケジューリングと静的優先度スケジューリングの両方に適合します。RTIC の実装では、静的優先度スケジューリングを高速化するために、基盤となるハードウェアを活用します。
ARM Cortex-M アーキテクチャでは、各割り込みベクタエントリ v[i] には、関数ポインタ (v[i].fn)、静的優先度 (v[i].priority)、enabled ビット (v[i].enabled) および pending ビット (v[i].pending) が関連付けられています。
割り込み i は、次の条件のもとでハードウェアによりスケジュール(実行)されます:
pendingかつenabledであり、その優先度が(オプションのBASEPRI)レジスタより高いこと、および- 1 を満たす割り込みの中で最も高い優先度を持つこと。
最初の条件 (1) はフィルタと見なすことができ、これにより RTIC は、どのタスクの開始を許可し(また、どのタスクの開始を阻止するか)を制御できます。
一方、シングルコア静的スケジューリングに対する SRP モデルでは、タスクは次の条件のもとでスケジュール(実行)されるべきであるとされています:
- 実行が
requestedされており、その静的優先度が現在のシステム天井値 (𝜫) より高いこと - 1 を満たすタスクの中で最も高い静的優先度を持つこと。
その類似性は際立っており、これは偶然でも幸運でも単なる一致でもありません。ハードウェアは、リアルタイムスケジューリングを念頭に置いて巧妙に設計されているのです。
SRP スケジューリングをハードウェアにマッピングするには、システム天井値 (𝜫) をもう少し詳しく見る必要があります。SRP では、𝜫 は現在保持されているリソースの優先度天井値の最大値として計算されるため、システム動作中に動的に変化します。
例
上記のタスクモデルを仮定します。アイドル状態のシステムから開始すると、𝜫 は 0 です(どのタスクもリソースを保持していません)。A が実行要求されたとすると、直ちにスケジュールされます。A がリソース R を取得(ロック)すると仮定します。その取得(R のロック)の間は、B へのいかなる要求も開始をブロックされます(𝜫 = max(𝝅(R) = 4) = 4 であり、p(B) = 4 なので、SRP のスケジューリング条件 1 は満たされません)。
マッピング
静的優先度の SRP ベーススケジューリングを Cortex M ハードウェアへマッピングするのは簡単です:
- 各タスク
tは、対応する関数v[i].fn = tを持つ割り込みベクタインデックスiにマッピングされ、静的優先度v[i].priority = p(t)が与えられます。 - 現在のシステム天井値は
BASEPRIレジスタにマッピングされるか、またはそれに応じて割り込みイネーブルビットをマスクすることで実装されます。
例
ここまでの例では、ARM Cortex M [Nested Vectored Interrupt Controller (NVIC)][NVIC] のスナップショットは、(タスク A が実行のために pending 状態にされた後)次のような構成になる可能性があります。
| インデックス | Fn | 優先度 | 有効 | 保留中 |
| ----- | --- | -------- | ------- | ------- |
| 0 | A | 2 | true | true |
| 1 | B | 4 | true | false |
[NVIC]: https://developer.arm.com/documentation/ddi0337/h/nested-vectored-interrupt-controller/about-the-nvic
(後で説明するように、割り込みベクタと例外ベクタの割り当てはユーザーに委ねられます。)
claim(lock(r))は現在のシステム天井値(𝜫)を変更し、*名前付き*クリティカルセクションとして実装できます。
- old_ceiling = 𝜫, 𝜫 = 𝝅(r)
- クリティカルセクション内のコードを実行
- 𝜫 = old_ceiling
これはリソース保護メカニズムに相当し、`BASEPRI` レジスタを管理するために、クリティカルセクションへの入場時にはわずか 2 つ、退出時には 1 つの機械命令しか必要としません。`BASEPRI` を備えていないアーキテクチャでは、名前付きクリティカルセクションへの入出時に割り込みを無効化/有効化する一連の機械命令によってシステム天井値を実装できます。必要な機械命令数は、更新しなければならないマスクレジスタの数によって異なります(単一の機械操作で最大 32 個の割り込みを扱えるため、M0/M0+ アーキテクチャでは 1 命令で十分です)。RTIC はコンパイル時に天井値とマスキング定数を決定するため、すべての操作は Rust の用語で言えばゼロコストです。
このようにして RTIC は、SRP ベースのプリエンプティブスケジューリングと、ゼロコストでハードウェアにより高速化された実装を融合し、「クラス最高」の保証と性能を実現します。
このアプローチはきわめて単純であるにもかかわらず、なぜ SRP とハードウェアにより高速化されたスケジューリングは、他のどの主流 RTOS にも採用されていないのでしょうか。
答えは簡単です。一般的に採用されているスレッドモデルは静的解析にあまり向いていません。つまり、コンパイル時にソースコードからタスク/リソースの依存関係を抽出する既知の方法が存在しません(そのため天井値を効率的に計算できず、リソースロックの LIFO 要件も保証できません)。したがって、SRP ベースのスケジューリングは、一般にはどのスレッドベース RTOS にとっても手の届かないものです。
## RTIC の未来
さまざまな形態の非同期プログラミングが、ますます人気を集め、言語レベルのサポートも広がっています。Rust は協調的マルチタスクのための `async`/`await` API をネイティブに提供しており、コンパイラは実行コンテキストの保存と復元に必要な定型コードを生成します(つまり、各 `await` をまたいで存続するローカル変数群を管理します)。
Rust 標準ライブラリは、動的に割り当てられるデータ構造のためのコレクションを提供しており、これは実行時に実行コンテキストを管理するのに有用です。しかし、リソース制約の厳しいリアルタイムシステムという文脈では、動的割り当ては問題があります(性能と信頼性の両面においてです。Rust はメモリ不足状態になると *panic* を起こします)。したがって、静的割り当てが望ましいアプローチです!
モデリングの観点では、`async/await` は SRP の run-to-completion 要件を取り払い、2 つの yield point(`await`)の間にある各コード区間を個別のタスクとみなせます。コンパイラは、リソースを保持したまま `await` しようとするあらゆる試みを拒否します(そうしないと、SRP におけるリソース使用の厳密な LIFO 要件が破られるためです)。
では、技術的な話をいったん脇に置くとして、`async/await` は何をもたらすのでしょうか?
答えは、使い勝手の向上です!繰り返し現れるユースケースの 1 つは、タスクが一連の要求を実行し、その結果を待って先へ進むというものです。`async`/`await` がなければ、プログラマはタスクを個別のサブタスクに分割し、何らかの状態表現を維持しなければならず(そしてサブタスクを選択しながら手動で進行させることになります)。`async/await` を使うと、各 yield point(`await`)は本質的に 1 つの状態を表し、進行の仕組みは `Futures` によってコンパイル時に自動的に構築されます。
Rust の `async`/`await` サポートは、依然として不完全であったり、現在も開発中であったりします。それでも、一般的なユースケースの大半はカバーしており、本番利用可能と見なせます。
重要な性質として、future は合成可能です。そのため、利用可能な future のいずれか、すべて、または任意の組み合わせを await できます(これにより、たとえばタイムアウトや非同期エラーを迅速に処理できます)。
## RTIC モデル
RTIC の `app` は、シングルコアアプリケーション向けの宣言的かつ実行可能なシステムモデルであり、(`init`、`idle`、*ハードウェア*、*ソフトウェア* の)タスク群によって操作される(`local` および `shared` の)リソース群を定義します。要するに、`init` タスクは他のどのタスクよりも先に実行され、リソース群(`local` と `shared`)を返します。タスクは関連付けられた静的優先度に基づいてプリエンプティブに実行され、`idle` は最も低い優先度を持ちます(そしてバックグラウンド処理や、何らかのイベントで起床するまでシステムをスリープさせるために使用できます)。ハードウェアタスクは基盤となるハードウェア割り込みに束縛される一方、ソフトウェアタスクは非同期エグゼキュータによってスケジュールされます(ソフトウェアタスクの優先度ごとに 1 つ)。
コンパイル時に、タスク/リソースモデルは SRP のもとで解析され、次の優れた特性を持つ実行可能コードが生成されます:
- 単一の共有スタック上で、競合のないリソースアクセスとデッドロックのない実行が保証される(SRP のおかげ)
- ハードウェアタスクのスケジューリングはハードウェアによって直接行われ、
- ソフトウェアタスクのスケジューリングは、そのアプリケーション向けに自動生成された async エグゼキュータによって行われます。
RTIC API の設計は、SRP の要件と Rust の健全性規則の両方が常に満たされることを保証するため、この実行可能モデルは構成により正しいものになります。全体として、生成されたコードは手書き実装と比べて追加のオーバーヘッドを生じさせないため、Rust の用語で言えば RTIC は並行性に対するゼロコスト抽象化を提供します。
新しいプロジェクトを開始する
RTIC プロジェクトをゼロから開始する場合の推奨事項として、
RTIC の defmt-app-template に従うことをお勧めします。
ARMv6-M または ARMv8-M-base アーキテクチャを対象とする場合は、注意すべきハードウェア上の制約の詳細について ターゲットアーキテクチャ の節を参照してください。
これにより、defmt による RTT ロギングのサポートと、flip-link を使用したスタックオーバーフロー
保護を備えた RTIC アプリケーションを利用できます。コミュニティによって提供されている多数のサンプルもあります:
参考として、RTIC examples を参照してください。
RISC-V デバイスでの RTIC
RTIC は当初 ARM Cortex-M 向けに開発されましたが、RISC-V デバイスでも RTIC を使用できます。 ただし、RISC-V のエコシステムはより異種混在的です。 この問題に対処するため、現在 RTIC は 3 種類の異なるバックエンドを実装しています:
-
riscv-esp32c3-backend: このバックエンドは ESP32-C3 SoC をサポートします。 これらのデバイスでは、RTIC は Cortex-M 版と非常によく似ています。 -
riscv-esp32c6-backend: このバックエンドは ESP32-C6 SoC をサポートします。 これらのデバイスでは、RTIC は Cortex-M 版と非常によく似ています。 -
riscv-mecall-backend: このバックエンドは あらゆる RISC-V デバイスをサポートします。 このバックエンドでは、保留中のタスクが Machine Environment Call 例外をトリガーします。 この例外ソースのハンドラは、優先度に従って保留中のタスクをディスパッチします。 このバックエンドの動作はriscv-clint-backendと同等です。 このバックエンドの主な違いは、すべてのタスクが 必ず ソフトウェアタスク でなければならないことです。 さらに、#[app]属性にディスパッチャの一覧を指定する必要はありません。RTIC がコンパイル時にそれらを生成するためです。 -
riscv-clint-backend: このバックエンドは CLINT ペリフェラルを備えたデバイスをサポートします。 これはriscv-mecall-backendと同等ですが、例外をトリガーする代わりに、CLINT のMSIPレジスタを介してソフトウェア割り込みをトリガーします。
RTIC を例で学ぶ
この本のこのパートでは、複雑さが段階的に増していく例を通して、 RTIC フレームワークを新しいユーザーに紹介します。
この本のこのパートにあるすべての例は、
examples ディレクトリにある RTICリポジトリ の一部です。
これらの例は QEMU 上で実行可能であり(Cortex M3 ターゲットをエミュレート)、
そのため、読み進めるのに特別なハードウェアは必要ありません。
例を実行する
QEMU で例を実行するには、qemu-system-arm プログラムが必要です。
QEMU を含む組み込み開発環境のセットアップ方法については、
the embedded Rust book の手順を参照してください。
QEMU を使って examples/ にある例をローカルで実行するには:
cargo xtask qemu
これにより、デフォルトの thumbv7m-none-eabi デバイス lm3s6965 に対して、すべての例が実行されます。
実行する例を絞り込むには、--example <example name> フラグを使用します。ここで名前は例のファイル名です。
依存関係が整っているとすると、次を実行すると:
$ cargo xtask qemu --example locals
次の出力が得られます:
Finished dev [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/xtask qemu --example locals`
INFO xtask > Testing for platform: Lm3s6965, backend: Thumbv7
INFO xtask::run > 👟 Build example locals (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::run > ✅ Success.
INFO xtask::run > 👟 Run example locals in QEMU (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::run > ✅ Success.
INFO xtask::results > ✅ Success: Build example locals (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::results > ✅ Success: Run example locals in QEMU (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::results > 🚀🚀🚀 All tasks succeeded 🚀🚀🚀
例が正常に通っており、これが RTIC の CI セットアップの一部でもあるのは素晴らしいことですが、この本の目的上、実際のプログラム出力を見るには --verbose フラグ、短くは -v を追加する必要があります:
❯ cargo xtask qemu --verbose --example locals
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/xtask qemu --example locals --verbose`
DEBUG xtask > Stderr of child processes is inherited: false
DEBUG xtask > Partial features: false
INFO xtask > Testing for platform: Lm3s6965, backend: Thumbv7
INFO xtask::run > 👟 Build example locals (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::run > ✅ Success.
INFO xtask::run > 👟 Run example locals in QEMU (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
INFO xtask::run > ✅ Success.
INFO xtask::results > ✅ Success: Build example locals (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
cd examples/lm3s6965 && cargo build --target thumbv7m-none-eabi --features test-critical-section,thumbv7-backend --release --example locals
DEBUG xtask::results >
cd examples/lm3s6965 && cargo build --target thumbv7m-none-eabi --features test-critical-section,thumbv7-backend --release --example locals
Stderr:
Finished release [optimized] target(s) in 0.02s
INFO xtask::results > ✅ Success: Run example locals in QEMU (thumbv7m-none-eabi, release, "test-critical-section,thumbv7-backend", in examples/lm3s6965)
cd examples/lm3s6965 && cargo run --target thumbv7m-none-eabi --features test-critical-section,thumbv7-backend --release --example locals
DEBUG xtask::results >
cd examples/lm3s6965 && cargo run --target thumbv7m-none-eabi --features test-critical-section,thumbv7-backend --release --example locals
Stdout:
bar: local_to_bar = 1
foo: local_to_foo = 1
idle: local_to_idle = 1
Stderr:
Finished release [optimized] target(s) in 0.02s
Running `qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel target/thumbv7m-none-eabi/release/examples/locals`
Timer with period zero, disabling
INFO xtask::results > 🚀🚀🚀 All tasks succeeded 🚀🚀🚀
出力の末尾付近にある Stdout: の後の内容を確認してください。プログラム出力には次の行が含まれているはずです:
bar: local_to_bar = 1
foo: local_to_foo = 1
idle: local_to_idle = 1
注記:
cargo xtaskのその他の便利なオプションについては、次を参照してください:cargo xtask qemu --help
--platformフラグを使うと、どのデバイス上で例を実行するかを変更できます。 現在はlm3s6965が最もよくサポートされており、ARM と RISC-V の両方を含む 他のデバイスのサポート拡充も進められています
#[app] 属性と RTIC アプリケーション
app 属性の要件
すべての RTIC アプリケーションは app 属性(#[app(..)])を使用します。この属性は、RTIC アプリケーションを含む mod アイテムにのみ適用されます。
app 属性には、値として パス を取る必須の device 引数があります。これは、svd2rust v0.14.x 以降を使用して生成された ペリフェラルアクセスクレート(PAC)を指す完全なパスでなければなりません。
app 属性は適切なエントリポイントに展開されるため、cortex_m_rt::entry 属性の使用を置き換えます。
構造とゼロコスト並行性
RTIC の app は、シングルコアアプリケーション向けの実行可能なシステムモデルであり、init、idle、ハードウェア タスク、ソフトウェア タスクの集合によって操作される local および shared リソースの集合を宣言します。
initはほかのどのタスクよりも前に実行され、localリソースとsharedリソースを返します。- タスク(ハードウェア、ソフトウェアの両方)は、それぞれに関連付けられた静的優先度に基づいてプリエンプティブに実行されます。
- ハードウェアタスクは、対応するハードウェア割り込みに束縛されます。
- ソフトウェアタスクは、非同期エグゼキュータの集合によってスケジュールされます。各ソフトウェアタスク優先度ごとに 1 つのエグゼキュータがあります。
idleは最も低い優先度を持ち、バックグラウンド処理に使用したり、何らかのイベントによって起床されるまでシステムをスリープさせたりできます。
コンパイル時に、タスク/リソースモデルは Stack Resource Policy(SRP)のもとで解析され、次の優れた特性を持つ実行コードが生成されます。
- 単一の共有スタック上で、競合のないリソースアクセスとデッドロックのない実行が保証されます。
- ハードウェアタスクのスケジューリングはハードウェアによって直接行われます。
- ソフトウェアタスクのスケジューリングは、アプリケーション向けに自動生成された async エグゼキュータによって行われます。
総じて、生成されるコードは手書きの実装と比較して追加のオーバーヘッドを生じさせないため、Rust の用語で言えば、RTIC は並行性に対するゼロコスト抽象化を提供します。
優先度
RTIC における優先度は、#[task] 属性に渡される priority = N(N は正の数)引数を使用して指定します。すべての #[task] は優先度を持つことができます。タスクの優先度が指定されていない場合は、デフォルト値の 0 に設定されます。
RTIC の優先度は、値が大きいほど重要であるという方式に従います。たとえば、優先度 2 のタスクは優先度 1 のタスクをプリエンプトします。
RTIC アプリケーションの例
RTIC の雰囲気をつかんでもらうために、次の例にはよく使われる機能が含まれています。 以降のセクションでは、各機能を詳しく見ていきます。
//! examples/common.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [UART0, UART1])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {
local_to_foo: i64,
local_to_bar: i64,
local_to_idle: i64,
}
// `#[init]` cannot access locals from the `#[local]` struct as they are initialized here.
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn().unwrap();
bar::spawn().unwrap();
(
Shared {},
// initial values for the `#[local]` resources
Local {
local_to_foo: 0,
local_to_bar: 0,
local_to_idle: 0,
},
)
}
// `local_to_idle` can only be accessed from this context
#[idle(local = [local_to_idle])]
fn idle(cx: idle::Context) -> ! {
let local_to_idle = cx.local.local_to_idle;
*local_to_idle += 1;
hprintln!("idle: local_to_idle = {}", local_to_idle);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
// error: no `local_to_foo` field in `idle::LocalResources`
// _cx.local.local_to_foo += 1;
// error: no `local_to_bar` field in `idle::LocalResources`
// _cx.local.local_to_bar += 1;
loop {
cortex_m::asm::nop();
}
}
// `local_to_foo` can only be accessed from this context
#[task(local = [local_to_foo], priority = 1)]
async fn foo(cx: foo::Context) {
let local_to_foo = cx.local.local_to_foo;
*local_to_foo += 1;
// error: no `local_to_bar` field in `foo::LocalResources`
// cx.local.local_to_bar += 1;
hprintln!("foo: local_to_foo = {}", local_to_foo);
}
// `local_to_bar` can only be accessed from this context
#[task(local = [local_to_bar], priority = 1)]
async fn bar(cx: bar::Context) {
let local_to_bar = cx.local.local_to_bar;
*local_to_bar += 1;
// error: no `local_to_foo` field in `bar::LocalResources`
// cx.local.local_to_foo += 1;
hprintln!("bar: local_to_bar = {}", local_to_bar);
}
}
ハードウェアタスク
RTIC はその中核で、タスクのスケジュールと実行開始にハードウェア割り込みコントローラー(cortex-m 上の ARM NVIC)を使用します。pre-init(隠れた「タスク」)、#[init]、#[idle] を除くすべてのタスクは、割り込みハンドラとして実行されます。
タスクを割り込みにバインドするには、#[task] 属性引数 binds = InterruptName を使用します。すると、このタスクはこのハードウェア割り込みベクターの割り込みハンドラになります。
明示的な割り込みにバインドされたタスクはすべて、ハードウェアイベントに反応して実行を開始するため、ハードウェアタスク と呼ばれます。
存在しない割り込み名を指定すると、コンパイルエラーになります。割り込み名は通常、PAC または HAL クレートで定義されています。
利用可能な割り込みベクターであれば、どれでも動作するはずです。特定のデバイスでは、ユーザーコードからは制御できない形で、特定の割り込み優先度が特定の割り込みベクターに結び付けられている場合があります。例として nRF “softdevice” を参照してください。
ハードウェア機能によって内部的に使用されている割り込みベクターを使用する際は注意してください。RTIC は、そのようなハードウェア固有の詳細を認識しません。
例
以下の例は、割り込みハンドラにバインドされたハードウェアタスクを宣言するための #[task(binds = InterruptName)] 属性の使い方を示しています。
//! examples/hardware.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use lm3s6965::Interrupt;
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
// Pends the UART0 interrupt but its handler won't run until *after*
// `init` returns because interrupts are disabled
rtic::pend(Interrupt::UART0); // equivalent to NVIC::pend
hprintln!("init");
(Shared {}, Local {})
}
#[idle]
fn idle(_: idle::Context) -> ! {
// interrupts are enabled again; the `UART0` handler runs at this point
hprintln!("idle");
// Some backends provide a manual way of pending an
// interrupt.
rtic::pend(Interrupt::UART0);
loop {
cortex_m::asm::nop();
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
#[task(binds = UART0, local = [times: u32 = 0])]
fn uart0(cx: uart0::Context) {
// Safe access to local `static mut` variable
*cx.local.times += 1;
hprintln!(
"UART0 called {} time{}",
*cx.local.times,
if *cx.local.times > 1 { "s" } else { "" }
);
}
}
$ cargo xtask qemu --verbose --example hardware
init
UART0 called 1 time
idle
UART0 called 2 times
ソフトウェアタスクと spawn
RTIC におけるソフトウェアタスクの概念は、ハードウェアタスク と多くの共通点があります。中核となる違いは、ソフトウェアタスクが特定の割り込みベクタに明示的に束縛されるのではなく、ソフトウェアタスクの意図した優先度で動作する「ディスパッチャ」割り込みベクタに束縛される点です(以下を参照)。
ハードウェア タスクと同様に、関数に付ける #[task] 属性はその関数をタスクとして宣言します。この属性に binds = InterruptName 引数がない場合、その関数は ソフトウェアタスク として宣言されます。
静的メソッド task_name::spawn() はソフトウェアタスクを spawn(開始)し、より高い優先度のタスクが実行中でなければ、そのタスクは直ちに実行を開始します。
ソフトウェア タスク自体は async な Rust 関数として与えられ、これによりユーザーは将来のイベントを任意に await できます。これにより、リアクティブプログラミング(ハードウェア タスクによる)と逐次的なプログラミング(ソフトウェア タスクによる)を組み合わせられます。
ハードウェア タスクは run-to-completion で実行されて終了するものと想定される一方、ソフトウェア タスクは一度開始(spawn)されると、任意のループ(実行パス)が少なくとも 1 つの await(yield 操作)によって中断されることを条件に、永続的に実行できます。
ディスパッチャ
同じ優先度レベルにあるすべての ソフトウェア タスクは、ソフトウェアタスクをディスパッチする async executor として動作する 1 つの割り込みハンドラを共有します。このディスパッチャのリスト dispatchers = [FreeInterrupt1, FreeInterrupt2, ...] は #[app] 属性の引数であり、そこで空いていて使用可能な割り込みの集合を定義します。
ディスパッチャとして機能する各割り込みベクタには 1 つの優先度レベルが割り当てられるため、ディスパッチャのリストはソフトウェアタスクで使用されるすべての優先度レベルをカバーする必要があります。
例: ソフトウェアタスクに 3 つの異なる優先度を使用するアプリケーションでは、dispatchers = 引数には少なくとも 3 つのエントリが必要です。
提供されたディスパッチャが不足している場合、またはディスパッチャのリストと ハードウェア タスクに束縛された割り込みの間で衝突が発生した場合、フレームワークはコンパイルエラーを出します。
以下の例を参照してください。
//! examples/spawn.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
hprintln!("init");
foo::spawn().unwrap();
(Shared {}, Local {})
}
#[task]
async fn foo(_: foo::Context) {
hprintln!("foo");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example spawn
init
foo
ソフトウェア タスクが run-to-completion で終了していれば、そのタスクを再度 spawn できます。
以下の例では、idle タスクから ソフトウェア タスク foo を spawn しています。ソフトウェア タスクの優先度は 1(idle より高い)なので、ディスパッチャは foo を実行します(idle をプリエンプトします)。foo は run-to-completion で終了するため、foo タスクを再度 spawn しても問題ありません。
技術的には、async executor は foo の future を poll し、この場合その future は completed 状態になります。
//! examples/spawn_loop.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
hprintln!("init");
(Shared {}, Local {})
}
#[idle]
fn idle(_: idle::Context) -> ! {
for _ in 0..3 {
foo::spawn().unwrap();
hprintln!("idle");
}
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
loop {}
}
#[task(priority = 1)]
async fn foo(_: foo::Context) {
hprintln!("foo");
}
}
$ cargo xtask qemu --verbose --example spawn_loop
init
foo
idle
foo
idle
foo
idle
すでに spawn 済みのタスク(実行中のタスク)を spawn しようとすると、エラーになります。ここで注目すべき点は、このエラーが foo タスクが実際に実行される前に報告されることです。これは、ソフトウェア タスクの実際の実行がディスパッチャ割り込み(SSIO)によって処理される一方で、その割り込みは init タスクを抜けるまで有効にならないためです。(init はクリティカルセクションで実行され、つまりすべての割り込みが無効化されていることを思い出してください。)
技術的には、completed 状態にない future に対する spawn はエラーと見なされます。
//! examples/spawn_err.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
hprintln!("init");
foo::spawn().unwrap();
match foo::spawn() {
Ok(_) => {}
Err(()) => hprintln!("Cannot spawn a spawned (running) task!"),
}
(Shared {}, Local {})
}
#[task]
async fn foo(_: foo::Context) {
hprintln!("foo");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example spawn_err
init
Cannot spawn a spawned (running) task!
foo
引数の受け渡し
次のように、spawn 時に引数を渡すこともできます。
//! examples/spawn_arguments.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn(1, 1).unwrap();
assert!(foo::spawn(1, 4).is_err()); // The capacity of `foo` is reached
(Shared {}, Local {})
}
#[task]
async fn foo(_c: foo::Context, x: i32, y: u32) {
hprintln!("foo {}, {}", x, y);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example spawn_arguments
foo 1, 1
発散タスク
タスクは 2 つのシグネチャのいずれかを取れます: async fn({name}::Context, ..) または async fn({name}::Context, ..) -> !。後者は 発散 タスク、つまり決して return しないタスクを定義します。発散タスクの主な利点は、'static なコンテキストを受け取り、local リソースが 'static ライフタイムを持つことです。さらに、このシグネチャを使うことでタスクの意図が明示され、短命なタスクと無期限に実行されるタスクを明確に区別できます。.await によって制御を明け渡し、同じ優先度レベルの他のタスクを飢餓状態にしないよう注意してください。
優先度ゼロのタスク
RTIC では、タスクは互いにプリエンプティブに実行され、優先度ゼロ(0)が最も低い優先度です。優先度ゼロのタスクは、厳密なリアルタイム要件のないバックグラウンド処理に使用できます。
概念的には、そのようなタスクはアプリケーションの main スレッドで実行されるものと見なせるため、関連するリソースには Send トレイト境界は要求されません。
//! examples/zero-prio-task.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use core::marker::PhantomData;
use panic_semihosting as _;
/// Does not impl send
pub struct NotSend {
_0: PhantomData<*const ()>,
}
#[rtic::app(device = lm3s6965, peripherals = true)]
mod app {
use super::NotSend;
use core::marker::PhantomData;
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {
x: NotSend,
}
#[local]
struct Local {
y: NotSend,
}
#[init]
fn init(_cx: init::Context) -> (Shared, Local) {
hprintln!("init");
async_task::spawn().unwrap();
async_task2::spawn().unwrap();
(
Shared {
x: NotSend { _0: PhantomData },
},
Local {
y: NotSend { _0: PhantomData },
},
)
}
#[task(priority = 0, shared = [x], local = [y])]
async fn async_task(_: async_task::Context) {
hprintln!("hello from async");
}
#[task(priority = 0, shared = [x])]
async fn async_task2(_: async_task2::Context) {
hprintln!("hello from async2");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example zero-prio-task
init
hello from async
hello from async2
注意: 優先度ゼロの ソフトウェア タスクは [idle] タスクと共存できません。理由は、
idleが優先度ゼロの、復帰しない Rust 関数として実行されるためです。したがって、優先度ゼロの executor が同じ優先度の ソフトウェア タスクに制御を渡す方法がありません。
アプリケーション側の安全性: 技術的には、RTIC フレームワークは completed 状態の future を持つ ソフトウェア タスクに対して poll が決して実行されないことを保証しており、これにより async Rust の健全性の規則に従います。
リソースの使用
RTIC フレームワークは共有リソースとタスクローカルリソースを管理し、unsafe コードを使用せずに永続的なデータ保存と安全なアクセスを可能にします。
RTIC のリソースは #[app] モジュール内で宣言された関数からのみ参照可能であり、フレームワークはリソースのアクセス可能性を(タスクごとに)ユーザーが完全に制御できるようにします。
システム全体のリソースは、#[app] モジュール内の 2 つ の struct に #[local] および #[shared] 属性を付けることで宣言します。これらの構造体の各フィールドは、それぞれ異なるリソース(フィールド名で識別)に対応します。この 2 種類のリソースの違いについては以下で説明します。
各タスクは、対応するメタデータ属性で local 引数と shared 引数を使って、アクセスする予定のリソースを宣言する必要があります。各引数にはリソース識別子のリストを指定します。列挙したリソースは、Context 構造体の local フィールドおよび shared フィールドを通じてコンテキストから利用可能になります。
init タスクは、システム全体の(#[shared] および #[local])リソースの初期値を返します。
#[local] リソース
#[local] リソースは特定のタスクからローカルにアクセス可能なリソースであり、そのタスクだけがロックやクリティカルセクションなしでそのリソースにアクセスできます。これにより、通常はドライバーや大きなオブジェクトであるリソースを #[init] で初期化し、その後特定のタスクに渡すことができます。
したがって、あるタスクの #[local] リソースには、ただ 1 つのタスクだけがアクセスできます。同じ #[local] リソースを複数のタスクに割り当てようとすると、コンパイル時エラーになります。
#[local] リソースの型は、init から対象タスクへ送られ、スレッド境界をまたぐため、Send トレイトを実装していなければなりません。
以下に示すアプリケーション例には、foo、bar、idle の 3 つのタスクがあり、それぞれが自身の #[local] リソースにアクセスできます。
//! examples/locals.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [UART0, UART1])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {
local_to_foo: i64,
local_to_bar: i64,
local_to_idle: i64,
}
// `#[init]` cannot access locals from the `#[local]` struct as they are initialized here.
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn().unwrap();
bar::spawn().unwrap();
(
Shared {},
// initial values for the `#[local]` resources
Local {
local_to_foo: 0,
local_to_bar: 0,
local_to_idle: 0,
},
)
}
// `local_to_idle` can only be accessed from this context
#[idle(local = [local_to_idle])]
fn idle(cx: idle::Context) -> ! {
let local_to_idle = cx.local.local_to_idle;
*local_to_idle += 1;
hprintln!("idle: local_to_idle = {}", local_to_idle);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
// error: no `local_to_foo` field in `idle::LocalResources`
// _cx.local.local_to_foo += 1;
// error: no `local_to_bar` field in `idle::LocalResources`
// _cx.local.local_to_bar += 1;
loop {
cortex_m::asm::nop();
}
}
// `local_to_foo` can only be accessed from this context
#[task(local = [local_to_foo], priority = 1)]
async fn foo(cx: foo::Context) {
let local_to_foo = cx.local.local_to_foo;
*local_to_foo += 1;
// error: no `local_to_bar` field in `foo::LocalResources`
// cx.local.local_to_bar += 1;
hprintln!("foo: local_to_foo = {}", local_to_foo);
}
// `local_to_bar` can only be accessed from this context
#[task(local = [local_to_bar], priority = 1)]
async fn bar(cx: bar::Context) {
let local_to_bar = cx.local.local_to_bar;
*local_to_bar += 1;
// error: no `local_to_foo` field in `bar::LocalResources`
// cx.local.local_to_foo += 1;
hprintln!("bar: local_to_bar = {}", local_to_bar);
}
}
この例を実行するには:
$ cargo xtask qemu --verbose --example locals
bar: local_to_bar = 1
foo: local_to_foo = 1
idle: local_to_idle = 1
#[init] と #[idle] のローカルリソースは 'static ライフタイムを持ちます。これは、どちらのタスクも再入可能ではないため安全です。
タスクローカルな初期化済みリソース
ローカルリソースは、#[task(local = [my_var: TYPE = INITIAL_VALUE, ...])] のようにリソース指定内で直接指定することもできます。これにより、#[init] で初期化する必要のないローカルを作成できます。
#[task(local = [..])] リソースの型は、スレッド境界をまたがないため、Send でも Sync でもある必要はありません。
以下の例では、さまざまな使い方とライフタイムを示しています。
//! examples/declared_locals.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::debug;
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init(local = [a: u32 = 0])]
fn init(cx: init::Context) -> (Shared, Local) {
// Locals in `#[init]` have 'static lifetime
let _a: &'static mut u32 = cx.local.a;
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
(Shared {}, Local {})
}
#[idle(local = [a: u32 = 0])]
fn idle(cx: idle::Context) -> ! {
// Locals in `#[idle]` have 'static lifetime
let _a: &'static mut u32 = cx.local.a;
loop {}
}
#[task(binds = UART0, local = [a: u32 = 0])]
fn foo(cx: foo::Context) {
// Locals in `#[task]`s have a local lifetime
let _a: &mut u32 = cx.local.a;
// error: explicit lifetime required in the type of `cx`
// let _a: &'static mut u32 = cx.local.a;
}
}
アプリケーションを実行することもできますが、この例はライフタイムの性質を示すことだけを目的としているため、出力はありません(アプリケーションをビルドするだけで十分です)。
$ cargo build --target thumbv7m-none-eabi --example declared_locals
#[shared] リソースと lock
#[shared] リソースにデータ競合なしでアクセスするにはクリティカルセクションが必要です。そのため、渡される Context の shared フィールドは、そのタスクからアクセス可能な各共有リソースについて Mutex トレイトを実装します。このトレイトには lock という 1 つのメソッドしかなく、そのクロージャ引数をクリティカルセクション内で実行します。
lock API によって作成されるクリティカルセクションは動的優先度に基づいています。つまり、他のタスクがそのクリティカルセクションをプリエンプトできないように、コンテキストの動的優先度を一時的に ceiling 優先度まで引き上げます。この同期プロトコルは Immediate Ceiling Priority Protocol (ICPP) として知られており、RTIC の Stack Resource Policy (SRP) ベースのスケジューリングに準拠しています。
以下の例では、優先度 1 から 3 の 3 つの割り込みハンドラがあります。低い優先度を持つ 2 つのハンドラは shared リソースをめぐって競合し、そのデータにアクセスするにはそのリソースのロック取得に成功する必要があります。shared リソースにアクセスしない最も高い優先度のハンドラは、最も低い優先度のハンドラが作成したクリティカルセクションを自由にプリエンプトできます。
//! examples/lock.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [GPIOA, GPIOB, GPIOC])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {
shared: u32,
}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn().unwrap();
(Shared { shared: 0 }, Local {})
}
// when omitted priority is assumed to be `1`
#[task(shared = [shared])]
async fn foo(mut c: foo::Context) {
hprintln!("A");
// the lower priority task requires a critical section to access the data
c.shared.shared.lock(|shared| {
// data can only be modified within this critical section (closure)
*shared += 1;
// bar will *not* run right now due to the critical section
bar::spawn().unwrap();
hprintln!("B - shared = {}", *shared);
// baz does not contend for `shared` so it's allowed to run now
baz::spawn().unwrap();
});
// critical section is over: bar can now start
hprintln!("E");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
#[task(priority = 2, shared = [shared])]
async fn bar(mut c: bar::Context) {
// the higher priority task does still need a critical section
let shared = c.shared.shared.lock(|shared| {
*shared += 1;
*shared
});
hprintln!("D - shared = {}", shared);
}
#[task(priority = 3)]
async fn baz(_: baz::Context) {
hprintln!("C");
}
}
$ cargo xtask qemu --verbose --example lock
A
B - shared = 1
C
D - shared = 2
E
#[shared] リソースの型は Send でなければなりません。
Multi-lock
lock の拡張として、また右方向へのインデントの増大を抑えるため、ロックはタプルとして取得できます。以下の例はその使用方法を示しています。
//! examples/mutlilock.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [GPIOA])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {
shared1: u32,
shared2: u32,
shared3: u32,
}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
locks::spawn().unwrap();
(
Shared {
shared1: 0,
shared2: 0,
shared3: 0,
},
Local {},
)
}
// when omitted priority is assumed to be `1`
#[task(shared = [shared1, shared2, shared3])]
async fn locks(c: locks::Context) {
let s1 = c.shared.shared1;
let s2 = c.shared.shared2;
let s3 = c.shared.shared3;
(s1, s2, s3).lock(|s1, s2, s3| {
*s1 += 1;
*s2 += 1;
*s3 += 1;
hprintln!("Multiple locks, s1: {}, s2: {}, s3: {}", *s1, *s2, *s3);
});
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example multilock
Multiple locks, s1: 1, s2: 1, s3: 1
共有 (&-) アクセスのみ
デフォルトでは、フレームワークはすべてのタスクがリソースへの排他的な可変アクセス (&mut-) を必要とすると仮定しますが、shared リストで &resource_name 構文を使うことで、タスクがリソースへの共有アクセス (&-) だけを必要とすることを指定できます。
リソースへの共有アクセス (&-) を指定する利点は、異なる優先度で実行される複数のタスクがそのリソースをめぐって競合している場合でも、リソースにアクセスするためのロックが不要になることです。欠点は、そのタスクがリソースへの共有参照 (&-) しか得られず、実行できる操作が制限されることですが、共有参照で十分な場合には、この方法によって必要なロックの数を減らせます。単純なイミュータブルデータに加えて、リソース型自体が適切なロックやアトミック操作によって安全に内部可変性を実装している場合にも、この共有アクセスは有用です。
この RTIC のリリースでは、異なるタスクから 同じ リソースに対して排他的アクセス (&mut-) と共有アクセス (&-) の両方を要求することはできない点に注意してください。そうしようとすると、コンパイルエラーになります。
以下の例では、キー(たとえば暗号鍵)を実行時にロード(または作成)し(init から返される)、その後、異なる優先度で実行される 2 つのタスクから、いかなる種類のロックも使わずに使用します。
//! examples/only-shared-access.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [UART0, UART1])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {
key: u32,
}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn().unwrap();
bar::spawn().unwrap();
(Shared { key: 0xdeadbeef }, Local {})
}
#[task(shared = [&key])]
async fn foo(cx: foo::Context) {
let key: &u32 = cx.shared.key;
hprintln!("foo(key = {:#x})", key);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
#[task(priority = 2, shared = [&key])]
async fn bar(cx: bar::Context) {
hprintln!("bar(key = {:#x})", cx.shared.key);
}
}
$ cargo xtask qemu --verbose --example only-shared-access
bar(key = 0xdeadbeef)
foo(key = 0xdeadbeef)
共有リソースへのロックフリーアクセス
#[shared] リソースが 同じ 優先度で動作するタスクからのみアクセスされる場合、それにアクセスするためにクリティカルセクションは 不要 です。この場合、リソース宣言にフィールドレベル属性 #[lock_free] を追加することで、lock API の使用を省略できます(以下の例を参照)。
Rust のaliasing規則に従うため、リソースには複数の不変参照を通じてアクセスするか、単一の可変参照を通じてアクセスするかのいずれかのみが可能です(ただし、同時に両方は不可です)。
異なる優先度で動作するタスク間で共有されるリソースに #[lock_free] を使用すると、コンパイル時 エラーになります – lock API を使用しないと、前述の aliasing 規則に違反するためです。同様に、各優先度について、共有リソースにアクセスできる ソフトウェア タスクは 1 つだけです(async タスクは、同じ優先度で動作するほかの ソフトウェア または ハードウェア タスクに実行を譲る可能性があるためです)。しかし、この単一タスク制約の下では、そのリソースは実質的にもはや shared ではなく、むしろ local であるとみなせます。したがって、#[lock_free] を付与した共有リソースを使用すると コンパイル時 エラーになります – 適用できる場合は、代わりに #[local] リソースを使用してください。
//! examples/lock-free.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use lm3s6965::Interrupt;
#[shared]
struct Shared {
#[lock_free] // <- lock-free shared resource
counter: u64,
}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
rtic::pend(Interrupt::UART0);
(Shared { counter: 0 }, Local {})
}
#[task(binds = UART0, shared = [counter])] // <- same priority
fn foo(c: foo::Context) {
rtic::pend(Interrupt::UART1);
*c.shared.counter += 1; // <- no lock API required
let counter = *c.shared.counter;
hprintln!(" foo = {}", counter);
}
#[task(binds = UART1, shared = [counter])] // <- same priority
fn bar(c: bar::Context) {
rtic::pend(Interrupt::UART0);
*c.shared.counter += 1; // <- no lock API required
let counter = *c.shared.counter;
hprintln!(" bar = {}", counter);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example lock-free
foo = 1
bar = 2
アプリケーションの初期化と #[init] タスク
RTIC アプリケーションでは、システムをセットアップする init タスクが必要です。対応する init 関数は
シグネチャ fn(init::Context) -> (Shared, Local) を持つ必要があります。ここで、Shared と Local はユーザー定義のリソース構造体です。
init タスクは、システムリセット後、[任意で定義された pre-init コードセクション]1 と、常に行われる RTIC の内部初期化の後に実行されます。
init タスクとオプションの pre-init タスクは、割り込みを無効化した状態で 実行され、Cortex-M への排他的アクセス権を持ちます(critical_section::CriticalSection トークンは cs として利用できます)。
デバイス固有のペリフェラルは、init::Context の core フィールドと device フィールドを通じて利用できます。
例
以下の例は、core、device、cs フィールドの型を示すとともに、'static ライフタイムを持つ local 変数の使用例を示しています。このような変数は、RTIC アプリケーションの init タスクから他のタスクへ委譲できます。
device フィールドは、peripherals 引数がデフォルト値 true に設定されている場合にのみ利用できます。
ごくまれに超軽量なアプリケーションを実装したい場合は、peripherals を明示的に false に設定できます。
//! examples/init.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, peripherals = true)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init(local = [x: u32 = 0])]
fn init(cx: init::Context) -> (Shared, Local) {
// Cortex-M peripherals
let _core: cortex_m::Peripherals = cx.core;
// Device specific peripherals
let _device: lm3s6965::Peripherals = cx.device;
// Locals in `init` have 'static lifetime
let _x: &'static mut u32 = cx.local.x;
// Access to the critical section token,
// to indicate that this is a critical section
let _cs_token: rtic::export::CriticalSection = cx.cs;
hprintln!("init");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
(Shared {}, Local {})
}
}
この例を実行すると、コンソールに init が出力され、その後 QEMU プロセスが終了します。
$ cargo xtask qemu --verbose --example init
init
バックグラウンドタスク #[idle]
idle 属性でマークされた関数は、モジュール内に任意で記述できます。これは特別な idle タスク となり、シグネチャは fn(idle::Context) -> ! でなければなりません。
存在する場合、ランタイムは init の後に idle タスクを実行します。init とは異なり、idle は 割り込みが有効な状態で 実行され、-> ! の関数シグネチャが示すとおり、決してリターンしてはいけません。
Rust の型 ! は「never」を意味します。
init と同様に、ローカルに宣言されたリソースは安全にアクセスできる 'static ライフタイムを持ちます。
以下の例は、idle が init の後に実行されることを示しています。
//! examples/idle.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
hprintln!("init");
(Shared {}, Local {})
}
#[idle(local = [x: u32 = 0])]
fn idle(cx: idle::Context) -> ! {
// Locals in idle have lifetime 'static
let _x: &'static mut u32 = cx.local.x;
hprintln!("idle");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
loop {
cortex_m::asm::nop();
}
}
}
$ cargo xtask qemu --verbose --example idle
init
idle
デフォルトでは、RTIC の idle タスクは特定のターゲット向けの最適化を行いません。
一般的で有用な最適化の 1 つは、SLEEPONEXIT を有効にして、idle に到達したときに MCU がスリープに入れるようにすることです。
注意: 一部のハードウェアでは、設定しない限り、スリープモード中にデバッグユニットが無効になります。
これは RTIC の範囲外であるため、ハードウェア固有のドキュメントを参照してください。
次の例は、
SLEEPONEXIT を設定し、デフォルトの nop() を wfi() に置き換えるカスタム idle タスクを提供することで、スリープを有効にする方法を示しています。
//! examples/idle-wfi.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(mut cx: init::Context) -> (Shared, Local) {
hprintln!("init");
// Set the ARM SLEEPONEXIT bit to go to sleep after handling interrupts
// See https://developer.arm.com/documentation/100737/0100/Power-management/Sleep-mode/Sleep-on-exit-bit
cx.core.SCB.set_sleeponexit();
(Shared {}, Local {})
}
#[idle(local = [x: u32 = 0])]
fn idle(cx: idle::Context) -> ! {
// Locals in idle have lifetime 'static
let _x: &'static mut u32 = cx.local.x;
hprintln!("idle");
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
loop {
// Now Wait For Interrupt is used instead of a busy-wait loop
// to allow MCU to sleep between interrupts
// https://developer.arm.com/documentation/ddi0406/c/Application-Level-Architecture/Instruction-Details/Alphabetical-list-of-instructions/WFI
rtic::export::wfi()
}
}
}
$ cargo xtask qemu --verbose --example idle-wfi
init
idle
注意:
idleタスクは、優先度 0 で動作する ソフトウェア タスクと一緒には使用できません。理由は、idleが優先度 0 でリターンしない Rust 関数として実行されるためです。そのため、優先度 0 のエグゼキュータが同じ優先度の ソフトウェア タスクに制御を渡す方法がありません。
チャネルを介した通信。
チャネルは、実行中のタスク間でデータをやり取りするために使用できます。チャネルは本質的には待機キューであり、複数のプロデューサーと単一のレシーバーを持つタスク間の通信を可能にします。チャネルは init タスクで構築され、静的に割り当てられたメモリを基盤としています。送信エンドポイントと受信エンドポイントは ソフトウェア タスクに配布されます。
...
const CAPACITY: usize = 5;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (s, r) = make_channel!(u32, CAPACITY);
receiver::spawn(r).unwrap();
sender1::spawn(s.clone()).unwrap();
sender2::spawn(s.clone()).unwrap();
...
この場合、チャネルは u32 型のデータを保持し、容量は 5 要素です。
チャネルは ハードウェア タスクからも使用できますが、Try API を使用した非 async の方法でのみ可能です。
データの送信
send メソッドは、以下のようにチャネルにメッセージを送信します。
#[task]
async fn sender1(_c: sender1::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 1 sending: 1");
sender.send(1).await.unwrap();
}
データの受信
受信側は到着するメッセージを await できます。
#[task]
async fn receiver(_c: receiver::Context, mut receiver: Receiver<'static, u32, CAPACITY>) {
while let Ok(val) = receiver.recv().await {
hprintln!("Receiver got: {}", val);
...
}
}
チャネルは、競合状態を防ぐために、小さな(グローバルな)クリティカルセクション(CS)を使って実装されています。ユーザーは CS 実装を提供しなければなりません。例を --features test-critical-section 付きでコンパイルすると、可能な実装の 1 つが得られます。
完全な例:
//! examples/async-channel.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_sync::{channel::*, make_channel};
#[shared]
struct Shared {}
#[local]
struct Local {}
const CAPACITY: usize = 5;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (s, r) = make_channel!(u32, CAPACITY);
receiver::spawn(r).unwrap();
sender1::spawn(s.clone()).unwrap();
sender2::spawn(s.clone()).unwrap();
sender3::spawn(s).unwrap();
(Shared {}, Local {})
}
#[task]
async fn receiver(_c: receiver::Context, mut receiver: Receiver<'static, u32, CAPACITY>) {
while let Ok(val) = receiver.recv().await {
hprintln!("Receiver got: {}", val);
if val == 3 {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
}
#[task]
async fn sender1(_c: sender1::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 1 sending: 1");
sender.send(1).await.unwrap();
}
#[task]
async fn sender2(_c: sender2::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 2 sending: 2");
sender.send(2).await.unwrap();
}
#[task]
async fn sender3(_c: sender3::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 3 sending: 3");
sender.send(3).await.unwrap();
}
}
$ cargo xtask qemu --verbose --example async-channel --features test-critical-section
Sender 1 sending: 1
Sender 2 sending: 2
Sender 3 sending: 3
Receiver got: 1
Receiver got: 2
Receiver got: 3
また、送信エンドポイントも await できます。チャネル容量がまだ上限に達していない場合、送信側を await した処理は直ちに進行できます。一方、容量に達している場合、キューに空きができるまで送信側はブロックされます。このようにしてデータが失われることはありません。
以下の例では、CAPACITY が 1 に減らされており、送信タスクはチャネル内のデータが受信されるまで待機することになります。
//! examples/async-channel-done.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_sync::{channel::*, make_channel};
#[shared]
struct Shared {}
#[local]
struct Local {}
const CAPACITY: usize = 1;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (s, r) = make_channel!(u32, CAPACITY);
receiver::spawn(r).unwrap();
sender1::spawn(s.clone()).unwrap();
sender2::spawn(s.clone()).unwrap();
sender3::spawn(s).unwrap();
(Shared {}, Local {})
}
#[task]
async fn receiver(_c: receiver::Context, mut receiver: Receiver<'static, u32, CAPACITY>) {
while let Ok(val) = receiver.recv().await {
hprintln!("Receiver got: {}", val);
if val == 3 {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
}
#[task]
async fn sender1(_c: sender1::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 1 sending: 1");
sender.send(1).await.unwrap();
hprintln!("Sender 1 done");
}
#[task]
async fn sender2(_c: sender2::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 2 sending: 2");
sender.send(2).await.unwrap();
hprintln!("Sender 2 done");
}
#[task]
async fn sender3(_c: sender3::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 3 sending: 3");
sender.send(3).await.unwrap();
hprintln!("Sender 3 done");
}
}
出力を見ると、Sender 2 は Sender 1 によって送信されたデータが受信されるまで待機することがわかります。
注意 同じ優先度の ソフトウェア タスクは互いに非同期に実行されるため、厳密な順序を 一切 仮定できません。(ここで示している順序は現在の実装にのみ当てはまるものであり、RTIC フレームワークのリリース間で変わる可能性があります。)
$ cargo xtask qemu --verbose --example async-channel-done --features test-critical-section
Sender 1 sending: 1
Sender 1 done
Sender 2 sending: 2
Sender 3 sending: 3
Receiver got: 1
Sender 2 done
Receiver got: 2
Sender 3 done
Receiver got: 3
エラーハンドリング
すべての送信側がドロップされている場合、空の受信チャネルを await するとエラーになります。これにより、さまざまな種類のシャットダウン処理を適切に実装できます。
//! examples/async-channel-no-sender.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_sync::{channel::*, make_channel};
#[shared]
struct Shared {}
#[local]
struct Local {}
const CAPACITY: usize = 1;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (_s, r) = make_channel!(u32, CAPACITY);
receiver::spawn(r).unwrap();
(Shared {}, Local {})
}
#[task]
async fn receiver(_c: receiver::Context, mut receiver: Receiver<'static, u32, CAPACITY>) {
hprintln!("Receiver got: {:?}", receiver.recv().await);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example async-channel-no-sender --features test-critical-section
Receiver got: Err(NoSender)
同様に、レシーバーがドロップされている場合、送信チャネルを await するとエラーになります。これにより、アプリケーションレベルのエラーハンドリングを適切に実装できます。
その際のエラーはデータを送信側に返すため、送信側は適切な対応(たとえば、後で再送するためにデータを保存するなど)を取ることができます。
//! examples/async-channel-no-receiver.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_sync::{channel::*, make_channel};
#[shared]
struct Shared {}
#[local]
struct Local {}
const CAPACITY: usize = 1;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (s, _r) = make_channel!(u32, CAPACITY);
sender1::spawn(s.clone()).unwrap();
(Shared {}, Local {})
}
#[task]
async fn sender1(_c: sender1::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 1 sending: 1 {:?}", sender.send(1).await);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
}
$ cargo xtask qemu --verbose --example async-channel-no-receiver --features test-critical-section
Sender 1 sending: 1 Err(NoReceiver(1))
Try API
Try API を使うと、操作の成功を前提とせずに、また非 async コンテキストでも、チャネルへの送信およびチャネルからの受信を行えます。
この API は Receiver::try_recv と Sender::try_send を通じて提供されています。
//! examples/async-channel-try.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_sync::{channel::*, make_channel};
#[shared]
struct Shared {}
#[local]
struct Local {
sender: Sender<'static, u32, CAPACITY>,
}
const CAPACITY: usize = 1;
#[init]
fn init(_: init::Context) -> (Shared, Local) {
let (s, r) = make_channel!(u32, CAPACITY);
receiver::spawn(r).unwrap();
sender1::spawn(s.clone()).unwrap();
(Shared {}, Local { sender: s.clone() })
}
#[task]
async fn receiver(_c: receiver::Context, mut receiver: Receiver<'static, u32, CAPACITY>) {
while let Ok(val) = receiver.recv().await {
hprintln!("Receiver got: {}", val);
}
}
#[task]
async fn sender1(_c: sender1::Context, mut sender: Sender<'static, u32, CAPACITY>) {
hprintln!("Sender 1 sending: 1");
sender.send(1).await.unwrap();
hprintln!("Sender 1 try sending: 2 {:?}", sender.try_send(2));
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
// This interrupt is never triggered, but is used to demonstrate that
// one can (try to) send data into a channel from a hardware task.
#[task(binds = GPIOA, local = [sender])]
fn hw_task(cx: hw_task::Context) {
cx.local.sender.try_send(3).ok();
}
}
$ cargo xtask qemu --verbose --example async-channel-try --features test-critical-section
Sender 1 sending: 1
Sender 1 try sending: 2 Err(Full(2))
Monotonic を使った遅延とタイムアウト
最小限のタイミング要件を表現する便利な方法の 1 つは、進行を遅延させることです。
これは monotonic タイマーをインスタンス化することで実現できます(実装については rtic-monotonics を参照してください):
...
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
hprintln!("init");
Mono::start(cx.core.SYST, 12_000_000);
...
_ソフトウェア_タスクは、遅延が満了するのを await できます:
#[task]
async fn foo(_cx: foo::Context) {
...
Mono::delay(100.millis()).await;
...
}
完全な例
//! examples/async-delay.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [SSI0, UART0], peripherals = true)]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use rtic_monotonics::systick::prelude::*;
systick_monotonic!(Mono, 100);
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
hprintln!("init");
Mono::start(cx.core.SYST, 12_000_000);
foo::spawn().ok();
bar::spawn().ok();
baz::spawn().ok();
(Shared {}, Local {})
}
#[task]
async fn foo(_cx: foo::Context) {
hprintln!("hello from foo");
Mono::delay(100.millis()).await;
hprintln!("bye from foo");
}
#[task]
async fn bar(_cx: bar::Context) {
hprintln!("hello from bar");
Mono::delay(200.millis()).await;
hprintln!("bye from bar");
}
#[task]
async fn baz(_cx: baz::Context) {
hprintln!("hello from baz");
Mono::delay(300.millis()).await;
hprintln!("bye from baz");
debug::exit(debug::EXIT_SUCCESS);
}
}
$ cargo xtask qemu --verbose --example async-delay --features test-critical-section
init
hello from bar
hello from baz
hello from foo
bye from foo
bye from bar
bye from baz
Monotonicの新しい実装への貢献や、monotonic の内部動作に関する詳しい情報に興味がありますか? Implementing aMonotonicの章を確認してください!
タイムアウト
Rust の Future(Rust の async/await を支える仕組み)は合成可能です。これにより、完了した Future の間で select することが可能になります。
一般的なユースケースは、タイムアウトを伴うトランザクションです。以下に示す例では、hal_get(n).await を呼び出すと何らかの想定上のトランザクションを実行する、ダミーの HAL デバイスを導入しています。所要時間は入力パラメーター(n)に基づいて 350ms + n * 100ms としてモデル化しています。
futures クレートの select_biased マクロを使うと、次のようになります:
// Call hal with short relative timeout using `select_biased`
select_biased! {
v = hal_get(1).fuse() => hprintln!("hal returned {}", v),
_ = Mono::delay(200.millis()).fuse() => hprintln!("timeout", ), // this will finish first
}
// Call hal with long relative timeout using `select_biased`
select_biased! {
v = hal_get(1).fuse() => hprintln!("hal returned {}", v), // hal finish first
_ = Mono::delay(1000.millis()).fuse() => hprintln!("timeout", ),
}
hal_get の完了に 450ms かかるとすると、200ms の短いタイムアウトは hal_get が完了する前に満了します。
タイムアウトを 1000ms に延ばすと、hal_get のほうが先に完了します。
select_biased を使えば任意の数の futures を組み合わせられるため、非常に強力です。しかし、タイムアウトのパターンは頻繁に使われるため、rtic-monotonics および rtic-time クレートによって提供される、より扱いやすいサポートが RTIC に組み込まれています。以下は別の例で、Mono::delay_until と Mono::timeout_after を使用します:
// get the current time instance
let mut instant = Mono::now();
// do this 3 times
for n in 0..3 {
// absolute point in time without drift
instant += 1000.millis();
Mono::delay_until(instant).await;
// absolute point in time for timeout
let timeout = instant + 500.millis();
hprintln!("now is {:?}, timeout at {:?}", Mono::now(), timeout);
match Mono::timeout_at(timeout, hal_get(n)).await {
Ok(v) => hprintln!("hal returned {} at time {:?}", v, Mono::now()),
_ => hprintln!("timeout"),
}
}
ドリフトなしで時間を正確に制御したい場合は、時刻の正確な一点を表す Instant と、時間の区間を表す Duration を使えます。Instant 型と Duration 型に対する操作は fugit クレートによって提供されます。
let mut instant = Mono::now() は、実行の開始時刻を設定します。
この開始時刻を基準に、1000ms ごとに hal_get を呼び出したいとします。これを実現するには、instant を 1000 ms ずつ増やし、その後で Mono::delay_until(instant).await を使います。このループを繰り返す中で発生する追加の遅延は、‘now + 1000’ ではなく ‘previous + 1000’ まで待機することで補償されます(‘now + 1000’ だとループのタイミングがドリフトします)。
上記の select! を使った async タイムアウトの例に対する別の方法として、将来の時点を timeout として定義し、Mono::timeout_at(timeout, hal_get(n)).await を呼び出します。
ループの 1 回目の反復では n == 0 なので、hal_get は 350ms かかり(前述のとおり)、タイムアウト前に完了します。2 回目の反復では遅延は 450ms で、これも依然としてタイムアウト前に完了します。3 回目の反復では n == 2 となり、hal_get の完了には 550ms かかるため、この場合はタイムアウトに達します。
完全な例
//! examples/async-timeout.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use cortex_m_semihosting::{debug, hprintln};
use panic_semihosting as _;
use rtic_monotonics::systick::prelude::*;
systick_monotonic!(Mono, 100);
#[rtic::app(device = lm3s6965, dispatchers = [SSI0, UART0], peripherals = true)]
mod app {
use super::*;
use futures::{future::FutureExt, select_biased};
#[shared]
struct Shared {}
#[local]
struct Local {}
// ANCHOR: init
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
hprintln!("init");
Mono::start(cx.core.SYST, 12_000_000);
// ANCHOR_END: init
foo::spawn().ok();
(Shared {}, Local {})
}
#[task]
async fn foo(_cx: foo::Context) {
// ANCHOR: select_biased
// Call hal with short relative timeout using `select_biased`
select_biased! {
v = hal_get(1).fuse() => hprintln!("hal returned {}", v),
_ = Mono::delay(200.millis()).fuse() => hprintln!("timeout", ), // this will finish first
}
// Call hal with long relative timeout using `select_biased`
select_biased! {
v = hal_get(1).fuse() => hprintln!("hal returned {}", v), // hal finish first
_ = Mono::delay(1000.millis()).fuse() => hprintln!("timeout", ),
}
// ANCHOR_END: select_biased
// ANCHOR: timeout_after_basic
// Call hal with long relative timeout using monotonic `timeout_after`
match Mono::timeout_after(1000.millis(), hal_get(1)).await {
Ok(v) => hprintln!("hal returned {}", v),
_ => hprintln!("timeout"),
}
// ANCHOR_END: timeout_after_basic
// ANCHOR: timeout_at_basic
// get the current time instance
let mut instant = Mono::now();
// do this 3 times
for n in 0..3 {
// absolute point in time without drift
instant += 1000.millis();
Mono::delay_until(instant).await;
// absolute point in time for timeout
let timeout = instant + 500.millis();
hprintln!("now is {:?}, timeout at {:?}", Mono::now(), timeout);
match Mono::timeout_at(timeout, hal_get(n)).await {
Ok(v) => hprintln!("hal returned {} at time {:?}", v, Mono::now()),
_ => hprintln!("timeout"),
}
}
// ANCHOR_END: timeout_at_basic
debug::exit(debug::EXIT_SUCCESS);
}
}
// Emulate some hal
async fn hal_get(n: u32) -> u32 {
// emulate some delay time dependent on n
let d = 350.millis() + n * 100.millis();
hprintln!("the hal takes a duration of {:?}", d);
Mono::delay(d).await;
// emulate some return value
5
}
$ cargo xtask qemu --verbose --example async-timeout --features test-critical-section
init
the hal takes a duration of Duration { ticks: 45 }
timeout
the hal takes a duration of Duration { ticks: 45 }
hal returned 5
the hal takes a duration of Duration { ticks: 45 }
hal returned 5
now is Instant { ticks: 213 }, timeout at Instant { ticks: 263 }
the hal takes a duration of Duration { ticks: 35 }
hal returned 5 at time Instant { ticks: 249 }
now is Instant { ticks: 313 }, timeout at Instant { ticks: 363 }
the hal takes a duration of Duration { ticks: 45 }
hal returned 5 at time Instant { ticks: 359 }
now is Instant { ticks: 413 }, timeout at Instant { ticks: 463 }
the hal takes a duration of Duration { ticks: 55 }
timeout
最小のアプリケーション
これは、可能な限り最小の RTIC アプリケーションです。
//! examples/smallest.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use core::panic::PanicInfo;
use cortex_m_semihosting::debug;
use rtic::app;
#[app(device = lm3s6965)]
mod app {
use super::*;
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
(Shared {}, Local {})
}
}
#[panic_handler]
fn panic_handler(_: &PanicInfo) -> ! {
debug::exit(debug::EXIT_FAILURE);
loop {}
}
RTIC は、リソース効率を念頭に置いて設計されています。RTIC 自体はいかなる動的メモリ割り当てにも依存しないため、必要な RAM 量はアプリケーションのみに依存します。割り込みベクタテーブルを含めても、フラッシュメモリの使用量は 1kB 未満です。
最小の例では、次のようなものが想定されます。
$ cargo xtask size --example smallest --backend thumbv7
text data bss dec hex filename
604 0 4 608 260 smallest
ヒントとコツ
このセクションでは、RTIC の使用に関する一般的なヒントとコツを紹介します。
リソースの分解
タスクが複数のリソースを受け取る場合、タスクのリソースを分割代入すると可読性が向上することがあります。以下に、リソース構造体を分割する 2 つの例を示します。
//! examples/destructure.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [UART0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
#[shared]
struct Shared {
a: u32,
b: u32,
c: u32,
}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
foo::spawn().unwrap();
bar::spawn().unwrap();
(Shared { a: 0, b: 1, c: 2 }, Local {})
}
#[idle]
fn idle(_: idle::Context) -> ! {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
loop {}
}
// Direct destructure
#[task(shared = [&a, &b, &c], priority = 1)]
async fn foo(cx: foo::Context) {
let a = cx.shared.a;
let b = cx.shared.b;
let c = cx.shared.c;
hprintln!("foo: a = {}, b = {}, c = {}", a, b, c);
}
// De-structure-ing syntax
#[task(shared = [&a, &b, &c], priority = 1)]
async fn bar(cx: bar::Context) {
let bar::SharedResources { a, b, c, .. } = cx.shared;
hprintln!("bar: a = {}, b = {}, c = {}", a, b, c);
}
}
$ cargo xtask qemu --verbose --example destructure
bar: a = 0, b = 1, c = 2
foo: a = 0, b = 1, c = 2
メッセージ受け渡しを高速化するための間接参照の利用
メッセージ受け渡しでは、常に送信側から静的変数へ、さらに静的変数から受信側へと、ペイロードのコピーが発生します。そのため、[u8; 128] のような大きなバッファをメッセージとして送信すると、高コストな
memcpy が 2 回発生します。
間接参照を使うと、メッセージ受け渡しのオーバーヘッドを最小化できます。つまり、バッファを値として送る代わりに、バッファへの所有権を持つポインタを送ることができます。
間接参照を実現するには、グローバルメモリアロケータ(alloc::Box、alloc::Rc など)を使う方法がありますが、Rust v1.37.0 時点では nightly チャネルの使用が必要です。あるいは、heapless::Pool のような静的に確保されたメモリプールを使うこともできます。
このアプローチの例は、shared と local を持つ RTIC のリソースモデルを完全に外れるため、プログラムは、この場合 heapless::pool であるメモリアロケータの正しさに依存することになります。
以下は、heapless::Pool を使って 128 バイトのバッファを「box 化」する例です。
//! examples/pool.rs
#![no_main]
#![no_std]
#![deny(warnings)]
use panic_semihosting as _;
use rtic::app;
// thumbv6-none-eabi does not support pool
// This might be better worked around in the build system,
// but for proof of concept, let's try having one example
// being different for different backends
// https://docs.rs/heapless/0.8.0/heapless/pool/index.html#target-support
cfg_if::cfg_if! {
if #[cfg(feature = "thumbv6-backend")] {
// Copy of the smallest.rs example
#[app(device = lm3s6965)]
mod app {
use cortex_m_semihosting::debug;
#[shared]
struct Shared {}
#[local]
struct Local {}
#[init]
fn init(_: init::Context) -> (Shared, Local) {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
(Shared {}, Local {})
}
}
} else {
// Run actual pool example
use heapless::{
box_pool,
pool::boxed::{Box, BoxBlock},
};
// Declare a pool containing 8-byte memory blocks
box_pool!(P: u8);
const POOL_CAPACITY: usize = 512;
#[app(device = lm3s6965, dispatchers = [SSI0, QEI0])]
mod app {
use crate::{Box, BoxBlock, POOL_CAPACITY};
use cortex_m_semihosting::debug;
use lm3s6965::Interrupt;
// Import the memory pool into scope
use crate::P;
#[shared]
struct Shared {}
#[local]
struct Local {}
const BLOCK: BoxBlock<u8> = BoxBlock::new();
#[init(local = [memory: [BoxBlock<u8>; POOL_CAPACITY] = [BLOCK; POOL_CAPACITY]])]
fn init(cx: init::Context) -> (Shared, Local) {
for block in cx.local.memory {
// Give the 'static memory to the pool
P.manage(block);
}
rtic::pend(Interrupt::I2C0);
(Shared {}, Local {})
}
#[task(binds = I2C0, priority = 2)]
fn i2c0(_: i2c0::Context) {
// Claim 128 u8 blocks
let x = P.alloc(128).unwrap();
// .. send it to the `foo` task
foo::spawn(x).ok().unwrap();
// send another 128 u8 blocks to the task `bar`
bar::spawn(P.alloc(128).unwrap()).ok().unwrap();
}
#[task]
async fn foo(_: foo::Context, _x: Box<P>) {
// explicitly return the block to the pool
drop(_x);
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
}
#[task(priority = 2)]
async fn bar(_: bar::Context, _x: Box<P>) {
// this is done automatically so we can omit the call to `drop`
// drop(_x);
}
}
}
}
$ cargo xtask qemu --verbose --example pool
'static の強力な力
#[init]、#[idle]、および発散するソフトウェアタスクでは、local リソースは 'static ライフタイムを持ちます。
これは、リソースを事前に割り当てたり、タスク、ドライバー、またはその他のオブジェクト間でリソースを分割したりする際に便利です。これは、USB ドライバーのようにメモリを割り当てる必要があるドライバーや、heapless::spsc::Queue のような分割可能なデータ構造を使用する場合に役立ちます。
次の例では、2 つの異なるタスクが heapless::spsc::Queue を共有し、共有キューにロックフリーでアクセスします。
//! examples/static-resources-in-init.rs
#![no_main]
#![no_std]
#![deny(warnings)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
use panic_semihosting as _;
#[rtic::app(device = lm3s6965, dispatchers = [UART0])]
mod app {
use cortex_m_semihosting::{debug, hprintln};
use heapless::spsc::{Consumer, Producer, Queue};
#[shared]
struct Shared {}
#[local]
struct Local {
p: Producer<'static, u32, 5>,
c: Consumer<'static, u32, 5>,
}
#[init(local = [q: Queue<u32, 5> = Queue::new()])]
fn init(cx: init::Context) -> (Shared, Local) {
// q has 'static life-time so after the split and return of `init`
// it will continue to exist and be allocated
let (p, c) = cx.local.q.split();
foo::spawn().unwrap();
(Shared {}, Local { p, c })
}
#[idle(local = [c])]
fn idle(c: idle::Context) -> ! {
loop {
// Lock-free access to the same underlying queue!
if let Some(data) = c.local.c.dequeue() {
hprintln!("received message: {}", data);
// Run foo until data
if data == 3 {
debug::exit(debug::EXIT_SUCCESS); // Exit QEMU simulator
} else {
foo::spawn().unwrap();
}
}
}
}
#[task(local = [p, state: u32 = 0], priority = 1)]
async fn foo(c: foo::Context) {
*c.local.state += 1;
// Lock-free access to the same underlying queue!
c.local.p.enqueue(*c.local.state).unwrap();
}
}
このプログラムを実行すると、期待どおりの出力が得られます。
$ cargo xtask qemu --verbose --example static-resources-in-init
received message: 1
received message: 2
received message: 3
生成されたコードの確認
#[rtic::app] は補助コードを生成するプロシージャルマクロです。
何らかの理由でこのマクロによって生成されたコードを確認する必要がある場合、方法は 2 つあります。
targetディレクトリ内のrtic-expansion.rsファイルを確認するcargo-expandサブコマンドを使う
生成された rtic-expansion.rs を使う
このファイルの場所は、ビルドの実行方法によって異なります。
たとえばメインの RTIC リポジトリ内で cargo xtask build-example を使うと、このファイルは使用した “platform” に応じて配置されます。
$ cargo xtask example-build --example smallest
$ cargo xtask example-build --example monotonic --platform esp32-c3
$ fd -u rtic-expansion.rs
examples/esp32c3/target/rtic-expansion.rs
examples/lm3s6965/target/rtic-expansion.rs
通常の cargo プロジェクトの場合は、target フォルダー直下に置かれます。
このファイルには、最後にビルドされた(cargo build または cargo check による)RTIC アプリケーションの #[rtic::app] アイテムの展開結果(プログラム全体ではありません!)が含まれます。
展開されたコードはデフォルトでは整形されていないため、読む前に rustfmt を実行したくなるでしょう。
$ cargo build --example smallest --target thumbv7m-none-eabi
$ rustfmt target/rtic-expansion.rs
$ tail target/rtic-expansion.rs
#[doc = r" Implementation details"]
mod app {
#[doc = r" Always include the device crate which contains the vector table"]
use lm3s6965 as _;
#[no_mangle]
unsafe extern "C" fn main() -> ! {
rtic::export::interrupt::disable();
let mut core: rtic::export::Peripherals = core::mem::transmute(());
core.SCB.scr.modify(|r| r | 1 << 1);
rtic::export::interrupt::enable();
loop {
rtic::export::wfi()
}
}
}
cargo-expand ツールを使う
利用できない場合は、インストールしてください。
$ cargo install cargo-expand
このサブコマンドは、#[rtic::app] 属性やクレート内のモジュールを含む すべて のマクロを展開し、その出力をコンソールに表示します。
# 前と同じ出力を生成します
cargo expand --example smallest | tail
Monotonic の仕組み
内部では、すべての Monotonic 実装が Timer Queue を使用します。これは、対応する Future が完了すべき時刻を記述したエントリを持つ優先度キューです。
スケジューリング用 Monotonic タイマーの実装
rtic-time フレームワークは、コンペアマッチを備え、さらに必要に応じてスケジューリング用のオーバーフロー割り込みもサポートする任意のタイマーを利用できるため、柔軟です。タイマーを RTIC で利用可能にするための唯一の要件は、rtic-time::Monotonic トレイトを実装することです。
RTIC 2.0 では、Monotonic を実装する際のすべての時間ベースの操作の基盤として、ユーザーが fugit などの時間ライブラリを利用していることを前提としています。これらのライブラリを使うことで、Monotonic トレイトを正しく実装することが大幅に容易になり、その結果、システム内のほぼ任意のタイマーをスケジューリングに使用できるようになります。
このトレイトには、各メソッドに対する要件が記載されています。参考にできるリファレンス実装が rtic-monotonics に用意されています。
Systick basedは固定の割り込み(tick)レートで動作します。多少のオーバーヘッドはありますが、シンプルで、長い時間範囲をサポートしますRP2040 Timerは、割り込みなしで長時間待機できる「本格的な」実装です。スケジューリングを処理するためにTimerQueueをどのように使うかを明確に示しています。nRF52 timersは、nRF52 の RTC と通常タイマー向けに Monotonic とタイマーキューを実装しています
貢献
Monotonic の新しい実装への貢献は、複数の方法で行えます。
rtic-monotonicsに feature flag の下でこのトレイトを実装し、それらをメインの RTIC リポジトリに含めるための PR を作成してください。この方法なら、実装は in-tree となるため、RTIC はその正しさを保証でき、新しいリリース時に更新できます。- 外部リポジトリで変更を実装してください。この方法では
rtic-monotonicsに含まれませんが、将来的にそうしやすくなる可能性があります。
The timer queue
タイマーキューはリストベースの優先度キューとして実装されており、リストノードは、monotonic を待機する際に作成される Future を await したときに生成される Future の一部として静的に割り当てられます。したがって、タイマーキューは実行時に失敗しません(そのサイズと割り当てはコンパイル時に決定されます)。
チャネル実装と同様に、タイマーキュー実装は競合を防ぐためにグローバルな クリティカルセクション (CS) に依存しています。例では、ビルドオプションに --features test-critical-section を追加することで、CS 実装が提供されます。
RTIC と他方式の比較
RTIC は、堅牢かつ信頼性の高い組み込みソフトウェアの開発に必要とされる、最小限の抽象化レベルを提供することを目指しています。
割り込みと非同期に実行されるタスクの間で可変リソースを安全に共有するために必要な、最小限の機構を提供します。スケジューリングプリミティブは基盤となるハードウェアを活用し、比類のない性能と予測可能性を実現します。事実上、RTIC は Rust の文脈において、並行リアルタイムプログラミングに対するゼロコスト抽象化を提供します。
安全性とセキュリティに関する比較
RTIC を従来のリアルタイムオペレーティングシステム(RTOS)と比較するのは困難です。まず、従来の RTOS は通常、システムの安全性に関して何の保証も伴いません。これは、形式的に検証された seL4 カーネルのような最も堅牢化されたカーネルであっても同様です。完全性、機密性、可用性に関するそれらの主張は、カーネル自体にのみ関するものです(さらに、その構成と環境について追加の仮定を置いた場合に限ります)。彼ら自身も次のように述べています。
「検証済みかどうかにかかわらず、OS カーネルがあるからといって、システムが自動的にセキュアになるわけではありません。実際、どれほどセキュアなシステムであっても、セキュアでない方法で使用される可能性があります。」 - seL4 FAQ
設計によるセキュリティ
情報セキュリティの世界では、一般に次の 3 つが挙げられます。
- 機密性。情報が権限のない第三者にさらされるのを防ぐこと。
- 完全性。データの正確性と完全さを指すこと。
- 可用性。認可されたユーザーがデータにアクセスできることを指すこと。
言うまでもなく、従来の OS は機密性も完全性も保証できません。どちらも、セキュリティクリティカルなコードが信頼できることを前提とするためです。可用性については、通常、問題はシステムリソースの利用に帰着します。リソースの動的割り当てを許可する OS はいずれも、アプリケーションが割り当て/解放と、割り当て失敗のケースを正しく処理することに依存します。
したがって、彼らの主張は正しく、セキュリティは OS の手の及ぶ範囲を完全に超えています。私たちが期待できる最善は、OS がさらなる脆弱性を追加しないことです。
一方、RTIC はその点で頼りになります。宣言的なシステム全体モデルにより、静的なタスクとリソースの集合が提供され、どのデータがどの主体間で共有されるのかを正確に制御できます。さらに、Rust というプログラミング言語自体が、完全性に関して強力な性質を備えています(コンパイル時のエイリアシング、可変性、ライフタイムに関する保証に加えて、データの妥当性も保証されます)。
RTIC を使用すると、これらの性質は、他に実行中のアプリケーションの干渉を受けることなく、システム全体モデルにも及びます。RTIC カーネルは、動的に割り当てられたデータを一切必要とせず、内部的に失敗しません。
RTIC vs. Embassy
違い
Embassy はハードウェア抽象化レイヤーとエグゼキューター/ランタイムの両方を提供しますが、RTIC は実行フレームワークのみを提供することを目指しています。たとえば、embassy は embassy-stm32(HAL)と embassy-executor(エグゼキューター)を提供します。一方、RTIC は rtic の形でフレームワークを提供し、PAC と HAL の実装はユーザーが用意する責任があります(一般的には stm32-rs プロジェクトのものを使用します)。
さらに、RTIC は可能な限り低いレベルでリソースへの排他的アクセスを提供することを目指しており、理想的には何らかの形のハードウェア保護によって保護されます。これにより、ソフトウェアレベルのロック機構を必ずしも必要とせずにハードウェアへアクセスできます。
Embassy と RTIC の併用
ほとんどの Embassy および RTIC のライブラリはランタイム非依存であるため、一方のプロジェクトの多くの要素はもう一方でも使用できます。たとえば、embassy-executor を使用するプロジェクトで rtic-monotonics を使用できますし、RTIC プロジェクトで embassy-sync(ただし rtic-sync を推奨)を使用することもできます。
Awesome RTIC のサンプル
完全なサンプルについては、rtic-rs/rtic/examples リポジトリを参照してください。
プルリクエストを歓迎します!
v1.0.x から v2.0.0 への移行
RTIC v1.0.x から v2.0.0 へプロジェクトを移行するには、次の手順が必要です。
v2.1.0は Rust Stable 1.75 以降で動作します(推奨)。一方、古いバージョンでは#![type_alias_impl_trait]を使用するため、nightlyコンパイラが必要です。v1.0.xに含まれている monotonic をrtic-timeおよびrtic-monotonicsへ移行し、spawn_after、spawn_atを置き換えます。- ソフトウェアタスクは
asyncであることが必須になったため、それらを正しく使用します。 rtic-syncが提供するデータ型を理解し、使用します。
変更点の詳細については、各小節を参照してください。
必要な変更のコード例を見たい場合は、完全な移行例のページ を参照してください。
TL;DR(長いので要点だけ)
spawn_afterとspawn_atの代わりに、rtic-monotonicsが実装を提供するasync関数delay、delay_until(および関連する関数)を使用するようになりました。- ソフトウェアタスクは теперь 必ず
async fnでなければなりません。タスク内にawaitがある限り、タスクから戻らないことも許可されます。共有リソースは引き続きlockできます。 - 新しいタスクを
spawnする代わりに、共有リソースへのアクセスをawaitするにはrtic_sync::arbiter::Arbiterを使用し、タスク間で通信するにはrtic_sync::channel::Channelを使用します。
rtic-monotonics への移行
以前の rtic のバージョンでは、モノトニックは #[rtic::app] の不可欠で密結合な一部でした。この新しいバージョンでは、rtic-monotonics によって、より疎結合な方法で提供されます。
#[monotonic] 属性はもう使用しません。代わりに、rtic-monotonics の create_X_token を使用します。このマクロを呼び出すと割り込み登録トークンが返され、これを使って目的のモノトニックのインスタンスを構築できます。
spawn_after と spawn_at はもう利用できません。代わりに、rtic-monotonics を通じて利用できる rtic_time::Monotonic トレイトの実装が提供する async 関数 delay と delay_until を使用します。
必要な変更の概要については、コード例 を参照してください。
現在のモノトニック実装の詳細については、rtic-monotonics のドキュメント と、サンプル を参照してください。
async ソフトウェアタスクの使用
ソフトウェアタスクにはいくつかの変更があります。以下に概要を示します。
ソフトウェアタスクは async でなければならなくなりました。
すべてのソフトウェアタスクは async であることが必須になりました。
必要な変更。
プロジェクト内で割り込みにバインドされていないタスクは、すべて async fn でなければならなくなりました。たとえば:
#[task(
local = [ some_resource ],
shared = [ my_shared_resource ],
priority = 2
)]
fn my_task(cx: my_task::Context) {
cx.local.some_resource.do_trick();
cx.shared.my_shared_resource.lock(|s| s.do_shared_thing());
}
これは次のようになります
#[task(
local = [ some_resource ],
shared = [ my_shared_resource ],
priority = 2
)]
async fn my_task(cx: my_task::Context) {
cx.local.some_resource.do_trick();
cx.shared.my_shared_resource.lock(|s| s.do_shared_thing());
}
ソフトウェアタスクは無限に実行できるようになりました
新しい async ソフトウェアタスクは、1 つの前提条件のもとで無限に実行することが許可されています。タスクの無限ループ内に await が存在しなければなりません。そのようなタスクの例を次に示します:
#[task(local = [ my_channel ] )]
async fn my_task_that_runs_forever(cx: my_task_that_runs_forever::Context) {
loop {
let value = cx.local.my_channel.recv().await;
do_something_with_value(value);
}
}
spawn_after と spawn_at は削除されました。
rtic-monotonics への移行 の章で説明したとおり、spawn_after と spawn_at はもう利用できません。
rtic-sync を使う
rtic-sync は、非同期コンテキストでのメッセージパッシングやリソース共有に使用できるプリミティブを提供します。
重要な構造体は次のとおりです。
Arbiter:lockを使用せずに、非同期コンテキストで共有リソースへのアクセスを await できます。Channel: タスク間(asyncと非asyncの両方)で通信できます。
これらの構造体の詳細については、rtic-sync のドキュメントを参照してください。
移行の完全な例
以下に、v1.0.x および v2.0.0 における stm32f3_blinky サンプルの実装コードを示します。さらに下に、diff を示します。
v1.0.X
#![allow(unused)]
#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]
fn main() {
use panic_rtt_target as _;
use rtic::app;
use rtt_target::{rprintln, rtt_init_print};
use stm32f3xx_hal::gpio::{Output, PushPull, PA5};
use stm32f3xx_hal::prelude::*;
use systick_monotonic::{fugit::Duration, Systick};
#[app(device = stm32f3xx_hal::pac, peripherals = true, dispatchers = [SPI1])]
mod app {
use super::*;
#[shared]
struct Shared {}
#[local]
struct Local {
led: PA5<Output<PushPull>>,
state: bool,
}
#[monotonic(binds = SysTick, default = true)]
type MonoTimer = Systick<1000>;
#[init]
fn init(cx: init::Context) -> (Shared, Local, init::Monotonics) {
// クロックを設定
let mut flash = cx.device.FLASH.constrain();
let mut rcc = cx.device.RCC.constrain();
let mono = Systick::new(cx.core.SYST, 36_000_000);
rtt_init_print!();
rprintln!("init");
let _clocks = rcc
.cfgr
.use_hse(8.MHz())
.sysclk(36.MHz())
.pclk1(36.MHz())
.freeze(&mut flash.acr);
// LED を設定
let mut gpioa = cx.device.GPIOA.split(&mut rcc.ahb);
let mut led = gpioa
.pa5
.into_push_pull_output(&mut gpioa.moder, &mut gpioa.otyper);
led.set_high().unwrap();
// 点滅タスクをスケジュール
blink::spawn_after(Duration::<u64, 1, 1000>::from_ticks(1000)).unwrap();
(
Shared {},
Local { led, state: false },
init::Monotonics(mono),
)
}
#[task(local = [led, state])]
fn blink(cx: blink::Context) {
rprintln!("blink");
if *cx.local.state {
cx.local.led.set_high().unwrap();
*cx.local.state = false;
} else {
cx.local.led.set_low().unwrap();
*cx.local.state = true;
}
blink::spawn_after(Duration::<u64, 1, 1000>::from_ticks(1000)).unwrap();
}
}
}
V2.0.0
#![deny(unsafe_code)]
#![deny(warnings)]
#![no_main]
#![no_std]
use panic_rtt_target as _;
use rtic::app;
use rtic_monotonics::systick::prelude::*;
use rtt_target::{rprintln, rtt_init_print};
use stm32f3xx_hal::gpio::{Output, PushPull, PA5};
use stm32f3xx_hal::prelude::*;
systick_monotonic!(Mono, 1000);
#[app(device = stm32f3xx_hal::pac, peripherals = true, dispatchers = [SPI1])]
mod app {
use super::*;
#[shared]
struct Shared {}
#[local]
struct Local {
led: PA5<Output<PushPull>>,
state: bool,
}
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
// Setup clocks
let mut flash = cx.device.FLASH.constrain();
let mut rcc = cx.device.RCC.constrain();
// Initialize the systick interrupt & obtain the token to prove that we did
Mono::start(cx.core.SYST, 36_000_000); // default STM32F303 clock-rate is 36MHz
rtt_init_print!();
rprintln!("init");
let _clocks = rcc
.cfgr
.use_hse(8.MHz())
.sysclk(36.MHz())
.pclk1(36.MHz())
.freeze(&mut flash.acr);
// Setup LED
let mut gpioa = cx.device.GPIOA.split(&mut rcc.ahb);
let mut led = gpioa
.pa5
.into_push_pull_output(&mut gpioa.moder, &mut gpioa.otyper);
led.set_high().unwrap();
// Schedule the blinking task
blink::spawn().ok();
(Shared {}, Local { led, state: false })
}
#[task(local = [led, state])]
async fn blink(cx: blink::Context) {
loop {
rprintln!("blink");
if *cx.local.state {
cx.local.led.set_high().unwrap();
*cx.local.state = false;
} else {
cx.local.led.set_low().unwrap();
*cx.local.state = true;
}
Mono::delay(1000.millis()).await;
}
}
}
2 つのプロジェクト間の diff
注: この diff は 100% 正確ではない可能性がありますが、重要な変更点を示しています。
#![no_main]
#![no_std]
use panic_rtt_target as _;
use rtic::app;
use stm32f3xx_hal::gpio::{Output, PushPull, PA5};
use stm32f3xx_hal::prelude::*;
-use systick_monotonic::{fugit::Duration, Systick};
+use rtic_monotonics::Systick;
#[app(device = stm32f3xx_hal::pac, peripherals = true, dispatchers = [SPI1])]
mod app {
@@ -20,16 +21,14 @@ mod app {
state: bool,
}
- #[monotonic(binds = SysTick, default = true)]
- type MonoTimer = Systick<1000>;
-
#[init]
fn init(cx: init::Context) -> (Shared, Local, init::Monotonics) {
- // Setup clocks
+ // クロックを設定
let mut flash = cx.device.FLASH.constrain();
let mut rcc = cx.device.RCC.constrain();
- let mono = Systick::new(cx.core.SYST, 36_000_000);
+ let mono_token = rtic_monotonics::create_systick_token!();
+ let mono = Systick::start(cx.core.SYST, 36_000_000, mono_token);
let _clocks = rcc
.cfgr
@@ -46,7 +45,7 @@ mod app {
led.set_high().unwrap();
- // Schedule the blinking task
+ // 点滅タスクをスケジュール
- blink::spawn_after(Duration::<u64, 1, 1000>::from_ticks(1000)).unwrap();
+ blink::spawn().unwrap();
(
Shared {},
@@ -56,14 +55,18 @@ mod app {
}
#[task(local = [led, state])]
- fn blink(cx: blink::Context) {
- rprintln!("blink");
- if *cx.local.state {
- cx.local.led.set_high().unwrap();
- *cx.local.state = false;
- } else {
- cx.local.led.set_low().unwrap();
- *cx.local.state = true;
- blink::spawn_after(Duration::<u64, 1, 1000>::from_ticks(1000)).unwrap();
- }
+ async fn blink(cx: blink::Context) {
+ loop {
+ // タスクは、ループ内のどこかに
+ // `await` がある限り、無限に実行できるようになりました。
+ SysTick::delay(1000.millis()).await;
+ rprintln!("blink");
+ if *cx.local.state {
+ cx.local.led.set_high().unwrap();
+ *cx.local.state = false;
+ } else {
+ cx.local.led.set_low().unwrap();
+ *cx.local.state = true;
+ }
+ }
+ }
}
内部の仕組み
この章は現在作業中です。 より完成度が高まった段階で再掲されます
このセクションでは、RTIC framework の内部実装を高レベルで説明します。
手続きマクロ(#[app])によって行われるパースやコード生成といった
低レベルの詳細については、ここでは説明しません。主な焦点は、
ユーザー仕様の解析と、ランタイムで使用されるデータ構造です。
この内容に入る前に、embedonomicon の concurrency に関するセクションを 読んでおくことを強く推奨します。
ターゲットアーキテクチャ
Cortex-M デバイス
RTIC は現在すべての Cortex-M デバイスを対象にできますが、ユーザーが認識しておくべき
重要なアーキテクチャ上の違いがいくつかあります。具体的には、RTIC で使用される
ハードウェアによる優先度上限サポートに非常に適した Base Priority Mask Register (BASEPRI)
が ARMv6-M および ARMv8-M-base アーキテクチャには存在しないため、RTIC は代わりに
ソースマスキングを使用せざるを得ません。lock の各実装とその長所・短所の詳しい解説については、
src/export.rs 内の lock の実装を参照してください。
これらの違いはクリティカルセクションの実現方法に影響しますが、ARMv6-M/ARMv8-M-base では共有リソースを持つタスクを例外ハンドラにバインドできない点を除けば、機能は同じであるはずです。 これは、それらをハードウェアでマスクできないためです。
以下の表 1 は、Cortex-M プロセッサの一覧と、それぞれが採用するクリティカルセクションの 種類を示しています。
表 1: プロセッサアーキテクチャ別クリティカルセクション実装
| Processor | Architecture | 優先度上限 | ソースマスキング |
|---|---|---|---|
| Cortex-M0 | ARMv6-M | ✓ | |
| Cortex-M0+ | ARMv6-M | ✓ | |
| Cortex-M3 | ARMv7-M | ✓ | |
| Cortex-M4 | ARMv7-M | ✓ | |
| Cortex-M7 | ARMv7-M | ✓ | |
| Cortex-M23 | ARMv8-M-base | ✓ | |
| Cortex-M33 | ARMv8-M-main | ✓ |
優先度上限
これについては、この書籍の リソース ページで説明しています。
ソースマスキング
Nested Vectored Interrupt Controller (NVIC) において優先度上限を直接設定できる
BASEPRI レジスタがないため、RTIC は代わりに割り込みの無効化(マスキング)に依存する
必要があります。以下の図 1 を考えてみましょう。ここでは、A は B より高い優先度を持ちながら、
B とリソースを共有する 2 つのタスク A と B を示しています。
図 1: 共有リソースとソースマスキング
┌────────────────────────────────────────────────────────────────┐
│ │
│ │
3 │ Pending Preempts │
2 │ ↑- - -A- - - - -↓A─────────► │
1 │ B───────────────────► - - - - B────────► │
0 │Idle┌─────► Resumes ┌────────► │
├────┴────────────────────────────────────────────┴──────────────┤
│ │
└────────────────────────────────────────────────────────────────┴──► Time
t1 t2 t3 t4
時刻 t1 で、タスク B は共有リソースをロックするために、B とリソースを共有する任意の
タスクと同じかそれより低い優先度を持つ他のすべてのタスクを選択的に無効化します(NVIC を使用)。
この結果、BASEPRI のアプローチを模倣する仮想的な優先度上限が作られます。タスク A は、
タスク B とリソースを共有するそのようなタスクの 1 つです。時刻 t2 で、タスク A は
タスク B によって spawn されるか、あるいは割り込み条件によってペンディング状態になりますが、
優先度がより高いにもかかわらず、まだタスク B をプリエンプトしません。これは、タスク A が
無効化されているため、NVIC がその開始を防いでいるからです。時刻 t3 で、タスク B は
NVIC でタスク群を再度有効化することでロックを解放します。タスク A はペンディング状態であり、
かつタスク B より高い優先度を持つため、直ちにタスク B をプリエンプトし、データ競合の危険なく
共有リソースを使用できます。時刻 t4 で、タスク A は完了し、実行コンテキストを B に返します。
ソースマスキングは NVIC の使用に依存するため、HardFault、SVCall、PendSV、SysTick などのコア例外ソースは、他のタスクとデータを共有できません。
RISC-V デバイス
現在のすべての RISC-V バックエンドは、優先度上限を用いる Cortex-M デバイスと 同様の方式で動作します。したがって、この書籍の リソース ページがよい参考資料になります。ただし、これらのバックエンドの一部は完全な ハードウェア実装ではなく、物理的な割り込みコントローラをソフトウェアでエミュレート します。そのため、これらのバックエンドはハードウェアタスクを実装せず、必要なのは ソフトウェアタスクのみです。さらに、これらのターゲットではソフトウェアタスクの数は 利用可能な物理割り込みソースの数によって制限されません。
以下の表 2 では、利用可能な RISC-V バックエンドを比較しています。
表 2: プロセッサアーキテクチャ別クリティカルセクション実装
| バックエンド | 対応ターゲット | バックエンド固有の設定 | ハードウェアタスク | ソフトウェアタスク | タスク数が HW によって制限されるか |
|---|---|---|---|---|---|
riscv-esp32c3-backend | ESP32-C3 のみ | ✓ | ✓ | ✓ | |
riscv-mecall-backend | 任意の RISC-V デバイス | ✓ | |||
riscv-clint-backend | CLINT ペリフェラル搭載デバイス | ✓ | ✓ |
riscv-mecall-backend
RTIC がコンパイル時にそれらを生成するため、#[app] 属性でディスパッチャの一覧を
指定する必要はありません。優先度レベルは 0(idle タスク用)から 255 までです。
riscv-clint-backend
RTIC がコンパイル時にそれらを生成するため、#[app] 属性で dispatchers の一覧を
指定する必要はありません。優先度レベルは 0(idle タスク用)から 255 までです。
RTIC がアプリケーションを実行している HART を識別するために使用する ID 番号を
認識できるよう、#[app] 属性には backend 固有の設定を 必ず 含める必要があります。
たとえば、e310x チップでは、最小限のアプリケーションを次のように設定します。
#![allow(unused)]
fn main() {
#[rtic::app(device = e310x, backend = H0)]
mod app {
// ここにアプリケーションを記述します
}
}
このようにして、RTIC は常に HART H0 を参照します。