Kanade Electronicskanaelecom

割り込み処理

RK16 マイコンの割り込みハードウェアと、OS から見たハンドラ実装

割り込み (interrupt) は、外部要因によって CPU を「いま実行している命令とは別の処理」へ強制的に飛ばす仕組みです。タイマー・シリアル・ボタン入力 — どれも「いつ来るか分からない」イベントを、ポーリングではなくハードウェアの自動制御で拾うために必要になります。

 RK16 では入力線 intr0-intr3 の 4 本を持ち、固定の割り込みベクタ 0x0004 にジャンプします。この記事ではハードウェアの挙動・例外モデル・コンテキスト退避・ハンドラ実装まで、上から順に見ていきます。

概要

 割り込みは「外部要因による例外」の一種です。RK16 では:

  • 割り込み入力 4 本 (intr0-intr3)
  • 受理時にハードウェアが PC を ira に退避し、PC を 0x0004 に飛ばす
  • csr.ENABLE (bit 0) で全体許可、csr.MASK0-MASK3 (bit 2-5) で個別マスク
  • csr.bit8-bit11 に発火フラグ
  • 復帰は iret() (CTRL 命令、PC = ira)

ハードウェアの挙動

割込発生
割込発生
通常の処理
通常の処理
INTR
INTR
割込の処理
割込の処理
割込解消
割込解消
割込許可
割込許可
割込禁止
割込禁止
ien
ien
元の処理
元の処理
割り込み
割り込み
PC
PC
ix
ix
0x1234
0x1234
IRA
IRA
0x1235
0x1235
0x0001
0x0001
0x0009
0x0009
0x000A
0x000A
割込リターン
割込リターン
0x1235
0x1235
Text is not SVG - cannot display

 割り込みは「命令の境界」でしか受理されません。実行中の命令を中断したりはしません。intr_i (i = 0..3) のいずれかが立っており、かつ csr.ENABLE = 1 かつ対応する csr.MASKi = 0 であれば、現在の命令の Dump ステージ後に次のシーケンスが走ります。

  1. 現在の PC (= 次に実行する命令のアドレス) を ira に退避
  2. csr.ENABLE (bit 0) を 0 に落とす (多重割り込みの防止)
  3. csr.bit (8+i) に発火フラグを立てる
  4. PC を割り込みベクタ 0x0004 に設定

 これは全てハードウェアが命令と命令の隙間で行う制御であり、ソフトウェアは何もしません。割り込みが受理された瞬間、次に実行されるのはベクタ 0x0004 の命令です。

割り込みベクタ

 ベクタ 0x0004 はリセットベクタ (0x0000) の直後に置かれます。tasm では次のように配置します。

asm @ 0x0000 reset {
    loadi(fp, 0xffff);
    call(init);
  halt:
    jumpr(halt);
}

asm @ 0x0004 irq {
    jump(_intr);         // 退避処理付きのプロローグへ
}

 ベクタは 1 命令分しか領域がないので、実体のプロローグ (_intr) は別の場所に置き、ベクタからジャンプします。

CSR レジスタの詳細

bit名前役割
bit 0ENABLE1 で割り込み全体許可、0 で完全禁止
bit 1(予約)
bit 2MASK01 で intr0 を個別マスク
bit 3MASK11 で intr1 を個別マスク
bit 4MASK21 で intr2 を個別マスク
bit 5MASK31 で intr3 を個別マスク
bit 6-7(予約)
bit 8FIRE0intr0 の発火フラグ (ハードウェアがセット、ソフトがクリア)
bit 9FIRE1intr1 発火フラグ
bit 10FIRE2intr2 発火フラグ
bit 11FIRE3intr3 発火フラグ

 CSR はレジスタなので、

ori(csr, csr,  0x0001);   // ENABLE 許可
andi(csr, csr, 0xFFFE);   // ENABLE 禁止
andi(csr, csr, 0xFEFF);   // FIRE0 クリア (bit 8)

のように 1 命令で制御できます。

マスクと発火フラグの違い

  • マスク (MASKi): 受理判定の段階で使う。マスクされていると受理自体が起こらない (発火フラグも立たない)
  • 発火フラグ (FIREi): 受理時にハードウェアが立てる。ハンドラがクリアしないと、ENABLE 再許可後に同じ割り込みが再度受理されたように見える (実際には立ったまま)

多重割り込みの防止

 ハードウェアは受理と同時に ENABLE = 0 に落とします。ハンドラ内では割り込みが入らないので、コンテキスト退避途中で破壊される心配がありません。

 ハンドラ末尾の典型パターン:

ori(csr, csr, 0x0001);   // ENABLE 再許可
iret();                  // PC = ira

 順序が重要です。iret の前に ENABLE を再許可しないと、その後の割り込みが永久に止まります。逆に ENABLE を許可してから iret までの間に別の割り込みが入ると、その瞬間は ira が上書きされる可能性があります — ハードウェアは「次の命令で iret する」とは知らないので。

 厳密に二重割り込みを防ぐには、退避が完全に終わるまで ENABLE = 0 のままにし、ハンドラ本体に入る直前で再許可する設計もあります。RK16 RTOS では協調型なのでハンドラ全体を ENABLE = 0 のまま走らせ、最後に再許可してから iret します。

