RISC-V OSを作ろう (3) ~ 割り込み

執筆者 : 高橋 浩和


はじめに

今回は、RISC-V OSに割り込み機能を追加します。今回は割り込みハンドラを動作させるところまで実装します。

割り込みは、CPU上でタスク等が実行している命令とは関係なく非同期に発生します。 割り込みが発生すると、OSは発生した割り込みの種別に対応した割り込みハンドラを呼び出します。

割り込みとよく似たものに、CPU上でタスク等が実行している命令が要因となって発生する例外があります。 例外の扱いについては別の機会に説明したいと思います。

RISC-Vの割り込み機能

今回実装するOSでは、単純化するためにRISC-Vの実行レベル(実行モード)としてマシンモードのみ利用することとします*1。マシンモードは最も権限の高い実行レベルで、CPU資源に制限なくアクセスすることができます。

割り込み発生時の動作

RISC-Vアーキテクチャは割り込みと例外を同列に扱います。 例外または割り込みが発生すると、CPUは現在実行中の処理を中断して、制御を例外ハンドラ・割り込みハンドラに移します。 処理の中断個所の状態は自動的に保存されます。

  • 中断個所のPCはEPCに保存さます。
  • 現在の実行モード(今回はマシンモード)はMSTATUSレジスタのMPPフィールドに保存され、CPUの実行モードはマシンモードに切り替わります。
  • MSTATUSレジスタ中にある現在の割り込み禁止許可状態を表すMIEビットは、例外・割り込み発生前の割り込み禁止許可状態を表すMPIEビットにコピーされ、MIEビットはゼロクリアされ割り込み禁止状態になります。

その後、MTVECが指すトラップベクタに制御を移します。トラップベクタから例外ハンドラまたは割り込みハンドラを呼び出します。

例外ハンドラ/割り込みハンドラ終了時にはmret命令を実行します。mret命令により中断させられていた処理が再開します。

  • 割り込み禁止許可状態を元に戻す。MSTATUSレジスタMPIEビットをMIEビットにコピーする。
  • CPUの実行モードを元に戻す。実行モードをMSTATUSレジスタMPPフィールドが示すモードに変更する(今回は変更先もマシンモード)。
  • EPCの値のPCへの代入。中断地点から処理を再開する
レジスタ 説明
MEPC 例外・割り込み発生時のPCの値を格納する
MSTATUS 現在の割り込み禁止許可状態(MIEビット)、例外・割り込み発生前の割り込み禁止許可状態(MPIEビット)および実行モード(MPPフィールド)を保持
MCAUSE 例外要因を表す。最上位ビットが割り込みか例外かを示し、下位フィールドに割り込みコード・例外コード(要因を示す番号)が入る。
MTVAL 例外の詳細情報が格納される
MTVEC トラップベクタ(例外・割り込み発生時のエントリ)を指す

f:id:takava:20210615133404j:plain

トラップベクタの形式は2種類から選ぶことができます。今回のOSの実装ではベクタモードを使用することにします。

形式 説明
ダイレクトモード MTVECのMODEフィールドに0を設定する。

全ての例外・割り込み発生時に、VTVECが指すエントリに制御を移す。

ベクタモード MTVECのMODEフィールドに1を設定する。

例外発生時の動作はダイレクトモードと同じ。割り込み発生時は、割り込み要因によりエントリが異なる。"MTVECが指すアドレス + 割り込み要因×4" が指すエントリへ制御を移す。

割り込み要因は下記のように分類されています。割り込みの要因毎に異なるエントリに制御を移します。 マシンタイマ割り込みが発生した時は、"MTVECが指すアドレス + 7×4" 番地のエントリに制御が移ります。

割り込み要因 説明
0 ユーザソフトウェア割り込み
1 スーパバイザソフトウェア割り込み
2 予約
3 マシンソフトウェア割り込み
4 ユーザタイマ割り込み
5 スーパバイザタイマ割り込み
6 予約
7 マシンタイマ割り込み
8 ユーザ外部割り込み
9 スーパバイザ外部割り込み
10 予約
11 マシン外部割り込み
12-15 予約
16以降 実装依存割り込み

