RISC-Vを目標にCPUを自作する話(実装編2:Rustで8bit CPUエミュレータを実装)


はじめに

前回の記事では、命令を削って最小構成の8bit CPUの設計を行った。

それを踏まえて、今回は実際にRustでエミュレータを実装していく。
このエミュレータは、前回設計した命令セットを実行できるようにすることが目標だ。

オペコード、レジスタ、メモリの構造を定義し、命令のデコードと実行のロジックを実装していく。
実際にコードを動かしながら理解していく。


実行可能なサンプルプログラム

全体のCPUコードは大きくなったのでここでは割愛するが、実装したCPUを動かすためのサンプルコードを用意してみた。 次のようなテストコードを用意して動かすことができる。

mod cpu;
use cpu::CPU;

fn main() {
    let mut cpu = CPU::new();

    println!("2 + 3 = {}", cpu.run_add(2, 3));      // 5
    println!("5 - 2 = {}", cpu.run_sub(5, 2));       // 3
    println!("3 * 4 = {}", cpu.run_mul(3, 4));       // 12
    println!("10 / 2 = {}", cpu.run_div(10, 2));     // 5
//    println!("7 / 2 = {}", cpu.run_div(7, 2));      // infinite loop
}

ソースコード全体はgithub - falog_tiny_cpuに置いてあるので、興味がある人はそちらも見てみてほしい。


実装する命令の説明

今回実装する命令は以下の通り。

  • ADD(加算)
  • SUB(減算)
  • LD(メモリ→レジスタ)
  • ST(レジスタ→メモリ)
  • JMP(無条件ジャンプ)
  • JE(条件ジャンプ)
  • MOV(レジスタコピー)
  • HLT(停止)

命令コードとオペランド

命令は3バイトで構成される。 1バイト目はオペコードで、命令の種類を表す。 2バイト目と3バイト目はオペランドで、レジスタ番号やメモリアドレスを指定する。 例えば、ADD命令は「r1 = r1 + r2」という形式で、オペコードが0、op1がr1の番号、op2がr2の番号になる。

1バイトは8ビットなので、命令は24ビット(3バイト)で構成される。

命令の基本フォーマット

1バイト目
オペコード
命令の種類
2バイト目
op1
レジスタ番号 / アドレス
3バイト目
op2
レジスタ番号 / アドレス

※ 各命令は固定長(3バイト)で、PC(プログラムカウンタ)は通常+3ずつ進む
ジャンプ系の命令は、命令インデックスを受け取り、内部で3倍してPCにセットすることで、命令単位でジャンプできるようにしている。

オペコード

オペコードは命令の種類を表す値で、以下のように定義されている。

命令オペコード
ADD0
SUB1
LD2
ST3
JMP4
JE5
MOV6
HLT7

命令の動作

  • ADD: 指定された2つのレジスタの値を加算し、 結果を最初のレジスタに格納する。
ADD
  • SUB: 指定された2つのレジスタの値を減算し、 結果を最初のレジスタに格納する。
SUB
  • LD: 指定されたメモリアドレスから値を読み取り、 指定されたレジスタに格納する。
LD
  • ST: 指定されたレジスタの値を読み取り、 指定されたメモリアドレスに書き込む。
ST
  • JMP: 指定されたアドレスに無条件でジャンプする。
    ジャンプ先は命令インデックスで指定され、内部で3倍される。
JMP
  • JE: 指定されたレジスタの値が0の場合に、 指定されたアドレスにジャンプする。そうでない場合は次の 命令に進む。
JE
  • MOV: 指定された2つのレジスタの値をコピーする。
MOV
  • HLT: CPUの実行を停止する。
HLT

CPUの実行の流れ

CPUは以下の3ステップを繰り返して命令を実行する。

  1. fetch(命令をメモリから読み出す)
  2. decode(命令を解釈する)
  3. execute(実際に処理を行う)

例えば ADD 命令の場合:

  • PCが指す位置から3バイト読む(fetch)
  • opcodeを解釈する(decode)
  • レジスタの値を加算する(execute)
  • PCを+3進める

この一連の流れをRustで実装したのが、fetch / execute / step の処理である。

プログラム解説

const DEBUG: bool = false;

#[derive(Debug, Clone, Copy)]
enum Opcode {
    Add,
    Sub,
    Ld,
    St,
    Jmp,
    Je,
    Mov,
    Hlt,
}

