[CS講座 #04] ビット演算の極意 — 複雑な命令セットを美しく捌く

[CS講座 #04] ビット演算の極意 — 複雑な命令セットを美しく捌く

はじめに

CPUの動作原理である「Fetch-Decode-Execute」サイクルを理解した次に立ちはだかる壁、それが**「数百種類に及ぶ膨大な命令セット(Opcode)をどう処理するか」**という設計問題です。

1バイト(8ビット)で表現できる命令数は最大256種類ですが、Z80のようなCPUはプレフィックス(拡張バイト)を駆使することで、実質1,000種類以上の命令を縦横無尽に実行します。

第4回となる今回は、低レイヤー開発の基本兵器である「ビット演算のテクニック」と、プレフィックスデコードの構造、そしてそれらを高速かつスマートに処理する「テーブル駆動法」の設計を解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. ビット演算の4大テクニック(AND / OR / XOR / SHIFT)

ハードウェアやエミュレーターの内部では、データは常に2進数のビット列として扱われます。特定のビットを取り出したり、書き換えるための必須テクニックが以下の4つです。

1. ビットマスク (AND)  : 不要なビットを削ぎ落とし、特定のビットだけ抽出する
2. ビットセット (OR)   : 特定のビットだけを強制的に 1 にする
3. ビットトグル (XOR)  : 特定のビットを反転させる (0->1, 1->0)
4. ビットシフト (SHIFT): ビット列を左右にずらし、2の乗算・除算やフラグ抽出を行う

A. ビットマスク(AND)

指定した位置以外のビットを 0 に押し潰す操作です。

// 例: Fレジスタ(0b11010101)から Zero Flag (Bit 6) のみを取り出す
const zeroFlag = flags & 0x40; // 0x40 = 0b01000000

B. ビットセット(OR)

他のビットに影響を与えず、目的のビットだけを 1 に立てる操作です。

// 例: Carry Flag (Bit 0) を立てる
flags |= 0x01; // 0x01 = 0b00000001

C. ビットトグル(XOR)と高速ゼロクリア

XOR はビットが異なれば 1、同じなら 0 を返します。

Z80のアセンブリで頻出する XOR A(レジスタA同士のXOR)は、自分自身と比較するため必ず結果が 0x00 になるという性質を利用した「Aレジスタのゼロクリア」命令です。LD A, 0(2バイト/7クロック)よりも XOR A(1バイト/4クロック)の方がメモリも実行時間も節約できるため、レトロゲームのコードでは徹底的に使われています。

D. 算術シフト・論理シフト・ローテート

ビットを左右に移動させる操作ですが、溢れたビットや空いた隙間の処理によって種類が分かれます。

  • 論理シフト(SRL / SLA): 空いた隙間に常に 0 を詰める。
  • 算術右シフト(SRA): 最上位ビット(符号ビット)の値を維持したまま右シフトする(負数の2割算用)。
  • ローテート(RLC / RRC): はみ出たビットを捨てるのではなく、反対側の端にぐるっと回し込む。
[ローテート RLC A のイメージ]
 +-----------------------------------+
 |  +---+---+---+---+---+---+---+---+ |
 +->| 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |-+  => 最上位Bit7がCarryとBit0に入る
    +---+---+---+---+---+---+---+---+

2. プレフィックスデコード:有限の8ビットを無限に引き伸ばす

1バイトのOpcode(0x00 〜 0xFF)だけでは256種類の命令しか定義できません。しかしZ80には 0xCB, 0xDD, 0xFD という特別な命令コードが存在します。これらをプレフィックス(Prefix)と呼びます。

プレフィックスのカラクリ

フェッチした1バイト目がプレフィックスだった場合、CPUは「次の1バイト(または2バイト)を読み込んで別系統の拡張命令テーブルを参照する」というモードに切り替わります。

[通常命令]
PC -> [0x3C] ----------> (INC A を実行)

[プレフィックス命令 (0xCB)]
PC -> [0xCB] [0x87] ---> (0xCBを検知 -> 2バイト目の 0x87 を読み取り -> RES 0, A を実行)
  1. 0xCB プレフィックス: ビット操作(BIT, SET, RES)およびシフト・ローテート専用の256命令拡張空間。
  2. 0xDD プレフィックス: 直後の命令内の HL レジスタ指定を IX レジスタ(16ビットインデックスレジスタ)へ差し替える。
  3. 0xFD プレフィックス: 直後の命令内の HL レジスタ指定を IY レジスタ へ差し替える。

このプレフィックス構造により、1バイトの枠組みを超えて複雑かつ膨大な命令セットを実現しています。


3. テーブル駆動法(Table-Driven Execution)による高速化

step() メソッドの中で switch (opcode) を使い、数百もの case を並べる実装は直感的ですが、エミュレーターの規模が大きくなると以下の問題が発生します。

  • コードの巨大化と可読性の低下: 数千行の巨大な switch 文になり、保守が不可能になる。
  • 分岐予測のオーバーヘッド: JavaScriptエンジン(V8等)の最適化が利きにくくなる場合がある。

これを解決するのが テーブル駆動法(Look-Up Table) です。

「Opcodeの値(0 〜 255)を配列のインデックスに見立て、実行すべき関数をあらかじめ配列に登録しておく」手法です。

// Opcode 0x00〜0xFF に対応する処理関数を配列で保持 ($O(1)$ のアクセス速度)
const opcodeTable = [
  execNOP,     // 0x00
  execLD_BC_nn,// 0x01
  execLD_pBC_A,// 0x02
  // ... 256個の関数がズラリと並ぶ
];

// 実行時は1行で完了!
const cycles = opcodeTable[opcode](this);

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

テーブル駆動法とプレフィックスデコードを組み合わせた、実践的なZ80エミュレーターのデコード構造の実装例です。

export class Z80Core {
  constructor(bus) {
    this.bus = bus;
    this.pc = 0x0000;
    this.a = 0;
    this.b = 0;
    this.c = 0;

    // 命令実行テーブルの初期化
    this.initOpcodeTables();
  }

  fetch8() {
    const val = this.bus.readByte(this.pc);
    this.pc = (this.pc + 1) & 0xffff;
    return val;
  }

  // 命令テーブルの構築
  initOpcodeTables() {
    // 1. メイン命令テーブル (256要素)
    this.mainTable = new Array(256).fill(this.execUnimplemented.bind(this));

    this.mainTable[0x00] = () => 4; // NOP
    this.mainTable[0x3c] = () => {  // INC A
      this.a = (this.a + 1) & 0xff;
      return 4;
    };
    this.mainTable[0xaf] = () => {  // XOR A (Aをゼロクリア)
      this.a = 0;
      return 4;
    };

    // プレフィックス 0xCB が来たら CBテーブルへエスケープ
    this.mainTable[0xcb] = () => {
      const cbOpcode = this.fetch8();
      return this.cbTable[cbOpcode]();
    };

    // 2. CB拡張命令テーブル (256要素)
    this.cbTable = new Array(256).fill(this.execUnimplemented.bind(this));

    // 例: BIT b, r 命令(ビットテスト)
    // 0x47 = BIT 0, A
    this.cbTable[0x47] = () => {
      const bit0 = this.a & 0x01; // ビットマスク
      // Zero Flag の更新(ビットが0ならZ=1)
      if (bit0 === 0) this.f |= 0x40; else this.f &= ~0x40;
      return 8; // CB命令は通常よりクロックがかかる
    };

    // 例: SET b, r 命令(ビットセット)
    // 0xc7 = SET 0, A
    this.cbTable[0xc7] = () => {
      this.a |= 0x01; // Bit 0 を1にセット
      return 8;
    };
  }

  execUnimplemented() {
    console.error(`未実装命令: PC=0x${(this.pc - 1).toString(16)}`);
    return 4;
  }

  // 1ステップ実行
  step() {
    const opcode = this.fetch8();
    // 配列インデックスで関数を直接コール (O(1)の高速デコード)
    return this.mainTable[opcode]();
  }
}

配列インデックスによる直接呼び出しにすることで、複雑な条件分岐を完全に消去し、どれほど命令数が増えても高速かつ見通しの良いコードを維持することができます。


まとめ

  • ビット演算(AND/OR/XOR/SHIFT)は、フラグ設定・抽出・レジスタ初期化などエミュレーターのあらゆる箇所で使われる基本言語。
  • プレフィックス(0xCB, 0xDD, 0xFD) は、1バイトの命令空間(256個)を破綻させずに1000種類以上の拡張命令をサポートするための設計。
  • テーブル駆動法 を採用することで、数百通りの命令デコードを O(1)O(1) の配列アクセスへ集約し、拡張性と実行速度を劇的に向上できる。

基礎編(第1回〜第4回)を通して、CPUの内部構造・バス通信・命令サイクル・デコードメカニズムのすべてが繋がりました。

次回からは【実践編】へ突入します。「第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み」をお届けします。


シリーズ目次

  • 【基礎編】

  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?

  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?

  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ

  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く(本記事)

  • 【実践編】

  • 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み(次回)