[CS講座 #02] メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?

[CS講座 #02] メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?

はじめに

CPUの内部には演算を行うALUや一時記憶であるレジスタが存在しますが、CPU単体では単なる「高速な計算機」に過ぎません。画面に画像を表示したり、キーボードからの入力を受け取ったり、プログラムコードを読み込むためには、外部のメモリやVDP(画面制御IC)、音源チップなどの周辺機器(ペリフェラル)とデータをやり取りする必要があります。

第2回となる今回は、CPUが外部デバイスとデータを行き来させる仕組みである「バス(Bus)」の構造と、メモリ・I/Oのアクセス方式について解説します。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. 3つの「システムバス」:CPUの外部足回り

CPUと基板上の各チップは、「バス」と呼ばれる物理的な信号線(配線)の束で接続されています。8ビットCPU(Z80等)におけるバスは、役割によって大きく3種類に分かれます。

+-------------------------------------------------------+
|                       CPU                             |
+-------+-------------------+-------------------+-------+
        |                   |                   |
  [アドレスバス]      [データバス]       [コントロールバス]
  (16 bits: 出力)    (8 bits: 双方向)    (RD, WR, MREQ, IORQ)
        |                   |                   |
+-------v-------------------v-------------------v-------+
|                 システムバス(基板配線)                 |
+-------+-------------------+-------------------+-------+
        |                   |                   |
+-------v-------+   +-------v-------+   +-------v-------+
|  RAM / ROM    |   |  VDP (画面)   |   |   PSG (音源)  |
+---------------+   +---------------+   +---------------+
  • アドレスバス (Address Bus - 16ビット) CPUが「どのメモリ番地」または「どのI/Oポート」にアクセスしたいかを指し示すための信号線です。Z80では配線が16本あるため、216=65,5362^{16} = 65,536 通り、すなわち 64KB(キロバイト) の空間を直接指定できます。
  • データバス (Data Bus - 8ビット) 実際にデータそのものを読み書きするための配線です。8本あるため、一度に送受信できるデータ量は1バイト(0x00 〜 0xFF)に限られます。
  • コントロールバス (Control Bus) 「今から読み込むのか(RD)、書き込むのか(WR)」「対象はメモリか(MREQ)、I/O機器か(IORQ)」といった制御信号を発信する配線です。

2. エンディアン(Endianness):多バイトデータの格納順

Z80のアドレスバスは16ビット(2バイト)ですが、データバスは8ビット(1バイト)しかありません。そのため、0x1234 という16ビットの数値をメモリに保存したい場合、1バイトずつ2回に分けて書き込む必要があります。

このとき、「高位バイト(0x12)と低位バイト(0x34)のどちらを先の番地に置くか」というルールの違いがエンディアンです。

方式概要0x0000番地0x0001番地採用されているアーキテクチャ
リトルエンディアン (Little-Endian)下位バイトを若いアドレスに配置0x34 (Low)0x12 (High)Z80, x86/x64, RISC-V
ビッグエンディアン (Big-Endian)上位バイトを若いアドレスに配置0x12 (High)0x34 (Low)68000, ネットワークプロトコル

Z80は リトルエンディアン を採用しています。アセンブリ言語で LD HL, $1234 という命令を書いた場合、メモリ上には 34 12 という並びで機械語が展開されます。

[アドレス]  [メモリの中身]
0x1000   : 0x34  (低位バイト: Low)
0x1001   : 0x12  (高位バイト: High)

直感的にはビッグエンディアンの方が人間には読みやすいですが、ハードウェア的には「アドレスを加算しながら下位桁から順に処理できる」という演算回路上のメリットがあります。

3. I/O空間の設計:Memory-Mapped I/O vs Port-Mapped I/O

CPUが周辺機器(画面、音源、コントローラーなど)とデータを送受信する方式には、大きく分けて2つの設計思想が存在します。

A. Memory-Mapped I/O(メモリマップドI/O)

メモリ空間の特定アドレス(例: 0xC000〜0xC00F)を周辺機器のレジスタに割り当てる方式です。

  • 特徴: メモリを読み書きする通常の命令(LD 命令など)でそのまま周辺機器を操作できます。
  • 採用例: MOS 6502 (NES/Apple II), ARM, RISC-V

B. Port-Mapped I/O(ポートマップドI/O / Isolated I/O)

通常の「64KBメモリ空間」とは完全に独立した「256個(または65536個)のI/Oポート空間」を個別に用意する方式です。

  • 特徴: メモリ読み書き用の命令とは別に、専用のポート通信命令(IN / OUT)を使用します。
  • 採用例: Z80, Intel x86

Z80は Port-Mapped I/O を採用しています。例えば、IORQ(I/O要求信号)をアクティブにしながら OUT ($99), A 命令を実行すると、メモリの64KB空間を一切汚さずにVDP(ビデオプロセッサ)のコマンドポートへ直接データ(レジスタ A)を送信できます。

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

エミュレーター開発において、CPUコアのロジック内に特定のメモリ配列(Array)や特定のVDP処理を直接ハードコーディングしてしまうと、他の機種(MSXからColecoVisionやゲームギアなど)へ移植する際の汎用性が失われます。

そこで、CPUコアは「アドレスバスとデータバスへ信号を流すインターフェース」のみを提供し、実際のメモリやI/Oへの接続はコールバック関数として外部から注入する設計をとります。

バス分離設計のJavaScriptコード

export class Z80Core {
  constructor(bus) {
    // 外部からバス接続用オブジェクトを受け取る
    // bus: { readByte, writeByte, inPort, outPort }
    this.bus = bus;

    // CPU内部レジスタ
    this.pc = 0x0000; // Program Counter (16-bit)
    this.sp = 0xffff; // Stack Pointer (16-bit)
    this.a = 0;
  }

  // 16ビットアドレスから1バイト読み込み
  memRead(addr) {
    return this.bus.readByte(addr & 0xffff) & 0xff;
  }

  // 16ビットアドレスへ1バイト書き込み
  memWrite(addr, val) {
    this.bus.writeByte(addr & 0xffff, val & 0xff);
  }

  // リトルエンディアンによる16ビット値の読み込み
  memRead16(addr) {
    const low = this.memRead(addr);
    const high = this.memRead(addr + 1);
    return (high << 8) | low;
  }

  // リトルエンディアンによる16ビット値の書き込み
  memWrite16(addr, val) {
    this.memWrite(addr, val & 0xff);         // 低位バイト
    this.memWrite(addr + 1, (val >> 8) & 0xff); // 高位バイト
  }

  // I/Oポートからの読み込み (IN 命令)
  portIn(port) {
    return this.bus.inPort(port & 0xff) & 0xff;
  }

  // I/Oポートへの書き込み (OUT 命令)
  portOut(port, val) {
    this.bus.outPort(port & 0xff, val & 0xff);
  }
}

システム側(仮想マシン)での組み上げ例

上記で作成したCPUコアに対し、64KBの物理メモリ(Uint8Array)とI/O機器(例:音源チップ)を接続するシステム側の実装例です。

// 1. 64KBの仮想メモリ空間を確保
const memory = new Uint8Array(65536);

// 2. 音源チップや画面のダミーモック
const psgSound = { register: 0, write(val) { /* 音源制御 */ } };

// 3. バス制御インターフェースを定義してCPUに接続
const cpu = new Z80Core({
  readByte: (addr) => memory[addr],
  writeByte: (addr, val) => { memory[addr] = val; },

  inPort: (port) => {
    if (port === 0xa8) return 0xff; // パラレルポート等
    return 0xff;
  },

  outPort: (port, val) => {
    if (port === 0xa0) psgSound.register = val;
    if (port === 0xa1) psgSound.write(val);
  }
});

// リトルエンディアン書き込みのテスト
cpu.memWrite16(0x1000, 0xabcd);
console.log(memory[0x1000].toString(16)); // "cd" (低位バイト)
console.log(memory[0x1001].toString(16)); // "ab" (高位バイト)

このようにCPUコアと外部バスを完全に分離することで、Z80コア自体のコードを変更することなく、接続するコールバック側を差し替えるだけで多種多様なハードウェア(MSX、SG-1000、ゲームギア、アーケード基板など)のエミュレーションに対応させることができます。

まとめ

  • アドレスバス(16bit) はアクセス先のアドレスを指し、データバス(8bit) は1バイトずつデータ本体を読み書きする。
  • Z80は リトルエンディアン であり、16ビットデータは「低位バイト → 高位バイト」の順でメモリに保持される。
  • Port-Mapped I/O により、Z80は64KBのメモリ空間とは別に IN / OUT 命令専用の256個のポート空間を持っている。
  • JavaScriptでエミュレーターを作る際は、readByte / writeByte / inPort / outPort のコールバックインターフェースでバスを分離するのが拡張性の鍵となる。

次回は、CPU内部でプログラムが1命令ずつ実行されていく生命のサイクル、「第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ」をお届けします。

シリーズ目次

  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?(本記事)
  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く