[CS講座 #03] Fetch-Decode-Execute — CPUが命を宿す無限ループ

[CS講座 #03] Fetch-Decode-Execute — CPUが命を宿す無限ループ

はじめに

電源が入った瞬間から切れるその瞬間まで、CPUは休むことなく一つの単純な作業を繰り返しています。それが「メモリから命令を読み出し、解釈し、実行する」という基本サイクルです。

どれほど複雑な最新3Dゲームも、AIの機械学習モデルも、低レイヤーの視点まで分解すれば、すべてこの愚直な繰り返しの集積に過ぎません。

第3回となる今回は、ノイマン型コンピュータの命の鼓動とも言える「Fetch-Decode-Execute」サイクルのカラクリと、時間を正確にカウントするためのクロック管理手法を解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. ノイマン型コンピュータの根幹:「命令サイクル」

現代のほとんどのコンピューターは「ノイマン型アーキテクチャ」に基づいて設計されています。その最大の特徴は、データとプログラム(命令群)が同じメモリ空間に混在して記憶されている(プログラム内蔵方式) という点にあります。

CPUはこのメモリ空間から1行ずつ命令を読み出し、以下の3ステップ(命令サイクル)をループ処理します。

       +-----------------------------------+
       |   1. Fetch (命令のフェッチ)       |
       |      メモリから1バイト読み出す    |
       +-----------------+-----------------+
                         |
                         v
       +-----------------+-----------------+
       |   2. Decode (命令の解釈)          |
       |      Opcodeを解析し処理を決定     |
       +-----------------+-----------------+
                         |
                         v
       +-----------------+-----------------+
       |   3. Execute (命令の実行)         |
       |      レジスタ計算・メモリ読み書き  |
       +-----------------+-----------------+
                         |
                         +-----------------+ (次の命令へループ)
  1. Fetch(フェッチ): プログラムカウンタ(PC)が指すメモリ番地から、命令コード(Opcode)を取り出す。
  2. Decode(デコード): 取り出したバイト列が「何の命令か(加算か、ジャンプか、メモリ転送か)」を判別する。
  3. Execute(実行): 判定された命令に従って、レジスタの書き換えや算術演算(ALU)、外部バスへの読み書きを行う。

2. プログラムカウンタ(PC)の歩みと可変長命令

命令サイクルをコントロールするキーパーソンが、16ビットレジスタである PC(Program Counter / プログラムカウンタ) です。

PCは「次に読み込むべき命令がメモリの何番地にあるか」を常時保持しています。

可変長命令とPCの進み方

Z80などの8ビットCPUでは、命令によってバイト長(命令の長さ)が異なります。これを可変長命令と呼びます。

  • 1バイト命令 (NOP, INC A など): 命令コード(Opcode)のみ。PCは +1 進む。
  • 2バイト命令 (LD A, $55, IN A, ($90) など): Opcode + 8ビット即値/ポート番号。PCは +2 進む。
  • 3バイト命令 (LD HL, $1234, JP $C000 など): Opcode + 16ビットアドレス。PCは +3 進む。
[メモリ番地]  [データ]   [解釈]               [PCの変化]
0x0000     : 0x3E     LD A, n (Opcode)    0x0000 -> 0x0001 (Fetch)
0x0001     : 0xFF     数値 0xFF (Operand)  0x0001 -> 0x0002 (Operand Fetch)
0x0002     : 0x3C     INC A   (Opcode)    0x0002 -> 0x0003 (Fetch)

フェッチのたびにPCが進み、オペランド(引数データ)が必要であればさらにPCを進めてメモリから読み出す。これが命令実行時のPCの挙動です。

※ JP(ジャンプ)や CALL(サブルーチン呼び出し)命令が実行された場合は、PCの値自体が指定先のアドレス(例: 0xC000)へ直接書き換えられるため、実行の波は別のメモリ領域へと飛ぶことになります。


3. クロックサイクル(T-State)のカウント管理

CPUは、水晶振動子(クリスタル)が発する「カチ・カチ」という高頻度の電気信号(クロックパルス)に同期して動いています。

Z80(3.58MHz)であれば、1秒間に約 3,579,545 回 のクロック(T-State)を刻みます。

なぜエミュレーターでクロックカウントが必要なのか?

JavaScriptなどの高位言語でエミュレーターを作成する場合、while(true) で全力で命令を実行してしまうと、最新PCの高速なCPUパワーによってレトロゲームが数十倍の超スピードで爆走してしまいます。

実機と同じ速度(例: 3.58MHz)で画面描画や音源処理(PSG)を同期させるためには、「各命令が実行に何クロック(T-State)を消費したか」 を正確に加算・管理する必要があります。

命令バイト数T-State (クロック数)理由
NOP1 byte4M1サイクル(フェッチ)のみで完了
LD A, n2 bytes7フェッチ(4) + 即値読み出し(3)
INC (HL)1 byte11フェッチ(4) + メモリ読み出し(3) + メモリ書き込み(4)
JP addr3 bytes10フェッチ(4) + アドレス下位読み(3) + アドレス上位読み(3)

命令ごとに定められた「T-State」を累積していき、1フレーム(1/60秒 = 約59,659 T-State)分に達したタイミングでブラウザへ描画・音声出力を行うのが、エミュレーターの同期メカニズムの基本となります。


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

実際にJavaScriptで「Fetch-Decode-Execute」を行うステップ実行関数(step())の実装例を見てみましょう。

CPUコアの実行ループコード

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

    // レジスタ群
    this.pc = 0x0000;
    this.a = 0;
    this.f = 0;
    this.h = 0;
    this.l = 0;
  }

  // 1バイト読み出してPCを進める(Fetch専用関数)
  fetch8() {
    const data = this.bus.readByte(this.pc);
    this.pc = (this.pc + 1) & 0xffff; // 16bit境界をラップアラウンド
    return data;
  }

  // 16バイト(2バイト)読み出してPCを2進める
  fetch16() {
    const low = this.fetch8();
    const high = this.fetch8();
    return (high << 8) | low;
  }

  // 1命令を実行し、消費した T-State (クロック数) を返す
  step() {
    // --- 1. FETCH ---
    const opcode = this.fetch8();

    // --- 2. DECODE & 3. EXECUTE ---
    switch (opcode) {
      case 0x00: // NOP
        return 4; // 消費 T-State

      case 0x3c: // INC A
        this.a = (this.a + 1) & 0xff;
        // (本来はここでフラグ更新処理)
        return 4;

      case 0x3e: { // LD A, n
        const n = this.fetch8(); // オペランドのフェッチ
        this.a = n;
        return 7;
      }

      case 0xc3: { // JP addr
        const addr = this.fetch16(); // 16ビットアドレスのフェッチ
        this.pc = addr; // PCをジャンプ先に書き換え
        return 10;
      }

      case 0x77: { // LD (HL), A
        const hl = (this.h << 8) | this.l;
        this.bus.writeByte(hl, this.a);
        return 7;
      }

      default:
        console.error(`未実装のOpcodeです: 0x${opcode.toString(16)} at PC: 0x${(this.pc - 1).toString(16)}`);
        return 4;
    }
  }
}