// 命令は3byte固定: [opcode, op1, op2]
// PCはバイト単位で進む(通常は +3)
// ジャンプ系は命令インデックスを受け取り、内部で *3 する
impl Opcode {
    fn to_byte(self) -> u8 {
        match self {
            // 0: r1 = r1 + r2
            Opcode::Add => 0,
            // 1: r1 = r1 - r2
            Opcode::Sub => 1,
            // 2: r1 = mem[addr]
            Opcode::Ld => 2,
            // 3: mem[addr] = r1
            Opcode::St => 3,
            // 4: PC = addr * 3
            Opcode::Jmp => 4,
            // 5: if r == 0 { PC = addr * 3 } else { PC += 3 }
            Opcode::Je => 5,
            // 6: r1 = r2
            Opcode::Mov => 6,
            // 7: halt
            Opcode::Hlt => 7,
        }
    }

    fn from_byte(byte: u8) -> Self {
        match byte {
            0 => Opcode::Add,
            1 => Opcode::Sub,
            2 => Opcode::Ld,
            3 => Opcode::St,
            4 => Opcode::Jmp,
            5 => Opcode::Je,
            6 => Opcode::Mov,
            7 => Opcode::Hlt,
            _ => panic!("unknown opcode: {}", byte),
        }
    }
}

まずは、オペコードを定義する列挙型を用意する。 命令は3バイト固定で、1バイト目がオペコード、2バイト目と3バイト目がオペランドになる。 オペコードをバイトに変換するためのメソッドも用意している。 また、バイトからオペコードに変換するためのメソッドも用意している。

#[derive(Debug, Clone, Copy)]
struct Instruction {
    opcode: Opcode,
    op1: u8,
    op2: u8,
}

pub struct CPU {
    regs: [u8; 4],
    pc: usize,
    mem: [u8; 256],
    halted: bool,
}

次に、命令を表す構造体と、CPUの構造体を定義する。 CPUは4つの8bitレジスタ、プログラムカウンタ、256バイトのメモリ、そして停止状態を表すフラグを持つ。

impl CPU {
    pub fn new() -> Self {
        Self {
            regs: [0; 4],
            pc: 0,
            mem: [0; 256],
            halted: false,
        }
    }

    // 以下、命令を実行するためのメソッドや、命令のデコードと実行のロジックが続く
    pub fn run_add(&mut self, a: u8, b: u8) -> u8 {
        const R0: usize = 0;
        const R1: usize = 1;

        self.set_reg(R0, a);
        self.set_reg(R1, b);

        self.load_program(&[
            Instruction {
                opcode: Opcode::Add,
                op1: R0 as u8,
                op2: R1 as u8,
            },
            Instruction {
                opcode: Opcode::Hlt,
                op1: 0,
                op2: 0,
            },
        ]);

        self.run();
        self.get_reg(R0)
    }

    pub fn run_sub(&mut self, a: u8, b: u8) -> u8 {
        const R0: usize = 0;
        const R1: usize = 1;

        self.set_reg(R0, a);
        self.set_reg(R1, b);

        self.load_program(&[
            Instruction {
                opcode: Opcode::Sub,
                op1: R0 as u8,
                op2: R1 as u8,
            },
            Instruction {
                opcode: Opcode::Hlt,
                op1: 0,
                op2: 0,
            },
        ]);

        self.run();
        self.get_reg(R0)
    }

    pub fn run_mul(&mut self, a: u8, b: u8) -> u8 {
        // mul用レジスタ構成:
        // r0: result
        // r1: counter
        // r2: value
        // r3: constant 1
        const RESULT: usize = 0;
        const COUNTER: usize = 1; 
        const VALUE: usize = 2;    
        const ONE: usize = 3;       
        const LOOP: usize = 0;
        const HLT_ADDR: usize = 4;

        self.set_reg(RESULT, 0);
        self.set_reg(COUNTER, b);
        self.set_reg(VALUE, a);
        self.set_reg(ONE, 1);

        self.load_program(&[
            // LOOP:
            Instruction {
                opcode: Opcode::Add,
                op1: RESULT as u8,
                op2: VALUE as u8,
            },
            Instruction {
                opcode: Opcode::Sub,
                op1: COUNTER as u8,
                op2: ONE as u8,
            },
            Instruction {
                opcode: Opcode::Je,
                op1: COUNTER as u8,
                op2: HLT_ADDR as u8,
            },
            Instruction {
                opcode: Opcode::Jmp,
                op1: 0,
                op2: LOOP as u8,
            },
            // HLT:
            Instruction {
                opcode: Opcode::Hlt,
                op1: 0,
                op2: 0,
            },
        ]);

        self.run();
        self.get_reg(RESULT)
    }

