50行で作る「最小の仮想マシン(CPUエミュレータ)」――メモリ上のただの数字が「計算」に化ける物理の瞬間

2026-11-05 / シリーズ「作って学ぶ「最小実装」シリーズ」第1話 / CPUエミュレータ, 仮想マシン, コンピュータアーキテクチャ, 低レイヤ, Python, 機械語

1. 素朴な疑問:プログラミング言語のコードは、CPUの中でどう動いているのか?

Pythonで a = 3 + 5 と書いたとき、あるいはJavaScriptでボタンをクリックしたとき、コンピュータの深部にある黒い半導体チップ(CPU)の中では、一体何が起きているのでしょうか?

「コンパイラが機械語に翻訳し、CPUがそれを実行している」 教科書にはそう書いてあります。しかし、機械語とは要するに 0x01 や 0x2A といった**「ただの数値(バイト列)」**です。

なぜ、メモリの上に並んだただの数字が、文字を表示したり、四則演算を行ったり、3Dゲームのキャラクターを動かしたりできるのでしょうか?

Intel Core i7-8700K CPUチップの上面写真。金属製のヒートスプレッダに型番と3.70GHZの刻印が見える
本文で言う「黒い半導体チップ」の実物、市販デスクトップPC向けCPU(Intel Core i7-8700K)。この金属カバーの下でも、フェッチ・デコード・実行のループが1秒に何十億回も回っている。写真:ElooKoN(CC BY-SA 4.0/Wikimedia Commons)
a = 3 + 5(高級言語) 人間の世界:抽象のレイヤー コンパイラ / インタプリタ [0x01, 0x00, 0x03, 0x01, 0x01, 0x05, 0x02 …] メモリの世界:ただの整数の並び(バイト列) 1バイトずつ順に取り出す CPUの世界:物理のからくり ① フェッチ メモリから数値を読む ② デコード 数値の意味を解釈する ③ 実行 演算して数値を更新する 次の番地へ(このループを1秒に何十億回も繰り返す)

結論から言えば、世界中のあらゆるCPU(Core i9も、Apple M4も、富岳のA64FXも)が行っていることは、驚くほどシンプルです。

  1. メモリから数値を1つ読み出す(フェッチ)
  2. その数値の意味を辞書のように解釈する(デコード)
  3. 演算器を動かして数値を更新する(実行)
  4. 次の番地へ進む

CPUとは、この極めて原始的な「4拍子のループ」を、1秒間に何十億回(GHz)も休むことなく繰り返しているだけの**「状態遷移マシン」**に過ぎません。

理屈を何冊も読むより、自分自身の手で「最小のCPU」をコードとして組んでみるのが一番の近道です。今回は、ライブラリを一切使わず、**Pythonわずか50行で動く最小の仮想マシン(CPUエミュレータ)**をゼロから作り上げます。


2. アーキテクチャの骨格:CPUの3大要素を設計する

ハードウェアとしてのCPUをエミュレート(模倣)するために必要な部品は、実はたったの3つしかありません。

CPU内部の部品 プログラムカウンタ(PC) いま何番地を読んでいるか レジスタ(R0, R1) 超高速な作業机 演算ユニット(ALU) 足し算・論理演算の実行 読み書き 外部メモリ(RAM) メモリ配列 [0, 1, 2, 3, …] プログラム(機械語)と データが同居する 番地を指定 結果を書き戻す 数値を読み出し

① メモリ(RAM):命令とデータが並ぶ「ただの配列」

コンピュータには「プログラム内蔵方式(フォン・ノイマン型)」という大原則があります。 特別な「プログラム専用の入れ物」があるわけではなく、プログラム(命令コード)もデータ(計算対象の数値)も、同じ1本のメモリ配列の中に数字として雑多に並んでいます。 Pythonでは、ただの整数のリスト [0, 0, 0, ...] として表現します。

② プログラムカウンタ(PC:Program Counter)

