組み込みRust RTOS vs C RTOS
Embedded Rust RTOS vs. C RTOS (tweedegolf.nl)
要約
この記事では、組み込みシステムにおける非同期Rust(Embassy)と、従来のC言語ベースのリアルタイムオペレーティングシステム(FreeRTOS)をSTM32F446マイクロコントローラー上で比較します。両者を、割り込みレイテンシ、プログラムサイズ、RAM使用量、プログラミングの容易さといった観点から評価し、それぞれのアーキテクチャと利点を解説します。
全文翻訳
Async Rust vs RTOS showdown!
組み込みシステムにおけるasync Rustに関する技術ブログ記事です。今回は、STM32F446マイクロコントローラー上でEmbassy/RustとFreeRTOS/Cを比較します。
両者は同じアクションを実行するアプリケーションを実行し、割り込みレイテンシ、プログラムサイズ、RAM使用量、およびプログラミングの容易さに基づいて評価します。CとRustの比較記事はすでに多く存在するため、今回はその点には焦点を当てません。
ここでは、2つの「通常の」アプリケーションを示します。どちらのプロジェクトも、多大な労力をかければパフォーマンスを向上させるように調整できます。それはほぼ無限のタスクになり得ます。そのため、ガイドラインとして、アプリケーションは以下のようになります。
- 他のチップやアーキテクチャへの移植性(HALへの依存を除く)
- 直接的
- コンパイラの最適化、RTOS設定、スレッド優先度などの通常のオプションと設定で調整
最終的に、RTOSとasyncエクゼキューターがどのように機能するか(または機能し得るか)についての基本的な理解を得られるはずです。
私は偏見がありますが、この記事が公平な比較を提供することを願っています。提案があれば、ぜひお知らせください!
STM32F446ZET6マイクロコントローラー(180MHz)を使用してテストを行い、一部の測定はRigol DS1054Zオシロスコープで行います。
Async Rust
Rustのasync関数は、Futureを返す関数のシンタックスシュガーです。
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
関数はステートマシンオブジェクトに変換され、ポーリングできるようになります。ステートマシンにより、コードは関数にジャンプし、以前停止した場所から再開できます。また、awaitポイント間で保持されるすべての変数を追跡します。
RustのFutureは遅延実行であり、ポーリングされたときにのみ実行されます。Futureを完了まで実行するには、Pending状態を返さなくなりReady(Output)状態を返すまで、poll関数を継続的に呼び出すだけです。
これは直接的ですが、あまり効率的ではありません。
それを解決するために、Wakerも存在します。Wakerは、Futureを再度ポーリングする必要があることをエクゼキューターにシグナルできます。このWakerはFuture自体から呼び出されるか、Futureが依存する別のプロセス/スレッドに渡すことができます。一般的に、エクゼキューターはpoll関数を一度呼び出し、Wakerがトリガーされたときにのみ再度呼び出します。
Futureは他のFutureを呼び出し、それらを自身に組み込むことができます。エクゼキューターにとって、ポーリングするトップレベルのFutureは通常taskと呼ばれます。
これについてさらに多くのことを言うことができます。幸い、私自身が言う必要はありません。なぜなら、素晴らしいリソースがいくつか存在するからです。
- Under the Hood: Executing Futures and Tasks
- How Rust optimizes async/await
- Understanding Rust futures by going way too deep
Embassyにおいて
Embassyもこのメカニズムを使用しますが、いくつかの制約が追加されます。
- タスクは静的に割り当てる必要があります
- Embassyはアロケーターに依存したくありません
- すべてのタスクはコンパイル時に知られている必要があります
- ナイトリーコンパイラが必要です
type_alias_impl_traitプレビュー機能が必要です- アロケーターがないため、ボックス化されたトレイトオブジェクトを使用できないためです
多くのペリフェラルに対して、Embassyは非同期インターフェースを作成しました。これにより、次のようなコードが可能になります。
#[embassy::task]
async fn my_task(mut button: ExtiInput<'static, PC13>) {
loop {
button.wait_for_rising_edge().await;
info!("Pressed!");
button.wait_for_falling_edge().await;
info!("Released!");
}
}
ここでいくつかのことが起こっています。
wait_for_rising_edgeは新しいFutureを作成して返します。Futureのコンストラクタはピンの割り込みを設定します。最初のポーリングで、FutureはそのWakerをEXTI Wakerのグローバル配列に配置します。EXTI割り込みが発生すると、その配列内の適切なWakerが使用され、正しいタスクがウェイクアップされます。
したがって、割り込みが終了すると、エクゼキューターはタスクを再度ポーリングし、wait_for_rising_edge Futureは割り込みが発火したことを検出し、準備完了であることを返します。そしてプログラムは続行されます。
Embassyが行わないことの1つはプリエンプションです。これは、アクティブなタスクが、より重要なタスクを待機するまで切り替えられないことを意味します。これは協調的マルチタスクと呼ばれます。しかし、Embassyにはこの欠けている機能を問題なくするための他の機能があり、それらはこの記事の後半で説明します。
RTOS
リアルタイムオペレーティングシステムは、すべてを独立したスレッドに分割します。タスクとは異なり、スレッドはステートマシンを実行するのではなく、通常のコードを実行します。これは、コードを特別な方法でプログラムする必要がないことを意味します。古い関数でもRTOSで実行できます。
スレッドの実行を一時停止して別のスレッドに切り替える必要がある場合、スレッドは通常のコードを実行しているため、プロセッサコンテキスト全体をキャプチャして保存する必要があります。そのコードが再開するときは、プロセッサコンテキストが同じである必要があります。
このマルチスレッディングの設計は、プリエンプティブスレッドに適しています。これは、カーネルがすべてのスレッドに公平な実行時間を提供でき、ユーザーが優先度を指定でき、カーネルがイベントや割り込みに予測可能な時間で応答できることを意味します。
この説明はRTOSの表面をかすめたにすぎません。より深く理解するために、興味のある方は以下の記事をご覧ください。
ショーダウン開始!
両方のモデルについて少し理解したところで、同じプログラムを両方で実装して対決させます。
プログラム
完全に現実的なプログラムを構築することはできません。なぜなら、構築に時間がかかりすぎるからです。しかし、あまり単純すぎないものを用意してみましょう。
現実性に近づいていると主張するために必要なことがいくつかあります。
- 複数のタスク
- タスク間のデータ共有
- 割り込みへの応答
そのため、私たちのプログラムは次の3つの(文字通りの)タスクを実行します。
- 200msごとに100ms間LEDを点滅させる
- ループに入り、エクゼキューターの遅延関数を使用する
- ユーザーボタンが押された場合、LEDは点灯させてはならない
- これは別のスレッドから通信される(レジスタを自分でチェックしない)
- ユーザーボタンの状態を追跡する
- GPIO割り込みを設定して、信号変化を検出できるようにする
- ボタンが高レベルか低レベルかを示す共有(アトミック)ブール値を通信する
- ボタンの状態が変化したら、メッセージキューに
Button is <0/1> (N)というテキストの文字列を配置する。ここで<0/1>はボタンが低レベルなら0、高レベルなら1、Nはトリガーの数とする
- メッセージキューをシリアルポートに出力する
- メッセージキューに文字列が含まれるのを待つ
- シリアルポートに出力する
測定するもの
このショーダウンは、以下の基準で勝敗が決まります。
パフォーマンス
ボタンGPIO割り込みにかかる時間はどれくらいか?
- 割り込みが発生したら、ピンをハイに設定する
- 割り込みが終了したら、ピンをローに設定する
- その間の時間はオシロスコープで測定する
ボタンのスレッドが再び待機するまでにかかる時間はどれくらいか?
- スレッドが待機を停止したら、ピンをハイに設定する
- スレッドが再び待機を開始したら、ピンをローに設定する
- その間の時間はオシロスコープで測定する
割り込み(処理)レイテンシ
ボタンGPIO割り込みの開始からボタンのスレッドが再開するまでの時間はどれくらいか?
- 割り込みピンの上昇エッジからスレッドピンの上昇エッジまでの時間はオシロスコープで測定する
プログラムサイズ
arm-none-eabi-sizeによって報告される.textセクション
静的メモリ使用量
arm-none-eabi-sizeによって報告される.data + .bssセクション
- すべてのタスクとスレッドは静的に割り当てられる
動的メモリ使用量は測定が難しいため、静的メモリ使用量のみを見ています。静的に大量のメモリを割り当てるプログラムは、そうしない同様のプログラムよりもスタックメモリを少なく使用する可能性が高いです。しかし、RTOSがこれに苦労する可能性があるため、比較する上で関連性のあるメトリックだと思います。
プログラミングの容易さ
非常に主観的です。
最初から繰り返しますが、最も最適化されたソリューションを探しているわけではありません。目標は、比較的通常のプログラムを持つことです。
期待
RTOSはパフォーマンスとレイテンシを最適化するために作られていることを除けば、あまり期待していません。したがって、それに基づいて、私の予測は次のとおりです。
パフォーマンス
RTOSはおそらくスレッド内でフラグを直接設定するでしょう。これは、async Wakerを見つけてトリガーするよりもおそらく高速です。
コードの再開と一時停止の方法を除けば、ボタンのスレッドに関しては両方の実装で大きな違いはありません。同様の時間で完了すると予想されます。
割り込み(処理)レイテンシ
RTOSはおそらくこれに対してより最適化されているでしょう。Embassyは実行中のタスクをプリエンプトできないため、これをあまり最適化してもあまり価値がありません。
プログラムサイズ
Rustプログラムは、高価なフォーマットとコンパイラによって挿入されるランタイムチェックのため、通常は少し大きくなります。プログラムの残りの部分は基本的に同じであるため、C実装の方がフラッシュメモリの使用量が少ないと予想されます。
静的メモリ使用量
Rustのコンパイラ生成Futureは、awaitポイントをまたいで保持される変数のみを格納し、完全なスタックサイズを割り当てる必要がないため、Rust実装が優れているはずです。
プログラミングの容易さ
「Rust vs C」の側面を無視すれば、asyncモデルの方が扱いやすいと思います。Webの世界では、async/awaitはすでにスレッドに勝利しているため、ここでも同様になるでしょう。
コードを見てみましょう
リポジトリはここで見つけることができます:github CプロジェクトはSTMCube 1.8で作成され、Rustプロジェクトは標準のcargoバイナリです。
ボタン割り込みの通知
割り込み内で全てを処理するわけではありません。割り込みが発生したことをエクゼキューターに通知するだけです。
Rustの場合、これはEmbassyがすでに実行していることなので、何もする必要はありません。
Cでは、割り込み用の関数を自分で作成し、スレッドに通知する必要があります。
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == USER_Btn_Pin) {
osThreadFlagsSet(buttonWaiterHandle, 1);
}
}
LEDの点滅
各エクゼキューターの通常の遅延関数を使用して時間を待ちます。ボタンが押されているかどうかを判断するために、読み取る必要があるアトミックブール値があります。Cでは、タスクはグローバルに作成され、voidポインタ引数から取得するのはあまり快適ではないため、そのブール値はグローバルに格納されます。
Rust
#[embassy::task]
async fn blink_led(mut led: Output<'static, PB0>, button_high: &'static AtomicBool) {
loop {
Timer::after(Duration::from_millis(100)).await;
if !button_high.load(Ordering::SeqCst) {
led.set_high().unwrap();
}
Timer::after(Duration::from_millis(100)).await;
led.set_low().unwrap();
}
}
Rustでは、タスク関数をアノテートして静的に割り当てられるようにする必要があります。LEDも引数として渡されます。なぜなら、ペリフェラルはRustの所有権モデルを使用してモデル化されているからです。
C
Cの型アトミックはC11仕様のオプション部分であり、幸いにもコンパイラが実装しているため、アトミック型の使用ははるかに快適です。
void StartBlinkLedTask(void *argument)
{
for (;;)
{
osDelay(100);
if (atomic_load(&buttonPressed) == GPIO_PIN_RESET) {
HAL_GPIO_WritePin(LD1_GPIO_Port, LD1_Pin, GPIO_PIN_SET);
}
osDelay(100);
HAL_GPIO_WritePin(LD1_GPIO_Port, LD1_Pin, GPIO_PIN_RESET);
}
}
メッセージキューをシリアルポートに書き込む
書き込みタスクに送信するメッセージは、基本的に文字列です。Rust実装はArrayVecライブラリを使用して、優れたスタック割り当てArrayString型にアクセスします。
Cではそれほど多くの贅沢はありません。そのため、簡単な型を作成しました。
typedef struct {
char data[32];
} UartMessage;
メッセージキューの容量は8メッセージです。スレッド/タスクは新しいメッセージが表示されるのを待ち、それをUARTに出力します。
Rust
Rustの実装は非常に直接的です。
#[embassy::task]
async fn uart_writer(
mut usart: Uart<'static, USART3, DMA1_CH3>,
mut receiver: Receiver<'static, Noop, ArrayString<32>, 8>,
) {
loop {
let message = receiver.recv().await.unwrap();
usart.write(message.as_bytes()).await.unwrap();
}
}
C
Cでは、より多くのメモリとサイズ管理を行う必要があります。
void StartUartWriter(void *argument) {
for (;;) {
UartMessage message;
CheckStatus(
osMessageQueueGet(uartQueueHandle, &message, NULL, osWaitForever)
);
size_t messageLength = strnlen(message.data, sizeof(message.data));
CheckStatus(
HAL_UART_Transmit(&huart3, (uint8_t*)&message.data, (uint16_t)messageLength, 1000)
);
}
}
ボタンの待機
ボタンのロジックはいくつかの部分に分かれています。
まず、ボタンピンは立ち上がりエッジで割り込みを生成するように設定されます。その後、割り込みが待機されます。測定のために、button_processedピンも待機ラインの周りでハイとローに設定されます。
待機が終了した後、トリガーカウントが増加し、ボタンが押されたフラグがハイに設定され、メッセージがフォーマットされてメッセージキューに送信されます。
これは、割り込みが立ち下がりエッジに設定された状態で繰り返されます。
観察力の鋭い読者は、ボタンのデバウンスがないことに気づくでしょう。それは間違いなく問題です。しかし、ここに遅延を入れると、実行する測定が台無しになると感じました。そのため、デバウンスは行われておらず、button_pressed変数の状態は少し信頼性が低くなっています。
Rust
とにかく、これがRustコードです。
#[embassy::task]
async fn button_waiter(
mut button: ExtiInput<'static, PC13>,
button_pressed: &'static AtomicBool,
sender: Sender<'static, Noop, ArrayString<32>, 8>,
mut button_processed: Output<'static, PG1>,
) {
let mut trigger_count = 0;
loop {
button_processed.set_low().unwrap();
button.wait_for_rising_edge().await;
button_processed.set_high().unwrap();
trigger_count += 1;
button_pressed.store(true, Ordering::SeqCst);
if sender.send(format_message(trigger_count, true)).await.is_err() {
panic!("SendError");
}
button_processed.set_low().unwrap();
button.wait_for_falling_edge().await;
button_processed.set_high().unwrap();
trigger_count += 1;
button_pressed.store(false, Ordering::SeqCst);
if sender.send(format_message(trigger_count, false)).await.is_err() {
panic!("SendError");
}
}
}
sender.send()の結果をアンラップすると、関連情報を示さないにもかかわらず、不当に高価なフォーマットコード(サイズ面で)になることがわかりました。そのため、現在は単純なパニックのみを行います。
C
Cコードでは、割り込みピンの方向を自分で変更する必要があります。
void StartButtonWaiterTask(void *argument) {
int triggerCount = 0;
UartMessage message;
/* Infinite loop */
for (;;) {
// Only react to rising edges
EXTI->RTSR |= USER_Btn_Pin;
EXTI->FTSR &= ~USER_Btn_Pin;
HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_RESET);
osThreadFlagsWait(1, osFlagsWaitAny, osWaitForever);
HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_SET);
triggerCount++;
// Set the button pressed variable
atomic_store(&buttonPressed, true);
message = FormatMessage(triggerCount, true);
CheckStatus(
osMessageQueuePut(uartQueueHandle, &message, 0, osWaitForever)
);
// Only react to falling edges
EXTI->RTSR |= USER_Btn_Pin;
EXTI->FTSR &= ~USER_Btn_Pin;
HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_RESET);
osThreadFlagsWait(1, osFlagsWaitAny, osWaitForever);
HAL_GPIO_WritePin(ButtonProcessed_GPIO_Port, ButtonProcessed_Pin, GPIO_PIN_SET);
triggerCount++;
// Set the button pressed variable
atomic_store(&buttonPressed, false);
message = FormatMessage(triggerCount, false);
CheckStatus(
osMessageQueuePut(uartQueueHandle, &message, 0, osWaitForever)
);
}
}
セットアップ以外のコードはほぼすべてこれです。
欠けているもう一つのことは、割り込みピンをハイに設定する割り込みコードです。これはCプロジェクトに含まれています。しかし、Embassyは独自の割り込み関数を提供するため、それを変更する必要がありました。
embassy_stm32のextiファイルで、ここにそれを追加しました。
macro_rules! impl_irq {
($e:ident) => {
#[interrupt]
unsafe fn $e() {
pac::gpio::Gpio(0x40021800 as *mut u8).odr().modify(|odr| odr.set_odr(0, stm32_metapac::gpio::vals::Odr::HIGH));
let x = on_irq();
pac::gpio::Gpio(0x40021800 as *mut u8).odr().modify(|odr| odr.set_odr(0, stm32_metapac::gpio::vals::Odr::LOW));
x
}
};
}
この長い時間の後、結果を見てみましょう!
結果
まず、async awaitモデルが本当に気に入りました。割り込みが発生するものをawaitできるという考え方を受け入れると、非常にうまく書けます!スレッドの管理はあまり楽しくないので、その部分はあまり恋しくありません。
Embassyは割り込みを中心に構築されているため、その設計は非常にうまく統合されていると感じます。FreeRTOSでの割り込みの処理は、はるかに人間工学的ではありません。私にとっては、これはEmbassyの勝利です。
テストを実行して、数値を調べてみましょう。
ボタンを100回押すと、200個のサンプルが得られます。その後、平均時間と標準偏差を記録します。
| Test | C | Rust | Difference | Difference % |
|---|---|---|---|---|
| Interrupt time (avg) | 2.962us | 1.450us | -1.512us | -51.0% |
| Interrupt time (stddev) | 124.8ns | 4.96ns | -119.84ns | -96.0% |
| Thread time (avg) | 16.19us | 11.64us | -4.55us | -28.1% |
| Thread time (stddev) | 248.2ns | 103.0ns | -145.2ns | -56.2% |
| Interrupt latency (avg) | 4.973us | 3.738us | -1.235us | -24.8% |
| Interrupt latency (stddev) | 158.0ns | 45.3ns | -112.7ns | -71.3% |
| Program size | 20676b | 14272b | -6404b | -31.0% |
| Static memory size | 5480b | 872b | -4608b | -84.1% |
おお...
すごい...
これは本当に予想外でした。
これらの数値は、異なる日でも再現可能です(もちろん、わずかな変動はあります)。
Embassy/Rustがすべてのカテゴリで勝利したようです!さて、少なくともFreeRTOS/Cが実際にRustを上回った何かを見てみましょう。割り込みの終了とスレッドのウェイクアップ開始の間の時間を見ると、次の数値が得られます。(割り込みレイテンシ - 割り込み時間)
- C:
4.973 - 2.962 = 2.011us - Rust:
3.738 - 1.450 = 2.288us
これは、純粋なコンテキストスイッチとスレッドの再開がRTOSの方が高速であることを示しています。しかし、2倍の時間がかかる割り込みに直面した場合、この勝利はそれほど関連性がありません。割り込み時間が長くなった正確な原因はわかりません。使用されているSTM Cube HALのせいか?それともFreeRTOSの非効率性か?それともスレッドシグナリングモデルの本質的なものか?それを答えるには、さらに多くのRTOSとHALをテストする必要があります。それはおそらく別の機会になるでしょう。
RTOSコードでできる最大の改善の1つは、ボタンのロジックを割り込み内に移動することです。これはEmbassyでは不可能なことです。なぜなら、Embassy自体が割り込み関数を作成するためです。ここにはトレードオフがあります。FreeRTOS/Cでの自由と、Embassy/Rustでの開発の容易さです。
勝者
Embassy/Rustを勝者と宣言せざるを得ません。
私の意見ではプログラミングしやすいだけでなく、すべての数値もそれを支持しているようです。
まとめ
まだ心配かもしれないことが一つだけあります。RTOSは実際のリアルタイムアプリケーションで使用できます。asyncタスクはプリエンプトできないため、別のタスクを時間内に実行することが常に可能とは限りません。これは1つのエクゼキューター内のタスクのセットには当てはまりますが、Embassyでは割り込みコンテキスト内で実行される追加のエクゼキューターを使用できます。
Wakerはエクゼキューターにタスクを実行させるだけでなく、エクゼキューターが割り込みコンテキストにある場合、Wakerはエクゼキューターの割り込みをペンドします。これにより、エクゼキューターがより高い優先度の割り込みにある場合、より低い優先度の他のエクゼキューターをプリエンプトします。
EmbassyがGithubで提供している例を次に示します。
Embassyの作成者であるDario氏、およびJitterのSjors氏に、この投稿へのフィードバックと質問への回答に感謝します。
フィードバックがあれば、ぜひお聞かせください!dion@tweedegolf.comまで、またはtwitterで連絡できます。
/r/rustおよび/r/embeddedでの議論。
編集
Cプロジェクトでヒープが使用されていないにもかかわらず有効になっていたことが指摘されました。これにより、静的メモリサイズが本来よりも15kb大きくなっていました。ヒープサイズを静的メモリサイズから差し引きました。ただし、結論は変わりません。
RTIC補遺(2022年2月17日)
リポジトリにRTICの実装を追加するプルリクエストがありました。Rafael Bachmann(barafael)に感謝します!
RTICは、Rust組み込みエコシステムでかなりの部分を占める、完全に割り込み駆動のランタイムです。詳細はこちらをご覧ください:https://rtic.rs/1/book/en/preface.html。
PRを少し変更して、FreeRTOSとEmbassyの実装に合わせ、再度数値を実行しました。
ただし、RTICの実装は他の2つの実装に対して完全に公平ではないことに注意することが重要です。RTICはマクロ内に割り込みハンドラを定義しているため(変更できません)、GPIOピンの1つをすぐにハイに設定することはできません。しかし、RTICは割り込みの上に非常に小さなレイヤーであるため、ユーザー提供の割り込み関数でピンをハイに設定することは、パフォーマンスを十分に表していると考えています。
Embassyをベースラインとして使用します。
| Test | RTIC | Embassy | Difference | Difference % |
|---|---|---|---|---|
| Interrupt time (avg) | 650.8ns | 1450ns | 799ns | 122.8% |
| Interrupt time (stddev) | 10.34ns | 4.96ns | -5.38ns | -52.0% |
| Thread time (avg) | 7.807us | 11.64us | -3.83us | 49.1% |
| Thread time (stddev) | 279.9ns | 103.0ns | -176.9ns | -63.2% |
| Interrupt latency (avg) | 1.184us | 3.738us | 2.554us | 215.7% |
| Interrupt latency (stddev) | 77.75ns | 45.3ns | -32.45ns | -41.7% |
| Program size | 8888b | 14272b | 5384b | 60.0% |
| Static memory size | 392b | 872b | 480b | 122.4% |
RTICはいくつかの印象的な結果を示しています。それはもちろん非常に論理的です。ランタイムが少ないほど、運ぶものも少なくなります。
RTICは、手動で割り込みを実装するよりも明らかに改善されています。通常、割り込みで機能を実装し続けると、最終的にはRTICの悪いバージョンになるため、RTICを使用するべきだと私は言います。
帰属
Rust Embedded Working Group Logoは、Rustロゴに基づいて、Erin Powerによってデザインされました。