【 デジスマチーム ブログリレー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命令に関するテストを一通り実行できるシミュレータの開発を目指します。
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向けのクロスコンパイラを用意する必要があります。 ソースをビルドすると非常に時間がかかるので、ここでは以下のリポジトリで配布されているビルド済みのツールチェーンを使用します。
手順は以下のとおりです。
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
テストの内容を確認
今回実行するテストの実行ファイルの処理の流れは大まかに以下のようになっています。
- 各種初期化処理を行う
- テストケース37個を順次実行
- テストが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>
シミュレータの実装
実際にシミュレータを実装していきます。実装の流れは以下のとおりです。
- メモリを実装
- プログラムカウンタを実装
- メインループを実装
- 整数レジスタを実装
- 命令デコーダを実装
- 命令実行処理を実装
- Control And Statusレジスタを実装
- 例外処理を実装
メモリを実装
テストの実行ファイルを配置するメモリを実装します。
以下のように、1MB分のメモリ領域を Vec0x80000000
以降のアドレスにしかアクセスしないため、メモリにアクセスする際はアドレスから 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; } } }
実装したコードは以下のとおりです。
プログラムカウンタを実装
次にフェッチする命令のアドレスを管理するためのプログラムカウンタを実装します。
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進むかの判定が必要になります。
メインループを実装
メモリから命令をフェッチ、デコード、実行を繰り返すメインループを実装します。
メインループの処理の流れは以下のとおりです。
- プログラムカウンタから読み込む命令のアドレスを読み取る
- メモリから命令をフェッチする
- 命令をデコードする
- 命令を実行する
- テストを終了するかどうか判定する
- プログラムカウンタのアドレスを +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 } }
実装したコードは以下のとおりです。
整数レジスタを実装
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; } }
実装したコードは以下のとおりです。
命令デコーダを実装
命令デコーダを実装します。
RISC-Vの基本命令フォーマットは以下の6種類があります。
- R-type: 2つのレジスタの値を使用し演算する命令で使用される
- I-type: 1つのレジスタと1つの即値を使用し演算する命令で使用される
- S-type: メモリに値をストアする命令で使用される
- B-type: 現在のアドレスから相対位置にジャンプする分岐命令で使用される
- U-type: 即値をレジスタに保存する命令で使用される
- J-type: メモリの絶対位置にジャンプする分岐命令で使用される
上記図中の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, } }) }
最後に実際に命令をデコードする処理を実装します。
各命令がどのようなフォーマットになっているかをマニュアルで確認しながら実装していきます。
#[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, ), // 以下略
実装したコードは以下のとおりです。
命令実行処理を実装
命令の実行処理を実装します。以下のようにデコードした命令を受け取り、演算結果によりプログラムカウンタ、レジスタ、メモリを変更します。 また例外が発生した場合は例外を返します。
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の例外と割り込みの原因は以下のとおりです。
これらの例外と割り込みの原因を実装していきます。原因(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)) } }, }, // 以下略
実装したコードは以下のとおりです。
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への書き込みなどを行います。以下の MEDELG
や MCAUSE
などはCSRの値を表しています。
MEDELG
,SEDELEG
(例外の種類より選択)から、次に遷移する特権モードを取得- 以降マシンモードに遷移すると仮定する
MCAUSE
に例外コードを書き込むMEPC
に例外が発生したアドレスを書き込むMTVAL
に例外が発生したアドレス or 例外を発生させた命令を書き込むMSTATUS
に以前の特権モードを記録STATUS_MIE
(例外が有効かどうか)の値をSTATUS_MPIE
に保存STATUS_MIE
の値を 例外を無効 に変更- 次に遷移する特権モードに遷移
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(()) } // 以下略
これで完成です!
実装したコードは以下のとおりです。
シミュレータを実行してみる
cargo runで実装したシミュレータを実行してみます。
無事にテストをパスしました! かなりあっさりとした結果表示ですが、各ループ毎にレジスタの変化などを出力するようにすれば処理の動きがよく分かるようになりより面白くなると思います。
まとめ
今回はRustでRISC-Vシミュレータを実装しました。特権モードや例外処理などテストを実行するために必要最低限のものは全て実装したので、今後はパスできるテストを1つずつ増やし、処理できる命令を徐々に追加していく事が出来る様になったと思います。
以下のリポジトリに今回実装したものより多くの命令を実装した開発中のシミュレータがあるので、良ければこちらも参照してみて下さい。 こちらのシミュレータはループ毎にレジスタの変化を出力するようになっています。 github.com
We are hiring!!
私の所属するデジスマ診療チームでは、日本のクリニック診療をDX化するデジスマ診療を開発しています! 開発チームの紹介資料もありますのでぜひご覧ください!
エムスリーではエンジニアを絶賛募集中です。チーム紹介資料を読んで興味を持たれた方も、そうでない方も是非ご応募ください!