RISC-Vではデバイスからの割り込みは11番となりますが、タイマ割り込みは特別扱いされており7番となります。 ソフトウェア割り込み(3番)は、コア間(Hart間)割り込みに利用することを想定しています。 16番以降の割り込みは、CPU実装毎に用途を決めて良いことになっています。

ユーザレベルおよびスーパバイザレベルの割り込みは、MIPレジスタ(保留中の割り込み要求を示すレジスタ)への書き込みによりソフトウェア的に発生させることを想定しています。割り込みコントローラからユーザレベルまたはスーパバイザレベルの割り込みを直接発生させるという実装も可能なようです。

f:id:takava:20210615152802j:plain

割り込み禁止

割り込みを禁止することにり、発生した割り込みの受付けを割り込み許可を行なうまで遅延させることができます。 割り込み禁止を設定できる個所はいくつかあり、用途により使い分けることができます。

割り込み禁止個所 説明
割り込みコントローラ 外部割り込みの発生源毎にマスクすることができる
MIE 先に示した割り込み要因毎に、割り込み許可・禁止を設定できる
MSTATUS MIEビットにより、全ての割り込みを禁止できる

f:id:takava:20210619171526j:plain

タイマ割り込み機能を実装する

OSにタイマ割り込み機能を実装します。 当面、タイマ以外の割り込みは発生しない、割り込みのネスト(割り込みハンドラ実行中に発生した別の割り込みを受けつける)はないという前提で実装します。

RISC-Vのタイマ割り込み

RISC-Vのタイマ割り込みはコア内部ではなく、コア外の機能として実装されています。 動作中にCPUクロックが動的に変化するモデルを考慮し、CPUクロックとは独立に動作できるようにコア外の機能としたようです。

mtimeレジスタは時刻を刻み続け、mtimecmpレジスタの値と等しい時刻になるとタイマ割り込みを発生させます*2。 この仕組みを使って周期割込みを実装する場合は、タイマ割り込み発生毎にmtimecmpを再設定する必要があります。

レジスタ 説明
mtime リアルタイムクロック。一定周期でカウンタ値が増える
mtimecmp mtimeの値がmtimecmpと一致した時にタイマ割り込みが発生する

外部割り込みが発生した場合、割り込みコントローラにその割り込みに対するackを返すことにより、再度同じ割り込みが発生するようになります。 タイマ割り込みの場合は、mtimecmpの更新がack処理を兼ねています。mtimecmp更新によりタイマ割り込み要求がクリアされ、また次のタイマ割り込み要求を受け付けられるようになります。

トラップベクタの登録

トラップベクタを用意します。 7番目(マシンタイマ割り込み)のエントリはタイマハンドラのエントリtimer_handlerに飛ばします。 それ以外のエントリはダミーハンドラundefined_handlerに飛ばすことにします。 トラップベクタの先頭を256バイトアラインしていますが、アライン値はRISC-V CPUの実装依存です。qemu仮想環境にはハードウェア制約は無いので、アラインは無くても動作するでしょう。

    .text
    .globl trap_vectors
    .type trap_vectors,@function
    .balign 256
trap_vectors:
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   timer_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .size trap_vectors,.-trap_vectors

    .balign 4
undefined_handler:
    mret

トラップベクタをMTVECレジスタに登録します。MTVECレジスタのMODEフィールドに1を設定することにより、ベクタモードとして登録されます。

#define MTVEC_VECTORED_MODE 0x1U

void main(void) {
    clearbss();
    SetTrapVectors((unsigned long)trap_vectors + MTVEC_VECTORED_MODE);
        :
        :
}
    .global SetTrapVectors
    .type SetTrapVectors,@function
SetTrapVectors:
    csrw  mtvec, a0
    ret
    .size SetTrapVectors,.-SetTrapVectors

割り込みスタック

割り込みハンドラには、割り込みスタック使って動作させます。 今回のOSでは、boot処理後使われていないブートスタックを割り込みスタックに再利用することとします。

