🛠️

CodexとSDDでμITRON風RTOSを作る 第3章 3.1: タスク管理(TCB編)

に公開

はじめに

この連載では、CodexとSDDを使って、学習・実験目的のμITRON風RTOSを少しずつ作っています。

ここまでの流れは次のとおりです。

  • 第1章:開発準備(構想・環境・公開方針)
  • 第2章:起動と基盤(QEMU起動・シリアル・HAL)
  • 第3章:タスク管理とスケジューラ(今回から)

第2章では、QEMU上で最小カーネルを起動し、COM1シリアル出力を整理し、kernel共通部からHAL経由でconsole出力できるようにしました。

ここまでで、ログ出力の経路は次のようになっています。

kernel -> HAL -> arch(x86_64) -> serial -> COM1

QEMUでは -serial stdio を使うことで、COM1への出力をホスト側の標準入出力で確認できます。

今回の3.1では、いよいよRTOSらしい要素である「タスク管理」に入ります。ただし、今回の目的はタスクを実行することではありません。

まずは、OS内部で「タスクとして管理する対象」を登録し、一覧表示できるところまでを作ります。

言い換えると、3.1は「TCB編」です。

このプロジェクトは学習・実験目的です。本番利用や安全要求のある用途は想定していません。また、実ITRON/T-Kernel/FreeRTOSなどの既存RTOS実装のソースコードは参照・コピー・流用していません。参考にしているのは、公開されている概念、用語、一般的なRTOS設計原則だけです。

今回のゴール

今回は、タスクを実行する前段階として、タスク管理の土台を作ります。

対応したfeature名は次の1つです。

task-management-initial

今回やったことは次のとおりです。

  • TCBを定義する
  • タスク状態を定義する
  • 最大256件の静的タスクテーブルを用意する
  • task_init() でタスク管理を初期化できるようにする
  • task_register() でタスクを登録できるようにする
  • task_dump() で登録済みタスクを一覧表示できるようにする
  • QEMU -serial stdio で登録状態を確認する
  • Doxygen形式コメントでAPIの意図を整理する

逆に、今回は次のものは扱っていません。

  • 登録したタスクの実行
  • スケジューラ
  • コンテキストスイッチ
  • タイマ割り込み
  • スタックフレームの初期化
  • 動的なタスク生成・削除
  • μITRON互換API

まずは「タスクをどう表現し、どう登録し、どう観測するか」だけに絞ります。

タスク管理とは何か

RTOSにおけるタスクは、アプリケーションやカーネル内の処理単位です。

一般的なRTOSでは、複数のタスクが存在し、スケジューラが「次にどのタスクを実行するか」を決めます。コンテキストスイッチによってCPUの実行状態を切り替え、タイマ割り込みや優先度に応じてタスクが切り替わっていきます。

ただし、それらを一気に実装すると、どこで問題が起きているのか分かりにくくなります。

たとえば、いきなりタスク実行まで実装すると、次のような要素が同時に絡みます。

  • TCBの設計
  • スタックの初期化
  • CPUレジスタ状態の保存・復元
  • コンテキストスイッチ
  • スケジューラ
  • タイマ割り込み
  • ログ出力

初学者向けの実験としては、これは少し重すぎます。

そこで今回は、タスクを「実行」しません。まずはタスクをOS内部で管理対象として登録し、QEMUのシリアルログで確認できる状態を作ります。

この段階を分けることで、TCBやタスクテーブルの設計を落ち着いて確認できます。

今回の設計方針

3.1の方針は、かなり明確に絞りました。

  • タスクは実行しない
  • entry 関数は呼び出さない
  • スケジューラは作らない
  • コンテキストスイッチはしない
  • 割り込み、タイマは使わない
  • 動的メモリは使わない
  • 静的配列でタスクを管理する
  • 最大タスク数は256

つまり、今回作るのは「タスク実行機構」ではなく「タスク管理台帳」です。

タスク登録APIに entry 関数やstack情報を渡しますが、それらは保持するだけです。スタックの中身を初期化したり、CPUの初期コンテキストを作ったりはしません。

これは少し遠回りに見えるかもしれません。しかしRTOSを段階的に作るうえでは、かなり重要な分離です。

まずは次の3つを確実にします。

  • 登録できる
  • 状態を持てる
  • 一覧表示できる

