[CS講座 #06] メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む

[CS講座 #06] メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む

はじめに

Z80やMOS 6502といった8ビット時代のCPUは、アドレスバスが16本しかないため、原理的に最大64KB(65,536バイト)のアドレス空間しか直接扱うことができません。

しかし、レトロゲームの歴史が進むにつれ、グラフィックの進化や大容量BGM、広大なマップを収めるために、ゲームソフトの容量は128KB、512KB、さらには数メガバイト(メガROM)へと肥大化していきました。

【実践編】第6回となる今回は、わずか64KBという狭い「小窓」を通してメガバイト級の巨大プログラムを縦横無尽に読み込む技術、「メモリバンキング(Bank Switching)」のカラクリとエミュレーターでの実装テクニックを解説します。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. 64KBの壁と「メモリバンキング」の原理

64KBのアドレス空間に対して1MB(1024KB)のROMをそのまま繋ぐことは物理的に不可能です。

そこで考案されたのが、「CPUから見える64KBの領域(論理アドレス空間)をいくつかの『枠(ページ/Page)』に分割し、そこに巨大ROMの中から必要な16KBや8KBの『ブロック(バンク/Bank)』をカセット側の回路でサッと差し替えて見せる」という手法です。

 [CPUが見ている64KB空間 (論理)]         [大容量1MB ROM (物理)]
+--------------------------------+     +--------------------+
| Page 0 (0x0000 - 0x3FFF)  16KB | --> | Bank 0 (固定ROM)   |
+--------------------------------+     +--------------------+
| Page 1 (0x4000 - 0x7FFF)  16KB | --> | Bank 3 (差し替え)  | --+ 1MBの中に
+--------------------------------+     +--------------------+   | 多数のBankが
| Page 2 (0x8000 - 0xBFFF)  16KB | --> | Bank 12(差し替え)  | --+ 詰まっている
+--------------------------------+     +--------------------+
| Page 3 (0xC000 - 0xFFFF)  16KB | --> | RAM (作業用メモリ) |
+--------------------------------+     +--------------------+

本のページをめくる動作に似ていることから「ページング」や「バンク切り替え」と呼ばれます。

CPU側からは常に普通の64KBメモリに見えていますが、特定のメモリアドレスやI/Oポートに書き込みを行うと、カセット内に組み込まれた特殊なIC(マッパーチップ)が働き、裏側で結線が切り替わって瞬時に異なるデータが現れる仕組みになっています。


2. 代表的な「メガROMマッパー」の系譜

バンク切り替えを実現する「マッパーIC」は、ハードウェアメーカーやソフトハウスごとに独自の進化を遂げました。

A. セガ(Master System / Game Gear)方式

  • 構造: 64KB空間を16KB×3つのフレームに分割。
  • 制御: 0xFFFC 〜 0xFFFF の特殊レジスタ(RAM領域の最上部)にバンク番号を書き込むことで、瞬時に指定した16KBブロックが対応するページへ割り当てられます。

B. Konami(MSX メガROM)方式

  • 構造: 64KB空間をより細かい 8KB×4枠(0x4000〜0xBFFF)に細分化。
  • 制御: 0x4000, 0x6000, 0x8000, 0xA000 などのアドレスにデータを書き込むと、それがスイッチのトリガーとなり、その領域に表示される8KBバンクが切り替わります。

C. ASCII 8KB / 16KB 方式

  • 構造: 主に信長の野望などのRPGやシミュレーションゲームで多用。
  • 特徴: ROMの切り替えだけでなく、カセット内に搭載された SRAM(セーブデータ保持用メモリ) をバンク空間内にマッピングする機能を持ちます。

このように、CPUの命令(LD (0x6000), A など)を「メモリへの書き込み」ではなく「マッパーICへの命令伝達(スイッチ)」として横取り利用するのがバンク切替の真骨頂です。


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

エミュレーターでバンク切り替えを愚直に実装しようとして、memRead のたびに if 文で条件分岐したり、大きな配列から memcpy(要素のコピー)を行うと、処理速度が壊滅的に低下します。

JavaScriptでこれを最高速に捌く鍵は、「8KBや16KBごとの Uint8Array 参照(ポインタ)の配列を用意し、参照そのものを差し替える」 というアプローチです。

バンク切り替え(8KB×8ページ構成)の実装例

export class MapperASCII8 {
  constructor(romBuffer) {
    // 1. 巨大ROM(例: 512KB)を8KB(8192バイト)ずつのチャンク(バンク)に分割
    this.bankSize = 8192; // 8KB
    this.numBanks = romBuffer.length / this.bankSize;
    this.romBanks = [];

    for (let i = 0; i < this.numBanks; i++) {
      const start = i * this.bankSize;
      // 元のArrayBufferのビュー(参照)を作成(メモリコピーなし)
      this.romBanks[i] = new Uint8Array(romBuffer.buffer, romBuffer.byteOffset + start, this.bankSize);
    }

    // 作業用RAM (16KB)
    this.ram = new Uint8Array(16384);

    // 2. 64KB空間を8KB×8個の「ページ参照スロット」として管理
    // pages[0] = 0x0000-0x1FFF, pages[1] = 0x2000-0x3FFF ...
    this.pages = new Array(8);

    // 初期状態のバンク割り当て (Bank 0, 1, 2, 3...)
    this.pageBankNum = [0, 1, 2, 3, 0, 0, 0, 0];
    this.updatePages();
  }

  // ページ参照の更新 ($O(1)$ の超高速差し替え)
  updatePages() {
    for (let p = 0; p < 8; p++) {
      const bankIdx = this.pageBankNum[p] % this.numBanks;
      this.pages[p] = this.romBanks[bankIdx];
    }
  }

  // 64KB空間からの1バイト読み込み
  readByte(addr) {
    const pageIndex = (addr >> 13) & 0x07; // 13ビットシフトで 8KB 単位のページ番号(0~7)を取得
    const offset = addr & 0x1fff;          // 8KB内でのオフセット(0~8191)

    // RAM領域(例: 0xC000 - 0xFFFF = Page 6, 7)
    if (pageIndex >= 6) {
      return this.ram[addr & 0x3fff];
    }

    // ROM領域は割り当てられたページの配列から直接参照
    return this.pages[pageIndex][offset];
  }

  // 64KB空間への書き込み(バンクセレクトの横取り)
  writeByte(addr, val) {
    // 0x6000 - 0x67FF への書き込み -> Bank Select for Page 2 (0x4000-0x5FFF)
    if (addr >= 0x6000 && addr <= 0x67ff) {
      this.pageBankNum[2] = val;
      this.pages[2] = this.romBanks[val % this.numBanks]; // 参照の即時更新
      return;
    }

    // 0x6800 - 0x6FFF への書き込み -> Bank Select for Page 3 (0x6000-0x7FFF)
    if (addr >= 0x6800 && addr <= 0x6fff) {
      this.pageBankNum[3] = val;
      this.pages[3] = this.romBanks[val % this.numBanks];
      return;
    }

    // RAM領域への書き込み
    if (addr >= 0xc000) {
      this.ram[addr & 0x3fff] = val;
    }
  }
}

この設計のメリット

  1. メモリコピーゼロ: TypedArray.prototype.subarray() や new Uint8Array(buffer, offset, length) を使うことで、大容量ROMのデータをメモリコピーすることなく「参照の差し替え」だけでバンク切り替えが完了します。
  2. 高速アクセス: readByte 内ではビッドシフトとマスク演算(addr >> 13, addr & 0x1FFF)だけで対象のバイナリ配列にアクセスできるため、V8エンジン上で非常に強力に最適化(JIT化)されます。

まとめ

  • 64KBの壁 は、16ビットアドレス空間を小さな枠(ページ)に区切り、巨大ROMのブロック(バンク)を切り替えて割り当てる バンク切り替え(Bank Switching) によって克服された。
  • マッパーIC は特定のメモリ書き込み命令を検知・横取りし、物理的な結線を切り替えることでROMの表示領域をスイッチする。
  • JavaScriptでのエミュレーションでは、分割した Uint8Array の配列(参照)を保持し、ポインタを差し替える設計 にすることで、メモリアクセスのパフォーマンスを一切落とさずにバンク切り替えを再現できる。

次回は、低レベルなハードウェア模倣から一歩進んだ高速化テクニック、「第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり」をお届けします。


シリーズ目次

  • 【基礎編】
  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
  • 【実践編】
  • 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み
  • 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む(本記事)
  • 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり(次回)