割り込みスタックを用意しなかった場合、割り込みハンドラは割り込まれたタスクのスタックを利用して動作することになります。 その場合、全てのタスクスタックに割り込みハンドラが必要とするスタック量分も余計に確保させておかなければならず、 タスク数が多いとスタック用のメモリ量も無視出来ない量になります。

タイマハンドラの用意

タイマハンドラは、割り込んだ処理(ここではタスク)のコンテキスト(レジスタ値とスタック)を壊さないこと、 つまりタイマハンドラ終了時に全てのレジスタの値とスタックの状態が元に戻っていることを保証しなければなりません。

関数呼び出しの場合は、 呼び出し先関数退避レジスタ(Callee saved registers)だけ壊さないようにすれば良いのですが、 割り込みハンドラの場合は、呼び出し先関数退避レジスタ(Callee saved registers)/関数呼び出し元退避レジスタ(Caller saved registers)両方のレジスタ群を壊さないようにしなければなりません。

割り込みハンドラ呼び出しは関数呼び出しではないので、割り込みエントリ処理にて関数呼び出し元退避レジスタの保存処理を行なう必要があります。 呼び出し先関数退避レジスタは割り込みエントリで利用するレジスタの分だけ保存すれば十分です。それ以外の呼び出し先関数退避レジスタは、その先で呼び出される関数が責任を持って保存処理を行ないます。

下記はタイマ割り込みエントリです。 タスクスタックにA0-A7,T0-T6,RAレジスタを保存します。 スタックを割り込みスタック(_stack_end)に切り替え、タイマハンドラ本体(Timer関数)を呼び出します。 切り替え前のSPの値はS0に退避しておくことにします。S0は値を書き換える前に元の値をタスクスタックに保存しておきます。

タイマハンドラ終了時には、スタックに退避しておいた値をレジスタに読み込み、最後にmret命令を実行します。 mret実行により、PCとMSTATUSレジスタが割り込み発生前の状態に戻ります。

    .balign 4
timer_handler:
    addi  sp, sp, -8*17
    sd    ra, 0*8(sp)
    sd    a0, 1*8(sp)
    sd    a1, 2*8(sp)
    sd    a2, 3*8(sp)
    sd    a3, 4*8(sp)
    sd    a4, 5*8(sp)
    sd    a5, 6*8(sp)
    sd    a6, 7*8(sp)
    sd    a7, 8*8(sp)
    sd    t0, 9*8(sp)
    sd    t1, 10*8(sp)
    sd    t2, 11*8(sp)
    sd    t3, 12*8(sp)
    sd    t4, 13*8(sp)
    sd    t5, 14*8(sp)
    sd    t6, 15*8(sp)
    sd    s0, 16*8(sp)

    mv    s0, sp
    la    sp, _stack_end
    jal   Timer
    mv    sp, s0

    ld    ra, 0*8(sp)
    ld    a0, 1*8(sp)
    ld    a1, 2*8(sp)
    ld    a2, 3*8(sp)
    ld    a3, 4*8(sp)
    ld    a4, 5*8(sp)
    ld    a5, 6*8(sp)
    ld    a6, 7*8(sp)
    ld    a7, 8*8(sp)
    ld    t0, 9*8(sp)
    ld    t1, 10*8(sp)
    ld    t2, 11*8(sp)
    ld    t3, 12*8(sp)
    ld    t4, 13*8(sp)
    ld    t5, 14*8(sp)
    ld    t6, 15*8(sp)
    ld    s0, 16*8(sp)
    addi  sp, sp, 8*17
    mret
    .size timer_handler,.-timer_handler

タイマ割り込みを発生させてみよう

OS起動時に現在の時刻(mtimeの値)からINTERVAL時間だけ将来の時刻をmtimecmpに設定します。 今回は、人間が目視で確認できるようINTERVALにはかなり長めの時間を設定しています。 qemu virtターゲットでは、mtime/mtimecmpレジスタは、それぞれ0x200BFF8番地、0x2004000番地に割りついています。

