Z80エミュレーター開発記 第4回:ハードウェアバスとエミュレーションの勘所

Z80エミュレーター開発記 第4回:ハードウェアバスとエミュレーションの勘所

はじめに

第1回でZ80の誕生の歴史を、第2回でレジスタ構造を、第3回で命令セットのエンコーディングを掘り下げてきた。

最終回となる第4回では、これらの命令が実際のハードウェアバス上でどう動くのか——つまりZ80が外の世界とどうやりとりするのかを解剖し、それをソフトウェアでエミュレートする際の設計方針を議論する。

前回の記事

1. マシンサイクルとT-state

Z80のあらゆるバス操作は「マシンサイクル(M-cycle)」という単位で行われる。1つのマシンサイクルは複数の「T-state」(クロックサイクル)から構成される。

Z80には5種類のマシンサイクルがある。

マシンサイクル略称T-states動作
オペコードフェッチM14PC番地からオペコードを読み、デコードし、DRAMリフレッシュを行う
メモリリードMR3指定アドレスからメモリを読む
メモリライトMW3指定アドレスにメモリを書く
I/OリードIOR4指定ポートからI/Oを読む(自動ウェイト1挿入)
I/OライトIOW4指定ポートにI/Oを書く(自動ウェイト1挿入)

1つの命令は1つ以上のマシンサイクルで構成される。例えばNOPはM1の4 T-statesだけで完了するが、LD A, (nn)はM1(4) + MR(3) + MR(3) + MR(3) = 13 T-statesかかる(M1でオペコードを読み、2回のMRで16ビットアドレスnnの下位・上位を読み、最後のMRでnn番地のデータを読む)。

2. M1サイクルの解剖

M1サイクルは全命令の最初に必ず発生するマシンサイクルであり、Z80の動作を理解する上で最も重要な4クロックだ。

M1サイクル (4 T-states):

 T1          T2          T3          T4
 ┌──┐    ┌──┐    ┌──┐    ┌──┐
 │  │    │  │    │  │    │  │
─┘  └────┘  └────┘  └────┘  └──── CLK

 ├─────────────┤ ├─────────────┤
   オペコードフェッチ   デコード+リフレッシュ

T1: PCをアドレスバスに出力、M1信号をアクティブに
T2: MREQ, RDをアクティブに(メモリにリード要求)
    ★ T2の立ち下がりでWAIT信号をサンプル
T3: データバスからオペコードを読み取り
    MREQ, RDを非アクティブに
    → 同時にIRレジスタ(I:R)をアドレスバスに出力
    → RFSH信号をアクティブに
T4: リフレッシュサイクル完了
    → Rレジスタの下位7ビットをインクリメント
    → オペコードのデコードと実行を開始

ここに3つの重要な設計が詰まっている。

  • オペコードフェッチとDRAMリフレッシュの並列化:
    T1-T2でオペコードを読み、T3-T4ではCPUがオペコードをデコードしている「隙間」を使ってDRAMリフレッシュを行う。CPUの内部動作と外部バスの動作を巧みに時分割多重化している。第1回で述べた「DRAMリフレッシュの自動化」は、このM1サイクルの設計によって実現されている。

  • WAIT信号のサンプリング:
    遅いメモリやI/Oデバイスが「まだ準備できてない」ことをCPUに伝えるWAIT信号は、T2の立ち下がりエッジ(クロックの下降エッジ)でサンプルされる。これは立ち上がりエッジ(上昇エッジ)で動作する他の信号と半クロック位相がずれている。エミュレーターにとっては厄介な問題で、後述する。

  • M1信号の外部利用:
    M1信号がアクティブであることは「今オペコードフェッチ中である」ことを外部に知らせる。MSXではこのM1信号を利用してDRAMアクセス速度を調整し、安価なメモリチップでもシステムが動作するようにしていた(M1サイクルに追加のウェイトを自動挿入する)。

3. メモリリード/ライトサイクル

M1以外のメモリアクセスは3 T-statesで完了する。M1より1 T-state短い理由は、リフレッシュサイクルが不要だからだ。

