[JavaScript] Z80エミュレーターをゼロから実装。レジスタ・フラグ・プレフィックスの完全制覇への道
はじめに
Z80エミュレーター開発記の連載4本(歴史・レジスタ・命令セット・バス設計)を書き上げた勢いで、そのまま実装に突入した。結果、JavaScriptでゼロからZ80コアを書き、zexdoc(Z80命令テストスイート)の全67テスト項目をパスした。
この記事では、実装の具体的なコードと、zexdoc通過までに潰したバグの詳細を記録する。
前提となる連載記事
Z80エミュレーター開発記 第1回:インテルからの独立とZ80誕生の歴史 // PROTOCOL.LAIN
Z80エミュレーター開発に向けた連載第1回。Intel 8080の開発者フェデリコ・ファジンがZilogを創業し、嶋正利とともにZ80を設計するまでの経緯を、Intelの組織的背景、Exxon Enterprisesによる出資の裏事情、8080の電源設計上の制約(+5V/-5V/+12Vの3系統電源と2相クロック)に対するZ80の回答(単一5V電源・単相クロック・内蔵DRAMリフレッシュ)、命令セットの拡張思想、CP/Mとの共生によるソフトウェアエコシステムの形成、そしてパソコン・アーケード・ゲーム機・ポケコンへの採用拡大までを技術的視点から詳述する。
lain-lab.com
Z80エミュレーター開発記 第2回:レジスタ構造と「裏レジスタ」の美学 // PROTOCOL.LAIN
Z80エミュレーター開発記の第2回。8080互換のAF/BC/DE/HLレジスタペア、Z80固有のIX/IYインデックスレジスタ、裏レジスタ(AF'/BC'/DE'/HL')のEX/EXX命令による切り替え機構、フラグレジスタFの全8ビット(S/Z/Y/H/X/P|V/N/Cと非公式フラグF3/F5)の命令ごとの挙動、特殊レジスタI/R/SP/PC/WZ、そしてこれらをJavaScriptのTypedArrayでどう表現するかの設計判断を、Ken Shirrifのダイ写真解析を交えながら詳述する。
lain-lab.com
Z80エミュレーター開発記 第3回:命令セットとプレフィックスデコードのカラクリ // PROTOCOL.LAIN
Z80エミュレーター開発記の最終回。M1サイクルの4 T-state構成(T1-T2でオペコードフェッチ、T3-T4でデコード+DRAMリフレッシュ)、メモリリード/ライトの3 T-state構成、I/Oサイクルの自動ウェイト挿入、WAIT信号の半クロック位相ズレ問題、命令単位エミュレーション vs サイクル単位エミュレーションの精度と性能のトレードオフ、テーブルデコーダーの現実的な性能優位性、コールバック方式によるCPUコアとシステムの分離設計、zexdoc/zexallによる検証戦略、そしてJS/WASMでのZ80コア全体設計を解説する。
lain-lab.com
Z80エミュレーター開発記 第4回:ハードウェアバスとエミュレーションの勘所 // PROTOCOL.LAIN
Z80エミュレーター開発記の最終回。M1サイクルの4 T-state構成(T1-T2でオペコードフェッチ、T3-T4でデコード+DRAMリフレッシュ)、メモリリード/ライトの3 T-state構成、I/Oサイクルの自動ウェイト挿入、WAIT信号の半クロック位相ズレ問題、命令単位エミュレーション vs サイクル単位エミュレーションの精度と性能のトレードオフ、テーブルデコーダーの現実的な性能優位性、コールバック方式によるCPUコアとシステムの分離設計、zexdoc/zexallによる検証戦略、そしてJS/WASMでのZ80コア全体設計を解説する。
lain-lab.com1. 全体構成
完成した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フラグの混同 | 算術演算のテストが軒並みERROR | SZP_TABLEのParityをOverflowとして使っていた | SZP_TABLE[ans] & ~FLAG_PVでParityを除去してからOverflow判定 |
| sub8/sbc8のキャリー判定 | 減算テストでERROR | res > 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>がERROR | N=1(減算後)のHalf Carry条件が不正確 | 加算後と減算後で別々の条件式を適用 |
| CCFのHalf Carry | 同上 | FLAG_Hに元のCarryの値をセットし忘れ | const oldC = (this.f & FLAG_C) ? FLAG_H : 0 |
| INC/DECのOverflow判定 | <inc,dec>テストがERROR | Overflow条件が間違っていた | 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。
GitHub - agn453/ZEXALL: A Z80 Instruction Set Exerciser for CP/M-80
A Z80 Instruction Set Exerciser for CP/M-80. Contribute to agn453/ZEXALL development by creating an account on GitHub.
github.com12. まとめ
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コアはコールバック設計で完全に分離されているため、バス接続部分を差し替えるだけで異なるシステムに載せ替えられる。