    pub fn run_div(&mut self, a: u8, b: u8) -> u8 {
        if b == 0 {
            panic!("division by zero");
        }
        // div用レジスタ構成:
        // r0: result
        // r1: counter
        // r2: divisor
        // r3: constant 1
        const RESULT: usize = 0;
        const COUNTER: usize = 1;
        const VALUE: usize = 2;
        const ONE: usize = 3;
        const LOOP: usize = 0;
        const HLT_ADDR: usize = 4;

        self.set_reg(RESULT, 0);
        self.set_reg(COUNTER, a);
        self.set_reg(VALUE, b);
        self.set_reg(ONE, 1);

        self.load_program(&[
            // LOOP:
            Instruction {
                opcode: Opcode::Sub,
                op1: COUNTER as u8,
                op2: VALUE as u8,
            },
            Instruction {
                opcode: Opcode::Add,
                op1: RESULT as u8,
                op2: ONE as u8,
            },
            Instruction {
                opcode: Opcode::Je,
                op1: COUNTER as u8,
                op2: HLT_ADDR as u8,
            },
            Instruction {
                opcode: Opcode::Jmp,
                op1: 0,
                op2: LOOP as u8,
            },
            // HLT:
            Instruction {
                opcode: Opcode::Hlt,
                op1: 0,
                op2: 0,
            },
        ]);

        self.run();
        self.get_reg(RESULT)
    }

    fn set_reg(&mut self, idx: usize, val: u8) {
        if idx >= self.regs.len() {
            panic!("invalid register: {}", idx);
        }
        self.regs[idx] = val;
    }

    fn get_reg(&self, idx: usize) -> u8 {
        if idx >= self.regs.len() {
            panic!("invalid register: {}", idx);
        }
        self.regs[idx]
    }

    fn load_program(&mut self, program: &[Instruction]) {
        let mut i = 0;

        for inst in program {
            self.mem[i] = inst.opcode.to_byte();
            self.mem[i + 1] = inst.op1;
            self.mem[i + 2] = inst.op2;
            i += 3;
        }

        self.pc = 0;
        self.halted = false;
    }

    fn fetch(&self) -> Instruction {
        if self.pc + 2 >= self.mem.len() {
            panic!("PC out of bounds: {}", self.pc);
        }

        Instruction {
            opcode: Opcode::from_byte(self.mem[self.pc]),
            op1: self.mem[self.pc + 1],
            op2: self.mem[self.pc + 2],
        }
    }

    fn reg(&self, idx: u8) -> usize {
        let i = idx as usize;
        if i >= self.regs.len() {
            panic!("invalid register: {}", i);
        }
        i
    }

    fn addr(&self, addr: u8) -> usize {
        let a = addr as usize;
        if a >= self.mem.len() {
            panic!("invalid memory address: {}", a);
        }
        a
    }

命令を実行する execute

CPUの中心となる処理が execute である。 opcode に応じて処理を切り替える。

    fn execute(&mut self, inst: Instruction) {
        match inst.opcode {
            Opcode::Add => {
                let r1 = self.reg(inst.op1);
                let r2 = self.reg(inst.op2);
                let result = self.regs[r1].wrapping_add(self.regs[r2]);
                self.regs[r1] = result;
                self.pc += 3;
            }
            Opcode::Sub => {
                let r1 = self.reg(inst.op1);
                let r2 = self.reg(inst.op2);
                let result = self.regs[r1].wrapping_sub(self.regs[r2]);
                self.regs[r1] = result;
                self.pc += 3;
            }
            Opcode::Ld => {
                let r1 = self.reg(inst.op1);
                let addr = self.addr(inst.op2);
                self.regs[r1] = self.mem[addr];
                self.pc += 3;
            }
            Opcode::St => {
                let r1 = self.reg(inst.op1);
                let addr = self.addr(inst.op2);
                self.mem[addr] = self.regs[r1];
                self.pc += 3;
            }
            Opcode::Jmp => {
                self.pc = self.addr(inst.op2) * 3;
            }
            Opcode::Je => {
                let r = self.reg(inst.op1);

                if self.regs[r] == 0 {
                    self.pc = self.addr(inst.op2) * 3;
                } else {
                    self.pc += 3;
                }
            }
            Opcode::Mov => {
                let r1 = self.reg(inst.op1);
                let r2 = self.reg(inst.op2);
                self.regs[r1] = self.regs[r2];
                self.pc += 3;
            }
            Opcode::Hlt => {
                self.halted = true;
            }
        }
    }

8bit値で減算を行うため、
0未満になると値はラップアラウンドする。
(例: 0 - 1 = 255)

