エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

RustでRISC-Vシミュレータを実装する

【 デジスマチーム ブログリレー2日目】

エンジニアリングGの立花です。

デジスマ診療 というサービスのアプリ(Flutter)、バックエンド、フロントエンドの開発をしています、宜しくお願いします。

最近低レイヤーの技術に興味があり、趣味でRISC-Vの勉強をしています。 今回は実際に実行ファイルを動かすことが出来るRISC-VシミュレータをRustで実装していきます。

はじめに

こちらの記事は、先日エムスリー社内LT会であるTechTalkで発表した内容で後日動画を公開予定です。

また、実装したシミュレータのソースコードは以下で閲覧できます。 github.com

目次

RISC-Vとは

RISC-Vとは、2011年に発表されたオープンな非営利団体が所有する命令セットアーキテクチャ(ISA)です。 オープンソースライセンスで提供されており、x86やARMといったISAと比較してマニュアルが短いため趣味でシミュレータを実装するのにうってつけです。

RISC-V ISAの承認済み仕様のマニュアルは以下の2部構成になっており、分量は合計300~400ページほどです。

  • Volume 1, Unprivileged Specification
  • Volume 2, Privileged Specification

これらのマニュアルは以下のページからダウンロードできます。

Specifications - RISC-V International

今回の目標

RISC-Vプロセッサのためのテストがまとめられた以下のリポジトリにある、add命令に関するテストを一通り実行できるシミュレータの開発を目指します。

github.com

add命令のテストと言ってもadd命令だけ実装すればいいのではなく、テストをパスするためには以下の17種類の命令を実装する必要があります。

命令 説明
addi Add Immediate
add Add
auipc Add Upper Immediate to PC
beq Branch if Equal
bge Branch if Greater Than or Equal
bne Branch if Not Equal
ecall Environment Call
fence Fence Memory and I/O
jal Jump and Link
lui Load Upper Immediate
slli Shift Left Logical Immediate
sw Store Word
addiw Add Word Immediate
csrrs Control And Status Register Read and Set
csrrwi Control and Status Register Read and Write Immediate
csrrw Control and Status Register Read and Write

開発環境

今回の開発はUbuntu 22.04.2 LTS上で行いました。また言語はRustを使用しました。

テスト内容の確認

クロスコンパイル環境の準備

テストの実行ファイルを作成するためにはRISC-V向けのクロスコンパイラを用意する必要があります。 ソースをビルドすると非常に時間がかかるので、ここでは以下のリポジトリで配布されているビルド済みのツールチェーンを使用します。

github.com

手順は以下のとおりです。

curl -o toolchain.tar.gz https://static.dev.sifive.com/dev-tools/riscv64-unknown-elf-gcc-8.1.0-2019.01.0-x86_64-linux-ubuntu14.tar.gz
sudo mkdir /opt/riscv
sudo tar xfz toolchain.tar.gz --strip-components 1 -C /opt/riscv
export PATH=/opt/riscv/bin:$PATH

テストの実行ファイルを作成

以下の手順でテストの実行ファイルを作成します。

git clone --recursive https://github.com/riscv/riscv-tests
cd riscv-tests/isa
make -j8 -s
find . -maxdepth 1 -not -type d -name 'rv*' -printf '%f\n' | grep -v '\\.' | xargs -I {} riscv64-unknown-elf-objcopy -O binary {} {}.bin

以下のファイルが作成されているのを確認します。

# テスト実行ファイル
riscv-tests/isa/rv64ui-p-add.bin
# 実行ファイルの内容
riscv-tests/isa/rv64ui-p-add.dump

テストの内容を確認

今回実行するテストの実行ファイルの処理の流れは大まかに以下のようになっています。

  1. 各種初期化処理を行う
  2. テストケース37個を順次実行
  3. テストがpassしたらアドレス 0x80001000 に 1 を書き込む

以下のファイルをテキストエディタで開き、実際に今回実行するテストの内容を確認してみます。

riscv-tests/isa/rv64ui-p-add.dump

ファイルの冒頭は以下のようになっており、このプログラムはメモリの 0x80000000 のアドレスから開始されること、開始直後に <reset_vector> のアドレスにジャンプすることがわかります。

rv64ui-p-add:     file format elf64-littleriscv


Disassembly of section .text.init:

0000000080000000 <_start>:
    80000000:   0500006f            j   80000050 <reset_vector>

<reset_vector>のアドレスを確認してみましょう。ここでは各レジスタの初期化などを行っているようです。

