借り初めのひみつきち

仮ブログです。

x86の割り込みが遅いワケ

CPUが現在実行中のプログラムを中断して処理しなければならない事象が発生した時、一般に「割り込み」というメカニズムを使ってその事象を処理します。

広義の割り込みは実際には以下の3種類に分類できます。

  • 例外

CPUが命令の実行を継続できない事象が発生したときにOSに判断を委ねるために例外が発生します。
多くの場合はプログラムのミスによるエラーや悪意あるプログラムによる不正な特権操作を行おうとした場合に例外が発生してアプリケーションが終了しますが、全ての例外が必ずしもエラー終了となるわけではなく、ページフォールトのようにOSの処理によって命令実行が続行可能な場合もある例外もあります。

  • 外部割り込み

外部デバイスのデータ転送が終了したり、データ転送要求があったり、タイマーの状態が変化したときなどにCPUに通知するために割り込みが発生します。狭義の割り込みはこれを指します。
x86は歴史的事情により通常のマスク可能割り込み(IRQ)とマスク不可能割り込み(NMI)の2種類に大別されます。マスク可能割り込みはフラグレジスターのIFフラグで割り込みをマスク(禁止)することができることからこの名前で呼ばれます。IRQはデバイスから直接通知される場合とPICやAPICを介して割り込みが通知される場合があります。Local APICを使ってIPIを送信する場合もマスク可能割り込みの一種として通知されます。

  • ソフトウェア割り込み

専用の命令を使って任意のタイミングでソフトウェア的に割り込みを発生させる場合に使います。
多くの場合はOSを呼び出すために使われるのでシステムコールと同一視されることもあります。

それぞれの詳細については今回の記事では重要ではないので省略します。

x86の場合、ほとんどの割り込みは割り込みベクターと呼ばれる0番〜255番までの256種類の番号で割り込みの要因を区別します。*1割り込みベクターの0番〜31番まではIntelによって例外のために予約されているため、通常のOSは外部割り込みやソフトウェア割り込みをこの範囲外のものを割り当てます。なお、レガシーBIOSが使用する割り込みは例外が多数定義されて予約のルールが徹底される前に設計されたので定義済みの例外と被っているものがたくさんあります。

さて、割り込みが発生するとCPUはまずメモリ上のIDTというテーブルからゲートディスクリプターというものを探します。
ゲートディスクリプターには割り込み先のCS,xIPレジスタの値やディスクリプターの属性などの情報が記述されています。
ゲートディスクリプターには割り込みで使えない種類のものも存在するので、CPUはディスクリプターが本当に存在するか、割り込みに適合したものかなどをチェックします。このチェックで不適切なディスクリプターと判断されると一般保護例外が発生します。一般保護例外も処理できないとダブルフォールトやトリプルフォールトという擬似的な例外が発生してCPUが停止し、通常はトリプルフォールトが発生するとリセットがかかるようにハードウェア設計されています。開発途中のOSでは例外がうまく処理できないとよくリセット地獄に陥りデバッグが困難です。

ゲートディスクリプターのチェックが終わると、そこに記述されているコードセグメントをロードしようとします。
セグメントのロードはメモリにあるGDTやLDTというテーブルに記述されたセグメントディスクリプターというものを読み込みます。
ここでもIDTのときのようにディスクリプターが適切なものかどうかチェックされ、不適切だった場合は一般保護例外などの例外が発生します。

多くの場合、割り込みが発生した時のコンテキストはユーザーモードのアプリケーションプログラムであるのに対し、割り込みを処理するプログラムはカーネルモードのシステムプログラムであるケースがほとんどです。
カーネルモードとユーザーモードを切り替えるときはお互いのスタックに不適切にアクセスされないようにスタックを自動的に切り替える機能がCPUにあります。このときTSSという構造体から新しいスタックセグメントとスタックポインターを読み込みます。
スタックセグメントはコードセグメントと同様にGDTやLDTからセグメントディスクリプターをロードしてチェックが行われ、不適切だった場合はスタック例外が発生します。

ここまで準備が終わると、割り込み処理のためのCS,xIPの内容がゲートディスクリプターからロードされ、割り込み発生前のCS,xIP,フラグレジスターなどがスタックにPUSHされ、やっと割り込み処理プログラムに制御が移ります。
割り込みを終了するときはIRET命令を実行するとスタックからCS,xIP,フラグレジスターを復元し、必要な場合は追加でSS,xSPも復元します。このときもセグメントレジスターは前述のようにセグメントディスクリプターから読み込んでチェックが行われます。

とても複雑ですね。

x86IDTには割り込みゲート、トラップゲート、タスクゲートを置くことができ、それぞれに16bitと32bitのバージョンが存在しました。また、大昔はメモリ空間の保護にセグメンテーション機構が使われており、これらのディスクリプターの内容が非常に重要だったため厳密にチェックする必要がありました。

x64になるとIDTに配置できるディスクリプターの種類が減り、64ビット固定の割り込みゲートとトラップゲートのみになりました。この2つの違いは自動的に割り込みを無効に設定するかどうかだけなので実質的にはほとんど同じものです。また、セグメンテーションも大幅に機能縮小され、通常の状態ではほぼ意識する必要がなくなりました。
にも関わらず、x64の割り込みのメカニズムはx86の時代とほとんど変わっていません。
拡張を繰り返した結果ゲートディスクリプターの構造は複雑奇怪になっています。
本来割り込みゲートに必要な情報は割り込み先のRIPとわずかな追加情報、セグメントの切り替えもカーネルモードかユーザーモードかのフラグを1ビットを切り替えるだけで必要十分なはずですが、互換性のためかx86の複雑な機構をほぼそのまま継承して引きずっています。

システムコールに関しては現代のOSでは割り込み命令を使わず専用の命令(sysenter,syscallなど)を使ってディスクリプターのロードやチェックなどを省略できるものが主流になっています。

通常の割り込みでもこの辺りが改良されると高速に割り込み処理できるようになると思うのですが…

*1:SMIやLocal APICのStartup IPIなどのようにベクター番号の存在しない特殊な割り込みも一部存在します。