TCBの設計

タスク管理の中心になるのがTCB、つまりTask Control Blockです。

今回のTCBは次のようにしました。

typedef void (*task_entry_t)(void);

typedef enum {
    TASK_STATE_UNUSED = 0,
    TASK_STATE_DORMANT,
    TASK_STATE_READY,
    TASK_STATE_RUNNING,
} task_state_t;

typedef struct {
    int id;
    const char *name;
    task_entry_t entry;
    int priority;
    task_state_t state;
    void *stack_base;
    unsigned long stack_size;
} tcb_t;

タスク状態の意味は次のとおりです。

状態 意味
TASK_STATE_UNUSED タスクテーブルの未使用スロット
TASK_STATE_DORMANT 将来、生成済みだが実行待ちではない状態を表すための予約
TASK_STATE_READY 実行可能な状態。今回は登録成功時にこの状態にする
TASK_STATE_RUNNING 将来、実行中タスクを表すための予約

今回実際に使うのは、主に TASK_STATE_UNUSEDTASK_STATE_READY です。DORMANTRUNNING は、今後の状態遷移を見据えて先に定義しています。

各メンバの役割は次のとおりです。

メンバ 役割
id タスクを識別するID
name ログ表示用のタスク名
entry 将来実行する入口関数
priority 将来スケジューラが使う優先度
state タスク状態
stack_base スタック領域の基底アドレス
stack_size スタック領域のサイズ

ここで重要なのは、entry とstack情報を持っているにもかかわらず、今回は使わないという点です。

entry は登録時に保持します。しかし task_register() の中で呼び出しません。

stack_basestack_size も保持します。しかしスタックフレームを作ったり、初期レジスタ値を書き込んだりはしません。

今の段階でstack情報を持たせる理由は、将来のコンテキストスイッチやタスク開始処理へ自然につなげるためです。TCBにstack情報があると、次の段階で「このタスクはどのスタック領域を使うのか」を明確にできます。

ただし、今回の責務はあくまで保持までです。

タスクテーブルの設計

今回、タスクは固定長配列で管理します。

#define MAX_TASKS 256

static tcb_t task_table[MAX_TASKS];
static int next_task_id = 1;

動的メモリは使いません。

これはRTOSの初期実装として扱いやすい方針です。特にカーネル開発の初期段階では、malloc のような動的確保を持ち込むと、メモリ管理そのものの問題も同時に考える必要が出てきます。

今回はタスク管理の第一歩なので、固定長配列にしました。

空きスロットの判定は、必ず state == TASK_STATE_UNUSED で行います。

static tcb_t *find_free_slot(void)
{
    int index;

    for (index = 0; index < MAX_TASKS; index++) {
        if (task_table[index].state == TASK_STATE_UNUSED) {
            return &task_table[index];
        }
    }

    return NULL;
}

ここで、id == 0name == NULL では判定しません。

理由は、空き状態の責務を state に集約したいからです。IDやnameは別の意味を持つフィールドです。将来、状態遷移や削除処理を追加したときに、空き判定が複数のフィールドに分散しているとバグの原因になります。

「空きかどうかはstateで見る」

このルールを最初に決めておくことで、後の実装が読みやすくなります。

ID採番の設計

タスクIDは、単純インクリメント方式にしました。

static int allocate_task_id(void)
{
    int id;

    if (next_task_id <= 0 || next_task_id >= TASK_ID_MAX) {
        return TASK_ERR_ID_OVERFLOW;
    }

    id = next_task_id;
    next_task_id++;

    return id;
}

初期値は1です。ID 0は無効値として扱います。

TASK_ID_MAX は、タスクIDとして採番できる範囲の上限です。最大タスク数である MAX_TASKS とは別の概念として扱っています。

MAX_TASKS は同時に管理できるスロット数です。一方、TASK_ID_MAX はログやAPI戻り値に出てくる識別子の上限です。

また、IDは配列インデックスとは分離しています。つまり、task_table[0] に入ったタスクのIDが必ず0や1になる、という設計にはしていません。

さらに、IDは再利用しません。

今回の実装ではタスク削除APIはありませんが、将来タスク削除を追加した場合でも、IDを安易に再利用するとログ解析が難しくなります。