    pub fn step(&mut self) {
        let inst = self.fetch();

        if DEBUG {
            println!("PC={} {:?} \n-----------------------------------------------\nR0={} R1={} R2={} R3={} \n",
                self.pc,
                inst,
                self.regs[0],
                self.regs[1],
                self.regs[2],
                self.regs[3],

            );
        }

        self.execute(inst);
    }

    pub fn run(&mut self) {
        while !self.halted {
            self.step();
        }
    }
}

RustでCPUのエミュレータを実装する。
CPU構造体の中に、レジスタ、プログラムカウンタ、メモリ、停止フラグを定義する。
CPUの初期化、プログラムのロード、命令のフェッチ、デコード、実行のロジックを実装する。
命令の実行は、オペコードに応じて適切な処理を行い、PCを更新する。
例えば、ADD命令の場合は、指定された2つのレジスタの値を加算し、結果を最初のレジスタに格納する。
HLT命令の場合は、停止フラグを立てて実行を終了する。

さあ実験だ!!!

テストコードを動かそう!
まず、加算から!
2 + 3 = 5
→ 正しく動作していることが確認できた。

5 - 2 = 3
→ 減算も問題ない。

3 * 4 = 12
→ 乗算命令なしでもループで実現できている。

乗算の仕組み

このCPUには乗算命令がないため、加算とループで表現している。

例えば 3 * 4 は

3 + 3 + 3 + 3

として計算する。

そのため、カウンタが0になるまで加算を繰り返すことで、 乗算を実現している。

じゃああ、最後に
7 / 2
→ 怖いけどやってみよう!
→ 固まった!!!フリーズ!!!!

なぜこうなるのかは、後ほど確認する。

デバッグを有効にして流れを確認

ここからは、実際にCPUがどのように命令を実行しているのかを、 ログを使って少しだけ詳しく見ていく。

PC=0 Instruction { opcode: Add, op1: 0, op2: 1 } 
-----------------------------------------------
R0=2 R1=3 R2=0 R3=0 

PC=3 Instruction { opcode: Hlt, op1: 0, op2: 0 } 
-----------------------------------------------
R0=5 R1=3 R2=0 R3=0 

2 + 3 = 5

このように、命令の実行前にPCとレジスタの状態が表示されるようになる。
これで、命令がどのように実行されているか、レジスタの値がどのように変化しているかを確認できるようになる。
掛け算を見てみよう

PC=0 Instruction { opcode: Add, op1: 0, op2: 2 } 
-----------------------------------------------
R0=0 R1=4 R2=3 R3=1 

PC=3 Instruction { opcode: Sub, op1: 1, op2: 3 } 
-----------------------------------------------
R0=3 R1=4 R2=3 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=3 R1=3 R2=3 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=3 R1=3 R2=3 R3=1 

PC=0 Instruction { opcode: Add, op1: 0, op2: 2 } 
-----------------------------------------------
R0=3 R1=3 R2=3 R3=1 

PC=3 Instruction { opcode: Sub, op1: 1, op2: 3 } 
-----------------------------------------------
R0=6 R1=3 R2=3 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=6 R1=2 R2=3 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=6 R1=2 R2=3 R3=1 

PC=0 Instruction { opcode: Add, op1: 0, op2: 2 } 
-----------------------------------------------
R0=6 R1=2 R2=3 R3=1 

PC=3 Instruction { opcode: Sub, op1: 1, op2: 3 } 
-----------------------------------------------
R0=9 R1=2 R2=3 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=9 R1=1 R2=3 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=9 R1=1 R2=3 R3=1 