liというのは疑似命令で、実際はluiやaddiといった別の命令のいずれかに展開されます。 ra, sp, gp, tp…というのが整数用のレジスタの名前で、これらのレジスタの値を0に変更しています。

0000000080000050 <reset_vector>:
    80000050:   00000093            li  ra,0
    80000054:   00000113            li  sp,0
    80000058:   00000193            li  gp,0
    8000005c:   00000213            li  tp,0
    80000060:   00000293            li  t0,0
    80000064:   00000313            li  t1,0
    80000068:   00000393            li  t2,0
    8000006c:   00000413            li  s0,0
    80000070:   00000493            li  s1,0

また以下ではcsrwという疑似命令(csrrwに展開される)を使用し、コントロールアンドステータスレジスタと呼ばれるレジスタに例外ハンドラのアドレスを登録しています。

    80000124:    00000297            auipc   t0,0x0
    80000128:   ee028293            addi    t0,t0,-288 # 80000004 <trap_vector>
    8000012c:   30529073            csrw    mtvec,t0

<reset_vector> から始まる初期化処理を抜けると、次は以下のような実際のテストに遷移します。

gpにテスト番号、raとspにadd命令に渡す値を入れ、add命令の実行結果をa4に入れています。 その後t2にテストの期待値を入れ、a4とt2を比較し違う値であれば <fail> にジャンプ、同じ値であればジャンプは発生せず次のテストに遷移するようです。

0000000080000180 <test_2>:
    80000180:   00200193            li  gp,2
    80000184:   00000093            li  ra,0
    80000188:   00000113            li  sp,0
    8000018c:   00208733            add a4,ra,sp
    80000190:   00000393            li  t2,0
    80000194:   4e771063            bne a4,t2,80000674 <fail>

0000000080000198 <test_3>:
    80000198:   00300193            li  gp,3
    8000019c:   00100093            li  ra,1
    800001a0:   00100113            li  sp,1
    800001a4:   00208733            add a4,ra,sp
    800001a8:   00200393            li  t2,2
    800001ac:   4c771463            bne a4,t2,80000674 <fail>

38番目のテストがパスすると、<pass> にジャンプします。 <pass> の処理を見てみると、テストに成功した場合はgpの値が1になるようです。 最後にecallが呼び出されています。ecallは環境呼び出し例外という例外を発生させる命令で、この命令が呼び出されると例外ハンドラにジャンプします。

0000000080000658 <test_38>:
    80000658:   02600193            li  gp,38
    8000065c:   01000093            li  ra,16
    80000660:   01e00113            li  sp,30
    80000664:   00208033            add zero,ra,sp
    80000668:   00000393            li  t2,0
    8000066c:   00701463            bne zero,t2,80000674 <fail>
    80000670:   02301063            bne zero,gp,80000690 <pass>

0000000080000674 <fail>:
    80000674:   0ff0000f            fence
    80000678:   00018063            beqz    gp,80000678 <fail+0x4>
    8000067c:   00119193            slli    gp,gp,0x1
    80000680:   0011e193            ori gp,gp,1
    80000684:   05d00893            li  a7,93
    80000688:   00018513            mv  a0,gp
    8000068c:   00000073            ecall

0000000080000690 <pass>:
    80000690:   0ff0000f            fence
    80000694:   00100193            li  gp,1
    80000698:   05d00893            li  a7,93
    8000069c:   00000513            li  a0,0
    800006a0:   00000073            ecall
    800006a4:   c0001073            unimp
    800006a8:   0000                    unimp

例外ハンドラ <trap_vector> を確認してみると、<write_tohost> にジャンプすることが分かります。

0000000080000004 <trap_vector>:
    80000004:   34202f73            csrr    t5,mcause
    80000008:   00800f93            li  t6,8
    8000000c:   03ff0863            beq t5,t6,8000003c <write_tohost>

<write_tohost> を確認すると、0x80001000 のアドレスにgpの値を書き込む処理がループしています。 テストがパスした場合gpの値には1が入っているので、 テストが成功したかどうかは 0x80001000 に1が書き込まれていることを確認すれば良さそうです。

000000008000003c <write_tohost>:
    8000003c:   00001f17            auipc   t5,0x1
    80000040:   fc3f2223            sw  gp,-60(t5) # 80001000 <tohost>
    80000044:   00001f17            auipc   t5,0x1
    80000048:   fc0f2023            sw  zero,-64(t5) # 80001004 <tohost+0x4>
    8000004c:   ff1ff06f            j   8000003c <write_tohost>