たとえば、ログに id=3 と出ていたとき、そのIDが過去のタスクなのか、再利用された別タスクなのか分からなくなる可能性があります。

初期実装では、デバッグしやすさを優先しました。

API設計

今回追加したAPIは3つです。

void task_init(void);

int task_register(
    const char *name,
    task_entry_t entry,
    int priority,
    void *stack_base,
    unsigned long stack_size
);

void task_dump(void);

task_init

task_init() は、タスク管理テーブル全体を初期化します。

全スロットを TASK_STATE_UNUSED にし、各フィールドを既知の値に戻します。next_task_id も1に戻します。

起動時に必ず呼び、タスク登録を始める前の状態を安定させます。

task_register

task_register() は、タスク情報を登録します。

主な処理は次のとおりです。

  1. 引数をチェックする
  2. 空きスロットを探す
  3. タスクIDを採番する
  4. TCBに情報を書き込む
  5. 状態を TASK_STATE_READY にする
  6. 登録ログを出力する
  7. 成功時はタスクIDを返す

失敗時は負のエラーコードを返します。

#define TASK_ERR_FULL        (-1)
#define TASK_ERR_INVAL       (-2)
#define TASK_ERR_ID_OVERFLOW (-3)

各エラーの意味は次のとおりです。

エラー 意味
TASK_ERR_FULL タスクテーブルに空きスロットがない
TASK_ERR_INVAL nameentrystack_basestack_size などの引数が不正
TASK_ERR_ID_OVERFLOW 新しいタスクIDを採番できない

task_dump

task_dump() は、登録済みタスクの一覧をシリアルログに出力します。

出力する項目は次のとおりです。

  • id
  • name
  • priority
  • state
  • entry address
  • stack_base
  • stack_size

未使用スロットは表示しません。

実装のポイント

今回の実装で意識したポイントを整理します。

IDオーバーフロー対策

next_task_id が正の範囲で割り当てられなくなった場合は、TASK_ERR_ID_OVERFLOW を返します。

オーバーフロー時にIDを巻き戻さないようにしているのもポイントです。

entry関数を呼ばない

entry は登録時に保持しますが、呼び出しません。

ここで呼んでしまうと、それはもう「タスク登録」ではなく「タスク実行」になってしまいます。タスク実行には、スタックやコンテキスト、戻り先などを設計する必要があります。

今回のAPIは、あくまで登録用です。

スタック初期化をしない

stack_basestack_size は保持しますが、スタックの中身は触りません。

スタック初期化を始めると、CPUアーキテクチャごとのレジスタ配置やコンテキストフレームの設計が必要になります。それは次の段階のテーマです。

SDDで進めた流れ

今回も、2.3と同じくChatGPTと相談しながら、cc-sddに渡す指示内容を検討しました。

ここで使っているcc-sddは、仕様、設計、タスク分解、実装、検証を段階化して進めるための流れです。

3.1ではタスク管理に入るため、最初に「タスク管理」という言葉の範囲をかなり絞りました。

特に意識したのは、task_register() を作ることと、タスクを実行することを混ぜないことです。ここを曖昧にすると、スケジューラ、コンテキストスイッチ、スタック初期化、割り込みまで一気に設計対象が広がってしまいます。

そのため、cc-sddに渡す前に次の制約を明確にしました。

  • 今回はTCBと静的タスクテーブルだけを扱う
  • entry 関数は保持するが呼び出さない
  • stack情報は保持するが初期化しない
  • タスク状態は定義するが、実行状態には遷移させない
  • READYのタスクを選ぶ処理は作らない
  • HAL境界を壊さず、ログ出力は hal_console_* 経由にする

実際には、次の順番で進めました。

以下はPowerShellで実行するコマンドではなく、Codex上でcc-sddのSKILLとして呼び出した流れです。

$kiro-spec-init task-management-initial
$kiro-spec-requirements task-management-initial
$kiro-spec-design task-management-initial -y
$kiro-spec-tasks task-management-initial -y
$kiro-impl task-management-initial
$kiro-validate-impl task-management-initial

requirements.md では、タスクを登録できること、登録済みタスクをdumpできること、QEMUのシリアルログで確認できることを受け入れ条件にしました。

design.md では、TCB、タスク状態、静的タスクテーブル、ID採番、エラーコード、ログ出力の責務を分けました。