PC=0 Instruction { opcode: Add, op1: 0, op2: 2 } 
-----------------------------------------------
R0=9 R1=1 R2=3 R3=1 

PC=3 Instruction { opcode: Sub, op1: 1, op2: 3 } 
-----------------------------------------------
R0=12 R1=1 R2=3 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=12 R1=0 R2=3 R3=1 

PC=12 Instruction { opcode: Hlt, op1: 0, op2: 0 } 
-----------------------------------------------
R0=12 R1=0 R2=3 R3=1 

3 * 4 = 12

ループでうまく動いていることが確認できる。
最後に割り算を見てみよう

PC=0 Instruction { opcode: Sub, op1: 1, op2: 2 } 
-----------------------------------------------
R0=0 R1=10 R2=2 R3=1 

PC=3 Instruction { opcode: Add, op1: 0, op2: 3 } 
-----------------------------------------------
R0=0 R1=8 R2=2 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=1 R1=8 R2=2 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=1 R1=8 R2=2 R3=1 

PC=0 Instruction { opcode: Sub, op1: 1, op2: 2 } 
-----------------------------------------------
R0=1 R1=8 R2=2 R3=1 

PC=3 Instruction { opcode: Add, op1: 0, op2: 3 } 
-----------------------------------------------
R0=1 R1=6 R2=2 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=2 R1=6 R2=2 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=2 R1=6 R2=2 R3=1 

(中略:同様のループが続く)

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=5 R1=0 R2=2 R3=1 

PC=12 Instruction { opcode: Hlt, op1: 0, op2: 0 } 
-----------------------------------------------
R0=5 R1=0 R2=2 R3=1 

10 / 2 = 5

ループでうまく動いていることが確認できる。 割り切れない数を試してみよう

PC=0 Instruction { opcode: Sub, op1: 1, op2: 2 } 
-----------------------------------------------
R0=0 R1=7 R2=2 R3=1 

PC=3 Instruction { opcode: Add, op1: 0, op2: 3 } 
-----------------------------------------------
R0=0 R1=5 R2=2 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=1 R1=5 R2=2 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=1 R1=5 R2=2 R3=1 

PC=0 Instruction { opcode: Sub, op1: 1, op2: 2 } 
-----------------------------------------------
R0=1 R1=5 R2=2 R3=1 

PC=3 Instruction { opcode: Add, op1: 0, op2: 3 } 
-----------------------------------------------
R0=1 R1=3 R2=2 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=2 R1=3 R2=2 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=2 R1=3 R2=2 R3=1 

PC=0 Instruction { opcode: Sub, op1: 1, op2: 2 } 
-----------------------------------------------
R0=2 R1=3 R2=2 R3=1 

PC=3 Instruction { opcode: Add, op1: 0, op2: 3 } 
-----------------------------------------------
R0=2 R1=1 R2=2 R3=1 

PC=6 Instruction { opcode: Je, op1: 1, op2: 4 } 
-----------------------------------------------
R0=3 R1=1 R2=2 R3=1 

PC=9 Instruction { opcode: Jmp, op1: 0, op2: 0 } 
-----------------------------------------------
R0=3 R1=1 R2=2 R3=1 
(略)

※ このCPUでは条件分岐が「== 0」のみのため、 割り算によって余りが出る場合、ループが終了しない。

これは単なるバグではなく、 「命令セットの表現力」による制約である。

実際のCPUでは、大小比較や符号判定などの条件分岐が用意されており、 より柔軟な制御が可能になっている。


おわりに

今回は、前回設計した最小構成の8bit CPUについて、命令のデコードと実行処理をRustで実装し、実際にサンプルプログラムが動作するところまで確認した。

最小構成ながら、CPUとしての基本的な動作は一通り実現できている。

これにより、シンプルな命令セットを持つCPUが、どのようにプログラムを実行するのかをコードとして形にすることができた。

ただし、実際の命令実行の流れはコードだけでは追いづらい部分もある。

次回は具体的なプログラムを例に、命令がどのように実行されるのかをステップごとに追いながら、CPUの動作をより直感的に理解できるように解説する。

また今後は、シフト命令や論理演算命令の追加などを行い、より実用的なCPUへと拡張していく予定である。