[CS講座 #11] マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)
はじめに
前回までで、CPU・画面・音という1台のコンピューターを動かす部品が揃いました。【応用編】の第11回は、そのCPUが「1個ではない」世界です。
1980年のパックマンは、Z80が1個だけの基板で動いていました。ところが翌1981年のギャラガになると、同じナムコの基板にZ80が3個載っています。メインのゲーム処理、敵の動きの補助、サウンドをそれぞれ別のCPUが担当し、共有RAMを通して情報をやり取りしていました。
実機では、3個のZ80は文字どおり同時に動いています。しかしブラウザのJavaScriptは基本的にシングルスレッドです。同時に動くはずのものを、1本のスレッドでどうやって「同時に動いているように」見せるのか。今回はその仕組みを解き明かします。
前回の記事
[CS講座 #10] 音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる // PROTOCOL.LAIN
離散信号とサンプリング、周波数計算式(f = Clock / (32×N) / (16×N))、矩形波・LFSRノイズ・2dB刻み減衰テーブル、Game Gearステレオ、SCC/WSG波形メモリ、エイリアシング対策、CPUサイクル付きレジスタキューによるAudioWorklet再生をJavaScriptコード付きで徹底解説。
lain-lab.comZ80 EMULATOR PROJECT
Z80 EMULATOR PROJECT
フルスクラッチ JavaScript Z80 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com1. なぜ1枚の基板にCPUを何個も載せたのか
当時のZ80は3〜4MHz程度です。1フレーム(1/60秒)に実行できる命令は、ざっと1万数千個しかありません。この中で、ゲームロジック・敵の移動・当たり判定・効果音の制御をすべてこなすのは厳しい。
そこでアーケード基板は、「CPUをもう1個足す」という力技で解決しました。家庭用ゲーム機と違い、1台あたりのコストより性能が優先される世界だったからできたことです。
代表的な構成は2つあります。
[A. メイン + サウンド構成] (非常に多い)
メインCPU ──(サウンドラッチ)──> サウンドCPU ──> 音源チップ
ゲーム処理 効果音・BGMの制御だけを担当
[B. 共有RAM構成] (ギャラガ、ゼビウスなど)
メインCPU ─┐
サブCPU ──┼── 共有RAM ── 画面・入力
サブCPU2 ─┘
どちらも考え方は同じで、マスター(メインCPU)が仕事を指示し、スレーブ(サブCPU)が自分の担当だけを黙々とこなします。サブCPUのRESETやHALTの信号線をメインCPU側が握っていて、必要なときだけ起こす基板も珍しくありません。
2. 並列と並行:同時に動くとはどういうことか
ここで2つの言葉を区別しておきます。
- 並列(Parallel): 本当に同じ瞬間に、複数の処理が動いている。実機の複数Z80はこちら。
- 並行(Concurrent): 1つの処理装置が、複数の処理を細かく切り替えながら進める。外から見ると同時に動いているように見える。
[実機: 並列]
CPU A: ████████████████████████████
CPU B: ████████████████████████████
─────────── 時間 ──────────→
[エミュレーター: 並行(インタリーブ)]
CPU A: ████ ████ ████
CPU B: ████ ████ ████
─────────── 時間 ──────────→
エミュレーターは後者で、実機の並列動作を並行動作で再現します。CPU Aを少し進めたら、CPU Bを同じ時間ぶん進め、また CPU A に戻る。この交互実行をインタリーブ(Interleave)、1回に進める時間の単位をクォンタム(Quantum / タイムスライス)と呼びます。
Web Workerで本当に並列にしないのか
ブラウザでも Web Worker と SharedArrayBuffer を使えば、CPUごとに別スレッドを立てて本当に並列に動かせます。それでも、レトロ基板のエミュレーターではほとんど使われません。
- 同期のコストが高すぎる: Z80の命令は4〜23クロック、時間にして数マイクロ秒です。スレッド同士が
Atomics.waitで待ち合わせるコストのほうが、命令の実行よりはるかに大きくなります。 - 結果が毎回変わる: スレッドの進み具合はOSの都合で毎回ずれるため、同じ入力でも同じ結果になりません。デバッグや再現が非常に難しくなります。
結局、1本のスレッドで順番を完全に制御しながら切り替えるほうが、速くて正確です。
3. クォンタムの大きさ:精度と速度のトレードオフ
クォンタムを大きくすると切り替え回数が減って速くなりますが、CPU同士の「時間のずれ」が大きくなります。小さくすると正確になりますが、切り替えのたびにかかるオーバーヘッドが積み上がります。
CPUのクロックを 、クォンタムを 秒とすると、1回のスライスで進めるサイクル数と、1フレームあたりの切り替え回数はこうなります。
ギャラガのZ80(3.072MHz)で比べてみます。1フレームは サイクルです。
| 1フレームの分割数 | クォンタム | 1スライスのサイクル数 | 最大のずれ |
|---|---|---|---|
| 1 | 約16.7ms | 51,200 | 1フレームまるごと |
| 100 | 約167μs | 512 | 数十命令ぶん |
| 1,000 | 約16.7μs | 約51 | 数命令ぶん |
| 命令ごと | 1命令 | 4〜23 | ほぼ無し |
「最大のずれ」が問題になるのは、CPU同士がやり取りする瞬間です。お互いが無関係な仕事をしている間は、どれだけずれていても結果は変わりません。
ずれが問題になる典型例:ハンドシェイク
メインCPUがサブCPUにコマンドを送り、返事を待つ処理を考えます。
メインCPU: 共有RAMにコマンドを書く → 「完了フラグ」が立つまでループで待つ
サブCPU : コマンドを見つけて処理 → 完了フラグを立てる
実機なら、この往復は数マイクロ秒で終わります。ところがクォンタムを1フレームにすると、次のことが起こります。
- メインCPUがコマンドを書き、残りのスライスをまるごと待ちループで空回りする。
- 次にサブCPUの番が来て、ようやくコマンドを見つけ、完了フラグを立てる。
- メインCPUが完了フラグに気づくのは、次のフレームのスライス。
1回の往復に1フレームかかるので、1フレームに何十回もやり取りするゲームは極端に遅くなるか、タイミングがずれて動かなくなります。
4. CPU同士の通信手段
複数CPUのやり取りには、主に3つの方法があります。
A. 共有RAM(Shared Memory)
複数のCPUのアドレス空間に、同じRAMチップをつなぐ方法です。片方が書いた値を、もう片方がそのまま読めます。
エミュレーターでは、同じ Uint8Array を両方のバスにマッピングするだけで再現できます。第2回で作ったバス分離設計(readByte / writeByte をコールバックで注入する形)が、ここで効いてきます。
B. サウンドラッチ(Sound Latch)+割り込み
メインとサウンドの間でよく使われる方法です。ラッチは、値を1つだけ保持する8ビットの箱だと思ってください。
メインCPU: ラッチに「効果音番号 0x12」を書く
└→ 同時にサウンドCPUへ NMI(または INT)を発生させる
サウンドCPU: 割り込みハンドラでラッチを読み、0x12 番の効果音を鳴らし始める
第5回で扱った割り込みが、今度はCPUとCPUの間で使われています。割り込みを使わずに、サウンドCPUがタイマー割り込みのたびにラッチを見に行く(ポーリング)基板もあります。
C. RESET / HALT 制御
メインCPUが、サブCPUのRESET線やHALT線をI/Oポート経由で操作する方法です。起動時にサブCPUを止めておき、共有RAMにプログラムを転送してから起こす、といった使い方をします。
5. クロックが違うCPUを同じ時間軸で進める
CPUごとにクロックが違う基板もあります。例えばカプコンの『1942』は、メインのZ80が4MHz、サウンドのZ80が3MHzです。「どちらも1万サイクル進める」では、実時間では両者の進み具合がずれてしまいます。
そこで、スケジューラーは「サイクル」ではなく「時刻」で管理します。ある時刻 までに、クロック のCPUが実行しているべきサイクル数は次の式で求まります。
各CPUの実行済みサイクル数が、この目標に届くまで step() を回します。命令は途中で止められないため、最後の命令ぶん少しだけ目標を超えます(オーバーシュート)。超えた分は次のスライスに持ち越されるので、長期的にはずれが溜まりません。
6. アクセス時に相手を追いつかせる:キャッチアップ同期
クォンタムを小さくすれば正確になりますが、常に小さいままでは遅くなります。そこで多くのエミュレーターは、「普段は大きく、CPU同士がやり取りする瞬間だけ正確に」という工夫をしています。
キャッチアップ(Catch-up)
メインCPUがサウンドラッチに書き込む瞬間を考えます。インタリーブでは、この時点でサウンドCPUはまだ「スライスの開始時刻」に取り残されています。そのままラッチを書き換えると、サウンドCPUから見て「未来の書き込み」が、少し早く届いてしまいます。
そこで、書き込む直前に、サウンドCPUを「メインCPUの現在時刻」まで先に進めてから書き込みます。
時刻: 0 ────────── t_write ────────── t_end
メインCPU: ████████████▲ ラッチ書き込み
サウンドCPU: (まだ0) ↑ ここまで先に進めてから書き込みを反映
こうすると、クォンタムが大きくても、やり取りの順序だけは実機と一致します。
インタリーブの一時増加
ハンドシェイクのように、何度も往復するやり取りでは、キャッチアップだけでは足りません。そこで、共有RAMの特定アドレスへの書き込みを検知したら、しばらくの間だけクォンタムを細かくする方法もあります。MAMEでは「boost interleave」と呼ばれている手法です。
7. エミュレーターでの実装(JavaScript)
ここまでの仕組みを、スケジューラー・共有RAM・サウンドラッチの3つに分けて実装します。CPUコアは、これまでの回で作った Z80Core(step() が消費サイクルを返し、requestNMI() / requestINT() を持つもの)をそのまま使います。
時刻ベースのスケジューラー
export class Scheduler {
constructor({ slicesPerFrame = 100, fps = 60 } = {}) {
this.cpus = []; // 登録された CPU
this.fps = fps;
this.slicesPerFrame = slicesPerFrame;
this.now = 0; // スライスの終了時刻(秒)
}
addCpu(name, cpu, clockHz) {
const entry = { name, cpu, clockHz, executed: 0, halted: false };
this.cpus.push(entry);
return entry;
}
// 指定したCPUを、時刻 t(秒)まで進める
runUntil(entry, t) {
const target = Math.floor(t * entry.clockHz);
if (entry.halted) {
// HALT 中は時間だけ進める(命令は実行しない)
if (entry.executed < target) entry.executed = target;
return;
}
while (entry.executed < target) {
entry.executed += entry.cpu.step(); // 最後の命令ぶんだけ目標を超える
}
}
// CPUの「現在時刻」(秒)
timeOf(entry) {
return entry.executed / entry.clockHz;
}
// 1フレーム分をインタリーブ実行する
runFrame() {
const frameTime = 1 / this.fps;
const quantum = frameTime / this.slicesPerFrame;
const frameEnd = this.now + frameTime;
while (this.now < frameEnd - 1e-12) {
this.now = Math.min(this.now + quantum, frameEnd);
// 登録順(メインCPUが先)に、同じ時刻まで進める
for (const entry of this.cpus) {
this.current = entry;
this.runUntil(entry, this.now);
}
}
this.current = null;
}
}
時刻を浮動小数点の「秒」で持つと、長時間動かしたときに丸め誤差が溜まります。本格的な実装では、MAMEのように整数の超微小単位(attosecond)で持つか、全CPUのクロックの最小公倍数を基準にした整数カウンタにします。ここでは読みやすさを優先しました。
サウンドラッチとキャッチアップ
export class SoundLatch {
constructor(scheduler, mainEntry, soundEntry) {
this.scheduler = scheduler;
this.main = mainEntry;
this.sound = soundEntry;
this.value = 0;
}
// メインCPUから書き込まれる
write(value) {
// 1. サウンドCPUを、メインCPUの現在時刻まで先に進める(キャッチアップ)
this.scheduler.runUntil(this.sound, this.scheduler.timeOf(this.main));
// 2. ラッチに値を置き、サウンドCPUに NMI を送る
this.value = value;
this.sound.cpu.requestNMI();
}
// サウンドCPUから読み出される
read() {
return this.value;
}
}
メインCPUをサウンドCPUより先に実行する順番にしているので、書き込みの瞬間、サウンドCPUは必ず「過去」にいます。過去から現在まで進めるだけで順序が揃うのが、この順番にしている理由です。
2つのCPUと共有RAMの組み上げ
アドレスは説明用の例です。実際の割り当ては基板ごとに異なります。
const scheduler = new Scheduler({ slicesPerFrame: 100 });
const mainRom = new Uint8Array(0x8000);
const soundRom = new Uint8Array(0x2000);
const mainRam = new Uint8Array(0x0800);
const soundRam = new Uint8Array(0x0800);
const sharedRam = new Uint8Array(0x0400); // 両方のCPUから見える RAM
let latch; // 後で生成(CPUの登録が先に必要なため)
// --- メインCPUのバス ---
const mainCpu = new Z80Core({
readByte(addr) {
if (addr < 0x8000) return mainRom[addr];
if (addr >= 0xc000 && addr < 0xc800) return mainRam[addr - 0xc000];
if (addr >= 0xd000 && addr < 0xd400) return sharedRam[addr - 0xd000];
return 0xff;
},
writeByte(addr, val) {
if (addr >= 0xc000 && addr < 0xc800) mainRam[addr - 0xc000] = val;
else if (addr >= 0xd000 && addr < 0xd400) sharedRam[addr - 0xd000] = val;
else if (addr === 0xe000) latch.write(val); // サウンドラッチ
},
inPort: () => 0xff,
outPort: () => {},
});
// --- サウンドCPUのバス ---
const soundCpu = new Z80Core({
readByte(addr) {
if (addr < 0x2000) return soundRom[addr];
if (addr >= 0x4000 && addr < 0x4800) return soundRam[addr - 0x4000];
if (addr >= 0x5000 && addr < 0x5400) return sharedRam[addr - 0x5000]; // 同じ配列
if (addr === 0x6000) return latch.read();
return 0xff;
},
writeByte(addr, val) {
if (addr >= 0x4000 && addr < 0x4800) soundRam[addr - 0x4000] = val;
else if (addr >= 0x5000 && addr < 0x5400) sharedRam[addr - 0x5000] = val;
},
inPort: () => 0xff,
outPort: (port, val) => psg.write(port, val), // 第10回の音源へ
});
// メインを先に登録する(キャッチアップの前提)
const mainEntry = scheduler.addCpu('main', mainCpu, 4_000_000);
const soundEntry = scheduler.addCpu('sound', soundCpu, 3_000_000);
latch = new SoundLatch(scheduler, mainEntry, soundEntry);
// 毎フレーム
function frame() {
scheduler.runFrame();
mainCpu.requestINT(); // V-Blank 割り込み(第5回)
renderScreen(); // 第9回
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
共有RAMの正体は、2つのバスから同じ sharedRam を参照しているだけです。CPUコアには手を入れず、バスの配線だけで「複数CPUの基板」が組み上がります。第2回でバスを分離しておいた設計が、ここで一番効いてきます。
実際の調整のしかた
- まず
slicesPerFrameを大きめ(1,000程度)にして、正しく動くことを確認する。 - 動いたら少しずつ減らし、ゲームの速度や効果音のタイミングが崩れない最小値を探す。
- 特定の場面だけ崩れるなら、その場面で使われている共有RAMのアドレスを調べ、キャッチアップやインタリーブの一時増加をそこにだけ入れる。
全体を細かくするより、やり取りが起きる場所だけを正確にするほうが、速さと正確さを両立できます。
まとめ
- アーケード基板 は、CPU性能の不足を「CPUを増やす」ことで補った。メイン+サウンド構成と、共有RAM構成が代表的。
- 実機の複数CPUは 並列 に動くが、エミュレーターは1本のスレッドで細かく切り替える 並行(インタリーブ) 実行で再現する。Web Workerによる本当の並列化は、同期コストと再現性の問題でほとんど使われない。
- クォンタム を大きくすると速く、小さくすると正確になる。ずれが問題になるのは、CPU同士がやり取りする瞬間だけ。
- 通信手段 は、共有RAM、サウンドラッチ+割り込み、RESET/HALT制御の3つ。
- クロックの違うCPU は、サイクルではなく時刻で進める。。
- キャッチアップ同期 で、やり取りの瞬間だけ相手を現在時刻まで進めれば、クォンタムが大きくても順序は実機と一致する。
1個のCPUは「命令を順番に実行する機械」でした。CPUが2個になると、「いつ、どの順番で実行したか」という時間の問題が加わります。第8回がCPUと実時間の同期だったのに対して、今回はCPUとCPUの同期の話でした。
次回はいよいよ最終回。「第12回:テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記」をお届けします。
シリーズ全目次
- 【基礎編】
- 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
- 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
- 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
- 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
- 【実践編】
- 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み
- 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む
- 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり
- 第8回:クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む
- 【応用編】
- 第9回:走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング
- 第10回:音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる
- 第11回:マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)(本記事)
- 第12回:テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記(次回)