tasks.md では、次の順番で小さく分けました。

  1. 既存のkernel/HAL構成を確認する
  2. task headerを追加する
  3. TCBとタスク状態を定義する
  4. 静的タスクテーブルを実装する
  5. task_init() を実装する
  6. task_register() を実装する
  7. task_dump() を実装する
  8. kernel_main から動作確認用タスクを登録する
  9. Makefileを更新する
  10. buildとQEMUログを確認する
  11. READMEやログを更新する
  12. 最終レビューを行う

タスクを分けたことで、今回やらないことを確認しやすくなりました。

特に、entry を呼び出さないこと、stackを初期化しないこと、READYの次を選ばないことは、実装中に何度も確認しました。

実装後は $kiro-validate-impl で、TCB定義、API、ログ、ビルド、QEMU出力、READMEとの整合をまとめて確認しました。

動作確認

ビルドは次のコマンドで確認しました。

make clean
make

QEMUで確認する場合は、既存の make run を使えます。

make run

直接 -serial stdio で見る場合は、次のように実行します。

qemu-system-x86_64 -kernel build/kernel.elf -serial stdio -display none -no-reboot

出力例は次のようになります。

アドレス値はビルド環境やリンク配置によって変わる可能性があります。

itron-rtos booting...
kernel_main reached
[kernel] task init
[task] registered: id=1 name=task_a state=READY prio=1 entry=0x1010e0 stack_base=0x108000 stack_size=1024
[kernel] task_register task_a returned 1
[task] registered: id=2 name=task_b state=READY prio=2 entry=0x101150 stack_base=0x108400 stack_size=1024
[kernel] task_register task_b returned 2
[task] dump start
[task] id=1 name=task_a prio=1 state=READY entry=0x1010e0 stack_base=0x108000 stack_size=1024
[task] id=2 name=task_b prio=2 state=READY entry=0x101150 stack_base=0x108400 stack_size=1024
[task] dump end

ここで重要なのは、task_atask_b の中に書いたログが出ていないことです。

つまり、タスクは登録されていますが、実行はされていません。

これは今回の設計どおりです。

確認結果

make は成功し、次の成果物が生成されました。

build/kernel.elf

QEMUでは -serial stdio 相当で起動し、タスク登録ログとdumpログを確認しました。

$kiro-validate-impl の結果は GO でした。

確認した観点は次のとおりです。

  • task_init() でタスクテーブルが初期化される
  • task_register() でタスクが登録される
  • 登録済みタスクが TASK_STATE_READY になる
  • task_dump() で登録済みタスクだけが表示される
  • 未使用スロットがdumpされない
  • entry 関数が呼び出されない
  • stack情報が保持されるだけで初期化されない
  • IDが1から採番される
  • IDと配列インデックスが分離されている
  • エラー時に負のエラーコードを返す
  • ログ出力がHAL経由で行われる
  • make が成功する
  • QEMU -serial stdio で期待ログが出る
  • スケジューラ、コンテキストスイッチ、割り込み、タイマに踏み込んでいない
  • READMEの説明と実装が一致している

つまずいた点と対処

タスク管理と言うと実行まで作りたくなる

タスク管理という名前を出すと、すぐにスケジューラやコンテキストスイッチを作りたくなります。

しかし、今回の目的はそこではありません。

今回は、タスクを登録し、内部状態を観測できるようにする回です。entry 関数を呼んでしまうと、登録ではなく実行になってしまいます。

そこで、今回は entry を保持するだけにしました。

task_register -> entryを保存する
task_register -> entryを呼ばない

この制約を明確にしたことで、実装範囲を小さく保てました。

stack情報を持つが初期化しない

stack_basestack_size をTCBに入れると、ついスタック初期化も実装したくなります。

しかし、スタック初期化はCPUアーキテクチャと強く結びつきます。x86_64で初期コンテキストをどう積むか、戻り先をどうするか、どのレジスタを保存・復元するかを決める必要があります。

これは今回の範囲を超えます。

そこで、stack情報は将来のために保持するだけにしました。

空き判定をどのフィールドで見るか

空きスロットを id == 0name == NULL で判定する案もありました。

しかし、それらはタスクの識別子や表示名であって、状態そのものではありません。

