50行で作る「最小の仮想マシン(CPUエミュレータ)」――メモリ上のただの数字が「計算」に化ける物理の瞬間
1. 素朴な疑問:プログラミング言語のコードは、CPUの中でどう動いているのか?
Pythonで a = 3 + 5 と書いたとき、あるいはJavaScriptでボタンをクリックしたとき、コンピュータの深部にある黒い半導体チップ(CPU)の中では、一体何が起きているのでしょうか?
「コンパイラが機械語に翻訳し、CPUがそれを実行している」
教科書にはそう書いてあります。しかし、機械語とは要するに 0x01 や 0x2A といった**「ただの数値(バイト列)」**です。
なぜ、メモリの上に並んだただの数字が、文字を表示したり、四則演算を行ったり、3Dゲームのキャラクターを動かしたりできるのでしょうか?
結論から言えば、世界中のあらゆるCPU(Core i9も、Apple M4も、富岳のA64FXも)が行っていることは、驚くほどシンプルです。
- メモリから数値を1つ読み出す(フェッチ)
- その数値の意味を辞書のように解釈する(デコード)
- 演算器を動かして数値を更新する(実行)
- 次の番地へ進む
CPUとは、この極めて原始的な「4拍子のループ」を、1秒間に何十億回(GHz)も休むことなく繰り返しているだけの**「状態遷移マシン」**に過ぎません。
理屈を何冊も読むより、自分自身の手で「最小のCPU」をコードとして組んでみるのが一番の近道です。今回は、ライブラリを一切使わず、**Pythonわずか50行で動く最小の仮想マシン(CPUエミュレータ)**をゼロから作り上げます。
2. アーキテクチャの骨格:CPUの3大要素を設計する
ハードウェアとしてのCPUをエミュレート(模倣)するために必要な部品は、実はたったの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_IMM | 0x01(1) | reg, val | 指定したレジスタに即値 val を代入する |
| ADD | 0x02(2) | reg1, reg2 | reg1 に reg2 の値を足す(reg1 += reg2) |
| STORE | 0x03(3) | reg, addr | レジスタの値をメモリの addr 番地に書き込む |
| HALT | 0xFF(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内部の時計の針を進めてみましょう。
- PCが 0 を指す:
メモリから
0x01(LOAD_IMM)を取得。「あ、後ろに2つ数字が続く命令だな」とデコードし、0(R0)と3を読んでself.reg[0] = 3とする。PCを3つ進めて3にする。 - PCが 3 を指す:
再び
0x01を取得。1(R1)と5を読んでself.reg[1] = 5とする。PCを3つ進めて6にする。 - PCが 6 を指す:
メモリから
0x02(ADD)を取得。「レジスタ同士を足す命令だな」と解釈し、R0の 3 とR1の 5 を足して、結果の8をR0に書き込む。PCを3つ進めて9にする。 - PCが 9 を指す:
メモリから
0x03(STORE)を取得。「レジスタの値をメモリに書き戻す命令だな」と解釈し、R0の中身(8)を、指定された30番地のメモリ枠に書き込む。PCを12にする。 - PCが 12 を指す:
メモリから
0xFF(HALT)を取得。ループフラグを倒し、CPUが完全に停止する。
画面に現れた「8」という数字は、魔法でも何でもありません。 「数値を取り出し、条件分岐で回路(コード)を振り分け、変数を書き換えてインデックスを進める」――たったこれだけの物理的ルールの連鎖が、計算という知的作業の正体なのです。
触って試す:仮想CPUのステップ実行
🖥️ 仮想CPUのステップ実行(記事のプログラムそのもの)
メモリには第4節のプログラム(3 + 5 を計算して結果を30番地に保存する機械語)が入っています。「次へ」を押すたびに、フェッチ → デコード → 実行の順に1段階ずつ進みます。PC・レジスタ・メモリの変化を追ってみてください。
現在の命令:―
「次へ」を押すと、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は以下の点が拡張されているに過ぎません。
- 命令の数が数千種類ある(浮動小数点、ベクトル演算、暗号化支援など)
- レジスタの数が多く、サイズが大きい(64ビット幅、レジスタが32個〜数百個)
- 命令を1個ずつ順番に待たない(前話で解説したパイプライン、スーパースカラー、アウトオブオーダー実行)
- メモリが遅いので、手前に高速なキャッシュ(L1/L2/L3)を挟む
Python、Java、Go、Rust、どんな高級言語を書いていても、コンパイラやインタプリタの皮を何枚も剥ぎ取っていけば、一番底辺には必ずこの**「フェッチ・デコード・実行」の50行のサイクル**が鎮座しています。
ブラックボックスに見えるコンピュータも、その心臓部は人間が積み上げた論理の結晶です。メモリの上に並んだただの数字が、自分の書いたコードによって計算へと昇華する――この感覚を掴むことこそが、すべての低レイヤ・システムプログラミングの原点なのです。
📝 3行で頭に入る『試験・実務の要点まとめ』
- 命令サイクルの三位一体: CPUは「メモリから命令を取得(フェッチ)」「命令の種類を特定(デコード)」「ALUやレジスタを操作(実行)」するループを高速反復する状態機械である。
- ノイマン型アーキテクチャの真実: プログラムもデータも区別なく同一のメモリ配列に格納されており、CPUはPC(プログラムカウンタ)が指す番地を愚直に機械語として解釈する。
- 分岐とループの物理:
ifやwhileなどの制御構造は、機械語レベルでは「特定の条件を満たしたときにPCの数値を強制的にジャンプ先の番地に書き換える」だけの処理である。