システム側でのメインループ制御

1フレーム(1/60秒)あたりに必要なクロック分だけ step() を回す制御ループのイメージです。

const CPU_CLOCK_PER_FRAME = 59659; // 3.58MHz / 60fps

function mainLoop() {
  let frameCycles = 0;

  // 1フレーム分(約59,659 T-State)に達するまでCPUを回す
  while (frameCycles < CPU_CLOCK_PER_FRAME) {
    const cycles = cpu.step(); // 1命令実行し、消費クロックを取得
    frameCycles += cycles;
  }

  // 画面描画やAudioWorkletへの音声データ送信などを実行
  renderScreen();

  // 次のフレームへ (60fps)
  requestAnimationFrame(mainLoop);
}

このように、1命令ごとに消費クロック数を集計して while ループを回すことで、JavaScriptという高位なイベント駆動環境の上でも、3.58MHzという物理ハードウェアのクロック速度を極めて正確に模倣することができます。


まとめ

  • 命令サイクル は 「Fetch(取得) → Decode(解釈) → Execute(実行)」 の3ステップで構成される。
  • プログラムカウンタ(PC) は命令の長さに応じて自動で進み、ジャンプ命令等で書き換えられることでプログラムの分岐を実現する。
  • 各命令には処理にかかる T-State(クロックサイクル数) が決まっており、エミュレーターはこのクロック数を集計することで実機速度(60fps)との同期をとる。
  • step() メソッドの中で 「フェッチして switch 文で分岐して実行する」 のがCPUエミュレーターの最も核心的な構造である。

次回は、プレフィックス拡張命令や大量のOpcode分岐をスマートに扱うテクニック、「第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く」をお届けします。


シリーズ目次

  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ(本記事)
  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く