今回は空き判定を TASK_STATE_UNUSED に統一しました。

if (task_table[index].state == TASK_STATE_UNUSED) {
    return &task_table[index];
}

状態管理の責務を state に寄せることで、後から削除や状態遷移を追加するときにも読みやすくなります。

ログだけ見るとタスクが動いたように見える

登録ログに entry のアドレスやstack情報が出るため、一見するとタスクが動いたように見えるかもしれません。

しかし、今回確認したいのは「登録されたこと」です。

そのため、出力確認では task_atask_b の中のログが出ていないことも確認しました。

これにより、登録と実行が分離されていることを確認できます。

ドキュメント化

今回、コードにはDoxygen形式のコメントも追加しました。

たとえば、task_register() には次のようなコメントを付けています。

/**
 * @brief タスク情報を静的テーブルへ登録する。
 *
 * @details
 * 入力を検証し、空きスロットへTCBを設定する。
 * 3.1では登録確認だけを目的とするため、entry呼び出し、コンテキスト作成、
 * スタック初期化は行わない。
 *
 * @param name タスク名。NULLの場合はTASK_ERR_INVAL。
 * @param entry タスク入口関数。NULLの場合はTASK_ERR_INVALだが、この関数では呼び出さない。
 * @param priority 優先度。今回は保存とdumpだけに使う。
 * @param stack_base スタック基底アドレス。NULLの場合はTASK_ERR_INVALだが初期化しない。
 * @param stack_size スタックサイズ。0の場合はTASK_ERR_INVAL。
 * @return 成功時は1以上のタスクID。失敗時はTASK_ERR_*。
 * @note スケジューラ、コンテキストスイッチ、割り込み、タイマは未実装である。
 */

Doxygen形式にした理由は、コードからAPIドキュメントを生成できるようにするためです。

RTOS開発では、関数の役割だけでなく「何をしないのか」も重要です。

たとえば、今回の task_register()entry 関数を保持します。しかし呼び出しません。この違いはかなり重要です。

コメントに次のような制約を書いておくことで、後から読んだときに設計意図を思い出しやすくなります。

  • entry関数は呼ばない
  • コンテキストは作らない
  • スタックは初期化しない
  • スケジューラは未実装

今はまだDoxyfileや生成環境は用意していません。ですが、コード側のコメント形式を揃えておくことで、将来API仕様書として活用しやすくなります。

特に低レイヤの開発では、実装と設計意図が離れると危険です。コメントを単なる説明ではなく、設計・運用の一部として扱うことが大切だと感じました。

今回のまとめ

3.1では、μITRON風RTOSの初期タスク管理機能を実装しました。

今回できるようになったことは次のとおりです。

  • TCBを定義した
  • タスク状態を定義した
  • 最大256件の静的タスクテーブルを用意した
  • task_init() で初期化できるようにした
  • task_register() でタスクを登録できるようにした
  • task_dump() で登録済みタスクを一覧表示できるようにした
  • QEMU -serial stdio で登録状態を確認できるようにした
  • Doxygen形式コメントでAPIの意図を整理した

設計として重要だったのは、次の点です。

  • タスク登録とタスク実行を分離した
  • 空き判定を TASK_STATE_UNUSED に集約した
  • IDと配列インデックスを分離した
  • IDを再利用しない方針にした
  • HAL境界を壊さずにログ出力した
  • 今回やらないことを明確にした

RTOSのタスク管理というと、すぐにスケジューラやコンテキストスイッチを想像しがちです。

しかし、その前に「タスクをどう表現するか」「どう登録するか」「どう観測するか」を固めることが大切です。

3.1は、その土台を作る回になりました。

ソースコード

公開リポジトリは次です。

https://github.com/pekopagu/itron-rtos

3.1に対応するタグ名は次のとおりです。

v3.1-task-tcb

次回以降

次回は、登録されたタスクをどう扱うかに進みます。

候補としては、READYキューの導入、または簡易スケジューラです。

今回の時点では、登録済みタスクは TASK_STATE_READY になります。しかし、READYのタスクが複数ある場合、どれを次に選ぶのかはまだ決めていません。

次のテーマは、まさにここです。

登録されたタスクをどう選ぶか

ここから少しずつ、RTOSらしい実行管理に近づいていきます。

Discussion