[CS講座 #02] メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
はじめに
CPUの内部には演算を行うALUや一時記憶であるレジスタが存在しますが、CPU単体では単なる「高速な計算機」に過ぎません。画面に画像を表示したり、キーボードからの入力を受け取ったり、プログラムコードを読み込むためには、外部のメモリやVDP(画面制御IC)、音源チップなどの周辺機器(ペリフェラル)とデータをやり取りする必要があります。
第2回となる今回は、CPUが外部デバイスとデータを行き来させる仕組みである「バス(Bus)」の構造と、メモリ・I/Oのアクセス方式について解説します。
前回の記事
[CS講座 #01] レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか? // PROTOCOL.LAIN
8ビットCPU(Z80)のレジスタ構造、2の補数表現による負の数の扱い、Carry/Zero/Sign/Overflowフラグの決定論理を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. 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本あるため、 通り、すなわち 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回:ビット演算の極意 — 複雑な命令セットを美しく捌く