CPUにおける例外処理の方法と、RISC-V CPUの割り込みについて見ていきましょう。
CPUの例外処理
CPUは通常どおり命令を実行している最中にも、即座に対応しなければならない 例外的な状況が発生します。主に次の三つがあります。
- 一つ目は、命令そのものが失敗して完了できない場合です。例えばゼロ除算 (divide by zero)や、アクセス権限のないメモリにアクセスしようとする場合が これに該当します。
- 二つ目は、外部I/OデバイスがCPUのサービスを要求する場合です。キーボード入力 が入ってきたり、ディスク読み出しが完了した状況などがこれに該当します。
- 三つ目は、タイムシェアリングシステムで割り当てられた時間(quantum)が満了した 場合です。OSが他のプロセスにCPUを渡さなければならないときにこのような状況 が発生します。
代表的には、各パイプライン段階で以下のような例外が起こり得ます。
- IF: 命令メモリが有効でない、あるいは保護されたアドレスにアクセスする場合
- ID: Opcodeが不正な場合、意図されたSystem callやTrap
- EX: ゼロ除算、オーバーフロー(いくつかのアーキテクチャにおいて)
- MEM: 有効でない、あるいは保護されたメモリアドレスにアクセスする場合
こうした状況を処理する方法として、以下のような方式があります。
ポーリング
周期的にイベントが発生したかどうかをCPUが直接確認する方式です。しかし、 イベントがあってもなくても毎回確認しなければならないため、オーバーヘッドが 相当なものになり得るという致命的な欠点があります。したがって、単純な組み込み システムなど限定的な環境でのみ使用されます。
割り込み
割り込みとは、CPUがイベントが発生したときに行っていた作業を中断し、 別の作業を先に処理してほしいというアラームを受け取る方式です。割り込みは 例外的条件(exceptional condition) が発生したときに生成され、CPUは 割り込みが発生すると例外ハンドラ(exception handler)に制御を移します。 割り込みの処理が終われば、元のプログラムに戻ります。
割り込み制御移譲
一般的なプログラムにおいて、CPUの「制御権」は実行中のプログラムが持って います。ところが割り込みが発生すると、CPUは行っていた作業を止めて割り込み ハンドラという別のルーチンを実行しに行きます。つまり、制御権が元のプログラム から割り込みハンドラへ移るわけです。この過程を割り込み制御移譲 (Interrupt Control Transfer) と呼びます。
割り込みは「計画されていない」関数呼び出しです。一般的な関数呼び出しは
プログラマがコードに直接call命令を入れることで意図的に実行しますが、
割り込みはそのような予告なしに発生します。
この過程で、割り込まれたスレッドは制御移譲を予測したりあらかじめ備えたり することができません。通常の関数呼び出しでは呼び出し前に引数を準備して レジスタを整理しますが、割り込みは命令の任意の時点で入ってくるため、 プログラムの立場からは割り込みの実行に備えることができません。
したがって、割り込みが実行される過程は完全に透過的(transparent) でなければなりません。割り込みハンドラが実行され、再び元のプログラムの 流れに戻ってきたとき、プログラムは割り込みが発生したという事実を まったく気づくことができないようにしなければなりません。このために、 割り込みハンドラに入る時点でハンドラ(あるいはCPU)はレジスタの状態、 プログラムカウンタ、フラグなどをすべて保存し、復帰時には完璧に復元しなければ なりません。
同期的/非同期的割り込み
同期的割り込み(Synchronous Interrupts) は、CPUが命令を実行する過程で 命令そのものによって発生する割り込みです。例えばページフォルト(Page Fault)、 ゼロ除算、不正なメモリアクセスのようなものです。これらは該当する命令を 実行するたびに常に同じ地点で発生するため再現が可能であり、同じプログラムを 同じ条件で実行すれば同じ地点で再び発生します。これらは通常例外(exception) とも呼ばれます。
非同期的割り込み(Asynchronous Interrupts) は、CPUの外部から来る信号で、 命令の実行とは無関係に任意のタイミングで発生します。キーボード入力、 ディスクI/Oの完了、タイマーの満了といったものがこれに該当します。
精密割り込み
割り込みを実際のCPUで実装するためには、先に説明した「透過性」をハードウェアが 保証しなければなりません。現在の実行状態を正確に保存し、ハンドラ実行後に 完璧に復帰するという過程が必要なのですが、これは命令を一つずつ実行するCPUでは 比較的容易でも、パイプラインを用いて複数の命令を同時に処理する構造では はるかに厄介な問題になります。
パイプラインCPUでは、ある時点で複数の命令が互いに異なる実行段階にまたがって 同時に進行しています。この状況で割り込みが発生すると、途中まで実行された 命令たちはどう扱うべきでしょうか。これを解決する方式が精密割り込み (precise interrupt) です。
精密割り込みとは、割り込みハンドラから見たときに、割り込みがちょうど二つの 命令の間で発生したかのように見せかける方式です。割り込みが発生すると、 特定の命令を基準にして、それより前(older)の命令はすべて完了させ、 それより後(younger)の命令はすべて取り消して、まったく実行を開始していない かのようにします。
同期的割り込みの場合は、割り込みを引き起こした命令の直前で止まったかのように 処理し、ハンドラが実行された後に復帰すると、その命令から再び実行します。
仮想化と保護
割り込みの実際の実装を見ていく前に、先に知っておくべき概念があります。 それがCPUの権限レベル(Privilege Level) です。コンピュータ上では 数十、数百のプロセスが同時に実行されます。しかしCPUは物理的に限られており、 メモリやディスクも多くのプロセスで共有して使用します。
しかし、もしすべてのプログラムがハードウェアに直接アクセスできるとしたら、 どうなるでしょうか。あるプログラムが誤って、あるいは故意に他のプログラムの メモリを上書きすることもあり得ますし、悪意あるプログラムがディスク上の 他のユーザーのファイルを読んだり削除したりすることもできてしまいます。 一つのプロセスのバグがシステム全体を壊してしまうこともあるでしょう。
そこでOSが各プログラムに仮想化(Virtualization)を提供します。各プロセスに それぞれのCPU、それぞれのメモリがあるかのように見せかけるのです。実際には OSが時間を細かく区切ってCPUを交互に割り当て(time-shared multiprocessing)、 メモリアドレスも仮想アドレスを通じて各自が独立した空間を持っているかのように 見せかけています。
権限レベル
ここで疑問が生じます。OSも結局はCPU上で動作するソフトウェアなのに、 どうやって他のプログラムを統制できるのでしょうか。OS上で動作する プログラム同士はお互いを統制できず、OSだけが他のプログラムを統制できる ようにするには、OSを特別なプログラムとして扱うハードウェア面での支援が 必要です。これこそが権限レベル(Privilege Levels) です。
現代のCPUは「今実行中のコードがどの程度信頼できるか」を区別するモードを ハードウェア的に内蔵しています。プログラムは通常、以下の三段階に分かれます。
ユーザーモード
ユーザーモード(User Mode) は一般の応用プログラムが実行される領域です。 このモードでは使用できる命令が制限されています。加算、減算、メモリの読み書き のような一般的な演算は可能ですが、ディスクコントローラに直接命令を下したり、 他のプロセスのメモリにアクセスしたり、割り込みを切ったりといった、システム 全体に破壊的な影響を与える作業は不可能です。このような命令を実行しようと すると、CPUが例外(exception)を発生させてOSに知らせます。
カーネルモード
カーネルモード(Privileged/Kernel Mode) はOSのカーネルコードが実行される 領域です。このモードではCPUのすべての命令を使うことができ、すべてのメモリ 領域にアクセスでき、システムのハードウェアデバイスを直接制御できます。 もしユーザープログラムがファイルを読みたければ、システムコール(System Call) を通じてOSにリクエストしなければならず、このときCPUの制御フローがカーネル へ移り、CPUモードがユーザーモードからカーネルモードに切り替わります。
ハイパーバイザーモード
一方、カーネルモードのさらに下にもう一つの階層を置きます。それが ハイパーバイザーモード(Hypervisor Mode) です。(これを指す用語はCPU ごとに異なります。)これはOSの下にさらにもう一つの仮想化レイヤを置くもので、 一つの物理マシンの上で複数のOSを同時に実行できるようにします。VMwareや VirtualBox、Proxmox VEのような仮想マシン環境がこのレイヤで動作します。
RISC-Vの特権モード
RISC-Vでは、これまで説明してきた権限レベルを四つの特権モード(Privilege Mode) として定義します。上から下に行くほど特権が高くなります。
- U モード (User): 一般の応用プログラムが実行される最も低い特権レベル です。先ほど説明したユーザーモードに該当します。
- S モード (Supervisor): OSのカーネルコードが実行されるモードです。 上で述べたカーネルモードに該当します。Sモードはページテーブルを扱ったり、 プロセスを交互にスケジューリングするなど、OSの主要な作業を行います。 LinuxやFreeBSDのような一般的なOSカーネルがこのモードで動作します。
- H 拡張 (Hypervisor): Sモードを拡張して仮想化をサポートするオプション の拡張です。厳密には独立したモードではなくSモードに乗せる形の拡張で、 「HSモード(Hypervisor-extended Supervisor Mode)」とも呼ばれます。この 上ではゲストOSがSモードだと錯覚した状態で動作します。上のハイパーバイザー モードに該当します。
- M モード (Machine): RISC-Vにのみ存在する、最も高い特権レベル です。 マシン自体に対する完全な制御権を持ち、起動直後にCPUが開始するモードで あり、どのCPUにおいても必ず実装しなければならない必須のモードです。 ハードウェアを最も間近で扱うファームウェアやブートローダ(OpenSBIなど) がここで動作し、CSRに対する全面的なアクセス権限を持ちます。
すべてのRISC-V CPUが四つのモードをすべて実装する必要はありません。実装例を 挙げると:
- M のみ: 非常に小さなマイクロコントローラ(MCU)。最もシンプルな構成で、 OSなしにファームウェアだけが動作します。
- M + U: 簡単な組み込みシステム。OSはありませんが、ある程度応用 プログラムとファームウェアを分離したいとき。
- M + S + U: Linuxのような汎用OSを動かすための一般的な構成。ほとんどの RISC-Vデスクトップ/サーバーCPUがこの構成です。
- M + (HS) + S + U: 仮想化までサポートする高性能構成。サーバー級の RISC-Vプロセッサで主に見られます。
他のアーキテクチャとの最大の違いはMモードの存在です。x86では
ハイパーバイザーの下に別途の「マシンレベル」がなく、ファームウェア
(BIOS/UEFI)とOSが比較的緩く結合されています。RISC-Vはこれとは異なり、
MモードをISAレベルで明示的に定義することで、起動からOS実行、仮想化に
至るまでのすべての権限遷移を一つの一貫したモデルで説明できるようにしています。
Sモードで行う割り込み処理もこのモデルの上で動作しており、これから説明する
sstatus、sepc、scauseのようなCSR名の接頭辞s-はまさにSupervisorモード
を意味します。(同じ動作をMモードで行うCSRにはmstatus、mepc、mcauseの
ようにm-接頭辞が付きます。)
RISC-Vにおける割り込み
割り込み処理を実装するためには、CPUはいくつかの状態情報をどこかに
保存しておかなければなりません。割り込みが発生した時点のPC、イベントの
原因、以前の権限レベル、割り込みマスクの設定といったものです。これらの
情報をどこに置くかを考えると、自然に二つの候補が浮かびます。
汎用レジスタファイル(x0〜x31) に置くか、あるいはメモリの特定の
アドレスに置くかです。ところが、どちらの方法にも深刻な問題があります。
まず汎用レジスタを使うのは不可能に近いです。割り込みはプログラム実行中の
任意の時点で割り込んでくるのですが、この瞬間、すべての汎用レジスタには
すでに元のプログラムが使っていた値が入っています。割り込み処理に使おうと
してx5、x6のようなレジスタに状態を書き込んでしまうと、元の値がすぐに
上書きされてしまい、復帰したプログラムはレジスタが壊れた状態で実行を
続けることになります。前節で強調した透過性(transparency) がまさに
崩れてしまうわけです。さらに割り込みハンドラ自体も汎用レジスタを使わなければ
ならないのに、状態保存用に押さえておくとハンドラが使えるレジスタが減って
しまいます。
だからといってメモリに置くのも簡単ではありません。割り込みが発生したその 一瞬にCPUがキャッシュやMMUを経由してメモリにアクセスし、PCと原因を記録 してまた読み出すという作業を行うには遅すぎます。割り込み処理は単一サイクル でほぼアトミックに起きなければならないのに、メモリアクセスは経路が長すぎ ます。より根本的な問題として、メモリアクセス自体が例外を引き起こし得る という点があります。もし割り込みを引き起こした原因がまさにページフォルトで あった場合、その状態をメモリに保存しようとしたときに別のページフォルトが 発生し得ますし、それを処理しようとして再びメモリにアクセスすると…と、 このように再帰的に崩壊してしまいます。
そこでRISC-Vは第三の選択肢を置きます。汎用レジスタファイルからもメモリ
からも分離された専用のレジスタ空間をISAレベルで別途定義しておくという
ものです。これがCSR(Control and Status Register) です。CSRは汎用レジスタ
ファイルとは独立しているため、割り込み発生時に元のプログラムのx0〜x31に
まったく触れずに状態を保存でき、メモリではなくCPU内部の専用ハードウェア
であるため単一サイクルでアクセスでき、アクセス権限をモード別に制御できるため、
ユーザープログラムが割り込みベクタのような機微な状態をむやみに触れないよう
にブロックできます。CSRとそれを操作する専用命令たちこそが、RISC-V割り込み
構造の骨格を成します。
Privileged CSRs
CSRは汎用レジスタファイル(x0〜x31)とは別個に存在する、12ビットの
アドレス空間を持つ特殊レジスタの集まりです。CSRアドレス空間には、
プログラムパフォーマンスカウンタから割り込み関連の状態、メモリ保護設定など
数多くのレジスタが定義されています。そしてそのうち相当数はPrivileged CSR
で、特権モード(SモードまたはMモード)でのみアクセスできます。ユーザーモード
プログラムがこのようなCSRにアクセスしようとすると、CPUはただちに
illegal instruction例外を発生させます。おかげでOSは、ユーザープログラムが
割り込みベクタを勝手に書き換えたり、割り込みを切ったり、他のプロセスの状態
を覗き見したりする行為をハードウェアレベルで遮断できます。
CSRアクセス命令: CSRRW
CSRは汎用レジスタファイルと分離されているため、lw/swのような通常の
メモリ命令でアクセスすることはできません。代わりにRISC-VはCSR専用の命令
セットを提供します。その中で最も基本となるのが**csrrw(Control and Status
Register Read and Write)** です。
csrrw rd, csr, rs1
rd: 該当CSRの以前の値を保存する汎用レジスタcsr: アクセスするCSRのアドレスrs1: 該当CSRに新しく書き込む値が入っている汎用レジスタ
すなわちcsrrwは一つの命令の中でCSRを読み取ると同時に新しい値で上書きする
演算を行います。読み取りと書き込みがアトミック(atomic) であり、その間に
他のコードがCSRの値を変更する隙がないという点が重要です。このほかにも、
特定のビットだけをセットするcsrrs(CSR Read and Set)、ビットをクリアする
csrrc(CSR Read and Clear)、そしてそれぞれの5ビット即値版である
csrrwi / csrrsi / csrrciまで、合計6種類のCSR命令があります。
割り込み関連の主要CSR
RISC-Vの割り込み処理フローで中核的に用いられるSモードのCSRをいくつか
まとめると以下のようになります。(Mモードにもmstatus、mepc、mcause、
mtvec、mtvalのように名前だけが変わった対応CSRが存在し、動作はほぼ
同一です。)
sstatus(Supervisor Status Register): 現在のCPUの状態を格納する レジスタです。最も重要なフィールドは**SIE(Supervisor Interrupt Enable) ビットで、1ならSモードで割り込みを受け取ることができ、0なら割り込みが 無効化されます。またSPP(Supervisor Previous Privilege)ビットは、 割り込みが発生する直前の権限レベル**を記録しておき、後で復帰するときに どのモードへ戻るべきかを教えてくれます。sepc(Supervisor Exception Program Counter): 割り込みや例外が 発生した時点のPCを保存するレジスタです。ハンドラの実行が終わると CPUはこのアドレスへ戻って元のプログラムの実行を続けます。同期的 例外であればその例外を引き起こした命令のアドレスが、非同期的な割り込み であればまだ実行されていない次の命令のアドレスが保存されます。scause(Supervisor Cause Register): 今回発生した割り込み/例外の 原因を表すレジスタです。最上位ビットが1なら非同期的割り込み、0なら 同期的例外であり、下位ビットは具体的なexception codeを格納します。 ハンドラはこの値を見てどのような処理をするかを決定します。詳細なビット 構造はすぐ下でもう少し扱います。stvec(Supervisor Trap Vector Base Address Register): 割り込みが 発生したときにCPUがジャンプしていくハンドラの開始アドレスが保存される レジスタです。OSは起動時にこのCSRに自身の割り込みハンドラのアドレスを 書いておき、以降はCPUが割り込みを受け取るたびに自動的にこのアドレスへ ジャンプすることになります。stval(Supervisor Trap Value): 例外の付加情報を格納します。 例えば不正なメモリアクセスでは問題となったアドレスが、illegal instruction 例外では問題となった命令のエンコーディングが入ります。ハンドラが追加の 診断に活用します。
scauseレジスタの構造
先ほど列挙したCSRのうち、scauseは割り込み処理フローで最も頻繁に読み取る
ことになるレジスタですので、そのビット構造をもう少し詳しく見ていきましょう。
scauseの幅はXLENビットで、RV32では32ビット、RV64では64ビットです。
いずれにせよ構造は本質的に同じです。最上位ビットが1個のInterrupt ビット
であり、残りのすべての下位ビットがException Code 領域です。図で表すと
RV32では次のようになります。
31 30 0
┌───┬──────────────────────────────────────────────────────────┐
│ I │ Exception Code (31 bits) │
└───┴──────────────────────────────────────────────────────────┘
│ │
│ └── 具体的な原因番号
│ (割り込みの種類あるいは例外の種類)
│
└── Interrupt ビット
1 = 非同期的割り込み
0 = 同期的例外
Interrupt ビット(I) はMSBの1ビットです。このビットを見て、ハンドラは
「今回入ってきたものが外部デバイスやタイマーから来た非同期的割り込みなのか、
それとも現在実行中だった命令そのものが引き起こした同期的例外なのか」を
真っ先に区別します。通常、両者は処理経路がまったく異なるためです。例えば
同期的例外であればsepcが指す命令を再実行するか、スキップするか、
プロセスを殺すかを決めなければならず、非同期的割り込みであれば該当する
デバイスのコントローラに応答を送った後に元のプログラムへ復帰すれば
済みます。
Exception Code 領域は残りの下位ビット全体です。この値が具体的に
どの事象なのかを番号で表します。同じ番号であってもIビットが0のときと
1のときで意味が異なるため、厳密には(I, code)の組として解釈する必要が
あります。
I = 1(割り込み)のときによく見るコード:
| Code | 意味 |
|---|---|
| 1 | Supervisor software interrupt (SSI) |
| 5 | Supervisor timer interrupt (STI) |
| 9 | Supervisor external interrupt (SEI) |
I = 0(例外)のときによく見るコード:
| Code | 意味 |
|---|---|
| 0 | Instruction address misaligned |
| 1 | Instruction access fault |
| 2 | Illegal instruction |
| 3 | Breakpoint |
| 4 | Load address misaligned |
| 5 | Load access fault |
| 6 | Store/AMO address misaligned |
| 7 | Store/AMO access fault |
| 8 | Environment call from U-mode (システムコール) |
| 9 | Environment call from S-mode |
| 12 | Instruction page fault |
| 13 | Load page fault |
| 15 | Store/AMO page fault |
興味深いのは、Interruptビットを最上位ビットにするという選択です。C
コードでscauseの値を符号付き整数として読み取ると、割り込みの場合は値が
負数になるため、「scause < 0なら割り込み、そうでなければ例外」という
形で分岐を簡潔に書くことができます。簡単なSモードのハンドラは通常、
以下のような形になります。
void trap_handler(void) {
uintptr_t cause = read_csr(scause);
uintptr_t code = cause & ~((uintptr_t)1 << (XLEN - 1));
if ((intptr_t)cause < 0) {
// 割り込み
switch (code) {
case 5: handle_timer(); break;
case 9: handle_external(); break;
// ...
}
} else {
// 例外
switch (code) {
case 2: handle_illegal_instruction(); break;
case 8: handle_syscall(); break;
case 13: handle_load_page_fault(); break;
// ...
}
}
}
このようにscauseは**「今なぜここに来ているのか」** への答えをたった1
ワードの中に収めているレジスタであり、割り込みハンドラはこの値を読むことで
最初の判断を下すことになります。
割り込みが起こったときに実際に何が起きるのか
割り込みや例外が発生したときにCPUがハードウェア的に行う一連の動作は 以下のとおりです。これらすべての段階は単一サイクル内にハードウェアが 自動的に終わらせる作業であり、ソフトウェアのコードは一行も介入しません。
- 原因の記録: 発生したイベントの種類を
scauseに記録します。最上位 ビットで割り込み/例外を区別し、下位ビットにexception codeを書き 込みます。 - PCの保存: 割り込みが発生した時点のPCを
sepcに保存します。精密 割り込みが保証されているため、このPCは「この命令の手前まではすべて 完了しており、この命令からはまったく実行されていない」という境界線に 該当します。 - 付加情報の記録: 必要であれば
stvalに追加情報(不正なアクセス アドレス、不正な命令のエンコーディングなど)を合わせて記録します。 - 権限切り替え: CPUモードをSモード(あるいはMモード)へ上げます。
以前の権限レベルは
sstatus.SPPにバックアップされ、後で復帰するときに 参照されます。 - 割り込み無効化:
sstatus.SIEを0に下げ、ハンドラの実行中にまた 別の割り込みが入ってきて状態を壊してしまわないようにします。以前のSIE値はsstatus.SPIE(Supervisor Previous Interrupt Enable)に バックアップされます。 - ハンドラへのジャンプ: 最後にPCを
stvecが指すアドレスに設定します。 これでCPUはOSがあらかじめ登録しておいた割り込みハンドラのコードを実行 し始めます。
ハンドラが開始する瞬間のCPUの状態を整理するとこうなります。scauseには
原因が、sepcには復帰するアドレスが、必要であればstvalには追加情報が
すでに埋められています。現在のモードはSモードであり、SIEは0にオフされて
おり、ネスト割り込みが遮断された状態です。ハンドラコードはこれらの情報を
読んでどのような割り込みがなぜ発生したのかを判断し、適切な処理を行った
うえで、最後に**sret**(Supervisor Return)命令で復帰します。sretが
実行されると、CPUはsepcに保存されたアドレスへPCを戻し、sstatus.SPPに
保存しておいた以前の権限レベルへモードを復元し、sstatus.SPIEをSIEへ
戻して割り込みを再び有効化します。元のプログラムからすれば何も起こらなかった
かのように、正確に中断された地点から実行が続くわけです。
ネスト割り込み
これまでの説明では「割り込みが発生すると、ハンドラが終わるまで新しい
割り込みは入ってこない」と仮定してきました。実際にsstatus.SIEが0に
下がるためにハードウェアが強制的にそう仕向けるのであり、これはハンドラが
自分自身を壊さないようにするための安全装置です。しかし常にこのようにだけ
動作するのは現実的に問題があります。
最大の問題は応答遅延です。ある割り込みハンドラが長く動作している間に 新しい割り込みが遮断されていると、その時間だけ外部デバイスやタイマーの 応答が遅れます。例えばディスクI/O完了を処理するハンドラが長くかかっている 間、タイマー割り込みが塞がれていると、OSはその時間スケジューリングの決定を 下せなくなり、他のすべてのプロセスが一時的に止まってしまいます。反応性が 重要なシステム(リアルタイムOS、ネットワーク機器など)では、こうした遅延は 深刻な問題になります。
これを解決する方法がネスト割り込み(Nested Interrupts) です。ハンドラが
ある程度の初期作業を終えた後、自らsstatus.SIEを1に戻して新しい
割り込みを再び受け付けられる状態にするのです。そうすればハンドラが実行中の
最中により急を要する割り込みが入ってくると、現在のハンドラを一時中断して
新しいハンドラへ制御が移り、新しいハンドラが終わると元のハンドラへ復帰して
残りの作業を引き続き行います。あたかも関数が別の関数を呼び出してから戻って
くるのと似た姿です。
ただしここには重要な注意点があります。RISC-Vのトラップ関連CSR
(sepc、scause、stval、sstatus.SPP、sstatus.SPIE)はすべて
たった一つの値だけを保存するということです。割り込みがまた発生すると
これらのCSRが新しい値で上書きされるため、元のハンドラが持っていた情報
は消えてしまいます。したがってハンドラがネスト割り込みを許可する前に、
現在のCSRの値を自身のスタックやレジスタにあらかじめ保存しておかなければ
ならず、復帰時にはsretを呼び出す前にこれらの値を再びCSRへ復元しなければ
なりません。この順序を誤ると、元の割り込みが復帰できなくなったり、
とんでもないところへ戻ってしまったりします。典型的なネスト許可ハンドラの
流れは以下のとおりです。
- ハンドラ進入。(この時点で
sstatus.SIE = 0、CSRには今回の割り込みの 情報が入っている) - 現在の
sepc、scause、sstatusなどの値をハンドラのスタックにpush。 - 必ず行わなければならない急ぎの作業(例: 割り込みコントローラへの応答、 フラグのクリア)を先に実施。
sstatus.SIEを1にセットしてネスト割り込みを許可。これで新しい割り込みが 入ってきても構わない。- 残りの長い作業を実施。途中で新しい割り込みが入ってきたら再帰的にネスト 処理される。
- 作業完了。
sstatus.SIEを再び0に下げてネストを遮断。 - スタックに保存しておいた
sepcなどをCSRへ復元。 sretで復帰。
この流れは概念的にはシンプルですが、実際の実装ではバグを生みやすい部分 であるため、Linuxのような汎用OSでもネスト割り込みを非常に限定的にのみ 許可したり、あるいはまったく許可しない場合が多いです。代わりにハンドラを 「急ぎの作業だけを行う短いトップハーフ(top half)」と「残りの処理を後で 行うボトムハーフ(bottom half、あるいはsoftirq、tasklet)」に分けて、 長い作業を割り込みコンテキストの外で処理する方式が広く使われます。
割り込みの優先順位
複数の割り込みがほぼ同時に発生したり、ハンドラが実行中のときに新しい 割り込みが入ってくると、どれを先に処理するかを決めなければなりません。 そのために割り込みには優先順位(Priority) が付けられています。優先順位 の高い割り込みは低い割り込みを押しのけて先に実行することができ、この規則 は先ほど見たネスト割り込みと結び付いて、「低い優先順位のハンドラが実行 中のときに、より高い優先順位の割り込みが入ってきたら、低い方のハンドラを 一時中断して高い方を先に処理する」 という動作を作ります。
RISC-Vにおいて割り込みの優先順位は三つの層で決まります。
特権モード間の優先順位
まず特権モードの間に固定された順序があります。Mモード割り込みが最も 優先され、次がSモード、その次がUモードの割り込みです。マシンレベルの 割り込み(例: マシンタイマー)はいつでもスーパバイザーレベルの割り込みを 押しのけることができます。この順序はISAに刻まれており、ソフトウェアが 変更することはできません。
同じモード内での優先順位
同じ特権モードの中で複数の割り込みが同時にpending状態にあるときの 優先順位は、RISC-V規格が定義しています。Sモード基準では以下の順序 です(上が優先)。
- SEI (Supervisor External Interrupt) — 外部デバイスからの割り込み
- SSI (Supervisor Software Interrupt) — ソフトウェアが明示的に上げる 割り込み
- STI (Supervisor Timer Interrupt) — タイマー割り込み
外部デバイスの割り込みが最も優先されるのは、外部イベントが最も遅延に 敏感で、取りこぼすとデータの喪失につながり得るためです。タイマーは周期 的なので少し遅れても大きな問題はありません。
外部割り込みの細かな優先順位: PLIC
SEIは「外部から来た割り込み」を統合して伝達する単一のチャネルですが、 実際には数十〜数百個の外部デバイス(キーボード、ディスク、ネットワーク カード、UARTなど)が各自割り込みを上げます。これらの間の優先順位はCPU コアではなく、別途のハードウェアブロックである PLIC(Platform-Level Interrupt Controller) が担当します。
PLICの役割は以下のとおりです。
- すべての外部割り込みソースから上がってくる要求を集めます。
- 各ソースにソフトウェアが設定しておいたpriority値を参照して、最も 高い優先順位の1件を選択します。
- 選択された割り込みを該当のハート(hart、RISC-VのCPUコア)にSEIとして 伝達します。
- ハンドラがPLICに「この割り込みを受け取った(claim)」と通知し、処理が 終わると「完了した(complete)」と通知します。この過程を通じて、同じ 割り込みが重複して処理されるのを防ぎます。
つまり、SモードのハンドラがSEIを受け取って入ってくると、このハンドラが 行うべき最初の仕事は、PLICを読んで「どの外部デバイスがこの割り込みを 上げたのか」を確認することです。PLICのpriority値は0〜7(実装によっては より広い場合もある)の範囲の整数で、OSがランタイムに自由に設定します。 おかげで「ネットワークカードは優先順位7、UARTは優先順位3」といった ように、システムの特性に合わせたポリシーをソフトウェアが柔軟に決めること ができます。
タイマーとソフトウェア割り込み: CLINT
STIとSSIは外部デバイスではなくシステム自体の内部イベントであるため、PLIC
ではない別のブロックで発生します。これを担当するのが
CLINT(Core-Local Interruptor) です。CLINTは各ハートに対してタイマー
比較レジスタ(mtimecmp)とソフトウェア割り込みトリガーを提供し、条件が
合えば該当のハートへSTIまたはSSIを上げます。PLICが「複数の外部デバイスの
うちどれが話す番か」を調停するのに対し、CLINTは「タイマーとハート間の
通信」を担当する、より小さくシンプルなブロックというわけです。
>> COMMENTS <<