[CS講座 #05] 割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み

[CS講座 #05] 割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み

はじめに

【基礎編】(第1回〜第4回)では、CPU内部のレジスタ、メモリバス、命令サイクル、そしてビット演算によるデコードという「プログラムが自律的に実行される閉じた世界」の仕組みを追ってきました。

しかし、実際のゲーム機やコンピューターは、プログラムの都合とは無関係に動く「外の世界」と対話しなければなりません。「プレイヤーがボタンを押した」「画面の描画(V-Blank)が終わった」「タイマーの時間が来た」といった瞬間的なイベントです。

【実践編】の幕開けとなる第5回は、こうした外部からの非同期イベントをCPUへ即座に伝える命の導線、「割り込み(Interrupt)」のメカニズムを解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. ポーリング vs 割り込み:CPU時間を無駄にしない知恵

外部からの変化を検知する方法には、大きく分けて「ポーリング」と「割り込み」の2つが存在します。

[ポーリング方式 (Polling)]
 CPU: 「キー押された?」「まだ」「キー押された?」「まだ」「キー押された?」「押された!」
  => 変化が起きるまでCPUが無限に確認し続け、計算資源を浪費する。

[割り込み方式 (Interrupt)]
 CPU: メイン処理を全力で実行中...
 ハードウェア: (ポンポン)「V-Blank入ったよ!」
 CPU: 「了解、今のPCをスタックに隠して、描画ハンドラへ一時ジャンプ!」
  => イベント発生時だけCPUの手を止めさせるため、無駄が一切ない。

もし割り込みがなければ、CPUは「画面が描き終わったか」を毎サイクル監視するループに追われ、肝心のゲームロジックや物理演算を実行する余裕を失ってしまいます。


2. INTとNMI:2種類の割り込みピン

Z80 CPUの物理パッケージには、割り込みを告げるためのピン(配線)が2本用意されています。

A. NMI (Non-Maskable Interrupt / マスカブル不能割り込み)

プログラム側から拒否(無効化)できない、最優先の緊急割り込みです。

  • 用途: 電源切断直前の退避処理や、ゲーム機(SMS/GG等)のPAUSEボタン。
  • 挙動: NMI信号が入ると、CPUは現在のPC(プログラムカウンタ)をスタックに避難させ、強制的に 0x0066 番地 へジャンプします。

B. INT (Maskable Interrupt / マスカブル割り込み)

プログラムの都合によって「今は割り込みを受け付ける(EI命令)」「今は受け付けない(DI命令)」を制御できる通常の割り込みです。

  • 用途: 画面描画の同期(V-Blank)、PSG音源のフレーム更新、タイマー割り込み。
  • 制御: CPU内部の IFF1 / IFF2(Interrupt Flip-Flop)という2つのフラグによって有効/無効化されます。

3. Z80の3つの割り込みモード(IM 0 / IM 1 / IM 2)

Z80の INT 割り込みには、基板の設計思想に合わせて3種類のモードが存在します(IM 0, IM 1, IM 2 命令で切替)。

+--------+-----------------------------------------------------------------------+
| モード  | 挙動と仕組み                                                          |
+--------+-----------------------------------------------------------------------+
| IM 0   | 外部ハードがデータバス上に命令(RST命令等)を直接流し込み、それを実行する。  |
| IM 1   | 割り込み発生時、自動的に固定アドレス 0x0038 へジャンプする。(最もシンプル) |
| IM 2   | Iレジスタ(上位8bit)+外部バスの値(下位8bit)で16bitベクタテーブルを参照する。|
+--------+-----------------------------------------------------------------------+

レトロハードでの採用例

  • MSX / Game Gear / ColecoVision: IM 1 を多用。VDP(画面チップ)がV-Blankを検知すると INT ピンをLOWにし、CPUは自動で 0x0038 へ跳んで画面更新処理(ISR: Interrupt Service Routine)を実行します。
  • アーケード基板 / 高級システム: IM 2 を使用。音源・画面・通信など複数の周辺機器が、それぞれ独自の割り込み処理アドレスへ分岐できるよう柔軟なベクタ構造を実現します。

4. 割り込み発生時の退避・復帰シーケンス

割り込み処理で最も重要なのは、「割り込みが終わったら、何事もなかったかのように元のプログラムの続きへ戻る」 という点です。

これを可能にしているのが スタック(Stack) です。

[1. メイン処理実行中]        [2. 割り込み発生 (IM 1)]      [3. 割り込みハンドラ実行]
  PC: 0x1234                  SP -> [ 0x1234 ] (PC退避)     PC: 0x0038
  SP: 0xFFFE                  PC -> 0x0038 へジャンプ       (処理実行...)

                                                          [4. RETI 命令実行]
                                                            SP から 0x1234 を復元
                                                            PC -> 0x1234 に戻り再開!

  1. CPUが命令の境界(step() の完了時)で割り込み要求を検知。
  2. 現在の PC の値をスタック(SP が指すメモリ)へ退避(PUSH)。
  3. 割り込みフラグ IFF1 = 0 にして二重割り込みを防止。
  4. 割り込みアドレス(0x0038 等)へ PC を書き換えてジャンプ。
  5. 割り込み処理の最後で RETI(Return from Interrupt)命令が実行されると、スタックから元の PC を復元(POP)して復帰。

5. エミュレーターでの実装(JavaScript)

実際にJavaScriptで割り込み状態を保持し、命令ループの間で安全に割り込みを割り込ませるコードを実装してみましょう。

CPUコアの割り込み制御機能

export class Z80Core {
  constructor(bus) {
    this.bus = bus;

    this.pc = 0x0000;
    this.sp = 0xffff;
    this.i = 0x00; // Interrupt Vector Register

    // 割り込み制御フラグ
    this.iff1 = false; // 割り込み許可フラグ (EI/DIで変化)
    this.iff2 = false;
    this.im = 1;       // 割り込みモード (0, 1, 2)

    this.intPending = false; // INT信号の受信状態
    this.nmiPending = false; // NMI信号の受信状態
  }

  // 外部デバイス(VDP等)から割り込みを発生させる
  requestINT() {
    this.intPending = true;
  }

  requestNMI() {
    this.nmiPending = true;
  }

  // スタックへの16ビット書き込み (PUSH相当)
  push16(val) {
    this.sp = (this.sp - 2) & 0xffff;
    this.bus.writeByte(this.sp, val & 0xff);         // 低位バイト
    this.bus.writeByte(this.sp + 1, (val >> 8) & 0xff); // 高位バイト
  }

  // 1命令ごとに割り込みの発生をチェック
  checkInterrupts() {
    // 1. NMI(最優先・マスク不可)
    if (this.nmiPending) {
      this.nmiPending = false;
      this.iff1 = false; // NMI実行中はINTを禁止
      this.push16(this.pc);
      this.pc = 0x0066; // NMI固定ベクタ
      return 11; // 割り込み応答に必要なT-State
    }

    // 2. INT(IFF1がtrueのときのみ受付)
    if (this.intPending && this.iff1) {
      this.intPending = false;
      this.iff1 = false; // 二重割り込み防止
      this.iff2 = false;

      this.push16(this.pc); // 現在の戻り先PCを退避

      // モードに応じた分岐
      if (this.im === 1) {
        this.pc = 0x0038; // IM 1固定ベクタ
      } else if (this.im === 2) {
        // Iレジスタ(8bit) + バス供給値(仮に0x00) で16bitアドレスを形成
        const vectorAddr = (this.i << 8) | 0x00;
        const targetPc = this.bus.readByte(vectorAddr) | (this.bus.readByte(vectorAddr + 1) << 8);
        this.pc = targetPc;
      }

      return 13; // 割り込み応答T-State
    }

    return 0; // 割り込みなし
  }

  // ステップ実行ループ
  step() {
    // 命令実行前に割り込みを判定
    const intCycles = this.checkInterrupts();
    if (intCycles > 0) {
      return intCycles; // 割り込み処理のクロックを返す
    }

    // 通常の Fetch-Decode-Execute
    const opcode = this.fetch8();
    return this.executeOpcode(opcode);
  }

  executeOpcode(opcode) {
    switch (opcode) {
      case 0xfb: // EI (Enable Interrupts)
        // EIの直後の1命令は割り込みが入らない仕様(次サイクルから有効)
        this.iff1 = true;
        this.iff2 = true;
        return 4;

      case 0xf3: // DI (Disable Interrupts)
        this.iff1 = false;
        this.iff2 = false;
        return 4;

      case 0xed: { // 拡張命令プレフィックス
        const sub = this.fetch8();
        if (sub === 0x4d) { // RETI (Return from Interrupt)
          // スタックから元のPCを復元
          const low = this.bus.readByte(this.sp);
          const high = this.bus.readByte(this.sp + 1);
          this.sp = (this.sp + 2) & 0xffff;
          this.pc = (high << 8) | low;
          this.iff1 = this.iff2; // 状態の復元
          return 14;
        }
        return 8;
      }

      default:
        return 4;
    }
  }
}

V-Blank割り込みの発火タイマー(システム側)

ブラウザの1フレーム(60fps = 約59,659 T-State)の中で、描画ラインがV-Blank期間に入ったタイミングで requestINT() をコールするイメージです。

function runFrame() {
  let cycles = 0;

  while (cycles < 59659) {
    // 画面下端(例: 50,000クロック経過地点)に達したらV-Blank割り込みを発火
    if (cycles >= 50000 && !vblankTriggered) {
      cpu.requestINT();
      vblankTriggered = true;
    }

    cycles += cpu.step();
  }

  vblankTriggered = false;
  requestAnimationFrame(runFrame);
}

この構造により、JavaScript上の仮想マシンは「普段はゲームの計算をしつつ、フレームの終わりにVDPからの割り込みを受けて 0x0038 の描画ルーチンへ跳ぶ」という実機と全く同じ非同期動作を完全に再現できます。


まとめ

  • 割り込み(Interrupt) は、CPUの無駄な監視(ポーリング)をなくし、外部イベント発生時だけ処理を割り込ませる高効率な仕組み。
  • NMI は拒否不能な緊急ジャンプ(0x0066)、INT は EI/DI 命令や IFF1 フラグでプログラム制御可能な通常割り込み。
  • IM 1 モード では割り込み時に自動で 0x0038 へ飛び、現在の PC をスタックへ退避させた上で復帰命令(RETI)を待つ。
  • エミュレーターの実装では、命令フェッチの直前に checkInterrupts() を挟むことで、非同期な実機イベントを同期的なJSループの上に再現できる。

次回は、64KBというアドレス空間の限界を打ち破る魔法、「第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む」をお届けします。


シリーズ目次

  • 【基礎編】
  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
  • 【実践編】
  • 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み(本記事)
  • 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む(次回)