メモリリードサイクル (3 T-states):

T1: アドレスをアドレスバスに出力
T2: MREQ, RDをアクティブに
    ★ WAITサンプリング
T3: データバスからデータを読み取り
    MREQ, RDを非アクティブに

メモリリードのアクセスタイム(メモリが有効なデータを出力するまでの時間)は、M1サイクルでは1.5クロック分しかないのに対し、通常のメモリリードでは2.5クロック分が利用できる。

これがM1サイクルでの「メモリ速度の制約が厳しい」理由であり、MSXがM1サイクルにウェイトを追加した動機でもある。

4. I/Oサイクルと自動ウェイト

I/Oリード/ライトサイクルは4 T-statesで、その中に1 T-stateの自動ウェイトが含まれている。

I/Oリードサイクル (4 T-states):

T1: アドレスをアドレスバスに出力
    ※ I/OではA0-A7がポートアドレス、A8-A15は通常レジスタAの値
T2: IORQをアクティブに, RDをアクティブに
TW: 自動挿入されるウェイトステート
    ★ WAITサンプリング
T3: データバスからデータを読み取り
    IORQ, RDを非アクティブに

Zilogが自動ウェイトを挿入した理由は、IORQがアクティブになってからI/Oデバイスがデコードを完了してWAIT信号を制御するまでの時間が極めて短く、自動ウェイトなしではMOSデバイス(当時の一般的なI/O IC)がフルスピードのCPUに追いつけなかったためだ。

エミュレーターの観点では、I/Oサイクルが自動的に1 T-state長いことを意識してT-stateカウントに反映する必要がある。

5. 割り込みのタイミング

Z80の割り込みは、現在実行中の命令の最後のマシンサイクルの最終T-stateの立ち上がりエッジでサンプルされる。つまり命令の途中では割り込みは認識されない。

命令 LD A, (HL) — 2マシンサイクル、7 T-states:

  M1 (4T)          MR (3T)
┌──┬──┬──┬──┐ ┌──┬──┬──┐
│T1│T2│T3│T4│ │T1│T2│T3│ ← ★ ここのT3立ち上がりでINTサンプル
└──┴──┴──┴──┘ └──┴──┴──┘

NMI(Non-Maskable Interrupt)は同じタイミングでサンプルされるが、IFF1フリップフロップの状態に関係なく常に認識される。NMIが認識されると、IFF1をリセット(割り込み禁止)した上で、PCをスタックにPUSHし、0x0066番地にジャンプする。

割り込みモード1では、INTが認識されるとPCをPUSHし、0x0038番地にジャンプする。

割り込みモード2では、INTが認識されると、データバスから1バイト読み(デバイスが供給するベクタ)、Iレジスタの値を上位バイト、読んだバイトを下位バイトとする16ビットアドレスからワード(2バイト)を読み、そこに格納されたアドレスにジャンプする。

EI命令の直後は、1命令分だけ割り込みが遅延する。これはEI; RETIのようなシーケンスで、EIの直後にRETIを実行する前に割り込みが入ってしまうのを防ぐための仕様だ。エミュレーターではEI実行後に「次の1命令は割り込みチェックをスキップする」フラグを設ける必要がある。

6. エミュレーションの精度レベル

Z80エミュレーターの設計で最初に決めるべきは、どのレベルの精度を目指すかだ。

6.1 命令単位(Instruction-ticked)

命令を1つ読み、デコードし、実行し、消費したT-statesの総数を返す。命令の途中でバスの状態を観測することはできないが、ほとんどの用途にはこれで十分。

// 命令単位エミュレーション
function step() {
  const opcode = readMem(regs.pc++);
  regs.incR();
  // ... デコード&実行 ...
  return tStates; // この命令が消費したT-states
}

// メインループ
function runFrame(cyclesPerFrame) {
  let remaining = cyclesPerFrame;
  while (remaining > 0) {
    remaining -= cpu.step();
  }
}

