Zen言語で作るRISC-Vエミュレータ、その1

Zen言語とは今注目の言語です。 そしてRISC-Vとは今注目の命令セットです。 (多分に個人的感想ですが)どちらも聞かない日はありません。

そんな両者の接点ともいえるエミュレータを開発します。現時点で実装している命令は5種類のみですが、 第一目標のフィボナッチ数列が計算できるようになりました。

開発環境

公式サイトのダウンロードページよりコンパイラのバイナリをダウンロード出来ます。

バージョンは次のコマンドにより確認できます。

$ zen version
0.8.20191124+552247019

注意

  • ここに登場する命令はすべてRV32Iと呼ばれる基本命令セットです。

リポジトリ

RISC-Vの5とZenをあわせ、「5zen」と名付けました。

github.com

現在の完成度

フィボナッチ数列の計算を最初の目標とし、それを達成した時点での記事のため、出来は非常にミニマルです。 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社が開発したHiFive1RISC-Vプロセッサが搭載されたボードです。

これは物理的なCPUですが、その動作を模倣するようなソフトウェアをエミュレータと呼びます。

そして今回は 「Zen言語を用いて、RISC-V命令を実行できるCPUエミュレータとして実装しました」 ということを紹介するブログポストです。

方針は決まりました。では一歩目は何をすれば良いのでしょう。 その答えは、CPUの具体的な動作を知ることで見えてきます。

CPUの動作

CPUは「フェッチ」、「デコード」、「実行」と呼ばれる3種類の動作を行います。

動作 解説
フェッチ 実行する命令をメモリからロードする
デコード フェッチした命令が命令セットのどの命令にあたるか解釈する
実行 デコードした命令を実行する

それぞれが一度きりの動作ではなく、 フェッチ、デコード、実行、フェッチ、デコード、実行、フェッチ・・・ とループされます。このループをCPUの実行サイクルと呼びます。

3つの動作例として、前章に挙げたaddi x5, x6, 1のフェッチから実行までを見てみましょう。

フェッチ例

命令はすべてメモリ内に機械語として格納されています。

f:id:toasa3:20200229220051p:plain
メモリ内のaddi命令

フェッチでは現在のPC(プログラムカウンタ)が指す命令をメモリからロードします。

デコード例

フェッチにより、実行すべき命令の機械語00000000000100110000001010010011を取得できました。 デコードではこのビット列が何命令であるか、そのオペランドは何かを解釈します。

このままでは見ずらいため、これを意味のあるビット列に分割すると次のようになります。

000000000001 00110 000 00101 0010011

ここで公式ドキュメントにある次の図を引用します。

f:id:toasa3:20200229221925p:plain
addi命令

ビット列と図を見比べてみてください。フェッチしたビット列が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,
};

そして5zenexecuteメソッドでは、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命令しか実装できておらず、割合では完成は程遠く感じます。ただインクリメンタルな開発のおかげで命令を増やす心理的な負荷はあまり感じません。いずれにせよなかなか楽しい趣味になりそうなので、空いた時間に開発を続けようと思います。

参考

サイト

書籍

*1:RV32Iの命令は6つの基本形式に分類され、addi 命令は I 形式に属します。実際のデコードでは0から6bit目までで表されるオペコードにより形式が確定し、funct と呼ばれるオペランドの値により RV32I としての命令が一意に定まります。

*2:http://uzusayuu.hatenadiary.jp/entry/2019/09/03/095235