Zen言語で作るRISC-Vエミュレータ、その1
Zen言語とは今注目の言語です。 そしてRISC-Vとは今注目の命令セットです。 (多分に個人的感想ですが)どちらも聞かない日はありません。
そんな両者の接点ともいえるエミュレータを開発します。現時点で実装している命令は5種類のみですが、 第一目標のフィボナッチ数列が計算できるようになりました。
開発環境
公式サイトのダウンロードページよりコンパイラのバイナリをダウンロード出来ます。
バージョンは次のコマンドにより確認できます。
$ zen version 0.8.20191124+552247019
注意
- ここに登場する命令はすべて
RV32I
と呼ばれる基本命令セットです。
リポジトリ
RISC-Vの5とZenをあわせ、「5zen」と名付けました。
現在の完成度
フィボナッチ数列の計算を最初の目標とし、それを達成した時点での記事のため、出来は非常にミニマルです。
RV32I
命令セットの全47命令のなかで以下の5命令にしか実装していません。
- add
- addi
- blt
- jal
- jalr
以下は第10項目のフィボナッチ数を求める、C言語コードとRISC-Vアセンブリ言語です。
実際に5zen
ではユニットテスト
により計算結果を確かめています。
int main() { int lhs = 1; int rhs = 1; int result = 0; // 第1, 2項目は計算済とし、第3項から数え上げる int counter = 3; while (counter <= 10) { result = lhs + rhs; lhs = rhs; rhs = result; counter++; } // result == 55 }
## t0: lhs ## t1: rhs ## t2: result ## t3: counter addi t0, t0, 1 addi t1, t1, 1 addi t3, t3, 3 addi t4, t4, 10 ## if (t3 < t4) { jump to `ret`} blt t4, t3, 24 add t2, t0, t1 add t0, t1, zero add t1, t2, zero addi t3, t3, 1 ## unconditional jump to `blt t4, t3, 24` jal zero, -20 ret
一年前の私へ
タイトルとここまでを一読しても、一年前の私にはほとんどピンと来ず、わからなかったでしょう。 そんな過去の自分に向けた章です。もし役立つ方がいれば幸甚です。
RISC-Vとは命令セットの一種です。
「セット」には集まりという意味があり、命令セットとは(ラフにいうと)命令の集まりです。
そしてこの「命令」とは、コンピュータの根幹部品であるCPUに対し、特定の動作を定めるものです。
例えばRISC-Vにはaddi
命令があります。これは次のように、3つのオペランドと合わせて1つの命令を構成します。
addi rd, rs, imm
具体的にはaddi x5, x6, 1
などと書き、これは「x6
レジスタの値に即値1
を足し、結果をx5
レジスタに格納する」
という命令として実行されます。
命令にはその命令を実行できるCPUが必要です。 例えば、SiFive社が開発したHiFive1はRISC-Vプロセッサが搭載されたボードです。
これは物理的なCPUですが、その動作を模倣するようなソフトウェアをエミュレータと呼びます。
そして今回は
「Zen言語を用いて、RISC-V命令を実行できるCPU
をエミュレータとして実装しました」
ということを紹介するブログポストです。
方針は決まりました。では一歩目は何をすれば良いのでしょう。 その答えは、CPUの具体的な動作を知ることで見えてきます。
CPUの動作
CPUは「フェッチ」、「デコード」、「実行」と呼ばれる3種類の動作を行います。
動作 | 解説 |
---|---|
フェッチ | 実行する命令をメモリからロードする |
デコード | フェッチした命令が命令セットのどの命令にあたるか解釈する |
実行 | デコードした命令を実行する |
それぞれが一度きりの動作ではなく、 フェッチ、デコード、実行、フェッチ、デコード、実行、フェッチ・・・ とループされます。このループをCPUの実行サイクルと呼びます。
3つの動作例として、前章に挙げたaddi x5, x6, 1
のフェッチから実行までを見てみましょう。
フェッチ例
命令はすべてメモリ内に機械語として格納されています。
フェッチでは現在のPC(プログラムカウンタ)が指す命令をメモリからロードします。
デコード例
フェッチにより、実行すべき命令の機械語00000000000100110000001010010011
を取得できました。
デコードではこのビット列が何命令であるか、そのオペランドは何かを解釈します。
このままでは見ずらいため、これを意味のあるビット列に分割すると次のようになります。
000000000001 00110 000 00101 0010011
ここで公式ドキュメントにある次の図を引用します。
ビット列と図を見比べてみてください。フェッチしたビット列がaddi x5, x6, 1
命令であることがわかります。*1
実行例
デコードによりaddi x5, x6, 1
という命令だとわかりました。
あとは「x6
レジスタの値に即値1
を足し、結果をx5
レジスタに格納する」ことを行い、プログラムカウンタの値をインクリメントし次の命令をフェッチできるようにします。
CPUの実装
5zen
ではフェッチ、デコード、実行の三動作をCPU
構造体のメソッドとしてそれぞれ次のように実装しています。
pub const CPU = struct { ... pub fn fetch(self: *Self) PROG_TYPE { ... } pub fn decode(self: *Self, inst_bytes: PROG_TYPE) InstructionSet { ... } pub fn execute(self: *Self, inst: InstructionSet) void { ... } }
エミュレータの実装
5zen
ではEmulator
構造体のrun
メソッドにCPU実行サイクルを実装しました。
RISC-Vにはサイクルを止めるhalt
命令に相当する命令が存在しません。
そのため現在はret
命令を実行後、サイクルループを脱出しています。これはMOIZこちらの記事*2を参考にしました。
pub const Emulator = struct { const Self = @This(); cpu: CPU, ... pub fn run(self: *Self) void { // Stop the CPU cycle loop when pc equals `RET_ADDR`. while (self.cpu.pc != RET_ADDR) { const inst_bytes = self.cpu.fetch(); const inst = self.cpu.decode(inst_bytes); self.cpu.execute(inst); } } };
Zen言語のメリット
今回の開発では、Zenで提供されるタグ付き共用体
というデータ構造の有用性を実感しました。これはHaskellでは代数的データ型、Rustでは列挙型に相当するものです。
タグ付き共用体
はあらかじめ取りうる選択肢がタグにより定められており、そのどれか1つを(紐づくヴァリアントを用いて)実行するような処理を書きたいときに便利です。
そしてこの特徴はエミュレータの実装において、命令セットからどれか1つの命令を実行したいという動機とマッチします。
実際RISC-Vの命令セットをタグ付き共用体
で次のように実装しました。
pub const InstructionSet = union(Opcode) { Add: AddInst, Addi: AddiInst, Blt: BltInst, Jal: JalInst, Jalr: JalrInst, }
ここでOpcode
は列挙型として次のように定義することで、実装する命令にタグを付けます。
pub const Opcode = enum { Add, Addi, Blt, Jal, Jalr, };
そして5zen
のexecute
メソッドでは、switch
文により各命令を分岐して、それぞれの動作を実行します。
pub fn execute(self: *Self, inst: InstructionSet) void { switch(inst) { .Add => |add| { ... }, .Addi => |addi| { ... }, .Blt => |blt| { ... }, .Jal => |jal| { ... }, .Jalr => |jalr| { ... } } }
このとき分岐をもれなくカバーしないとコンパイルエラーとしてくれます。そしてこの機能が開発に役立ちます。
5zen
では実装する命令を1つづつ追加する、インクリメンタルな開発を行います。
そのときこの機能のおかげで、「Opcode
には新命令をタグとして追加したが、execute
内の実装を忘れてしまった」というミスをコンパイル時に発見してくれます。
(実際にAddi
命令がある状態で、Add
命令を実装しようとしたとき、コンパイラが分岐の網羅漏れを発見してくれました。)
今回はインクリメンタルな開発を目指し、1つづつ対応する命令を増やして行きました。 Zen言語ではソースコード内にユニットテストをかける、コンパイラによるタグ付き共用体の網羅性チェックなど、 インクリメンタルな開発と相性が良いようです。 このあたりはZenの思想に通じており公式サイトでは漸と紹介されています。
さいごに
RV32I命令だけ見ても全47命令中5命令しか実装できておらず、割合では完成は程遠く感じます。ただインクリメンタルな開発のおかげで命令を増やす心理的な負荷はあまり感じません。いずれにせよなかなか楽しい趣味になりそうなので、空いた時間に開発を続けようと思います。