また、リセット直後のRISC-Vは割り込み禁止で動作しているため、割り込みを許可することも必要です。 EnableTimer関数にてMIEレジスタのMTIEビットを操作し、タイマ割り込みを許可します。 さらに、EnableInt関数にてMSTATUSレジスタのMIEビットを操作し、全割り込み禁止状態を解除します。

volatile unsigned long * const reg_mtime = ((unsigned long *)0x200BFF8U);
volatile unsigned long * const reg_mtimecmp = ((unsigned long *)0x2004000U);

#define INTERVAL 10000000

static void StartTimer(void)
{
    *reg_mtimecmp = *reg_mtime + INTERVAL;
    EnableTimer();
    EnableInt();
}

void main(void) {
           :
    StartTimer();
           :
}
    .equ   MIE_MTIE, 0x80
    .equ   MSTATUS_MIE, 0x8

    .global EnableTimer
    .type EnableTimer,@function
    .balign 4
EnableTimer:
    li    t0, MIE_MTIE
    csrrs zero, mie, t0
    ret
    .size EnableTimer,.-EnableTimer

    .global EnableInt
    .type EnableInt,@function
EnableInt:
    li    t0, MSTATUS_MIE
    csrrs zero, mstatus, t0
    ret
    .size EnableInt,.-EnableInt

タイマハンドラの本体Timer関数を用意します。Timer関数では、次のタイマ割り込み発生のためのmtimecmp更新のみを行なうこととします。

今回の実装ではまず発生しませんが、mtimecmpに次のタイマ割り込みの時刻を設定した時に、既にその時刻を過ぎてしまっている可能性があります。 下記のコードは、その場合には時刻を再設定するという実装にしてあります。 さらに、カウンタの値が溢れてゼロに戻った場合にも備えた計算式にしてあります。

void Timer(void)
{
    print_message("Timer\n");

    do {
        *reg_mtimecmp += INTERVAL;
    } while ((long)(*reg_mtime - *reg_mtimecmp) >= 0);
}

今までと同じ手順でコンパイルし、オブジェクトa.outをqemu上で起動します。

$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c main.c primitives.s start.s
$ riscv64-unknown-elf-ld main.o primitives.o start.o -T riscv-virt.lds
$ qemu-system-riscv64 -nographic -machine virt -m 128M -kernel a.out -bios none  -S -gdb tcp::10000

gdb-multiarchコマンドで接続し、タイマ割り込み発生前後のMSTATUSレジスタとMCAUSEレジスタの値を覗いてみましょう。

(gdb) b load_context
Breakpoint 1 at 0x800004e6: file primitives.s, line 148.
(gdb) b Timer
Breakpoint 2 at 0x80000250: file main.c, line 117.
(gdb) c
Continuing.

1つめのタスク起動時(load_context実行時)、MSTATUSレジスタのMIEビット(割り込み許可ビット)が1になっていることが確認できます。

Breakpoint 1, switch_context () at primitives.s:148
148         ld    sp, (a0)
(gdb) info registers mstatus mcause
mstatus        0x8      SD:0 VM:00 MXR:0 PUM:0 MPRV:0 XS:0 FS:0 MPP:0 HPP:0 SPP:0 MPIE:0 HPIE:0 SPIE:0 UPIE:0 MIE:1 HIE:0 SIE:0 UIE:0
mcause         0x0      0
(gdb) d 1
(gdb) c
Continuing.

タイマハンドラ(Timer関数)実行時、MSTATUSレジスタのMIEビットが0になり、MPIEビットが1になっていることが確認できます。 MPIEビットには割り込み発生前のMIEビットの値が入ります。 MSTATUSレジスタのMPPフィールドが3になっていることも確認できます。3は割り込み発生前の実行モードがマシンモードであったことを示します。

MCAUSEレジスタの最上位ビットは、割り込みが発生したことを表しています。 下位ビットフィールドは割り込み要因7番(マシンタイマ割り込み)が発生したことを示しています。