floooh氏(YAKC/chips)のベンチマークによれば、命令単位のZ80エミュレーターは現代のCPU上で1.2GHz相当のZ80速度(元の約480倍速)を達成できる。1エミュレーション・クロックサイクルあたり2〜3ホストクロックサイクルという効率は、命令をアトミック(不可分)に実行できるからこそ可能な数字であり、サイクル単位では実現不可能な領域だ。

6.2 マシンサイクル単位(M-cycle ticked)

マシンサイクルごとにコールバックを呼び出す。メモリリード/ライト、I/Oリード/ライトのタイミングがマシンサイクル精度で再現される。ほとんどの実機システムのエミュレーション(ZX Spectrum、MSX、Master System等)にはこの精度で十分。

6.3 クロックサイクル単位(Cycle-stepped)

T-stateごとにステップ実行する。WAIT信号やバスリクエスト(BUSREQ)のタイミングを正確に再現できるが、性能コストが大きい。Amstrad CPCのようにゲートアレイとCPUがクロックレベルで同期するシステムのエミュレーションに必要。

WAIT信号の半クロック位相ズレ問題(T2の立ち下がりでサンプルされる)を正しく処理するには、このレベルの精度が必要になる。命令単位やマシンサイクル単位では、WAITのサンプリングを0.5 T-state早くまたは遅くせざるを得ず、位相誤差が生じる。

6.4 実装する精度の選択

今回のエミュレーター開発では、まず命令単位で実装し、zexdoc/zexallテストをパスすることを最初の目標とする。命令単位でも命令の正しさ(レジスタ・フラグの結果)は完全に検証できる。タイミング精度が必要になった段階(特定のシステムのエミュレーションに進む段階)で、マシンサイクル単位にリファクタリングすればよい。

7. コールバック設計:CPUコアとシステムの分離

エミュレーターの設計でもう1つ重要なのは、CPUコアとシステム(メモリマップ、I/Oデバイス、割り込みソース)をどう分離するかだ。

Z80はメモリマップドI/Oではなく、専用のIN/OUT命令によるポートI/Oを持つ(メモリアドレス空間64KBとI/Oアドレス空間256ポートが独立している)。これにより、CPUコアは「メモリの読み書き」と「I/Oの読み書き」を別々のインターフェースとして公開できる。

class Z80 {
  constructor(callbacks) {
    // コールバック: CPUコアはメモリやI/Oの実装を知らない
    this.readMem = callbacks.readMem;     // (addr) => byte
    this.writeMem = callbacks.writeMem;   // (addr, byte) => void
    this.readIO = callbacks.readIO;       // (port) => byte
    this.writeIO = callbacks.writeIO;     // (port, byte) => void
    this.regs = new Z80Registers();
    this.tStates = 0;
  }

  step() {
    const pc = this.regs.pc;
    const opcode = this.readByte(pc);
    this.regs.pc = (pc + 1) & 0xFFFF;
    this.regs.incR();
    this.tStates = 4; // M1サイクルの基本コスト

    this.executeUnprefixed(opcode);
    return this.tStates;
  }

  readByte(addr) {
    return this.readMem(addr & 0xFFFF);
  }

  writeByte(addr, val) {
    this.writeMem(addr & 0xFFFF, val & 0xFF);
    this.tStates += 3; // メモリライトのコスト
  }
  // ...
}

// システム側:例えばシンプルな64KB RAMシステム
const ram = new Uint8Array(65536);
const cpu = new Z80({
  readMem: (addr) => ram[addr],
  writeMem: (addr, val) => { ram[addr] = val; },
  readIO: (port) => 0xFF,           // 未接続ポートは0xFF
  writeIO: (port, val) => {},       // 何もしない
});

// ROMイメージをロード
ram.set(romData, 0x0000);

// 実行
cpu.regs.reset();
while (!cpu.regs.halted) {
  cpu.step();
}

この設計により、同じCPUコアを「ベアメタルのテスト環境」「ZX Spectrumのエミュレーション」「MSXのエミュレーション」など異なるシステムで再利用できる。コールバックの中身を差し替えるだけで、メモリバンキング、ROMライトプロテクト、I/Oポートデコード、割り込みコントローラとの連携がすべてシステム側の責務になる。

