[JavaScript] Z80エミュレーターをゼロから実装。レジスタ・フラグ・プレフィックスの完全制覇への道

[JavaScript] Z80エミュレーターをゼロから実装。レジスタ・フラグ・プレフィックスの完全制覇への道

はじめに

Z80エミュレーター開発記の連載4本(歴史・レジスタ・命令セット・バス設計)を書き上げた勢いで、そのまま実装に突入した。結果、JavaScriptでゼロからZ80コアを書き、zexdoc(Z80命令テストスイート)の全67テスト項目をパスした。

この記事では、実装の具体的なコードと、zexdoc通過までに潰したバグの詳細を記録する。

前提となる連載記事

1. 全体構成

完成したz80.jsは約1,128行。構成は以下の通り。

セクション行数(概算)内容
フラグ定義+SZPテーブル~30行フラグビット定数、256エントリのルックアップテーブル
Z80クラス・レジスタ定義~100行コンストラクタ、get/setプロパティ、EXX/EX AF
汎用ユーティリティ~50行fetchByte/Word, push/pop, getReg8/16, checkCondition
8ビットALU~120行add8〜cp8, inc8/dec8
16ビットALU~60行addHL, adc16, sbc16
CBプレフィックス~100行executeCB — シフト/ローテート/BIT/SET/RES
EDプレフィックス~150行executeED — ブロック転送、I/O、NEG、RRD/RLD
DD/FDプレフィックス~200行executeDDFD — IX/IY読み替え、IXH/IXL、DDCB
メインデコーダー~330行executeOpcode — 無プレフィックス命令の全デコード

2. レジスタ設計:個別変数方式

連載第2回ではArrayBufferの共有ビュー方式を検討したが、最終的な実装では個別変数方式を採用した。

this.a = 0; this.f = 0;
this.b = 0; this.c = 0;
this.d = 0; this.e = 0;
this.h = 0; this.l = 0;

this.a_ = 0; this.f_ = 0;  // 裏レジスタ
// ...

16ビットペアはget/setプロパティで合成する。

get af() { return (this.a << 8) | this.f; }
set af(val) { this.a = (val >> 8) & 0xff; this.f = val & 0xff; }
get bc() { return (this.b << 8) | this.c; }
set bc(val) { this.b = (val >> 8) & 0xff; this.c = val & 0xff; }

この方式を選んだ理由は、V8のJITが個別変数への直接アクセスを最も効率的に最適化するため。EXXのスワップコストは全命令実行回数のうちごくわずかなので、レジスタアクセスの頻度との比率で個別変数が有利になる。

3. SZPルックアップテーブル:フラグ計算の心臓部

Z80のフラグ計算で最も頻繁に使われるのが、Sign(ビット7)、Zero(結果=0)、Parity(1ビットの数が偶数)の3フラグ+非公式フラグ(ビット3/5)だ。これらは結果の値だけで決まるため、256エントリのルックアップテーブルに事前計算しておく。

const SZP_TABLE = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
  let f = 0;
  if (i === 0) f |= FLAG_Z;            // Zero
  if (i & 0x80) f |= FLAG_S;           // Sign

  let ones = 0;
  for (let b = 0; b < 8; b++) {
    if (i & (1 << b)) ones++;
  }
  if (ones % 2 === 0) f |= FLAG_PV;    // Parity (偶数パリティ)

  f |= (i & (FLAG_X | FLAG_Y));        // 非公式フラグ (bit3, bit5)
  SZP_TABLE[i] = f;
}

重要な注意点:SZP_TABLEにはParity(偶数パリティ)としてFLAG_PVが入っているが、算術演算(add8/sub8等)ではPVフラグはParityではなくOverflow(符号付きオーバーフロー)として使われる。これがzexdocで最初に引っかかったバグだった。

算術演算でSZP_TABLEを使う場合は、まずPVビットをクリアしてからOverflow判定を個別に行う必要がある。

add8(val) {
  const res = this.a + val;
  const ans = res & 0xff;
  let f = SZP_TABLE[ans] & ~FLAG_PV;  // ★ PVをクリア
  if (res > 0xff) f |= FLAG_C;
  if ((this.a ^ val ^ ans) & 0x10) f |= FLAG_H;
  // Overflow判定(両オペランドが同符号で結果が異符号)
  if (((this.a ^ ans) & (val ^ ans) & 0x80) !== 0) f |= FLAG_PV;
  this.f = f;
  this.a = ans;
}

SZP_TABLE[ans] & ~FLAG_PVでテーブルからParityビットを取り除き、その後に算術オーバーフロー判定を個別に行う——このパターンはadd8/adc8/sub8/sbc8/inc8/dec8すべてで必要。

4. オペコードデコーダー:x/y/z/p/qパターン分解

連載第3回で解説したx/y/z/p/qパターン分解をそのまま実装に落とし込んだ。