「CPUがいま、メモリの何番地を実行しているか」を指し示す専用のポインタ(インデックス番号)です。 命令を1つ実行したら、PCは次の命令の長さぶん進みます(0番地 $\to$ 3番地 $\to$ 6番地…)。このPCの数値を書き換えることこそが、プログラミングにおける「if 分岐」や「while ループ」の正体です。

③ レジスタ(Registers)

CPUの内部に埋め込まれた、極めて小さな「超高速の一時記憶スペース」です。 メモリ(RAM)はCPUから見ると物理的に遠く、読み書きに時間がかかります。そのため、CPUは**「一度メモリから手元のレジスタに数字を拾い上げ、レジスタの上で足し算を行い、終わったらメモリに書き戻す」**という手順を踏みます。今回は R0 と R1 の2つの汎用レジスタを用意します。


3. 命令セット(ISA)の定義:機械語の仕様書を作る

CPUを作る第一歩は、「どの数字が、どの命令を意味するか」という**命令セット(ISA)**を決めることです。 今回は、足し算とループを実行できる「最小の4命令」を定義します。

命令名オペコード(数値)引数(オペランド)動作
LOAD_IMM0x01(1)reg, val指定したレジスタに即値 val を代入する
ADD0x02(2)reg1, reg2reg1 に reg2 の値を足す(reg1 += reg2)
STORE0x03(3)reg, addrレジスタの値をメモリの addr 番地に書き込む
HALT0xFF(255)なしCPUのクロックを停止する(プログラム終了)

命令の構造(フォーマット)を統一しましょう。

  • LOAD_IMM は「命令番号」「対象レジスタ」「入れる値」の3バイト。
  • ADD は「命令番号」「足されるレジスタ」「足すレジスタ」の3バイト。
  • STORE は「命令番号」「元レジスタ」「保存先番地」の3バイト。
  • HALT は「命令番号」の1バイト。

これで仕様策定は完了です。ハードウェア設計者になった気分で、実装コードを書いていきましょう。


4. 完全実装:50行で動く「最小の仮想マシン」

余計な抽象化を一切排除し、CPUの基本構造だけを愚直に落とし込んだPythonスクリプトです。このまま1つのファイル(vm.py)として実行できます。

class MiniVM:
    def __init__(self, memory_size=32):
        self.memory = [0] * memory_size  # メモリ空間(RAM)
        self.reg = [0, 0]                # 汎用レジスタ: R0, R1
        self.pc = 0                      # プログラムカウンタ
        self.running = True              # CPU稼働フラグ

    def fetch(self):
        """1. フェッチ: PCが指す番地から数値を1つ取り出す"""
        instruction = self.memory[self.pc]
        return instruction

    def step(self):
        """命令サイクルの実行 (Fetch -> Decode -> Execute)"""
        op = self.fetch()

        if op == 0x01:  # LOAD_IMM reg, val
            reg_idx = self.memory[self.pc + 1]
            val = self.memory[self.pc + 2]
            self.reg[reg_idx] = val
            self.pc += 3

        elif op == 0x02:  # ADD reg1, reg2 (reg1 = reg1 + reg2)
            r1 = self.memory[self.pc + 1]
            r2 = self.memory[self.pc + 2]
            self.reg[r1] += self.reg[r2]
            self.pc += 3

        elif op == 0x03:  # STORE reg, addr
            r = self.memory[self.pc + 1]
            addr = self.memory[self.pc + 2]
            self.memory[addr] = self.reg[r]
            self.pc += 3

        elif op == 0xFF:  # HALT
            self.running = False
            self.pc += 1

        else:
            raise RuntimeError(f"未知の命令(不正なオペコード): {hex(op)} at PC={self.pc}")

    def run(self):
        """CPUクロックのループ(停止命令まで回し続ける)"""
        while self.running:
            self.step()