8. 検証戦略:zexdoc と zexall

Z80エミュレーターの正しさを検証する事実上の標準テストスイートが、Frank D. Cringle作のzexdoc/zexall(1994年)だ。

※ Frank Cringle氏が過去に発表したオリジナルのzexdoc / zexallおよびyazeのソースコードを、Alexander Demin氏がGitHub上にアーカイブ・ミラーとして保存したもの。

zexdocとzexallはCP/Mの.COM形式で配布されており、実行にはCP/Mのシステムコール(BDOS呼び出し)の最小限のエミュレーションが必要になる。具体的に必要なのは以下の2つだけだ。

// CP/Mの最小BDOSエミュレーション
// CALL 5 (BDOS呼び出し) をトラップする
function handleBDOS(cpu) {
  const func = cpu.regs.c;
  if (func === 2) {
    // C_WRITE: Eレジスタの文字を出力
    process.stdout.write(String.fromCharCode(cpu.regs.e));
  } else if (func === 9) {
    // C_WRITESTR: DEが指すアドレスから'$'まで文字列を出力
    let addr = cpu.regs.de;
    while (true) {
      const ch = ram[addr++];
      if (ch === 0x24) break; // '$'
      process.stdout.write(String.fromCharCode(ch));
    }
  }
}

// アドレス0x0005にRET命令を置き、CALL 5でトラップ
ram[0x0005] = 0xC9; // RET
// → step()の中でPC===0x0005を検出したらhandleBDOSを呼ぶ

zexdocは公式6フラグ(S, Z, H, P/V, N, C)のみを検証し、zexallは非公式フラグ(Y, X)も含めた全8ビットを検証する。実機のZ80上では完了に数時間かかるが、現代のCPU上のエミュレーターでは1分以内に完了する。

テスト結果は命令グループごとにCRC32チェックサムとして表示され、不一致があれば「ERROR」と出力される。全テスト通過の出力は以下のようになる(zexdocの場合)。

Z80doc instruction exerciser
ld hl,(nnnn)................OK
ld sp,(nnnn)................OK
ld (nnnn),hl................OK
...
Tests complete

zexdocを全パスすれば、ほとんどの実用ソフトウェア(ゲーム、アプリケーション)が正しく動作するZ80コアが完成したことになる。zexallの全パスは、非公式フラグとWZレジスタの正確なエミュレーションが要求される、より高い精度の指標だ。

9. 性能の現実:テーブルデコーダー vs パターンデコーダー

第3回でx/y/z/p/qのパターン分解を紹介したが、実際のエミュレーター開発では、このアルゴリズム的デコーダーと巨大なswitch/caseテーブルデコーダーのどちらが速いかが長年議論されてきた。

floooh氏の経験則が示唆的だ。アルゴリズム的デコーダーは「少ないコード量で命令キャッシュに優しいはず」という期待で始まるが、Z80の特殊ケース(同じx/y/zパターンでも命令によって全く違う動作をするもの)を処理するための分岐と間接参照が増え、結局switch/caseの方が2倍速くなるという。

理由は明確で、現代CPUの分岐予測器は巨大なswitch/caseのジャンプテーブルを非常に効率的に処理する一方、アルゴリズム的デコーダーの多段分岐は予測が困難になるからだ。

とはいえ、6502のように命令セットがZ80より遥かに規則的なCPUではアルゴリズム的デコーダーの方が有利になることもあり、これはCPUアーキテクチャとの相性の問題だ。

Z80エミュレーターの最初の実装としてはパターン分解(アルゴリズム的デコーダー)の方が見通しが良く、記事としても説明しやすい。性能が問題になった段階でテーブルデコーダーに移行するのが現実的だ。

10. まとめ:Z80コアの全体像

4回にわたってZ80のアーキテクチャを掘り下げてきた。最後に、エミュレーターの全体設計を1枚の図にまとめる。