executeOpcode(opcode) {
  if (opcode === 0xcb) { this.executeCB(); return; }
  if (opcode === 0xed) { this.executeED(); return; }
  if (opcode === 0xdd) { this.executeDDFD(false); return; }
  if (opcode === 0xfd) { this.executeDDFD(true); return; }

  const x = (opcode >> 6) & 0x03;
  const y = (opcode >> 3) & 0x07;
  const z = opcode & 0x07;
  const p = y >> 1;
  const q = y & 1;

  switch (x) {
    case 0: /* 制御・LD imm・INC/DEC・JR */ break;
    case 1: /* LD r,r or HALT */ break;
    case 2: /* ALU A,r */ break;
    case 3: /* RET/JP/CALL/RST/misc */ break;
  }
}

x=1(LD r,r)ブロックとx=2(ALU A,r)ブロックは、パターン分解だけで全命令をカバーできる。

// x=1: LD r,r(y=6,z=6 は HALT)
case 1:
  if (y === 6 && z === 6) {
    this.halted = true;
  } else {
    this.setReg8(y, this.getReg8(z));
  }
  break;

// x=2: ALU A,r
case 2:
  const val = this.getReg8(z);
  switch (y) {
    case 0: this.add8(val); break;
    case 1: this.adc8(val); break;
    // ...
  }
  break;

5. DD/FDプレフィックスの罠:IXH/IXLと(IX+d)の競合

zexdocで2番目に引っかかったバグがここだった。

DD/FDプレフィックスのルールは「HLの参照をIX/IYに読み替える」だが、(HL)が(IX+d)に読み替わった場合、同じ命令内のH/Lは通常のH/Lレジスタとして動作し、IXH/IXLにはならない。

つまりDD 46 05はLD B, (IX+5)であり、LD B, (IX+5)の中のBは通常のBレジスタだ。しかしDD 44はLD B, IXHになる——H/Lが(HL)を伴わない文脈ではIXH/IXLに読み替わる。

実装上の罠は、(IX+d)を含む命令では通常のH/Lレジスタを使い、(IX+d)を含まない命令ではIXH/IXLに読み替えるという分岐が必要なこと。

// LD (IX+d), r — ★ rは通常のgetReg8(H,Lも通常のH,L)
if (y === 6) {
  this.writeByte(addr, this.getReg8(z));  // 通常のH/L
}

// LD r, r' (IXH/IXL置換) — ★ (HL)を含まない場合だけ読み替え
if (x === 1 && opcode !== 0x76) {
  const src = (z === 4 || z === 5) ? getIXY8(z) : this.getReg8(z);
  if (y === 4 || y === 5) setIXY8(y, src);
  else this.setReg8(y, src);
}

この「(HL)が出現したらH/Lの読み替えを抑制する」ルールは、連載第3回で理論的に説明した非対称性の実装上の表現だ。

6. DAA命令:最も複雑なフラグ計算

DAA(Decimal Adjust Accumulator)は、Z80の命令の中でフラグ計算が最も複雑な命令だ。直前の加算/減算の結果をBCD(Binary Coded Decimal)に補正する。

case 4: // DAA
  let a = this.a;
  let incr = 0;
  let carry = !!(this.f & FLAG_C);
  let halfCarry = !!(this.f & FLAG_H);
  let n = !!(this.f & FLAG_N);

  if (halfCarry || (a & 0x0f) > 9) {
    incr |= 0x06;
  }
  if (carry || a > 0x99 || ((a & 0x0f) > 9 && a > 0x95)) {
    incr |= 0x60;
    carry = true;
  }

  if (n) {
    this.a = (a - incr) & 0xff;     // 減算後の補正
  } else {
    this.a = (a + incr) & 0xff;     // 加算後の補正
  }

  let f = SZP_TABLE[this.a] | (n ? FLAG_N : 0);
  if (carry) f |= FLAG_C;

  // Half Carryの判定が加算/減算で異なる
  if (n) {
    if (halfCarry && (a & 0x0f) < 6) f |= FLAG_H;
  } else {
    if ((a & 0x0f) > 9) f |= FLAG_H;
  }
  this.f = f;
  break;

DAAのHalf Carryフラグの判定は、Nフラグ(直前が減算だったか)によって条件が変わる。これはZilogの公式マニュアルでも不完全にしか記述されておらず、Sean Youngの “The Undocumented Z80 Documented” の補足情報が必要だった。

7. BIT命令のフラグ:WZレジスタが顔を出す

CB空間のBIT命令では、非公式フラグ(X/Y、ビット3/5)の出所が通常の命令と異なる。

通常:結果のビット3/5 BIT n, r:オペランドレジスタのビット3/5 BIT n, (HL):WZレジスタの上位バイトのビット3/5 BIT n, (IX+d):アドレスの上位バイトのビット3/5

case 1: // BIT b, r
  let f = (this.f & FLAG_C) | FLAG_H;
  if ((val & mask) === 0) f |= FLAG_Z | FLAG_PV;
  if (bit === 7 && (val & 0x80)) f |= FLAG_S;

  if (addr !== null) {
    // (HL)または(IX+d) — アドレスの上位バイトからX/Y
    f |= ((addr >> 8) & (FLAG_X | FLAG_Y));
  } else {
    // レジスタ — オペランドからX/Y
    f |= (val & (FLAG_X | FLAG_Y));
  }
  this.f = f;
  return;