# -------------------------------------------------------------
# 実験:機械語プログラムをメモリに注入して実行してみる
# やりたい計算: 「メモリ[30]番地に、3 + 5 の結果を保存せよ」
# -------------------------------------------------------------
vm = MiniVM(memory_size=32)

program = [
    0x01, 0, 3,     # [0..2番地] LOAD_IMM R0, 3  (R0に3を入れる)
    0x01, 1, 5,     # [3..5番地] LOAD_IMM R1, 5  (R1に5を入れる)
    0x02, 0, 1,     # [6..8番地] ADD R0, R1      (R0 = R0 + R1)
    0x03, 0, 30,    # [9..11番地] STORE R0, 30    (R0の値をメモリ30番地に書き込む)
    0xFF            # [12番地]    HALT            (停止)
]

# メモリの先頭(0番地)にプログラムを展開
for i, byte in enumerate(program):
    vm.memory[i] = byte

print(f"【実行前】メモリ30番地の値: {vm.memory[30]}")
vm.run()
print(f"【実行後】レジスタ R0 の値  : {vm.reg[0]}")
print(f"【実行後】メモリ30番地の値: {vm.memory[30]}")

実行結果

【実行前】メモリ30番地の値: 0
【実行後】レジスタ R0 の値  : 8
【実行後】メモリ30番地の値: 8

5. 解剖:メモリの数字が「足し算」へと変わるプロセスの全貌

このコードを実行したとき、VMの内部では何が起きていたのでしょうか? 1ステップずつ、CPU内部の時計の針を進めてみましょう。

ステップ1 ― LOAD_IMM R0, 3(PC=0) 0x01 を読み、オペランド(0, 3)を取得 → R0 に 3 をセット / PC → 3 ステップ2 ― LOAD_IMM R1, 5(PC=3) 0x01 を読み、オペランド(1, 5)を取得 → R1 に 5 をセット / PC → 6 ステップ3 ― ADD R0, R1(PC=6) 0x02 を読み、R0(3)と R1(5)をALUで加算 → R0 に 8 を上書き / PC → 9 ステップ4 ― STORE R0, 30(PC=9) 0x03 を読み、R0 の値(8)を取り出す → メモリ30番地に書き込む / PC → 12 ステップ5 ― HALT(PC=12) 0xFF を読み、running = False → CPU停止。計算結果「8」がメモリに残る
  1. PCが 0 を指す: メモリから 0x01(LOAD_IMM)を取得。「あ、後ろに2つ数字が続く命令だな」とデコードし、0(R0)と 3 を読んで self.reg[0] = 3 とする。PCを3つ進めて 3 にする。
  2. PCが 3 を指す: 再び 0x01 を取得。1(R1)と 5 を読んで self.reg[1] = 5 とする。PCを3つ進めて 6 にする。
  3. PCが 6 を指す: メモリから 0x02(ADD)を取得。「レジスタ同士を足す命令だな」と解釈し、R0 の 3 と R1 の 5 を足して、結果の 8 を R0 に書き込む。PCを3つ進めて 9 にする。
  4. PCが 9 を指す: メモリから 0x03(STORE)を取得。「レジスタの値をメモリに書き戻す命令だな」と解釈し、R0 の中身(8)を、指定された 30 番地のメモリ枠に書き込む。PCを 12 にする。
  5. PCが 12 を指す: メモリから 0xFF(HALT)を取得。ループフラグを倒し、CPUが完全に停止する。

画面に現れた「8」という数字は、魔法でも何でもありません。 「数値を取り出し、条件分岐で回路(コード)を振り分け、変数を書き換えてインデックスを進める」――たったこれだけの物理的ルールの連鎖が、計算という知的作業の正体なのです。

触って試す:仮想CPUのステップ実行

🖥️ 仮想CPUのステップ実行(記事のプログラムそのもの)

メモリには第4節のプログラム(3 + 5 を計算して結果を30番地に保存する機械語)が入っています。「次へ」を押すたびに、フェッチ → デコード → 実行の順に1段階ずつ進みます。PC・レジスタ・メモリの変化を追ってみてください。

