システムコールと割り込み 基礎
3 つのメカニズムが制御をユーザ / カーネル境界を越えて動かす:システムコール(コードが自発的に頼む)、割り込み(ハードウェアが注意を要求)、例外(CPU がコードでフォールトする)。 ソケットからの各 read、各キー入力、各ディスク I/O、各ページフォールトが これら 3 つのうちの 1 つに乗る。5 セクション:3 つの扉の対比;IDT が割り込みをハンドラに接続する仕組み;Linux のtop-half / softirq 分割 + 対話的なパケット到着ウォークスルー;シグナル —— ユーザコードへの非同期通知; 最後にクイックリファレンス。
特権境界を越える 3 つの扉
ハードウェアはちょうど 3 つのメカニズムでカーネルモードへの制御移行を露出する。 各イベントがどれを使うか理解することは、誰が発起したか、どの状態が保存されたか、コストが何かを伝える。
3 つの扉が存在し、それぞれ異なる発起者と異なる風味のコストを持つ:
- Syscall —— ユーザコードが自発的にカーネルに何かを頼む。 命令(x86-64 で
syscall、ARM64 でsvc #0)はユーザが実行; 引数は周知のレジスタに置かれる(assembly-isa primer が呼び出し規約を扱う)。 コスト:KPTI オーバーヘッド込みで ~150 ns。あらゆるread、write、open、forkで使用。 - 割り込み(Interrupt) —— ハードウェアが注意を要求。 NIC がパケットを受信、タイマーが発火、ディスクが I/O を完了。 CPU の命令ストリームの外から来る;任意のユーザ(またはカーネル)コードをプリエンプトできる。 コストはハンドラで変わるが入退場は syscall と類似(~150 ns)。
- 例外(Exception) —— CPU がユーザ自身の命令でフォールトした。 0 除算、未マップアドレスのデリファレンス、未定義 opcode の実行、デバッグブレークポイントへの到達。 CPU 状態は割り込み入場と類似だが、原因は外部イベントではなくコード自身。
3 つに共通すること
3 つの遷移すべてがアトミックに:(1)特権リングを 3 から 0 に上げる;(2)CPU ごとの カーネルスタックに切り替え;(3)カーネルが戻れるようユーザ命令ポインタ、スタックポインタ、 フラグを保存;(4)アドレスがブート時に設定されたカーネルハンドラへジャンプ。 ハードウェアはユーザコードがこれを傍受できないことを保証する —— ring 0 への唯一の道は、カーネルが初期化中に書いた制御されたエントリポイント経由。
微妙な違い
同期 vs 非同期。Syscall と例外はユーザコードと同期的 —— 現在の命令ストリームが何かしたから起こる。割り込みは非同期; 任意の命令境界で発火でき、別のカーネルハンドラ中(ネストルールあり)でも含む。
再開可能性。Syscall は常に syscall の次の命令に戻る。 割り込みは IRQ が発火したときにいた場所に戻る、何も起こらなかったかのように (カーネルがその間に多くを行ったかもしれない)。例外はフォールティング命令に戻る (リトライ、カーネルが原因を修正した後 —— 例:ページフォールト)、 または修正できなければプロセスを殺す。
Vector ディスパッチ。3 つすべてが Interrupt Descriptor Table(IDT、次節) と呼ばれる CPU テーブルを使う。各 vector(エントリインデックス 0-255)が固定のカーネル関数にマップされる。 Syscall は速さのために IDT をバイパスする別個の高速パスメカニズム (x86-64 で SYSCALL/SYSRET、ARM64 で SVC)を使うが、概念的には依然として vector ジャンプ。
なぜこれがプロダクションで重要か
- 割り込みストーム。挙動異常の NIC や割り込みの奔流をトリガするソフトウェアバグが ユーザコードを餓死させる。修正は原因を直す、IRQ アフィニティでストームを隔離、または 割り込み合体(NIC ファームウェアが IRQ を上げる前にバッチ化)。
- Syscall 削減。最も一般的な高スループットサーバ技術。 各 syscall は ~150 ns の純オーバーヘッド;io_uring や epoll でのバッチングが 数を桁単位で削減する。
- ページフォールトは例外、シグナルだけではない。ハンドラがページを割り当てる(良)、1 つをスワップイン(遅いが正しい)、 または SIGSEGV を配信する(あなたのコードが間違っていた)かを決める。
要点。「カーネルモードへの 3 つの扉:syscall(あなたが頼む)、 割り込み(ハードウェアが要求)、例外(あなたのコードがフォールトする)。 3 つすべてアトミックに特権を上げ、スタックを切り替え、固定ハンドラにジャンプする。 同期 vs 非同期と再開可能 vs リトライ-または-死が実際の違い。 高スループットサーバは syscall カウント削減に執着する;割り込みストームは実故障モード。」
IDT —— 割り込みからハンドラへの配線
Interrupt Descriptor Table は 256 エントリの関数ポインタ配列。 割り込みや例外が発火すると、CPU は vector 番号でこのテーブルをインデックスしハンドラへジャンプする。
x86-64 では IDT はメモリに住み、CPU の IDTR レジスタがそれを指す。 256 エントリの各々が 16 バイトのgate descriptorで、 ハンドラのアドレス、特権リング遷移ルール、スタックスイッチヒントを含む。 最初の 32 エントリは CPU 定義の例外用に予約され、残り 224 はハードウェア割り込み用にソフトウェア割り当て可能。
Vector 割り当て
- 0-31:CPU 例外。0 除算(0)、デバッグブレーク(1)、ページフォールト(14)、 general protection(13)、無効 opcode(6)、ひと握り。アーキテクチャ的に固定; カーネルがブート時に埋める。
- 32-127:外部ハードウェア割り込み(レガシー)。IRQ コントローラ(APIC)が線ベースの割り込みをこれらの vector にルーティングする。 現代システムは Message-Signalled Interrupt(MSI / MSI-X)を使い、 デバイスが直接 vector をメモリに書く。
- 128-255:ソフトウェア割り込みと現代の MSI。Linux はレガシーの
int $0x80syscall メカニズム(SYSCALL に置き換えられ非推奨)に vector 128 を使い、MSI 駆動デバイスにより高い番号を動的に割り当てる。
割り込みが実際にどう発火するか
例えば NIC 割り込みを歩く:
- NIC が Message-Signalled Interrupt を書く:CPU が「vector N で割り込みが到着」と見るマジック アドレスへのマジック値。Vector は PCIe enumeration 中にカーネルがこの NIC に割り当てた番号の 1 つ。
- CPU は現在の命令を終える(意味論を保つために割り込みは命令境界に遅延される)、 IDT のエントリ N を検索する。
- エントリ N が CPU に伝える:ハンドラアドレス H、ターゲット特権リング 0、 CPU ごとの TSS からカーネルスタックをロード。CPU はユーザ RIP / RSP / RFLAGS を カーネルスタックにプッシュし、H へジャンプする。
- H はカーネルの共通 IRQ エントリポイント。vector に基づいて、登録されたドライバ IRQ ハンドラ (この場合 NIC のもの)にディスパッチする。
なぜ syscall は IDT をバイパスするか
初期の x86 syscall は int $0x80 を使った —— 他のあらゆる割り込みと まったく同じように IDT を通る。遅い:descriptor ロード + 特権チェックで ~200 サイクル。 AMD64 が SYSCALL 命令を追加、MSR(LSTAR がハンドラアドレス、STAR がセグメント セレクタ)を使って IDT 参照なしで直接ジャンプする。~75 サイクル。 Linux は初期 2.6 カーネルでデフォルトを SYSCALL に切り替えた;int $0x80 は レガシーコード用に依然動作する。
CPU ごとの IDT と APIC
SMP システムでは各 CPU が独自の IDT を持つ(論理的に;大半のカーネルはすべての CPU の IDTR を同じテーブルに設定して共有する)。どの CPU がどの IRQ を処理するかのルーティングは IO-APIC(線ベース)または LAPIC + MSI メカニズム(現代)の仕事。IRQ アフィニティ (/proc/irq/N/smp_affinity で設定可能)は特定の IRQ を特定の CPU にピン留めする —— 性能上重要、特に高レート NIC では割り込みを CPU 間で弾くとキャッシュ局所性が殺される。
要点。「IDT はハンドラアドレスの 256 エントリテーブル。Vector 0-31 = CPU 例外、 32+ = ハードウェア割り込み(今日は MSI ルーティング)。CPU ハードウェアはあらゆる割り込みで ハンドラを見つけるためにそれをインデックスする。Syscall は専用の SYSCALL 命令経由でそれをバイパスする (レガシー int $0x80 よりずっと速い)。IRQ アフィニティはどの CPU がどのデバイスを 処理するか決める —— 高レートハードウェアで実性能ノブ。」
Top-half / softirq —— Linux の遅延作業モデル
NIC は秒間数百万回割り込みを発生する。割り込みハンドラは小さく保たねば、 他のすべてを餓死させる。Linux は作業を 2 つに分割することでこれを解決する。
ハードウェア割り込み自体(IRQ ハンドラ、時に top half と呼ばれる)は 現在の IRQ 線が無効化された状態で走る —— 戻るまで同種の新しい割り込みは到着できない。 パケット処理に 10 μs 費やすと、その 10 μs の間別のパケットも自身の割り込みを発火できず、 スループットを IRQ 線あたり 100K パケット/秒に制限する。現代の 100 Gb/s NIC は 秒間数百万パケットを配信できる。
修正:top half を小さく保つ。デバイスを ack、カーネルが見つけられる場所にパケットポインタを保存、softirq で実作業を行うようスケジュール、戻る。Softirq は割り込み有効で走るので、 次の割り込みはもうブロックされない。割り込み線をロックする必要のない実作業がこの低優先度コンテキストで起こる。
NAPI —— ポーリングのトリック
非常に高いパケットレートでは、パケットあたり 1 割り込みでも高すぎる。NAPI(New API、~2002)は NIC ドライバがバーストの最初の割り込みを受け取った後、 デバイスの割り込みを無効化し、代わりに RX リングをポーリングしてさらにパケットを取る。 リングが一定時間空になったら、割り込みを再有効化する。これは 1 つの割り込みを 潜在的に数千パケットに償却し、10/40/100 Gb/s NIC を実用可能にする。
Tasklet、workqueue、threaded IRQ
3 つの他の遅延作業メカニズムが softirq と並存する:
- Tasklet —— softirq の上の古い API、新コードでは非推奨。 同じ発想、簿記が少し異なる。
- Workqueue —— スリープできる作業(例:メモリ割り当てやロック待ちが必要)が workqueue 経由でカーネルスレッドコンテキストで走る。softirq より高レイテンシだが、 softirq ができないことができる。
- Threaded IRQ —— 新しいメカニズム、IRQ ハンドラ自体がカーネルスレッド。 Linux RT preempt パッチが IRQ 処理をスケジュール可能にするために使う (これで高優先度リアルタイム作業が遅い IRQ ハンドラをプリエンプトできる)。
なぜこれがユーザコードで重要か
softirq ハンドラは書かない —— それらはカーネルの関心事。しかし 3 つの運用上の含意がユーザコードに影響する:
ksoftirqdCPU%。top でksoftirqd/Nが CPU を燃やしていれば、カーネルは softirq 作業をしすぎている —— 通常ネットワークか ディスクの放水から。perf topで NIC 割り込みレートを確認する。- IRQ アフィニティが重要。NIC IRQ を CPU 全体に散らす (
set_irq_affinity.shまたは RPS / RFS 経由)と softirq が複数コアで走れる。 なしだと 1 コアがすべてのパケットを処理しボトルネック。 - ユーザコード内のレイテンシ。長く走る softirq はユーザスレッドの スケジューリングをミリ秒単位で遅延し得る。リアルタイム構成は PREEMPT_RT と threaded IRQ を有効化してこれを制限する。
要点。「Linux は割り込み処理を分割する:top-half(IRQ ハンドラ)は小さい —— デバイスを ack、遅延作業をスケジュール —— softirq が割り込み再有効化下で実処理を行う。 NAPI はドライバ側ポーリングでこれを拡張し、高レートでオーバーヘッドを償却する。 ユーザコードはこれを ksoftirqd CPU 時間として、そしてコア全体に スケールするのに必要な IRQ アフィニティ / RPS チューニングとして体感する。」
シグナル —— ユーザコードへの非同期通知
カーネルはプロセスに「あなたに何かが起きた」と伝える方法が必要。 シグナルが POSIX の答え。強力で、奇癖満載、他のどのメカニズムよりも 「Ctrl-C で神秘的にクラッシュ」バグの源。
シグナルはイベントを示すためにプロセスに配信される小さな整数 (1-31 標準、32-64 リアルタイム)。SIGTERM は「クリーンに終了してください」; SIGKILL は「カーネルが今あなたを終わらせる、抵抗できない」; SIGSEGV は「触れるべきでないメモリにアクセスした」; SIGPIPE は「対岸が閉じたソケットに書いた」。約 30 の標準シグナルが存在; ほとんどはアプリケーションコードに重要でない。
シグナルがどう配信されるか
カーネルがシグナル配信を決めたとき(例:他プロセスからの kill(pid, 15)、 またはフォールトからの SIGSEGV、または子が終了したときの SIGCHLD)、 ターゲットプロセスの pending-signal マスクにビットをセットする。 ターゲットプロセスがカーネルモードからユーザモードに次に戻るときに、 カーネルはマスクをチェックし、現在ブロックされていない最高優先度の pending シグナルを選び、 次のユーザ命令の代わりにシグナルハンドラを走らせる手配をする。ハンドラ戻り後、 制御はシグナルが中断した場所に再開する。
デフォルトアクション
各シグナルはデフォルトの処置を持つ:term(プロセスを殺す)、core(殺してコアをダンプ)、ignore(何もしない)、stop、continue。プロセスは signal(POSIX)またはsigaction(POSIX のつまみがより多い)経由でハンドラをインストールできる。 2 つのシグナルはキャッチも無視もできない:SIGKILL と SIGSTOP。他はすべてオーバーライド可能。
Async-signal-safety —— 罠
シグナルハンドラは任意の命令境界で発火し得る、プロセスが malloc、printf、または他のライブラリ呼び出しの最中も含む。 ハンドラがその後、中断されたコードが既に保持するロックを取る関数を呼ぶと、 プロセスはデッドロックする。POSIX 標準は ~30 個の関数をasync-signal-safe としてリストする —— 再入可能でロックを取らないことが保証される。 他はシグナルハンドラから呼ぶのが安全でない。
signal-handler 安全サブセットは write(syscall、stdio ではない)、_exit、sigaction、シグナルマスク操作、単純な整数代入を含む。安全でない:malloc、printf、ロックする何か、glibc の大半。 プロダクションコードの標準イディオムは、ハンドラ内では主ループがポーリングする sig_atomic_t フラグをセットする以外何もしない。
リアルタイムシグナル
標準シグナル(1-31)は合体される —— プロセスが最初の SIGUSR1 を処理する前に 2 つ送ると、 配信は 1 回のみ。リアルタイムシグナル(SIGRTMIN..SIGRTMAX、32-64)はキューされる —— 各送信が個別に配信され、sigqueue 経由で小さなペイロードを添付できる。 合体が情報を失う高レートプロセス間通知に使う。
signalfd —— シグナルを epoll 可能に
シグナル処理はあらゆる epoll 駆動サーバの主イベントループと干渉する。signalfd はシグナルが pending になると読み取り可能になるファイルディスクリプタを作成する; 他のあらゆる fd と同様に epoll でき、シグナルを通常の readiness イベントとして扱える。 現代のサーバ(nginx、envoy など)は signal-handler を完全に回避するためにこれを使う。
要点。「シグナルはカーネルからプロセスへの非同期通知。 標準セット(~30)が終了、フォールト、子状態、IO イベントをカバー。 ハンドラは任意の命令境界で発火するので、内部では async-signal-safe 関数のみ合法 —— フラグをセットして主ループで処理する。リアルタイムシグナルはキューされる;signalfd はシグナルをイベントループとクリーンに統合するための epoll 可能 fd に変える。」
クイックリファレンス
冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。
ユーザ / カーネル境界の 3 つの扉とは?
Syscall(コードが頼む)、割り込み(ハードウェアが要求)、例外(コードがフォールトする)。 3 つすべてアトミックに特権を上げ、カーネルスタックに切り替え、固定ハンドラへジャンプする。 違い:syscall と例外は現在の命令ストリームと同期;割り込みは非同期で任意の境界で発火し得る。
IDT は何で、CPU はどう使うか?
Interrupt Descriptor Table —— メモリに保持される 256 エントリの(ハンドラ、特権リング、 スタックスイッチヒント)タプル配列、IDTR レジスタが指す。あらゆる割り込みや例外で、 CPU は vector 番号でこのテーブルをインデックスしハンドラへジャンプする。 Vector 0-31 は CPU 例外;32+ はハードウェア割り込みと現代の MSI 用にソフトウェア割り当て可能。
Linux の top-half / softirq 分割を説明。
ハードウェア割り込みは現在の IRQ 線が無効化された状態で走るので、小さく保たねばならない —— そうでなければ次の同種割り込みをブロックする。Top-half はデバイスを ack して softirq をスケジュール。 Softirq は割り込み再有効化下で実作業を実行する。NAPI がこれを拡張:最初のパケット後、 ドライバが割り込みを無効化してポーリングし、高レートでオーバーヘッドを償却する (10/40/100 Gb/s NIC に必須)。
なぜシグナルハンドラから malloc を呼ぶのは安全でないか?
ハンドラは任意の命令境界で発火し得る、同じプロセスが malloc 呼び出しの最中で アロケータロックを保持している場合も含む。ハンドラから malloc を呼ぶと 同じロックを取ろうとする —— デッドロック。POSIX async-signal-safe リスト(~30 関数)が シグナルハンドラから合法に呼べる唯一のセット。標準パターン:フラグをセット、戻る; 主ループで処理。
signalfd はいつ役立ち、いつ役立たないか?
epoll 駆動のイベントループを実行していてシグナルを通常の readiness イベントとして 扱いたい場合に役立つ。SIGKILL や SIGSTOP には役立たない(ハンドラに配信不可); フォールトからの SIGSEGV のような致命的同期シグナルには役立たない (カーネルが signalfd へのキューイングではなく即座の配信を強制する)。 古典的なユースケースは SIGTERM / SIGINT を主ループでのクリーンシャットダウンロジックに変換すること。
IRQ アフィニティは何をする、なぜ重要か?
IRQ アフィニティは各 IRQ を CPU のセットにバインドする。Linux はデフォルトで散らす; チューニングが重要な理由は(a)NIC の IRQ を CPU 間で弾くとネットワークスタック作業の キャッシュ局所性が破壊される、(b)NUMA ボックスでは IRQ を NIC のソケット近くにしたい、 (c)高パケットレートでは CPU ごとに 1 IRQ(RSS)で softirq が並列に走れるようにしたい。/proc/irq/N/smp_affinity 経由で設定。
コードレビューの赤旗
- シグナルハンドラがフラグセット以上のことをする。stdio、malloc、ロックを取る何かを呼ぶ —— レースコンディションかデッドロック待ち。 作業を主ループに移動する。
- syscall でいっぱいのタイトループ。各 syscall は ~150 ns。 秒間 10 万+ を見るなら、epoll、io_uring、readv/writev でバッチ化、 または eBPF/AF_XDP 経由でカーネルに作業を持ち上げる。
- サーバに SIGPIPE ハンドラなし。クライアントが閉じたソケットへの書き込みは デフォルトで SIGPIPE 経由でプロセスを殺す。無視するか、send() で MSG_NOSIGNAL を使う。
- タイトループでの
/proc/statポーリング。各反復で syscall + パース オーバーヘッド。値をキャッシュするか、 実際に安いカウンタには perf_event_open を使う。 - 子を刈り取らないカスタム
SIGCHLDハンドラ。デフォルトアクションは「無視するが自動収穫」;ハンドラをインストールしてwaitpidを忘れると、プロセステーブルが満タンになるまでゾンビプロセスを蓄積する。