この区別を正しく実装しないと、zexdocのbit n,(<ix,iy>+1)テストが失敗する。

8. DDCBの「おまけ」:結果のレジスタコピー

DDCB(二重プレフィックス)命令では、z≠6のオペコードが非公式命令として動作し、(IX+d)への書き戻しに加えてr[z]にも結果をコピーする。

if (addr !== null) {
  this.writeByte(addr, res);
  if (prefixAddress !== null && z !== 6) {
    this.setReg8(z, res);  // ★ 非公式:結果をr[z]にもコピー
  }
}

この1行(if (prefixAddress !== null && z !== 6))が、連載第3回で理論的に説明した「回路設計の副産物」の実装上の全てだ。

9. CP/M BDOSトラップ:zexdocを動かす最小環境

zexdocはCP/Mの.COMファイルとして配布されている。実行に必要なCP/Mエミュレーションは最小限で済む。

// test_runner.js
const memory = new Uint8Array(65536);
const binary = fs.readFileSync('./zexdoc.com');
memory.set(binary, 0x0100);  // CP/Mの.COMはアドレス0x0100にロード

const cpu = new Z80({
  readByte: (addr) => memory[addr],
  writeByte: (addr, val) => { memory[addr] = val & 0xff; },
  inPort: () => 0xff,
  outPort: () => {}
});

cpu.pc = 0x0100;
cpu.sp = 0xf000;

while (true) {
  if (cpu.pc === 0x0005) {  // BDOS呼び出し
    if (cpu.c === 2) {
      process.stdout.write(String.fromCharCode(cpu.e));
    } else if (cpu.c === 9) {
      let ptr = cpu.de;
      while (memory[ptr] !== 0x24) {
        process.stdout.write(String.fromCharCode(memory[ptr]));
        ptr = (ptr + 1) & 0xffff;
      }
    }
    cpu.pc = cpu.pop();  // RETをシミュレート
  }
  if (cpu.pc === 0x0000) break;  // CP/M終了
  cpu.step();
}

必要なBDOS関数はたった2つ——関数2(1文字出力)と関数9(文字列出力)だけ。これだけでzexdocの全テスト項目の結果を画面に出力できる。

10. zexdocで潰したバグのまとめ

zexdoc通過までに修正した主なバグを整理する。

バグ症状原因修正
PVフラグの混同算術演算のテストが軒並みERRORSZP_TABLEのParityをOverflowとして使っていたSZP_TABLE[ans] & ~FLAG_PVでParityを除去してからOverflow判定
sub8/sbc8のキャリー判定減算テストでERRORres > 0xffで判定していた(加算のまま)res < 0に修正(減算はボローが発生する)
DD/FDのH/L読み替え非対称性ld <h,l>,(<ix,iy>+1)がERROR(IX+d)を含む命令でもH/LをIXH/IXLに読み替えていた(IX+d)が出現した場合はH/Lの読み替えを抑制
DAAのHalf Carry<daa,cpl,scf,ccf>がERRORN=1(減算後)のHalf Carry条件が不正確加算後と減算後で別々の条件式を適用
CCFのHalf Carry同上FLAG_Hに元のCarryの値をセットし忘れconst oldC = (this.f & FLAG_C) ? FLAG_H : 0
INC/DECのOverflow判定<inc,dec>テストがERROROverflow条件が間違っていたINC: val === 0x7Fでセット、DEC: val === 0x80でセット

11. テスト結果

--- ZEXDOC Test Start ---
Z80 instruction exerciser
<adc,sbc> hl,<bc,de,hl,sp>....  OK
add hl,<bc,de,hl,sp>..........  OK
add ix,<bc,de,ix,sp>..........  OK
(中略)
ld (<ix,iy>+1),a..............  OK
ld (<bc,de>),a................  OK
Tests complete
--- ZEXDOC Test Complete ---

全67テスト項目、全てOK。

[JavaScript] Z80エミュレーターをゼロから実装。レジスタ・フラグ・プレフィックスの完全制覇への道

12. まとめ

Z80エミュレーターをゼロからJavaScriptで実装し、zexdocの全テストをパスした。

実装を通じて得た知見:

  • SZP_TABLEのPVフラグは論理演算用(パリティ)であり、算術演算では必ずクリアしてからOverflow判定を個別に行う。 これは「テーブルの値をそのまま使えば正しい」という思い込みが最大の罠
  • DD/FDプレフィックスのH/L読み替えは非対称。 (IX+d)が出現する命令では通常のH/Lが使われ、IXH/IXLにはならない。連載の理論的説明が実装で真価を発揮した
  • DAAは公式マニュアルだけでは正しく実装できない。 Half Carryの加算後/減算後の条件差はSean Youngの文書が必要
  • CP/M BDOSトラップはBDOS関数2つ(文字出力+文字列出力)だけで済む。 zexdocの実行環境としてはこれで完全に十分

次のステップは、特定のシステム(MSX、ZX Spectrum等)のメモリマップとI/Oを実装して、実際のソフトウェアを動かすこと。CPUコアはコールバック設計で完全に分離されているため、バス接続部分を差し替えるだけで異なるシステムに載せ替えられる。