シミュレータの実装

実際にシミュレータを実装していきます。実装の流れは以下のとおりです。

  1. メモリを実装
  2. プログラムカウンタを実装
  3. メインループを実装
  4. 整数レジスタを実装
  5. 命令デコーダを実装
  6. 命令実行処理を実装
  7. Control And Statusレジスタを実装
  8. 例外処理を実装

メモリを実装

テストの実行ファイルを配置するメモリを実装します。

以下のように、1MB分のメモリ領域を Vec で用意します。 今回のテストでは 0x80000000 以降のアドレスにしかアクセスしないため、メモリにアクセスする際はアドレスから 0x80000000 を引いたindexに対して読み書きします。

pub const MEMORY_SIZE: u64 = 1024 * 1024;
const MEMORY_BASE_ADDRESS: u64 = 0x8000_0000;

pub struct Memory {
    pub memory: Vec<u8>,
}

impl Default for Memory {
    fn default() -> Self {
        Self {
            memory: vec![0; MEMORY_SIZE as usize],
        }
    }
}

メモリから4byteを読み書きする処理を実装します。

impl Memory {
    pub fn size(&self) -> u64 {
        MEMORY_BASE_ADDRESS + MEMORY_SIZE
    }

    pub fn load(&self, address: u64) -> u32 {
        (0..4 as usize).fold(0, |acc, i| {
            acc | (self.memory[(address - MEMORY_BASE_ADDRESS) as usize + i] as u32) << (8 * i)
        })
    }

    pub fn store(&mut self, address: u64, value: u32) {
        for i in 0..4 as usize {
            self.memory[(address - MEMORY_BASE_ADDRESS) as usize + i] = (value >> (i * 8)) as u8;
        }
    }
}

実装したコードは以下のとおりです。

github.com

プログラムカウンタを実装

次にフェッチする命令のアドレスを管理するためのプログラムカウンタを実装します。

RISC-Vではプログラムカウンタはレジスタとは別に用意されています。もしプログラムカウンタがレジスタの1つだった場合(ARM-32などはこの設計)レジスタを変更する命令全てが分岐命令になる可能性があり、分岐予測を実装する際に面倒なことになります。

プログラムカウンタを読み取る処理、アドレスを次の命令のアドレスに進める処理、分岐命令用に任意のアドレスにジャンプする処理を実装します。

pub struct ProgramCounter {
    pc: u64,
}

impl Default for ProgramCounter {
    fn default() -> Self {
        Self {
            pc: MEMORY_BASE_ADDRESS,
        }
    }
}

impl ProgramCounter {
    pub fn read(&self) -> u64 {
        self.pc
    }

    pub fn increment(&mut self) {
        self.pc += 4;
    }

    pub fn jump(&mut self, address: u64) {
        self.pc = address;
    }
}

今回実装する命令の命令長は全て4byteなので、プログラムカウンタを次の命令のアドレスに進める場合は常に4byteずつ進めれば良いです。

ちなみにRISC-VにはRV32Cという圧縮命令の拡張があり、この拡張を実装する場合は命令長は2byteもしくは4byteとなるため、次の命令のアドレスに進む際は前に読み込んだ命令によって2byte進むか4byte進むかの判定が必要になります。

メインループを実装

メモリから命令をフェッチ、デコード、実行を繰り返すメインループを実装します。

メインループの処理の流れは以下のとおりです。

  1. プログラムカウンタから読み込む命令のアドレスを読み取る
  2. メモリから命令をフェッチする
  3. 命令をデコードする
  4. 命令を実行する
  5. テストを終了するかどうか判定する
  6. プログラムカウンタのアドレスを +4byte する

引数のterminatorはテストを終了するかどうかを判定する関数で、テストを終了する際はテスト結果を数字で返すような設計にしています。

命令のデコードと実行は後のステップで実装します。

#[derive(Default)]
pub struct Simulator {
    pc: ProgramCounter,
    memory: Memory,
}

impl Simulator {
    fn run(&mut self, terminator: impl Fn(&Simulator) -> Option<u64>) -> u64 {
        while self.pc.read() < self.memory.size() {
            let address = self.pc.read();
            let instruction = self.memory.load(address);

            // TODO: decode and execute the instruction

            if let Some(result) = terminator(self) {
                return result;
            }

            self.pc.increment();
        }
        0
    }
}

実装したコードは以下のとおりです。