① フェッチ② デコード③ 実行
PC = 0
R0 = 0
R1 = 0

現在の命令:―

「次へ」を押すと、PCが指す番地の数値をフェッチ(読み出し)します。


6. 条件分岐の真実:if 文とは「PCの数値を無理やり書き換えること」

「足し算の仕組みは分かった。でも、プログラムの醍醐味である if 文やループ処理はどうやって動いているのか?」

これも驚くほど原始的です。 命令セットに、次の「ジャンプ命令(条件分岐命令)」を1つ追加するだけで実現できます。

  • JNZ(Jump if Not Zero): 「もしレジスタの値が0でなければ、PCを指定した番地へ強制的にワープさせろ」
# もしJNZ命令(0x04)を実装するなら
elif op == 0x04:  # JNZ reg, target_addr
    r = self.memory[self.pc + 1]
    target_addr = self.memory[self.pc + 2]
    if self.reg[r] != 0:
        self.pc = target_addr  # PCを書き換えて過去の番地へ戻る(ループ!)
    else:
        self.pc += 3           # 条件不成立なら普通に次へ進む

通常、PCは「3つ進む、3つ進む」とリニアに前進します。 しかし、JNZ命令を使って**「PCを昔の番地(例えば0番地)に無理やり書き戻す」と、CPUは過去に実行した命令をもう一度最初から実行し始めます。これが「ループ(for / while)」**の正体です。

そして、レジスタの数値が引き算されて最終的に 0 になった瞬間、JNZはワープを中止して普通に次の番地へ進みます。これが**「ループの脱出(条件分岐)」**です。

高級言語で書く複雑な制御フローも、機械語の視点で見れば**「PCというただの整数変数を、条件に応じて書き換えているだけ」**に過ぎないのです。


7. まとめ:すべての巨人は、この50行の延長線上に立っている

私たちが作った50行のエミュレータと、世界最高峰のスーパーコンピュータ「富岳」や最新の「Core i9」「Apple M4」との間にある差は何でしょうか?

本質的なアーキテクチャに、差は何もありません。

実在するCPUも、行っている基本原理は全く同じです。 ただ、実用的なCPUは以下の点が拡張されているに過ぎません。

  1. 命令の数が数千種類ある(浮動小数点、ベクトル演算、暗号化支援など)
  2. レジスタの数が多く、サイズが大きい(64ビット幅、レジスタが32個〜数百個)
  3. 命令を1個ずつ順番に待たない(前話で解説したパイプライン、スーパースカラー、アウトオブオーダー実行)
  4. メモリが遅いので、手前に高速なキャッシュ(L1/L2/L3)を挟む

Python、Java、Go、Rust、どんな高級言語を書いていても、コンパイラやインタプリタの皮を何枚も剥ぎ取っていけば、一番底辺には必ずこの**「フェッチ・デコード・実行」の50行のサイクル**が鎮座しています。

ブラックボックスに見えるコンピュータも、その心臓部は人間が積み上げた論理の結晶です。メモリの上に並んだただの数字が、自分の書いたコードによって計算へと昇華する――この感覚を掴むことこそが、すべての低レイヤ・システムプログラミングの原点なのです。


📝 3行で頭に入る『試験・実務の要点まとめ』

  • 命令サイクルの三位一体: CPUは「メモリから命令を取得(フェッチ)」「命令の種類を特定(デコード)」「ALUやレジスタを操作(実行)」するループを高速反復する状態機械である。
  • ノイマン型アーキテクチャの真実: プログラムもデータも区別なく同一のメモリ配列に格納されており、CPUはPC(プログラムカウンタ)が指す番地を愚直に機械語として解釈する。
  • 分岐とループの物理: if や while などの制御構造は、機械語レベルでは「特定の条件を満たしたときにPCの数値を強制的にジャンプ先の番地に書き換える」だけの処理である。

確認クイズ

📝 確認クイズ(3問)