┌─────────────────────────────────────────┐
│              Z80 CPU Core               │
│                                         │
│  ┌──────────────┐  ┌──────────────────┐ │
│  │  Registers   │  │   Decoder        │ │
│  │  AF/BC/DE/HL │  │   Unprefixed     │ │
│  │  AF'/BC'/DE' │  │   CB table       │ │
│  │  /HL'        │  │   ED table       │ │
│  │  IX/IY/SP/PC │  │   DD/FD filter   │ │
│  │  I/R/WZ      │  │   DDCB handler   │ │
│  │  IFF1/IFF2   │  └──────────────────┘ │
│  │  IM          │                       │
│  └──────────────┘  ┌──────────────────┐ │
│                    │   ALU            │ │
│                    │   add8/sub8/and8 │ │
│                    │   add16/sbc16    │ │
│                    │   Flag calc      │ │
│                    └──────────────────┘ │
│                                         │
│  Callbacks:                             │
│    readMem(addr) → byte                 │
│    writeMem(addr, byte)                 │
│    readIO(port) → byte                  │
│    writeIO(port, byte)                  │https://localhost:4321/categories/%F0%9F%92%BE-retro_log
└─────────────────────────────────────────┘
              │            │
    ┌─────────┘            └─────────┐
    ▼                                ▼
┌──────────┐                  ┌──────────┐
│  Memory  │                  │  I/O     │
│  (RAM/   │                  │  Devices │
│   ROM)   │                  │          │
└──────────┘                  └──────────┘
Z80エミュレーター開発記 第4回:ハードウェアバスとエミュレーションの勘所(Z80コアの全体像)

この構造は、第2回のZ80Registersクラス、第3回のデコーダー設計、そして本回のコールバック設計を統合したものだ。 CPUコア自体はメモリやI/Oの実装を一切知らず、コールバックを通じてのみ外界とやりとりする。

この分離により、同じコアを異なるシステム(ZX Spectrum、MSX、ベアメタルテスト環境)で再利用できる。

11. 次のステップ

この連載はここまでが「設計と理解」のフェーズだ。次のステップは実装——実際にJavaScriptでZ80コアを書き、zexdocテストをパスさせることになる。

実装の順序は以下を想定している。

  1. Z80Registersクラス(第2回で設計済み)
  2. メモリとI/Oのコールバックインターフェース
  3. 無プレフィックス命令のデコーダー(x/y/z分解)
  4. ALU(add8, sub8, and8, … とフラグ計算)
  5. CB/ED/DD/FDプレフィックスの追加
  6. 割り込み処理(IM 0/1/2, NMI, EI遅延)
  7. zexdocのCP/M BDOSトラップと実行
  8. テスト通過の確認と修正

ここまでが「正しく動くZ80コア」の完成ライン。その先には、特定のシステム(ZX Spectrum、MSXなど)のメモリマップとI/Oを実装してゲームを動かすというフェーズが待っている。

歴史から始まって、レジスタ、命令セット、バスタイミングと進んできたこの連載が、Z80というCPUの設計思想を立体的に理解する手助けになっていれば幸いだ。そしてこの理解は、実際にエミュレーターを書く段階で「なぜこう実装するのか」の根拠として機能するはずだ。

連載構成

回テーマ内容
第1回インテルからの独立とZ80誕生の歴史ファジンのIntel離脱、Exxonの出資、8080の設計上の負債、Z80の技術革新、CP/Mとの共生
第2回レジスタ構造と「裏レジスタ」の美学AF/BC/DE/HL、IX/IY、裏レジスタ、フラグレジスタの全8ビット、WZレジスタ、JSでの設計判断
第3回命令セットとプレフィックスデコードのカラクリx/y/z/p/qパターン分解、CB/ED/DD/FDプレフィックス、DDCB二重プレフィックス、非公式命令
第4回(本記事)ハードウェアバスとエミュレーションの勘所M1サイクル、I/O自動ウェイト、割り込みタイミング、精度レベル選択、コールバック設計、zexdoc検証