github.com

整数レジスタを実装

32個の整数レジスタを実装していきます。

ちなみに0番目のレジスタは値が常にゼロとなっており、これによってゼロを使った演算をする際にわざわざレジスタに0を入れる手間が無くなります。

#[derive(Default)]
pub struct IntegerRegister {
    x: [u64; 32],
}

impl IntegerRegister {
    pub fn read(&self, register: usize) -> u64 {
        self.x[register]
    }

    pub fn write(&mut self, register: usize, value: u64) {
        if register != 0 {
            self.x[register] = value;
        }
    }

実装したコードは以下のとおりです。

github.com

命令デコーダを実装

命令デコーダを実装します。

RISC-Vの基本命令フォーマットは以下の6種類があります。

  • R-type: 2つのレジスタの値を使用し演算する命令で使用される
  • I-type: 1つのレジスタと1つの即値を使用し演算する命令で使用される
  • S-type: メモリに値をストアする命令で使用される
  • B-type: 現在のアドレスから相対位置にジャンプする分岐命令で使用される
  • U-type: 即値をレジスタに保存する命令で使用される
  • J-type: メモリの絶対位置にジャンプする分岐命令で使用される

Figure 1: RISC-V base instruction formats showing immediate variants.

RISC-V Specification Volume 1, Unprivileged Spec v. 20191213, p.16, https://riscv.org/technical/specifications/, 2023/3/2参照

上記図中のopcodeはオペコード、rdは演算結果の保存先のレジスタ、rs1・rs2はオペランド、immは即値(カッコ中の数字は即値全体の何ビット目に当たるかを表す)です。

funct3やfunct7はどの命令かを判定するためなどに使用されます。

まず各フォーマットごとにニーモニックを表すenumを実装します。

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeR {
    Add,
}

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeI {
    Slli,
    Addi,
    Fence,
    Ecall,
}

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeS {
    Sw,
}

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeB {
    Beq,
    Bne,
    Bge,
}

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeU {
    Lui,
    Auipc,
}

#[derive(Debug, PartialEq)]
pub enum Rv32iOpcodeJ {
    Jal,
}

次にデコードした値を保持するenumを実装します。

#[derive(Debug, PartialEq)]
pub enum Instruction<OpcodeR, OpcodeI, OpcodeS, OpcodeB, OpcodeU, OpcodeJ> {
    TypeR {
        opcode: OpcodeR,
        rd: usize,
        funct3: usize,
        rs1: usize,
        rs2: usize,
        funct7: usize,
    },
    TypeI {
        opcode: OpcodeI,
        rd: usize,
        funct3: usize,
        rs1: usize,
        imm: u64,
    },
    TypeS {
        opcode: OpcodeS,
        funct3: usize,
        rs1: usize,
        rs2: usize,
        imm: u64,
    },
    TypeB {
        opcode: OpcodeB,
        funct3: usize,
        rs1: usize,
        rs2: usize,
        imm: u64,
    },
    TypeU {
        opcode: OpcodeU,
        rd: usize,
        imm: u64,
    },
    TypeJ {
        opcode: OpcodeJ,
        rd: usize,
        imm: u64,
    },
}

各フォーマット毎に命令をデコードする処理を実装します。

    #[allow(clippy::type_complexity)]
    fn decode(
        instruction: u32,
    ) -> Option<
        Instruction<
            Self::OpcodeR,
            Self::OpcodeI,
            Self::OpcodeS,
            Self::OpcodeB,
            Self::OpcodeU,
            Self::OpcodeJ,
        >,
    >;