例外モデル

 「例外 (exception)」は、通常の命令フローを離脱させる事象全体を指す傘の用語です。RK16 で実装しているのは割り込みのみですが、将来拡張の枠として次の分類を持っています。

  • 外部要因 (命令と非同期に発生)
    • 割り込み (Interrupt): 外部デバイスからの通知。RK16 では intr0-intr3 の 4 本
  • 内部要因 (実行中の命令の結果として発生)
    • トラップ (Trap): システムコールなど、ソフトウェアが意図的に発生させる遷移。syscall 命令など
    • フォールト (Fault): 回復可能。命令を再実行できる。ページフォルトなど
    • アボート (Abort): 回復不能。例: メモリパリティエラー

 RK16 のハードウェアは現状トラップ命令を持たないので、システムコールに相当するものはユーザコードからの通常の関数呼出として実装します (Ring 0/Ring 3 分離もありません)。

コンテキスト退避

 ベクタからジャンプしてくる _intr プロローグは、まず全レジスタをスタックに退避します。退避領域は callee 規約の延長で、FP から下向きに 12 word 取ります。

FP オフセット内容備考
fp + 0ra退避時の戻りアドレス
fp + 1fp中断時の FP (再開後の FP に戻す)
fp + 2t0
fp + 3t1
fp + 11t9

 pc, ira, csr は退避領域には書きません — pc はハードウェアが ira に保存済み、csr は全体で 1 つしかないため (タスクごとに切り替えるなら別途 TCB に保存)。

 _intr の実コード (RTOS の中身):

asm _intr {
    subi(fp, fp, 12);       // 退避領域を確保
    store(ra, fp,  0);
    store(t0, fp,  2);
    store(t1, fp,  3);
    store(t2, fp,  4);
    store(t3, fp,  5);
    store(t4, fp,  6);
    store(t5, fp,  7);
    store(t6, fp,  8);
    store(t7, fp,  9);
    store(t8, fp, 10);
    store(t9, fp, 11);
    addi(t0, fp, 12);       // 中断時の FP を計算
    store(t0, fp,  1);      // 旧 fp スロットに書く

    // 中断時の ira / fp を TCB に保存
    load(t0, z, task_running@);
    store(ira, t0, (@(0 as *TCB)).ra@);
    store(fp,  t0, (@(0 as *TCB)).fp@);

    jump(intr_dispatch);
}

 なぜ irafp を TCB に保存するかというと、ハンドラ実行中に別タスクへ切り替える可能性があるからです。スケジューラが現在の TCB の中断時の状態を読んで、後で復帰できるようにしておきます。

割り込みディスパッチャ

 退避が終わると intr_dispatch() に飛びます。これは普通の tasm 関数で、CSR の発火フラグを順に見てユーザハンドラを呼びます。

fn intr_dispatch() {
    if ((csr & 0x0100) != 0) { intr0(); }   // FIRE0
    if ((csr & 0x0200) != 0) { intr1(); }   // FIRE1
    if ((csr & 0x0400) != 0) { intr2(); }   // FIRE2
    if ((csr & 0x0800) != 0) { intr3(); }   // FIRE3
    os_task_scheduler();                    // 戻らない
}

 ディスパッチ後はスケジューラに飛び、ready なタスクへコンテキストを切り替えて iret します。割り込みハンドラ自体は普通の関数として書けるので、ABI 規約 (引数なし / 戻り値なし) に従えば OK。

ユーザハンドラの書き方

 ユーザは intr0() から intr3() の関数を定義するだけで、ハードウェアからの割り込みを処理できます。最低限の責任は 2 つだけ。

  1. 実際の処理を行う
  2. 自分の発火フラグを クリアする
fn intr0() {
    // 例: タイマー割り込みでカウンタを進める
    timer_count = timer_count + 1;

    // 発火フラグ FIRE0 (bit 8) をクリア
    csr = csr & 0xFEFF;
}

 クリアを忘れると、os_task_scheduler から iret で復帰した瞬間に同じ割り込みが再受理され、無限ループになります。逆にクリアを最後に置くことで、「ハンドラ実行中に同じデバイスから次のイベントが来ても、フラグがまだ立っているので取りこぼさない」という挙動になります。

時分割スケジューリングの例

 ハードウェアタイマからの周期的な intr0task_list を 1 つだけ回転させると、ready タスクが順番に選ばれる簡易ラウンドロビンになります。

fn intr0() {
    // [head, A, B, tail] -> [A, B, tail, head]
    if (task_list != (0 as *TCB)) {
        var head: *TCB = task_list;
        var second: *TCB = (@head).next as *TCB;
        if (second != (0 as *TCB)) {
            var tail: *TCB = head;
            while ((@tail).next != (0 as *void)) {
                tail = (@tail).next as *TCB;
            }
            (@tail).next = head as *void;
            (@head).next = 0 as *void;
            task_list = second;
        }
    }
    csr = csr & 0xfeff;   // FIRE0 クリア
}

 この 1 個のハンドラだけで、task_list の先頭が毎回ずれていきます。ディスパッチャの後にスケジューラが呼ばれて、新しい先頭の ready タスクが走ります。

iret の動作

 iret() は機械語上 CTRL(z, ira, z, 0) で、つまり PC = [ira] + 0 を実行する CTRL 命令の特殊形です。rd = z なので戻りアドレス書き込みは破棄、rs2 = z なので無条件分岐。

 iret 自体は csr.ENABLE を触りません。ENABLE の再許可はソフトウェア責任なので、ハンドラ末尾で:

ori(csr, csr, 0x0001);   // ENABLE = 1
iret();

の順で書くか、コンテキスト復帰関数 (os_restore_context) の中で csr を復元してから iret します。

まとめ

  • ハードウェア: 受理時に PC→ira / ENABLE=0 / PC=0x0004 / FIREi=1。これだけ
  • ソフトウェア (RTOS 側): 退避 → ディスパッチ → ハンドラ → フラグクリア → スケジューラ → 復帰 → iret
  • ソフトウェア (ユーザ側): intr0()-intr3() を書く。発火フラグをクリアする

関連項目