Breakpoint 2, Timer () at main.c:117
117         print_message("Timer\n");
(gdb) info registers mstatus mcause
mstatus        0x1880   SD:0 VM:00 MXR:0 PUM:0 MPRV:0 XS:0 FS:0 MPP:3 HPP:0 SPP:0 MPIE:1 HPIE:0 SPIE:0 UPIE:0 MIE:0 HIE:0 SIE:0 UIE:0
mcause         0x8000000000000007       -9223372036854775801
(gdb)
MSTATUS MPPフィールドの値 割り込み発生前の実行モード
3 マシンモード
1 スーパバイザモード
0 ユーザモード

タスクをゆっくり動かしてみる

人間が目視で動作を追えるようにタスクの処理速度を下げることにします。 タイマ割り込み周期の1/4の時間だけCPUを無駄使いする関数spend_timeを用意し、タスクから呼び出すようにしてみました。

void spend_time(void)
{
    unsigned long t = *reg_mtime;
    while ( *reg_mtime - t < INTERVAL/4 );
}

void Task1(void)
{
    while (1) {
        print_message("Task1\n");
        spend_time();
        Schedule();
    }
}

void Task2(void)
{
    while (1) {
        print_message("Task2\n");
        spend_time();
        Schedule();
    }
}

void Task3(void)
{
    while (1) {
        print_message("Task3\n");
        spend_time();
        Schedule();
    }
}

コンパイルして動かしてみます。 タスクが動作する中で、周期的にタイマハンドラが呼び出されています。

$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c main.c primitives.s start.s
$ riscv64-unknown-elf-ld main.o primitives.o start.o -T riscv-virt.lds
$ qemu-system-riscv64 -nographic -machine virt -m 128M -kernel a.out -bios none
Task1
Task2
Task3
Task1
Task2
Timer
Task3
Task1
Task2
Task3
Timer
Task1
Task2
Task3
Task1
Timer
Task2
Task3
Task1

最後に

ここで紹介したプログラムはgithubにて公開しています。qemu上で動作させ、レジスタの動きなどを追いかけてみてください。 github.com

次回はタイムシェアリング機能の実現です。タイマ割り込みを契機としてタスクスケジューラを動かします。

おまけ

今まで64bitモードのRISC-Vのアーキテクチャは1種類であるかのように話を進めてきましたが、 RISC-Vアーキテクチャは実CPUに組み込む機能を選択可能となっており、CPUによって利用できる機能や命令の範囲が異なります。

では、qemuがエミュレートする64bitモードRISC-V CPUにはどの機能が組み込まれているか見てみましょう。 MISAレジスタを参照することによりRISC-V CPUが提供する機能を確認できます。 gdbを使って中を覗いてみます。

   (gdb) info registers misa
   misa           0x800000000014112d       RV64ACDFIMSU

MISAレジスタの各ビットが機能を表しています。このRISC-Vでは下記の機能を持つことが分かります。

汎用レジスタ長 説明
64ビット 他に32ビットと128ビットのモードがある
サポートする命令 説明
RV64I整数命令 64ビットモード整数演算機能の提供。32ビットモードの場合は、フルセットの整数演算命令を提供するRV32Iと、限定的な整数演算命令を提供するRV32Eがある。
浮動小数点演算命令 単精度と倍精度の演算命令を提供。四倍精度演算は利用できない。
整数乗算除算命令 この命令を提供しないCPUでは、コンパイラが乗算除算式をライブラリ関数呼び出しに変換する。
16ビット長命令 圧縮(Compressed)命令と呼ばれている。テキストサイズを圧縮する効果がある。ARMのThumb命令セットのようなもの。
アトミック命令 コア間排他命令*3
サポートする実行モード 説明
マシンモード 全てのRISC-V CPUが備えている権限レベルの最も高いモード
スーパバイザモード OSを動かすことを想定したモード
ユーザモード ユーザプログラムを動かすことを想定したモード

*1:実行権限の低いスーパバイザモードやユーザモードもあります。

*2:mtimeレジスタの更新頻度は実装依存です。これらレジスタを持たないRISC-V実装もあるようです。

*3:正確にはHart(Hardware Thread)間排他命令。1つのコア内で、複数のハードウェアスレッドが並列動作するRISC-Vを実装することも考慮されています。