    #[allow(clippy::type_complexity)]
    fn decode_r(
        opcode: Option<Self::OpcodeR>,
        instruction: u32,
    ) -> Option<
        Instruction<
            Self::OpcodeR,
            Self::OpcodeI,
            Self::OpcodeS,
            Self::OpcodeB,
            Self::OpcodeU,
            Self::OpcodeJ,
        >,
    > {
        opcode.map(|o| {
            let rd = ((instruction >> 7) & MASK_5BIT) as usize;
            let funct3 = ((instruction >> 12) & MASK_3BIT) as usize;
            let rs1 = ((instruction >> 15) & MASK_5BIT) as usize;
            let rs2 = ((instruction >> 20) & MASK_5BIT) as usize;
            let funct7 = ((instruction >> 25) & MASK_7BIT) as usize;
            Instruction::TypeR {
                opcode: o,
                rd,
                funct3,
                rs1,
                rs2,
                funct7,
            }
        })
    }

最後に実際に命令をデコードする処理を実装します。

各命令がどのようなフォーマットになっているかをマニュアルで確認しながら実装していきます。

Figure 2: RV32I Base Instruction Set

RISC-V Specification Volume 1, Unprivileged Spec v. 20191213, p.130, https://riscv.org/technical/specifications/, 2023/3/2参照
    #[allow(clippy::type_complexity)]
    fn decode(
        instruction: u32,
    ) -> Option<
        Instruction<
            Self::OpcodeR,
            Self::OpcodeI,
            Self::OpcodeS,
            Self::OpcodeB,
            Self::OpcodeU,
            Self::OpcodeJ,
        >,
    > {
        let opcode = instruction & MASK_7BIT;
        let funct3 = (instruction >> 12) & MASK_3BIT;
        let funct7 = (instruction >> 25) & MASK_7BIT;
        match opcode {
            0b0110111 => Self::decode_u(Some(Rv32iOpcodeU::Lui), instruction),
            0b0010111 => Self::decode_u(Some(Rv32iOpcodeU::Auipc), instruction),
            0b1101111 => Self::decode_j(Some(Rv32iOpcodeJ::Jal), instruction),
            0b1100011 => Self::decode_b(
                match funct3 {
                    0b000 => Some(Rv32iOpcodeB::Beq),
                    0b001 => Some(Rv32iOpcodeB::Bne),
                    0b101 => Some(Rv32iOpcodeB::Bge),
                    _ => None,
                },
                instruction,
            ),
// 以下略

実装したコードは以下のとおりです。

github.com

命令実行処理を実装

命令の実行処理を実装します。以下のようにデコードした命令を受け取り、演算結果によりプログラムカウンタ、レジスタ、メモリを変更します。 また例外が発生した場合は例外を返します。

pub trait Executor {
    type OpcodeR;
    type OpcodeI;
    type OpcodeS;
    type OpcodeB;
    type OpcodeU;
    type OpcodeJ;

    #[allow(clippy::type_complexity)]
    fn execute(
        instruction: Instruction<
            Self::OpcodeR,
            Self::OpcodeI,
            Self::OpcodeS,
            Self::OpcodeB,
            Self::OpcodeU,
            Self::OpcodeJ,
        >,
        prv: &PrivilegeMode,
        pc: &mut ProgramCounter,
        x: &mut IntegerRegister,
        memory: &mut Memory,
    ) -> Result<(), Cause>;
}

例外が発生した時に返す例外用のenumを実装します。RISC-Vの例外と割り込みの原因は以下のとおりです。

Figure 3: Machine cause register (mcause) values after trap.

RISC-V Specification Volume 2, Privileged Specification version 20211203, p.39, https://riscv.org/technical/specifications/, 2023/3/2参照

これらの例外と割り込みの原因を実装していきます。原因(Cause)には割り込み(Interrupt)、例外(Exception)があります。また、実装を単純にするため例外ハンドラから戻る際に発生するExceptionReturnも追加します。例外処理の詳細は後で実装します。

pub enum Cause {
    Interrupt(Interrupt),
    Exception(Exception),
    ExceptionReturn(ExceptionReturn),
}

pub enum Interrupt {
    UserSoftware,
    SupervisorSoftware,
    MachineSoftware,
    UserTimer,
    SupervisorTimer,
    MachineTimer,
    UserExternal,
    SupervisorExternal,
    MachineExternal,
}

pub enum Exception {
    InstructionAddressMisaligned,
    InstructionAccessFault,
    IllegalInstruction,
    Breakpoint,
    LoadAddressMisaligned,
    LoadAccessFault,
    StoreAddressMisaligned,
    StoreAccessFault,
    EnvironmentCallFromUserMode,
    EnvironmentCallFromSupervisorMode,
    EnvironmentCallFromMachineMode,
    InstructionPageFault,
    LoadPageFault,
    StorePageFault,
}

pub enum ExceptionReturn {
    User,
    Supervisor,
    Machine,
}

ここで特権レベルについても簡単に説明します。

RISC-Vには以下のような特権モードがあります。

  • ユーザーモード
  • スーパーバイザモード
  • マシンモード

特権の強さはマシンモード>スーパーバイザモード>ユーザーモードの順に強く、特権が強いモードは特権が弱いモードの全ての機能にアクセスできます。

通常アプリケーションコードはユーザーモードで実行されます。

特権モードについても例外処理に関わってくるため先に実装しておきます。

pub enum PrivilegeMode {
    User = 0b00,
    Supervisor = 0b01,
    Machine = 0b11,
}

impl Default for PrivilegeMode {
    fn default() -> Self {
        PrivilegeMode::Machine
    }
}

impl PrivilegeMode {
    pub fn from_primitive(mode: u64) -> Self {
        match mode {
            0 => Self::User,
            0b01 => Self::Supervisor,
            0b11 => Self::Machine,
            _ => panic!(),
        }
    }
}

後はどんどん命令の実行処理を実装していくだけです。

add命令の実行処理では、rs1のレジスタの値と、rs2のレジスタの値を足してrdのレジスタに書き込んでいます。

fence命令は複数のハードウェアスレッドでメモリ操作命令実行順序を保証するための命令ですが、今回実装するシミュレータは複数スレッドで動作するような事は無いため特に処理を行っていません。

ecall命令の実行処理では環境呼び出し例外を発生させています。

impl Executor for Rv32iExecutor {
    type OpcodeR = Rv32iOpcodeR;
    type OpcodeI = Rv32iOpcodeI;
    type OpcodeS = Rv32iOpcodeS;
    type OpcodeB = Rv32iOpcodeB;
    type OpcodeU = Rv32iOpcodeU;
    type OpcodeJ = Rv32iOpcodeJ;

    fn execute(
        instruction: Instruction<
            Rv32iOpcodeR,
            Rv32iOpcodeI,
            Rv32iOpcodeS,
            Rv32iOpcodeB,
            Rv32iOpcodeU,
            Rv32iOpcodeJ,
        >,
        prv: &PrivilegeMode,
        pc: &mut ProgramCounter,
        x: &mut IntegerRegister,
        memory: &mut Memory,
    ) -> Result<(), Cause> {
        match instruction {
            Instruction::TypeR {
                opcode,
                rd,
                funct3: _,
                rs1,
                rs2,
                funct7: _,
            } => match opcode {
                Rv32iOpcodeR::Add => {
                    x.write(rd, x.read(rs1).wrapping_add(x.read(rs2)));
                    Ok(())
                }
            },
            Instruction::TypeI {
                opcode,
                rd,
                funct3: _,
                rs1,
                imm,
            } => match opcode {
                Rv32iOpcodeI::Slli => {
                    x.write(rd, x.read(rs1) << (imm << MASK_6BIT));
                    Ok(())
                }
                Rv32iOpcodeI::Addi => {
                    x.write(rd, x.read(rs1).wrapping_add(extend_sign(imm, 12)));
                    Ok(())
                }
                Rv32iOpcodeI::Fence => Ok(()), // not yet supported
                Rv32iOpcodeI::Ecall => match prv {
                    PrivilegeMode::User => {
                        Err(Cause::Exception(Exception::EnvironmentCallFromUserMode))
                    }
                    PrivilegeMode::Supervisor => Err(Cause::Exception(
                        Exception::EnvironmentCallFromSupervisorMode,
                    )),
                    PrivilegeMode::Machine => {
                        Err(Cause::Exception(Exception::EnvironmentCallFromMachineMode))
                    }
                },
            },
// 以下略

実装したコードは以下のとおりです。

github.com

Control And Statusレジスタを実装

例外処理を実装する前に、コントロールアンドステータスレジスタ(以下CSR)と呼ばれるレジスタを実装する必要があります。

CSRには以下のようなものが保存されます。

  • どの例外が発生したか
  • 例外が発生したときにジャンプする先のアドレス
  • 例外を引き起こしているアドレス
  • 例外を引き起こした命令
  • サイクル数
  • 実行完了した命令数
  • 浮動小数点演算時に発生したエラー

CSRは最大4096個の値を格納できます。

以下のようにCSRの読み込み、書き込み処理を実装していきます。

pub struct ControlAndStatusRegister {
    csr: HashMap<u64, u64>,
}

impl ControlAndStatusRegister {
    fn contains(&self, address: u64) -> bool {
        self.csr.contains_key(&address)
    }

    fn read(&self, address: u64) -> u64 {
        if self.contains(address) {
            return self.csr[&address];
        }
        panic!("address not found. {:x}", address);
    }

    fn write(&mut self, address: u64, value: u64) {
        if self.contains(address) {
            *self.csr.get_mut(&address).unwrap() = value;
            return;
        }
        panic!("address not found. {:x}", address);
    }

    pub fn csrrw(&mut self, address: u64, value: u64) -> u64 {
        let t = self.read(address);
        self.write(address, value);
        t
    }

    pub fn csrrs(&mut self, address: u64, value: u64) -> u64 {
        let t = self.read(address);
        self.write(address, self.csr[&address] | value);
        t
    }

    pub fn csrrc(&mut self, address: u64, value: u64) -> u64 {
        let t = self.read(address);
        self.write(address, self.csr[&address] & !value);
        t
    }
}

impl Default for ControlAndStatusRegister {
    fn default() -> Self {
        Self {
            csr: [
                MSTATUS, MEDELEG, MIDELEG, MTVEC, MEPC, MCAUSE, MTVAL, SSTATUS, SEDELEG, SIDELEG,
                STVEC, SEPC, SCAUSE, STVAL, USTATUS, UTVEC, UEPC, UCAUSE, UTVAL,
            ]
            .iter()
            .cloned()
            .map(|a| (a, 0))
            .collect::<HashMap<_, _>>(),
        }
    }
}

例外処理を実装

何らかの例外や割り込みが発生した際に例外ハンドラへ処理が遷移しますが、遷移の前に色々とやらなければならない処理があります。

以下の手順でCSRへの書き込みなどを行います。以下の MEDELGMCAUSE などはCSRの値を表しています。

  1. MEDELG, SEDELEG (例外の種類より選択)から、次に遷移する特権モードを取得
  2. 以降マシンモードに遷移すると仮定する
  3. MCAUSE に例外コードを書き込む
  4. MEPC に例外が発生したアドレスを書き込む
  5. MTVAL に例外が発生したアドレス or 例外を発生させた命令を書き込む
  6. MSTATUS に以前の特権モードを記録
  7. STATUS_MIE (例外が有効かどうか)の値を STATUS_MPIE に保存
  8. STATUS_MIE の値を 例外を無効 に変更
  9. 次に遷移する特権モードに遷移
  10. MTVEC に設定された例外ハンドラのアドレスに遷移
fn handle_trap(
    cause: &Cause,
    pc_address: u64,
    instruction: u32,
    current_privilege_mode: PrivilegeMode,
    csr: &mut ControlAndStatusRegister,
) -> (PrivilegeMode, u64) {
    let next_privilege_mode = delegated_privilege_mode(csr, cause);
    // set cause register
    let cause_address = select_address(&next_privilege_mode, MCAUSE, SCAUSE, UCAUSE);
    csr.csrrw(cause_address, cause.to_primitive());

    // set exception program counter
    let epc_address = select_address(&next_privilege_mode, MEPC, SEPC, UEPC);
    csr.csrrw(epc_address, pc_address);

    // set trap value register
    let tval_address = select_address(&next_privilege_mode, MTVAL, STVAL, UTVAL);
    let tval = select_tval(cause, instruction);
    csr.csrrw(tval_address, tval);

    // set previous privilege
    let status_address = select_address(&next_privilege_mode, MSTATUS, SSTATUS, USTATUS);
    match next_privilege_mode {
        PrivilegeMode::Machine => update_status_field(
            csr,
            status_address,
            &STATUS_MPP,
            current_privilege_mode as u64,
        ),
        PrivilegeMode::Supervisor => update_status_field(
            csr,
            status_address,
            &STATUS_SPP,
            current_privilege_mode as u64,
        ),
        PrivilegeMode::User => {}
    }

    // set previous interrupt enable
    let ie_field = select_status_field(&next_privilege_mode, STATUS_MIE, STATUS_SIE, STATUS_UIE);
    let ie = read_status_field(csr, status_address, &ie_field);
    let pie_field =
        select_status_field(&next_privilege_mode, STATUS_MPIE, STATUS_SPIE, STATUS_UPIE);
    update_status_field(csr, status_address, &pie_field, ie);

    // disable interrupt enable
    update_status_field(csr, status_address, &ie_field, 0);

    // set pc to trap-vector base-address register
    let tvec_address = select_address(&next_privilege_mode, MTVEC, STVEC, UTVEC);
    let tvec = csr.csrrs(tvec_address, 0);
    (next_privilege_mode, tvec)
}

完成

シミュレータにデコード処理、実行処理、例外ハンドラの処理を実装していきます。

#[derive(Default)]
pub struct Simulator {
    prv: PrivilegeMode,
    pc: ProgramCounter,
    x: IntegerRegister,
    csr: ControlAndStatusRegister,
    memory: Memory,
}

impl Simulator {
    fn run(&mut self, terminator: impl Fn(&Simulator) -> Option<u64>) -> u64 {
        while self.pc.read() < self.memory.size() {
            let address = self.pc.read();
            let instruction = self.memory.load(address);

            let result = if let Some(decoded) = PrivilegedDecoder::decode(instruction) {
                PrivilegedExecutor::execute(
                    decoded,
                    &self.prv,
                    &mut self.pc,
                    &mut self.x,
                    &mut self.csr,
                    &mut self.memory,
                )
            } else if let Some(decoded) = Rv32iDecoder::decode(instruction) {
                Rv32iExecutor::execute(
                    decoded,
                    &self.prv,
                    &mut self.pc,
                    &mut self.x,
                    &mut self.csr,
                    &mut self.memory,
                )
            } else if let Some(decoded) = Rv64iDecoder::decode(instruction) {
                Rv64iExecutor::execute(
                    decoded,
                    &self.prv,
                    &mut self.pc,
                    &mut self.x,
                    &mut self.csr,
                    &mut self.memory,
                )
            } else if let Some(decoded) = ZicsrDecoder::decode(instruction) {
                ZicsrExecutor::execute(
                    decoded,
                    &self.prv,
                    &mut self.pc,
                    &mut self.x,
                    &mut self.csr,
                    &mut self.memory,
                )
            } else {
                // end the loop when unable to decode the instruction
                break;
            };

            if let Some(result) = terminator(self) {
                return result;
            }

            // handle the trap
            if let Err(cause) = result {
                let (prv, pc) =
                    handle_cause(&cause, self.pc.read(), instruction, self.prv, &mut self.csr);
                self.prv = prv;
                self.pc.jump(pc);
            }
            // increment the pc when the pc has not been updated
            else if self.pc.read() == address {
                self.pc.increment();
            }
        }
        0
    }
}

例外・割り込みが発生した時は例外ハンドラにジャンプするためプログラムカウンタを4byte進める処理は行わないようにします。 直前に分岐命令が実行されていた時も同様です。

シミュレータにテストの実行ファイルを読み込む処理を実装します。

テストが全てパスすればPASSと出力されます。

fn main() -> Result<()> {
    let file = File::open("./riscv-tests/isa/rv64ui-p-add.bin")?;
    let mut simulator = Simulator::default();
    simulator.load(file)?;
    let terminator = |simulator: &Simulator| {
        let value = simulator.memory.load(0x80001000);
        if value == 1 {
            Some(1)
        } else {
            None
        }
    };
    let result = simulator.run(terminator);
    println!("{}", if result == 1 { "PASS" } else { "FAIL" });
    Ok(())
}

#[derive(Default)]
pub struct Simulator {
    prv: PrivilegeMode,
    pc: ProgramCounter,
    x: IntegerRegister,
    csr: ControlAndStatusRegister,
    memory: Memory,
}

impl Simulator {
    pub fn load(&mut self, file: File) -> Result<()> {
        let buffer = BufReader::new(file);
        for (address, byte) in buffer.bytes().enumerate() {
            self.memory
                .store(address as u64 + MEMORY_BASE_ADDRESS, byte? as u32);
        }
        Ok(())
    }
// 以下略

これで完成です!

実装したコードは以下のとおりです。

github.com

シミュレータを実行してみる

cargo runで実装したシミュレータを実行してみます。

無事にテストをパスしました! かなりあっさりとした結果表示ですが、各ループ毎にレジスタの変化などを出力するようにすれば処理の動きがよく分かるようになりより面白くなると思います。

まとめ

今回はRustでRISC-Vシミュレータを実装しました。特権モードや例外処理などテストを実行するために必要最低限のものは全て実装したので、今後はパスできるテストを1つずつ増やし、処理できる命令を徐々に追加していく事が出来る様になったと思います。

以下のリポジトリに今回実装したものより多くの命令を実装した開発中のシミュレータがあるので、良ければこちらも参照してみて下さい。 こちらのシミュレータはループ毎にレジスタの変化を出力するようになっています。 github.com

We are hiring!!

私の所属するデジスマ診療チームでは、日本のクリニック診療をDX化するデジスマ診療を開発しています! 開発チームの紹介資料もありますのでぜひご覧ください!

speakerdeck.com

エムスリーではエンジニアを絶賛募集中です。チーム紹介資料を読んで興味を持たれた方も、そうでない方も是非ご応募ください!

jobs.m3.com