はじめに
組み込みソフトウェアの開発を始めたばかりのころ、「RTOS(リアルタイムOS)を使う」と聞いてもピンとこないことがあります。「OSなんて大げさじゃないか」「スーパーループで十分では?」と思う方も多いのではないでしょうか。
このシリーズでは、RTOSの基本概念からTOPPERS/ATK1のシステムコールの使い方まで、順を追って解説していきます。前編ではRTOSが何のためにあるのか、スーパーループと何が違うのか、そしてタスクがどのような状態を持ちどう遷移するのかを整理します。
スーパーループの限界
RTOSを使わない組み込みソフトウェアの基本構造は「スーパーループ」です。
int main(void) {
init(); /* 初期化 */
while (1) {
task_A(); /* 処理A */
task_B(); /* 処理B */
task_C(); /* 処理C */
}
}
シンプルで理解しやすく、小規模なシステムでは十分に機能します。しかし処理が増えてくると、いくつかの問題が出てきます。
応答性の問題
task_A() の処理に時間がかかっていると、task_C() はその間ずっと待たされます。「ボタンが押されたら即座に反応する」といったリアルタイム性の要求に応えにくくなります。
処理の優先度をつけられない
「安全に関わる処理は最優先で実行したい」という要求があっても、スーパーループでは順番通りにしか実行できません。
コードの複雑化
複数の処理を「時分割」で動かそうとすると、各処理を細かく分割してフラグ管理をする必要が出てきます。処理が増えるほどコードが複雑になります。
割り込みを組み合わせても残る限界
ここで「割り込みを使えばいいのでは?」と思った方もいるはずです。実際、RTOSを使わない構成は上のような単純なループではなく、割り込みと組み合わせた「フォアグラウンド/バックグラウンド構造」を取ります。周期処理はタイマー割り込みで作るのが定石です。
volatile uint8_t tick_flag;
void timer_isr(void) { /* 1msのタイマー割り込み */
tick_flag = 1;
}
int main(void) {
init();
while (1) {
while (!tick_flag) {
__WFI(); /* 割り込みが来るまでCPUを停止させる */
}
tick_flag = 0;
task_A();
task_B();
task_C();
}
}
この形なら周期処理は作れますし、待っているあいだのCPUも __WFI() で止まるので無駄になりません。緊急の処理を割り込みハンドラ側に置けば応答性も確保できます。先ほど挙げた「応答性」と「優先度」の問題は、割り込みである程度まで解決できるのです。
問題が出るのは、処理の途中に待ちが入ったときです。
void task_A(void) {
start_sensor();
while (!sensor_ready()) { ; } /* センサーの応答待ち 10ms */
read_sensor();
}
この10msのあいだ、task_B() も task_C() も止まります。__WFI() はループの先頭で全員まとめて待つ分には効きますが、処理の途中の待ちには使えません。
そこで、待ちを「状態」として持たせる書き方に変えることになります。
void task_A(void) {
static enum { IDLE, WAITING } state = IDLE;
switch (state) {
case IDLE:
start_sensor();
state = WAITING;
break; /* いったん抜けてB・Cに順番を回す */
case WAITING:
if (sensor_ready()) {
read_sensor();
state = IDLE;
}
break;
}
}
これで task_B() と task_C() は止まらなくなりました。ただし「待つ」という動作が state 変数に姿を変えています。処理が増えるほどこうした状態変数が積み上がっていくのが、先ほど挙げた「コードの複雑化」の正体です。
RTOSとは何か
RTOS(Real-Time Operating System)は、複数の処理を「タスク」という単位に分割し、優先度に基づいてスケジューリングする仕組みを提供するOSです。
通常のOS(LinuxやWindowsなど)と異なり、RTOSは「いつ・どの処理が実行されるか」を設計者が制御できることを重視しています。「リアルタイム」とは「速い」という意味ではなく、「決められた時間内に処理を完了できる」という意味です。
コードはどう変わるか
先ほどの周期処理をRTOSで書き直してみます。RTOSでは周期処理を「アラーム」として、OILファイル(システムの構成を静的に定義するファイル)に宣言します。
ALARM alarm_10ms {
COUNTER = system_counter;
ACTION = ACTIVATETASK {
TASK = task_A;
};
AUTOSTART = TRUE {
ALARMTIME = 10; /* 最初の発火まで10ティック */
CYCLETIME = 10; /* 以降10ティックごとにtask_Aを起動 */
APPMODE = OSDEFAULTAPPMODE;
};
};
Cのコードはこれだけです。
TASK(task_A) {
do_something();
TerminateTask();
}
int main(void) {
init();
StartOS(OSDEFAULTAPPMODE); /* 以降はOSがタスクを回す */
}
tick_flag も timer_isr() も __WFI() も、main() の while (1) ループそのものも消えました。周期の定義はOILの宣言に、タイマー割り込みの処理はOSカーネルの中に移っています。
なお ALARMTIME と CYCLETIME の単位は時間ではなくカウンタの「ティック数」です。何msに相当するかはカウンタの設定で決まります(後編で扱います)。
またこの例では AUTOSTART = TRUE によって、OSの起動と同時にアラームが動き出します。実行中にアラームを設定・変更したい場合は SetRelAlarm() などのAPIを使います(API編・後編で扱います)。
待ちについても同じです。状態変数を使わず、上から下に読める形で書けます。
TASK(task_control) {
while (1) {
start_sensor();
WaitEvent(event_sensor_ready); /* 待ち状態へ。CPUは他のタスクが使う */
ClearEvent(event_sensor_ready);
read_sensor();
}
}
WaitEvent() を呼んだタスクは「待ち状態」に入り、そのあいだ他のタスクが動きます。この状態については後半の「タスクの状態」で詳しく扱います。
なお、割り込みがまったく不要になるわけではありません。時間をきっかけにする処理(周期・遅延)はアラームとしてOSに任せられますが、センサーの完了通知や通信の受信といったハードウェアをきっかけにする処理は、割り込みハンドラからタスクへ通知する必要があります。
| きっかけ | RTOSでの書き方 |
|---|---|
| 時間(周期・遅延) | アラーム。割り込みハンドラを書かない |
| ハードウェア(センサー完了・受信) | 割り込みハンドラからタスクへイベント通知 |
OSEK/ATK1では後者を「ISR2」として書きます(設計編①で扱います)。
割り込みと __WFI() で解決できるのは「CPUを無駄にしない」ところまでです。「待っているあいだ別の処理を動かす」は状態機械を自分で書くしかなく、それをOSに肩代わりさせるのがRTOSだと考えると分かりやすいと思います。
タスクとは何か
タスクはRTOSにおける処理の基本単位です。それぞれのタスクは独立したスタックと実行コンテキストを持ち、あたかも独立したプログラムのように動作します。
TASK(task_A) {
/* タスクAの処理 */
TerminateTask(); /* OSEK/ATK1固有のタスク終了API。returnは使わない */
}
TASK(task_B) {
/* タスクBの処理 */
TerminateTask();
}
TASK() はOSEK/ATK1固有のタスク定義マクロです。スーパーループの「関数」と似ていますが、重要な違いがあります。RTOSのスケジューラがタスクの実行順序と実行タイミングを管理するため、高優先度のタスクが低優先度のタスクを途中で割り込んで実行できます(プリエンプション)。
スケジューリングの仕組み
ATK1(OSEK/VDX 2.2.1 ECC2準拠、ヴィッツ+名古屋大学共同開発)が採用するスケジューリング方式は「固定優先度プリエンプティブスケジューリング」です。
- 各タスクにはあらかじめ優先度が割り当てられる(静的に決まる)
- 実行可能なタスクの中で最も優先度の高いタスクが実行される
- より高優先度のタスクが実行可能になると、現在実行中のタスクを中断して高優先度タスクに切り替える(プリエンプション)
高優先度 タスクA ---- ----
タスクB ----
低優先度 タスクC ----
時間→
タスクAが実行可能になると、タスクBやタスクCを中断してタスクAが実行されます。
コンテキストスイッチ
タスクの切り替えが発生するとき、RTOSは現在実行中のタスクの「コンテキスト(CPUレジスタの値やスタックポインタなど)」を保存し、次に実行するタスクのコンテキストを復元します。これをコンテキストスイッチと呼びます。
コンテキストスイッチには若干のオーバーヘッドがありますが、適切に設計すれば問題になりません。むしろタスクを適切に分割することで、割り込みだけでは扱いにくかったリアルタイム性を確保できます。
OSEK/ATK1のタスクの種類
OSEK/ATK1にはタスクの種類が2つあります。
基本タスク(Basic Task)
最もシンプルなタスクです。起動されると処理を実行し、TerminateTask() または ChainTask() で終了します。待ち状態に入ることができません。
拡張タスク(Extended Task)
イベント待ちができるタスクです。WaitEvent() を呼ぶことで待ち状態に入り、他のタスクや割り込みからイベントが通知されると実行を再開します。
タスクの状態
OSEK/ATK1のタスクは以下の4つの状態を持ちます。
休止状態(Suspended)
タスクが起動されていない状態です。メモリ上に存在しますが、スケジューラの対象外です。ActivateTask() が呼ばれると実行可能状態に遷移します。
実行可能状態(Ready)
実行可能な状態ですが、より高優先度のタスクが実行中のため待機しています。スケジューラが選択すると実行状態に遷移します。
実行状態(Running)
現在CPUで実行中の状態です。同時に実行状態になれるタスクは1つだけです。
待ち状態(Waiting)
拡張タスクのみが取り得る状態です。WaitEvent() を呼ぶことでこの状態に入ります。指定したイベントが設定されると実行可能状態に戻ります。
状態遷移図
各遷移のトリガー
休止状態 → 実行可能状態
ActivateTask() が呼ばれたとき。アラームや割り込みからも起動できます。
実行可能状態 → 実行状態
スケジューラが最高優先度のタスクとして選択したとき。
実行状態 → 実行可能状態
より高優先度のタスクがプリエンプションしたとき。
実行状態 → 待ち状態
拡張タスクが WaitEvent() を呼んだとき。
実行状態 → 休止状態
TerminateTask() が呼ばれたとき。または ChainTask() が呼ばれたとき(自タスクを終了して指定タスクを起動する)。
待ち状態 → 実行可能状態
待っていたイベントが SetEvent() で設定されたとき。
基本タスクと拡張タスクの使い分け
基本タスクは待ち状態に入れないため、処理を最後まで実行するか、明示的に終了する必要があります。シンプルな周期処理や、短時間で完了する処理に向いています。
拡張タスクはイベント待ちができるため、「何かが起きるまで待つ」という処理を自然に書けます。ただし待ち状態でも実行コンテキスト(ローカル変数・呼び出し履歴)をスタックに保持したまま待機するため、基本タスクよりメモリを多く消費します。
/* 基本タスクの例 */
TASK(basic_task) {
do_something();
TerminateTask(); /* 必ず終了する */
}
/* 拡張タスクの例 */
TASK(extended_task) {
while (1) {
WaitEvent(my_event); /* イベント待ち */
ClearEvent(my_event); /* イベントクリア */
do_something(); /* 処理 */
}
}
まとめ
- スーパーループは小規模システムには向いているが、処理が増えると応答性・優先度管理・保守性で限界が出る
- 割り込みと
__WFI()を組み合わせれば応答性とCPU効率は確保できるが、処理の途中の待ちは状態機械として自分で書くことになる - RTOSでは周期処理をアラームとしてOILに宣言し、待ちを
WaitEvent()で表現できる - RTOSは処理をタスク単位に分割し、優先度に基づいてスケジューリングする
- ATK1(OSEK/VDX 2.2.1 ECC2準拠)は固定優先度プリエンプティブスケジューリングを採用している
- コンテキストスイッチによりタスクの切り替えが実現される
- OSEK/ATK1のタスクには基本タスクと拡張タスクの2種類があり、状態は休止・実行可能・実行・待ちの4つ
後編では、タスク間の連携(イベント・リソース・メッセージ)と時間管理(カウンタ・アラーム・スケジュールテーブル)の考え方を整理します。