Verylで作るCPU Star

第3章
RV32Iの実装

本章では、RISC-Vの基本整数命令セットであるRV32Iを実装します。基本整数命令という名前の通り、整数の足し引きやビット演算、ジャンプ、分岐命令などの最小限の命令しか実装されていません。また、32ビット幅の汎用レジスタが32個定義されています。ただし、0番目のレジスタの値は常に0です。

RISC-VのCPUは基本整数命令セットを必ず実装して、他の命令や機能は拡張として実装します。複雑な機能を持つCPUを実装する前に、まずは最小限の命令を実行できるCPUを実装しましょう。

3.1 CPUは何をやっているのか?

CPUを実装するには何が必要でしょうか?まずはCPUとはどのような動作をするものなのかを考えます。プログラム内蔵方式(stored-program computer)と呼ばれるコンピュータのCPUは、次の手順でプログラムを実行します。

  1. メモリ(memory, 記憶装置)からプログラムを読み込む
  2. プログラムを実行する
  3. 1、2の繰り返し

ここで、メモリから読み込まれる「プログラム」とは一体何を指しているのでしょうか?普通のプログラマが書くのはC言語やRustなどのプログラミング言語のプログラムですが、通常のCPUはそれをそのまま解釈して実行することはできません。そのため、メモリから読み込まれる「プログラム」とは、CPUが読み込んで実行できる形式のプログラムです。これはよく機械語(machine code)と呼ばれ、01で表される2進数のビット列*1で記述されています。

[*1] その昔、Setunという3進数のコンピュータが存在したらしく、機械語は3進数のトリット(trit)で構成されていたようです

メモリから機械語を読み込んで実行するのがCPUの仕事ということが分かりました。これをもう少し掘り下げます。

まず、機械語をメモリから読み込むためには、メモリのどこを読み込みたいのかという情報(アドレス, address)をメモリに与える必要があります。また、当然ながらメモリが必要です。

CPUは機械語を実行しますが、一気にすべての機械語を読み込んだり実行するわけではなく、機械語の最小単位である命令(instruction)を一つずつ読み込んで実行します。命令をメモリに要求、取得することを、命令をフェッチすると呼びます。

命令がCPUに供給されると、CPUは命令のビット列がどのような意味を持っていて、何をすればいいかを判定します。このことを、命令をデコードすると呼びます。

命令をデコードすると、いよいよ計算やメモリの読み書きを行います。しかし、例えば足し算を計算するにも、何と何を足し合わせればいいのか分かりません。この計算に使うデータは、次のいずれかで指定されます。

  • レジスタ(= CPU内に存在する計算データ用のレジスタ列)の番号
  • 即値(= 命令のビット列から生成される数値)

計算対象のデータにレジスタと即値のどちらを使うかは命令によって異なります。レジスタの番号は命令のビット列の中に含まれています。

フォンノイマン型アーキテクチャ(von Neumann architecture)と呼ばれるコンピュータの構成方式では、メモリのデータの読み書きを、機械語が格納されているメモリと同じメモリに対して行います。

計算やメモリの読み書きが終わると、その結果をレジスタに格納します。例えば、足し算を行う命令なら足し算の結果、メモリから値を読み込む命令なら読み込まれた値を格納します。

これで命令の実行は終わりですが、CPUは次の命令を実行する必要があります。今現在実行している命令のアドレスを格納しているレジスタのことをプログラムカウンタ(program counter, PC)と呼びます。CPUはPCの値をメモリに渡すことで命令をフェッチしています。

CPUは次の命令を実行するために、PCの値を次の命令のアドレスに設定します。ジャンプ命令の場合はPCの値をジャンプ先のアドレスに設定します。分岐命令の場合は、まず、分岐の成否を判定します。分岐が成立する場合はPCの値を分岐先のアドレスに設定します。分岐が成立しない場合は通常の命令と同じです。

ここまでの話をまとめると、CPUの動作は次のようになります(図3.1)。

CPUの動作

図3.1: CPUの動作

  1. PCに格納されたアドレスにある命令をフェッチする
  2. 命令を取得したらデコードする
  3. 計算で使用するデータを取得する (レジスタの値を取得したり、即値を生成する)
  4. 計算する命令の場合、計算を行う
  5. メモリにアクセスする命令の場合、メモリ操作を行う
  6. 計算やメモリアクセスの結果をレジスタに格納する
  7. PCの値を次に実行する命令のアドレスに設定する

CPUが一体どんなものなのかが分かりましたか?実装を始めましょう。

3.2 プロジェクトの作成

まず、Verylのプロジェクトを作成します(リスト3.1)。プロジェクトはcoreという名前にしています。

リスト3.1: リスト3.1: 新規プロジェクトの作成
$ veryl new core
[INFO ]      Created "core" project

すると、プロジェクト名のディレクトリと、その中にVeryl.tomlが作成されます。Veryl.tomlを次のように変更してください(リスト3.2)。

リスト3.2: リスト3.2: Veryl.toml
[project]
name = "core"
version = "0.1.0"

[build]
sourcemap_target = {type ="none"}

Verylのソースファイルを格納するために、プロジェクトのディレクトリ内にsrcディレクトリを作成してください(リスト3.3)。

リスト3.3: リスト3.3: srcディレクトリを作成する
$ cd core
$ mkdir src

3.3 定数の定義

いよいよコードを記述します。まず、CPU内で何度も使用する定数や型を書いておくためのパッケージを作成します。

src/eei.verylを作成し、次のように記述します(リスト3.4)。

リスト3.4: リスト3.4: eei.veryl
package eei {
    const XLEN: u32 = 32;
    const ILEN: u32 = 32;

    type UIntX  = logic<XLEN>;
    type UInt32 = logic<32>  ;
    type UInt64 = logic<64>  ;
    type Inst   = logic<ILEN>;
    type Addr   = logic<XLEN>;
}

eeiとは、RISC-V execution environment interfaceの略です。RISC-Vのプログラムの実行環境とインターフェースという広い意味があり、ISAの定義もeeiに含まれているため、この名前を使用しています。

eeiパッケージには、次の定数を定義します。

XLEN
XLENは、RISC-Vにおいて整数レジスタの長さを示す数字として定義されています。 RV32Iのレジスタの長さは32ビットであるため、値を32にしています。
ILEN
ILENは、RISC-VにおいてCPUの実装がサポートする命令の最大の幅を示す値として定義されています。 RISC-Vの命令の幅は、後の章で説明する圧縮命令を除けばすべて32ビットです。 そのため、値を32にしています。

また、何度も使用することになる型に、type文によって別名を付けています。

UIntX、UInt32、UInt64
幅がそれぞれXLEN、32、64の符号なし整数型
Inst
命令のビット列を格納するための型
Addr
メモリのアドレスを格納するための型。 RISC-Vで使用できるメモリ空間の幅はXLENなのでUIntXでもいいですが、 アドレスであることを明示するための別名を定義しています。

3.4 メモリ

CPUはメモリに格納された命令を実行します。そのため、CPUの実装のためにはメモリの実装が必要です。RV32Iにおいて命令の幅は32ビット(ILEN)です。また、メモリからの読み込み命令、書き込み命令の最大の幅も32ビットです。

これを実現するために、次のような要件のメモリを実装します。

  • 読み書きの単位は32ビット
  • クロックに同期してメモリアクセスの要求を受け取る
  • 要求を受け取った次のクロックで結果を返す

3.4.1 メモリのインターフェースを定義する

このメモリモジュールには、クロックとリセット信号の他に表3.1のようなポートを定義する必要があります。これを一つ一つ定義して接続するのは面倒なため、interfaceを定義します。

表3.1: メモリモジュールに必要なポート

ポート名向き意味
validlogicinputメモリアクセスを要求しているかどうか
readylogicoutputメモリアクセス要求を受容するかどうか
addrlogic<ADDR_WIDTH>inputアクセス先のアドレス
wenlogicinput書き込みかどうか (1なら書き込み)
wdatalogic<DATA_WIDTH>input書き込むデータ
rvalidlogicoutput受容した要求の処理が終了したかどうか
rdatalogic<DATA_WIDTH>output受容した読み込み命令の結果

src/membus_if.verylを作成し、次のように記述します(リスト3.5)。

リスト3.5: リスト3.5: インターフェースの定義 (membus_if.veryl)
interface membus_if::<DATA_WIDTH: const, ADDR_WIDTH: const> {
    var valid : logic            ;
    var ready : logic            ;
    var addr  : logic<ADDR_WIDTH>;
    var wen   : logic            ;
    var wdata : logic<DATA_WIDTH>;
    var rvalid: logic            ;
    var rdata : logic<DATA_WIDTH>;

    modport master {
        valid : output,
        ready : input ,
        addr  : output,
        wen   : output,
        wdata : output,
        rvalid: input ,
        rdata : input ,
    }

    modport slave {
        valid : input ,
        ready : output,
        addr  : input ,
        wen   : input ,
        wdata : input ,
        rvalid: output,
        rdata : output,
    }
}

membus_ifはジェネリックインターフェースです。ジェネリックパラメータとして、ADDR_WIDTHDATA_WIDTHが定義されています。ADDR_WIDTHはアドレスの幅、DATA_WIDTHは1つのデータの幅です。

interfaceを利用することで変数の定義が不要になり、ポートの相互接続を簡潔にできます。

3.4.2 メモリモジュールを実装する

メモリを作る準備が整いました。src/memory.verylを作成し、次のように記述します(リスト3.6)。

リスト3.6: リスト3.6: メモリモジュールの定義 (memory.veryl)
module memory::<DATA_WIDTH: const, ADDR_WIDTH: const> #(
    param FILEPATH_IS_ENV: logic  = 0 , // FILEPATHが環境変数名かどうか
    param FILEPATH       : string = "", // メモリの初期化用ファイルのパス, または環境変数名
) (
    clk   : input   clock                                     ,
    rst   : input   reset                                     ,
    membus: modport membus_if::<DATA_WIDTH, ADDR_WIDTH>::slave,
) {
    type DataType = logic<DATA_WIDTH>;

    var mem: DataType [2 ** ADDR_WIDTH];

    initial {
        // memを初期化する
        if FILEPATH != "" {
            if FILEPATH_IS_ENV {
                $readmemh(util::get_env(FILEPATH), mem);
            } else {
                $readmemh(FILEPATH, mem);
            }
        }
    }

    always_comb {
        membus.ready = 1;
    }

    always_ff {
        if_reset {
            membus.rvalid = 0;
            membus.rdata  = 0;
        } else {
            membus.rvalid = membus.valid;
            membus.rdata  = mem[membus.addr[ADDR_WIDTH - 1:0]];
            if membus.valid && membus.wen {
                mem[membus.addr[ADDR_WIDTH - 1:0]] = membus.wdata;
            }
        }
    }
}

memoryモジュールはジェネリックモジュールです。次のジェネリックパラメータを定義しています。

DATA_WIDTH
メモリのデータの単位の幅を指定するためのパラメータです。
この単位ビットでデータを読み書きします。
ADDR_WIDTH
データのアドレスの幅(メモリの容量)を指定するためのパラメータです。
メモリの容量はDATA_WIDTH * (2 ** ADDR_WIDTH)ビットになります。

ポートには、クロック信号とリセット信号とmembus_ifインターフェースを定義しています。

読み込み、書き込み時の動作は次の通りです。

読み込み
読み込みが要求されるとき、 membus.valid1membus.wen0membus.addrが対象アドレスになっています。 次のクロックで、membus.rvalid1になり、 membus.rdataは対象アドレスのデータになります。
書き込み
書き込みが要求されるとき、 membus.valid1membus.wen1membus.addrが対象アドレスになっています。 always_ffブロックでは、 membus.wen1であることを確認し、 1の場合は対象アドレスにmembus.wdataを書き込みます。 次のクロックでmembus.rvalid1になります。

3.4.3 メモリの初期化、環境変数の読み込み

memoryモジュールのパラメータには、FILEPATH_IS_ENVFILEPATHを定義しています。memoryモジュールをインスタンス化するとき、FILEPATHには、メモリの初期値が格納されたファイルのパスか、ファイルパスが格納されている環境変数名を指定します。初期化は$readmemhシステムタスクで行います。

FILEPATH_IS_ENV1のとき、環境変数の値を取得して、初期化用のファイルのパスとして利用します。環境変数はutilパッケージのget_env関数で取得します。

utilパッケージとget_env関数を作成します。src/util.verylを作成し、次のように記述します(リスト3.7)。

リスト3.7: リスト3.7: util.veryl
embed (inline) sv{{{
    package svutil;
        import "DPI-C" context function string get_env_value(input string key);
        function string get_env(input string name);
            return get_env_value(name);
        endfunction
    endpackage
}}}

package util {
    function get_env (
        name: input string,
    ) -> string {
        return $sv::svutil::get_env(name);
    }
}

utilパッケージのget_env関数は、コード中に埋め込まれたSystemVerilogのsvutilパッケージのget_env関数の結果を返しています。svutilパッケージのget_env関数は、C(C++)で定義されているget_env_value関数の結果を返しています。get_env_value関数は後で定義します。

3.5 最上位モジュールの作成

次に、最上位のモジュール(Top Module)を作成して、memoryモジュールをインスタンス化します。

最上位のモジュールとは、設計の階層の最上位に位置するモジュールのことです。論理設計では、最上位モジュールの中に、あらゆるモジュールやレジスタなどをインスタンス化します。

memoryモジュールはジェネリックモジュールであるため、1つのデータのビット幅とメモリのサイズを指定する必要があります。これらを示す定数をeeiパッケージに定義します(リスト3.8)。メモリのアドレス幅(サイズ)には、適当に16を設定しています。これによりメモリ容量は32ビット * (2 ** 16) = 256KiBになります。

リスト3.8: リスト3.8: メモリのデータ幅とアドレスの幅の定数を定義する (eei.veryl)
    // メモリのデータ幅
    const MEM_DATA_WIDTH: u32 = 32;
    // メモリのアドレス幅
    const MEM_ADDR_WIDTH: u32 = 16;

それでは、最上位のモジュールを作成します。src/top.verylを作成し、次のように記述します(リスト3.9)。

リスト3.9: リスト3.9: 最上位モジュールの定義 (top.veryl)
import eei::*;

module top (
    clk: input clock,
    rst: input reset,
) {
    inst membus: membus_if::<MEM_DATA_WIDTH, MEM_ADDR_WIDTH>;

    inst mem: memory::<MEM_DATA_WIDTH, MEM_ADDR_WIDTH> #(
        FILEPATH_IS_ENV: 1                 ,
        FILEPATH       : "MEMORY_FILE_PATH",
    ) (
        clk     ,
        rst     ,
        membus  ,
    );
}

topモジュールでは、先ほど作成したmemoryモジュールと、membus_ifインターフェースをインスタンス化しています。

memoryモジュールとmembusインターフェースのジェネリックパラメータには、DATA_WIDTHMEM_DATA_WIDTHADDR_WIDTHMEM_ADDR_WIDTHを指定しています。メモリの初期化は、環境変数MEMORY_FILE_PATHで行うようにパラメータで指定しています。

3.6 命令フェッチ

メモリを作成したので、命令フェッチ処理を作れるようになりました。

いよいよ、CPUのメインの部分を作成します。

3.6.1 命令フェッチを実装する

src/core.verylを作成し、次のように記述します(リスト3.10)。

リスト3.10: リスト3.10: core.veryl
import eei::*;

module core (
    clk   : input   clock                          ,
    rst   : input   reset                          ,
    membus: modport membus_if::<ILEN, XLEN>::master,
) {

    var if_pc          : Addr ;
    var if_is_requested: logic; // フェッチ中かどうか
    var if_pc_requested: Addr ; // 要求したアドレス

    let if_pc_next: Addr = if_pc + 4;

    // 命令フェッチ処理
    always_comb {
        membus.valid = 1;
        membus.addr  = if_pc;
        membus.wen   = 0;
        membus.wdata = 'x; // wdataは使用しない
    }

    always_ff {
        if_reset {
            if_pc           = 0;
            if_is_requested = 0;
            if_pc_requested = 0;
        } else {
            if if_is_requested {
                if membus.rvalid {
                    if_is_requested = membus.ready && membus.valid;
                    if membus.ready && membus.valid {
                        if_pc           = if_pc_next;
                        if_pc_requested = if_pc;
                    }
                }
            } else {
                if membus.ready && membus.valid {
                    if_is_requested = 1;
                    if_pc           = if_pc_next;
                    if_pc_requested = if_pc;
                }
            }
        }
    }

    always_ff {
        if if_is_requested && membus.rvalid {
            $display("%h : %h", if_pc_requested, membus.rdata);
        }
    }
}

coreモジュールは、クロック信号とリセット信号、membus_ifインターフェースをポートに持ちます。membus_ifインターフェースのジェネリックパラメータには、データ単位としてILEN(1つの命令のビット幅)、アドレスの幅としてXLENを指定しています。

if_pcレジスタはPC(プログラムカウンタ)です。ここでif_という接頭辞はinstruction fetch(命令フェッチ)の略です。if_is_requestedはフェッチ中かどうかを管理しており、フェッチ中のアドレスをif_pc_requestedに格納しています。どのレジスタも0で初期化しています。

always_combブロックでは、アドレスif_pcにあるデータを常にメモリに要求しています。命令フェッチではメモリの読み込みしか行わないため、membus.wen0にしています。

上から1つめのalways_ffブロックでは、フェッチ中かどうかとメモリがready(要求を受け入れる)状態かどうかによって、if_pcif_is_requestedif_pc_requestedの値を変更しています。

メモリにデータを要求するとき、if_pcを次の命令のアドレス(4を足したアドレス)に変更して、if_is_requested1に変更しています。フェッチ中かつmembus.rvalid1のとき、命令フェッチが完了し、データがmembus.rdataに供給されています。メモリがready状態なら、すぐに次の命令フェッチを開始します。この状態遷移を繰り返すことによって、アドレス048c10...の命令を次々にフェッチします。

上から2つめのalways_ffブロックは、デバッグ用の表示を行うコードです。命令フェッチが完了したとき、その結果を$displayシステムタスクによって出力します。

3.6.2 memoryモジュールとcoreモジュールを接続する

次に、topモジュールでcoreモジュールをインスタンス化し、membus_ifインターフェースでメモリと接続します。

coreモジュールが指定するアドレスは1バイト単位のアドレスです。それに対して、memoryモジュールは32ビット(=4バイト)単位でデータを整列しているため、データは4バイト単位のアドレスで指定する必要があります。

まず、1バイト単位のアドレスを、4バイト単位のアドレスに変換する関数を作成します(リスト3.11)。これは、1バイト単位のアドレスの下位2ビットを切り詰めることによって実現できます。

リスト3.11: リスト3.11: アドレスを変換する関数を作成する (top.veryl)
    // アドレスをメモリのデータ単位でのアドレスに変換する
    function addr_to_memaddr (
        addr: input logic<XLEN>          ,
    ) -> logic<MEM_ADDR_WIDTH> {
        return addr[$clog2(MEM_DATA_WIDTH / 8)+:MEM_ADDR_WIDTH];
    }

addr_to_memaddr関数は、MEM_DATA_WIDTH(=32)をバイトに変換した値(=4)のlog2をとった値(=2)を使って、addr[17:2]を切り取っています。範囲の選択には+:を利用しています。

次に、coreモジュール用のmembus_ifインターフェースを作成します(リスト3.12)。ジェネリックパラメータには、coreモジュールのインターフェースのジェネリックパラメータと同じく、ILENとXLENを割り当てます。

リスト3.12: リスト3.12: coreモジュール用のmembus_ifインターフェースをインスタンス化する (top.veryl)
    inst membus     : membus_if::<MEM_DATA_WIDTH, MEM_ADDR_WIDTH>;
    inst membus_core: membus_if::<ILEN, XLEN>;

membusmembus_coreを接続します。アドレスにはaddr_to_memaddr関数で変換した値を割り当てます(リスト3.13)。

リスト3.13: リスト3.13: membusとmembus_coreを接続する (top.veryl)
    always_comb {
        membus.valid      = membus_core.valid;
        membus_core.ready = membus.ready;
        // アドレスをデータ幅単位のアドレスに変換する
        membus.addr        = addr_to_memaddr(membus_core.addr);
        membus.wen         = 0; // 命令フェッチは常に読み込み
        membus.wdata       = 'x;
        membus_core.rvalid = membus.rvalid;
        membus_core.rdata  = membus.rdata;
    }

最後にcoreモジュールをインスタンス化します(リスト3.14)。メモリとCPUが接続されました。

リスト3.14: リスト3.14: coreモジュールをインスタンス化する (top.veryl)
    inst c: core (
        clk                ,
        rst                ,
        membus: membus_core,
    );

3.6.3 命令フェッチをテストする

ここまでのコードが正しく動くかを検証します。

Verylで記述されたコードはveryl buildコマンドでSystemVerilogのコードに変換できます。変換されたソースコードをオープンソースのVerilogシミュレータであるVerilatorで実行することで、命令フェッチが正しく動いていることを確認します。

まず、Verylのプロジェクトをビルドします(リスト3.15)。

リスト3.15: リスト3.15: Verylのプロジェクトのビルド
$ veryl fmt ← フォーマットする
$ veryl build ← ビルドする

上記のコマンドを実行すると、verylファイルと同名のsvファイルとcore.fファイルが生成されます。拡張子がsvのファイルはSystemVerilogのファイルで、core.fには生成されたSystemVerilogのファイルのリストが記載されています。これをシミュレータのビルドに利用します。

シミュレータのビルドにはVerilatorを利用します。Verilatorは、与えられたSystemVerilogのコードをC++プログラムに変換することでシミュレータを生成します。Verilatorを利用するために、次のようなC++プログラムを書きます*2

[*2] Verilogのソースファイルだけでビルドすることもできます

src/tb_verilator.cppを作成し、次のように記述します(リスト3.16)。

リスト3.16: リスト3.16: tb_verilator.cpp
#include <iostream>
#include <filesystem>
#include <stdlib.h>
#include <verilated.h>
#include "Vcore_top.h"

namespace fs = std::filesystem;

extern "C" const char* get_env_value(const char* key) {
    const char* value = getenv(key);
    if (value == nullptr)
        return "";
    return value;
}

int main(int argc, char** argv) {
    Verilated::commandArgs(argc, argv);

    if (argc < 2) {
        std::cout << "Usage: " << argv[0] << " MEMORY_FILE_PATH [CYCLE]" << std::endl;
        return 1;
    }

    // メモリの初期値を格納しているファイル名
    std::string memory_file_path = argv[1];
    try {
        // 絶対パスに変換する
        fs::path absolutePath = fs::absolute(memory_file_path);
        memory_file_path = absolutePath.string();
    } catch (const std::exception& e) {
        std::cerr << "Invalid memory file path : " << e.what() << std::endl;
        return 1;
    }

    // シミュレーションを実行するクロックサイクル数
    unsigned long long cycles = 0;
    if (argc >= 3) {
        std::string cycles_string = argv[2];
        try {
            cycles = stoull(cycles_string);
        } catch (const std::exception& e) {
            std::cerr << "Invalid number: " << argv[2] << std::endl;
            return 1;
        }
    }

    // 環境変数でメモリの初期化用ファイルを指定する
    const char* original_env = getenv("MEMORY_FILE_PATH");
    setenv("MEMORY_FILE_PATH", memory_file_path.c_str(), 1);

    // top
    Vcore_top *dut = new Vcore_top();

    // reset
    dut->clk = 0;
    dut->rst = 1;
    dut->eval();
    dut->rst = 0;
    dut->eval();

    // 環境変数を元に戻す
    if (original_env != nullptr){
        setenv("MEMORY_FILE_PATH", original_env, 1);
    }

    // loop
    dut->rst = 1;
    for (long long i=0; !Verilated::gotFinish() && (cycles == 0 || i / 2 < cycles); i++) {
        dut->clk = !dut->clk;
        dut->eval();
    }

    dut->final();
}

このC++プログラムは、topモジュール(プログラム中ではVtop_coreクラス)をインスタンス化し、そのクロック信号を反転して実行するのを繰り返しています。

このプログラムは、コマンドライン引数として次の2つの値を受け取ります。

MEMORY_FILE_PATH
メモリの初期値のファイルへのパス
実行時に環境変数MEMORY_FILE_PATHとして渡されます。
CYCLE
何クロックで実行を終了するかを表す値
0のときは終了しません。デフォルト値は0です。

Verilatorによるシミュレーションは、topモジュールのクロック信号を更新してeval関数を呼び出すことにより実行します。プログラムでは、clkを反転させてevalするループの前に、topモジュールをリセット信号によりリセットする必要があります。そのため、topモジュールのrst1にしてからevalを実行し、rst0にしてまたevalを実行し、rst1にもどしてからclkを反転しています。

シミュレータのビルド

verilatorコマンドを実行し、シミュレータをビルドします(リスト3.17)。

リスト3.17: リスト3.17: シミュレータのビルド
$ verilator --cc -f core.f --exe src/tb_verialtor.cpp --top-module top --Mdir obj_dir
$ make -C obj_dir -f Vcore_top.mk ← シミュレータをビルドする
$ mv obj_dir/Vcore_top obj_dir/sim ← シミュレータの名前をsimに変更する

verilator --ccコマンドに次のコマンドライン引数を渡して実行することで、シミュレータを生成するためのプログラムがobj_dirに生成されます。

-f
SystemVerilogプログラムのファイルリストを指定します。 今回はcore.fを指定しています。
--exe
実行可能なシミュレータの生成に使用する、main関数が含まれたC++プログラムを指定します。 今回はsrc/tb_verilator.cppを指定しています。
--top-module
トップモジュールを指定します。 今回はtopモジュールを指定しています。
--Mdir
成果物の生成先を指定します。 今回はobj_dirディレクトリに指定しています。

リスト3.17のコマンドの実行により、シミュレータがobj_dir/simに生成されました。

メモリの初期化用ファイルの作成

シミュレータを実行する前にメモリの初期値となるファイルを作成します。src/sample.hexを作成し、次のように記述します(リスト3.18)。

リスト3.18: リスト3.18: sample.hex
01234567
89abcdef
deadbeef
cafebebe
← 必ず末尾に改行をいれてください

値は16進数で4バイトずつ記述されています。シミュレータを実行すると、memoryモジュールは$readmemhシステムタスクでsample.hexを読み込みます。それにより、メモリは次のように初期化されます(表3.2)。

表3.2: sample.hexによって設定されるメモリの初期値

アドレス
0x00000000 01234567
0x00000004 89abcdef
0x00000008 deadbeef
0x0000000c cafebebe
0x00000010~不定

シミュレータの実行

生成されたシミュレータを実行し、アドレスが048cのデータが正しくフェッチされていることを確認します(リスト3.19)。

リスト3.19: リスト3.19: 命令フェッチの動作チェック
$ obj_dir/sim src/sample.hex 5
00000000 : 01234567
00000004 : 89abcdef
00000008 : deadbeef
0000000c : cafebebe

メモリファイルのデータが、4バイトずつ読み込まれていることを確認できます。

Makefileの作成

ビルド、シミュレータのビルドのために一々コマンドを打つのは非常に面倒です。これらの作業を一つのコマンドで済ますために、Makefileを作成し、次のように記述します(リスト3.20)。

リスト3.20: リスト3.20: Makefile
PROJECT = core
FILELIST = $(PROJECT).f

TOP_MODULE = top
TB_PROGRAM = src/tb_verilator.cpp
OBJ_DIR = obj_dir/
SIM_NAME = sim
VERILATOR_FLAGS = ""

build:
        veryl fmt
        veryl build

clean:
        veryl clean
        rm -rf $(OBJ_DIR)

sim:
        verilator --cc $(VERILATOR_FLAGS) -f $(FILELIST) --exe $(TB_PROGRAM) --top-module $(PROJECT)_$(TOP_MODULE) --Mdir $(OBJ_DIR)
        make -C $(OBJ_DIR) -f V$(PROJECT)_$(TOP_MODULE).mk
        mv $(OBJ_DIR)/V$(PROJECT)_$(TOP_MODULE) $(OBJ_DIR)/$(SIM_NAME)

.PHONY: build clean sim

これ以降、次のようにVerylのソースコードのビルド、シミュレータのビルド、成果物の削除ができるようになります(リスト3.21)。

リスト3.21: リスト3.21: Makefileによって追加されたコマンド
$ make build ← Verylのソースコードのビルド
$ make sim ← シミュレータのビルド
$ make clean ← ビルドした成果物の削除

3.6.4 フェッチした命令をFIFOに格納する

FIFO

図3.2: FIFO

フェッチした命令は次々に実行されますが、その命令が何クロックで実行されるかは分かりません。命令が常に1クロックで実行される場合は、現状の常にフェッチし続けるようなコードで問題ありませんが、例えばメモリにアクセスする命令は実行に何クロックかかるか分かりません。

複数クロックかかる命令に対応するために、命令の処理が終わってから次の命令をフェッチするように変更する場合、命令の実行の流れは次のようになります。

  1. 命令の処理が終わる
  2. 次の命令のフェッチ要求をメモリに送る
  3. 命令がフェッチされ、命令の処理を開始する

このとき、命令の処理が終わってから次の命令をフェッチするため、次々にフェッチするよりも多くのクロック数が必要です。これはCPUの性能を露骨に悪化させるので許容できません。

FIFOの作成

そこで、FIFO(First In First Out, ファイフォ)を作成して、フェッチした命令を格納します。FIFOとは、先に入れたデータが先に出されるデータ構造のことです(図3.2)。命令をフェッチしたらFIFOに格納(enqueue)し、命令を処理するときにFIFOから取り出し(dequeue)ます。

Verylの標準ライブラリ*3にはFIFOが用意されていますが、FIFOは簡単なデータ構造なので自分で作ってみましょう。src/fifo.verylを作成し、次のように記述します(リスト3.22)。

リスト3.22: リスト3.22: FIFOモジュールの実装 (fifo.veryl)
module fifo #(
    param DATA_TYPE: type = logic,
    param WIDTH    : u32  = 2    ,
) (
    clk   : input  clock    ,
    rst   : input  reset    ,
    wready: output logic    ,
    wvalid: input  logic    ,
    wdata : input  DATA_TYPE,
    rready: input  logic    ,
    rvalid: output logic    ,
    rdata : output DATA_TYPE,
) {
    type Ptr = logic<WIDTH>;

    var mem : DATA_TYPE [2 ** WIDTH];
    var head: Ptr                   ;
    var tail: Ptr                   ;

    let tail_plus1: Ptr = tail + 1 as Ptr;
    let tail_plus2: Ptr = tail + 2 as Ptr;

    always_comb {
        rvalid = head != tail;
        rdata  = mem[head];
    }

    assign wready = if WIDTH == 1 {
        head == tail || rready
    } else {
        tail_plus1 != head
    };

    // 2つ以上空きがあるかどうか
    let wready_two: logic = wready && tail_plus2 != head;

    always_ff {
        if_reset {
            head = 0;
            tail = 0;
        } else {
            if wready && wvalid {
                mem[tail] = wdata;
                tail      = tail + 1;
            }
            if rready && rvalid {
                head = head + 1;
            }
        }
    }
}

fifoモジュールは、DATA_TYPE型のデータを2 ** WIDTH - 1個格納できるFIFOです。操作は次のように行います。

データを追加する
wready1のとき、データを追加できます。 データを追加するためには、追加したいデータをwdataに格納し、wvalid1にします。 追加したデータは次のクロック以降に取り出せます。
データを取り出す
rvalid1のとき、データを取り出せます。 データを取り出せるとき、rdataにデータが供給されています。 rready1にすることで、FIFOにデータを取り出したことを通知できます。

データの格納状況は、headレジスタとtailレジスタで管理します。データを追加するとき、つまりwready && wvalidのとき、tail = tail + 1しています。データを取り出すとき、つまりrready && rvalidのとき、head = head + 1しています。

データを追加できる状況とは、tail1を足してもheadを超えないとき、つまり、tailが指す場所が一周してしまわないときです。この制限から、FIFOには最大でも2 ** WIDTH - 1個しかデータを格納できません。データを取り出せる状況とは、headtailの指す場所が違うときです。WIDTH1のときは特別で、既にデータが1つ入っていても、rready1のときはデータを追加できるようにしています。

命令フェッチ処理の変更

fifoモジュールを使って、命令フェッチ処理を変更します。

まず、FIFOに格納する型を定義します(リスト3.23)。if_fifo_typeには、命令のアドレス(addr)と命令のビット列(bits)を格納するためのフィールドを含めます。

リスト3.23: リスト3.23: FIFOで格納する型を定義する (core.veryl)
    // ifのFIFOのデータ型
    struct if_fifo_type {
        addr: Addr,
        bits: Inst,
    }

次に、FIFOと接続するための変数を定義します(リスト3.24)。

リスト3.24: リスト3.24: FIFOと接続するための変数を定義する (core.veryl)
    // FIFOの制御用レジスタ
    var if_fifo_wready: logic       ;
    var if_fifo_wvalid: logic       ;
    var if_fifo_wdata : if_fifo_type;
    var if_fifo_rready: logic       ;
    var if_fifo_rvalid: logic       ;
    var if_fifo_rdata : if_fifo_type;

FIFOモジュールをインスタンス化します(リスト3.25)。DATA_TYPEパラメータにif_fifo_typeを渡すことで、アドレスと命令のペアを格納できるようにします。WIDTHパラメータには3を指定することで、サイズを2 ** 3 - 1 = 7にしています。このサイズは適当です。

リスト3.25: リスト3.25: FIFOをインスタンス化する (core.veryl)
    // フェッチした命令を格納するFIFO
    inst if_fifo: fifo #(
        DATA_TYPE: if_fifo_type,
        WIDTH    : 3           ,
    ) (
        clk                   ,
        rst                   ,
        wready: if_fifo_wready,
        wvalid: if_fifo_wvalid,
        wdata : if_fifo_wdata ,
        rready: if_fifo_rready,
        rvalid: if_fifo_rvalid,
        rdata : if_fifo_rdata ,
    );

fifoモジュールをインスタンス化したので、メモリへデータを要求する処理を変更します(リスト3.26)。

リスト3.26: リスト3.26: フェッチ処理の変更 (core.veryl)
    // 命令フェッチ処理
    always_comb {
        // FIFOに2個以上空きがあるとき、命令をフェッチする
        membus.valid = if_fifo.wready_two;
        membus.addr  = if_pc;
        membus.wen   = 0;
        membus.wdata = 'x; // wdataは使用しない

        // 常にFIFOから命令を受け取る
        if_fifo_rready = 1;
    }

リスト3.26では、メモリに命令フェッチを要求する条件をFIFOに2つ以上空きがあるという条件に変更しています*4。これにより、FIFOがあふれてしまうことがなくなります。また、FIFOから常にデータを取り出すようにしています。

[*4] 1つ空きがあるという条件だとあふれてしまいます。FIFOが容量いっぱいのときにどうなるか確認してください

命令をフェッチできたらFIFOに格納する処理をalways_ffブロックの中に追加します(リスト3.27)。

リスト3.27: リスト3.27: FIFOへのデータの格納 (core.veryl)
    // IFのFIFOの制御
    if if_is_requested && membus.rvalid { ← フェッチできた時
        if_fifo_wvalid     = 1;
        if_fifo_wdata.addr = if_pc_requested;
        if_fifo_wdata.bits = membus.rdata;
    } else {
        if if_fifo_wvalid && if_fifo_wready { ← FIFOにデータを格納できる時
            if_fifo_wvalid = 0;
        }
    }

if_fifo_wvalidif_fifo_wdata0に初期化します(リスト3.28)。

リスト3.28: リスト3.28: 変数の初期化 (core.veryl)
    if_reset {
        if_pc           = 0;
        if_is_requested = 0;
        if_pc_requested = 0;
        if_fifo_wvalid  = 0;
        if_fifo_wdata   = 0;
    } else {

命令をフェッチできたとき、if_fifo_wvalidの値を1にして、if_fifo_wdataにフェッチした命令とアドレスを格納します。これにより、次のクロック以降のFIFOに空きがあるタイミングでデータが追加されます。

それ以外のとき、FIFOにデータを格納しようとしていてFIFOに空きがあるとき、if_fifo_wvalid0にすることでデータの追加を完了します。

命令フェッチはFIFOに2つ以上空きがあるときに行うため、まだ追加されていないデータがif_fifo_wdataに格納されていても、別のデータに上書きされてしまうことはありません。

FIFOのテスト

FIFOをテストする前に、命令のデバッグ表示を行うコードを変更します(リスト3.29)。

リスト3.29: リスト3.29: 命令のデバッグ表示を変更する (core.veryl)
    let inst_pc  : Addr = if_fifo_rdata.addr;
    let inst_bits: Inst = if_fifo_rdata.bits;

    always_ff {
        if if_fifo_rvalid {
            $display("%h : %h", inst_pc, inst_bits);
        }
    }

シミュレータを実行します(リスト3.30)。命令がフェッチされて表示されるまでに、FIFOに格納してから取り出すクロック分だけ遅延があることに注意してください。

リスト3.30: リスト3.30: FIFOをテストする
$ make build
$ make sim
$ obj_dir/sim src/sample.hex 7
00000000 : 01234567
00000004 : 89abcdef
00000008 : deadbeef
0000000c : cafebebe

3.7 命令のデコードと即値の生成

命令をフェッチできたら、フェッチした命令がどのような意味を持つかをチェックし、CPUが何をすればいいかを判断するためのフラグや値を生成します。この作業のことを命令のデコード(decode)と呼びます。

RISC-Vの命令のビット列には次のような要素が含まれています。

オペコード (opcode)
5ビットの値です。命令を区別するために使用されます。
funct3、funct7
funct3は3ビット、funct7は7ビットの値です。 命令を区別するために使用されます。
即値 (Immediate, imm)
命令のビット列の中に直接含まれる数値です。
ソースレジスタ(Source Register)の番号
計算やメモリアクセスに使う値が格納されているレジスタの番号です。 レジスタは32個あるため5ビットの値になっています。
デスティネーションレジスタ(Destination Register)の番号
命令の結果を格納するためのレジスタの番号です。 ソースレジスタと同様に5ビットの値になっています。

RISC-Vにはいくつかの命令の形式がありますが、RV32IにはR、I、S、B、U、Jの6つの形式の命令が存在しています (図3.3)。

RISC-Vの命令形式 <a href="bib.html#bib-isa-manual.1.2.3.enc">[5]</a>

図3.3: RISC-Vの命令形式 [5]

R形式
ソースレジスタ(rs1、rs2)が2つ、デスティネーションレジスタ(rd)が1つの命令形式です。 2つのソースレジスタの値を使って計算し、その結果をデスティネーションレジスタに格納します。 例えばADD(足し算)、SUB(引き算)命令に使用されています。
I形式
ソースレジスタ(rs1)が1つ、デスティネーションレジスタ(rd)が1つの命令形式です。 12ビットの即値(imm[11:0])が命令中に含まれており、これとrs1を使って計算し、 その結果をデスティネーションレジスタに格納します。 例えばADDI(即値を使った足し算)、ANDI(即値を使ったAND演算)命令に使用されています。
S形式
ソースレジスタ(rs1、rs2)が2つの命令形式です。 12ビットの即値(imm[11:5]、imm[4:0])が命令中に含まれており、 即値とrs1を足し合わせたメモリのアドレスに、rs2を書き込みます。 例えばSW命令(メモリに32ビット書き込む命令)に使用されています。
B形式
ソースレジスタ(rs1、rs2)が2つの命令形式です。 12ビットの即値(imm[12]、imm[11]、imm[10:5]、imm[4:1])が命令中に含まれています。 分岐命令に使用されており、 ソースレジスタの計算の結果が分岐を成立させる場合、 PCに即値を足したアドレスにジャンプします。
U形式
デスティネーションレジスタ(rd)が1つの命令形式です。 20ビットの即値(imm[31:12])が命令中に含まれています。 例えばLUI命令(レジスタの上位20ビットを設定する命令)に使用されています。
J形式
デスティネーションレジスタ(rd)が1つの命令形式です。 20ビットの即値(imm[20]、imm[19:12]、imm[11]、imm[10:1])が命令中に含まれています。 例えばJAL命令(ジャンプ命令)に使用されており、 PCに即値を足したアドレスにジャンプします。

全ての命令形式にはopcodeが共通して存在しています。命令の判別にはopcode、funct3、funct7を利用します。

3.7.1 デコード用の定数と型を定義する

デコード処理を書く前に、デコードに利用する定数と型を定義します。src/corectrl.verylを作成し、次のように記述します(リスト3.31)。

リスト3.31: リスト3.31: corectrl.veryl
import eei::*;

package corectrl {
    // 命令形式を表す列挙型
    enum InstType: logic<6> {
        X = 6'b000000,
        R = 6'b000001,
        I = 6'b000010,
        S = 6'b000100,
        B = 6'b001000,
        U = 6'b010000,
        J = 6'b100000,
    }

    // 制御に使うフラグ用の構造体
    struct InstCtrl {
        itype   : InstType   , // 命令の形式
        rwb_en  : logic      , // レジスタに書き込むかどうか
        is_lui  : logic      , // LUI命令である
        is_aluop: logic      , // ALUを利用する命令である
        is_jump : logic      , // ジャンプ命令である
        is_load : logic      , // ロード命令である
        funct3  : logic   <3>, // 命令のfunct3フィールド
        funct7  : logic   <7>, // 命令のfunct7フィールド
    }
}

InstTypeは、命令の形式を表すための列挙型です。InstTypeの幅は6ビットで、それぞれのビットに1つの命令形式が対応しています。どの命令形式にも対応しない場合、すべてのビットが0のInstType::Xを対応させます。

InstCtrlは、制御に使うフラグをひとまとめにした構造体です。itypeには命令の形式、funct3funct7にはそれぞれ命令のfunct3とfunct7フィールドを格納します。これ以外の構造体のフィールドは、使用するときに説明します。

命令をデコードするとき、まずopcodeを使って判別します。このために、デコードに使う定数をeeiパッケージに記述します(リスト3.32)。

リスト3.32: リスト3.32: opcodeの定数を定義する (eei.veryl)
    // opcode
    const OP_LUI   : logic<7> = 7'b0110111;
    const OP_AUIPC : logic<7> = 7'b0010111;
    const OP_OP    : logic<7> = 7'b0110011;
    const OP_OP_IMM: logic<7> = 7'b0010011;
    const OP_JAL   : logic<7> = 7'b1101111;
    const OP_JALR  : logic<7> = 7'b1100111;
    const OP_BRANCH: logic<7> = 7'b1100011;
    const OP_LOAD  : logic<7> = 7'b0000011;
    const OP_STORE : logic<7> = 7'b0100011;

これらの値とそれぞれの命令の対応は、仕様書[6]を確認してください。

3.7.2 制御フラグと即値を生成する

デコード処理を書く準備が整いました。src/inst_decoder.verylを作成し、次のように記述します(リスト3.33)。

リスト3.33: リスト3.33: inst_decoder.veryl
import eei::*;
import corectrl::*;

module inst_decoder (
    bits: input  Inst    ,
    ctrl: output InstCtrl,
    imm : output UIntX   ,
) {
    // 即値の生成
    let imm_i_g: logic<12> = bits[31:20];
    let imm_s_g: logic<12> = {bits[31:25], bits[11:7]};
    let imm_b_g: logic<12> = {bits[31], bits[7], bits[30:25], bits[11:8]};
    let imm_u_g: logic<20> = bits[31:12];
    let imm_j_g: logic<20> = {bits[31], bits[19:12], bits[20], bits[30:21]};

    let imm_i: UIntX = {bits[31] repeat XLEN - $bits(imm_i_g), imm_i_g};
    let imm_s: UIntX = {bits[31] repeat XLEN - $bits(imm_s_g), imm_s_g};
    let imm_b: UIntX = {bits[31] repeat XLEN - $bits(imm_b_g) - 1, imm_b_g, 1'b0};
    let imm_u: UIntX = {bits[31] repeat XLEN - $bits(imm_u_g) - 12, imm_u_g, 12'b0};
    let imm_j: UIntX = {bits[31] repeat XLEN - $bits(imm_j_g) - 1, imm_j_g, 1'b0};

    let op: logic<7> = bits[6:0];
    let f7: logic<7> = bits[31:25];
    let f3: logic<3> = bits[14:12];

    const T: logic = 1'b1;
    const F: logic = 1'b0;

    always_comb {
        imm = case op {
            OP_LUI, OP_AUIPC: imm_u,
            OP_JAL          : imm_j,
            OP_JALR, OP_LOAD: imm_i,
            OP_OP_IMM       : imm_i,
            OP_BRANCH       : imm_b,
            OP_STORE        : imm_s,
            default         : 'x,
        };
        ctrl = {case op {
            OP_LUI   : {InstType::U, T, T, F, F, F},
            OP_AUIPC : {InstType::U, T, F, F, F, F},
            OP_JAL   : {InstType::J, T, F, F, T, F},
            OP_JALR  : {InstType::I, T, F, F, T, F},
            OP_BRANCH: {InstType::B, F, F, F, F, F},
            OP_LOAD  : {InstType::I, T, F, F, F, T},
            OP_STORE : {InstType::S, F, F, F, F, F},
            OP_OP    : {InstType::R, T, F, T, F, F},
            OP_OP_IMM: {InstType::I, T, F, T, F, F},
            default  : {InstType::X, F, F, F, F, F},
        }, f3, f7};
    }
}

inst_decoderモジュールは、命令のビット列bitsを受け取り、制御信号ctrlと即値immを出力します。

即値の生成

B形式の命令を考えます。まず、命令のビット列から即値部分を取り出して変数imm_b_gを生成します。B形式の命令内に含まれている即値は12ビットで、最上位ビットは符号ビットです。最上位ビットを繰り返す(符号拡張する)ことによって、32ビットの即値imm_bを生成します。

always_combブロックでは、opcodeをcase式で分岐することによりimmポートに適切な即値を供給しています。

制御フラグの生成

opcodeがOP-IMMな命令、例えばADDI命令を考えます。ADDI命令は、即値とソースレジスタの値を足し、デスティネーションレジスタに結果を格納する命令です。

always_combブロックでは、opcodeがOP_OP_IMM(OP-IMM)のとき、次のように制御信号ctrlを設定します。1ビットの1'b01'b1を入力する手間を省くために、FTという定数を用意していることに注意してください。

  • 命令形式itypeInstType::Iに設定します
  • 結果をレジスタに書き込むため、rwb_en1に設定します
  • ALU(計算を実行する部品)を利用するため、is_aluop1に設定します
  • funct3funct7に命令中のビットをそのまま設定します
  • それ以外のフィールドは0に設定します

3.7.3 デコーダをインスタンス化する

inst_decoderモジュールを、coreモジュールでインスタンス化します(リスト3.34)。

リスト3.34: リスト3.34: inst_decoderモジュールのインスタンス化 (core.veryl)
    let inst_pc  : Addr     = if_fifo_rdata.addr;
    let inst_bits: Inst     = if_fifo_rdata.bits;
    var inst_ctrl: InstCtrl;
    var inst_imm : UIntX   ;

    inst decoder: inst_decoder (
        bits: inst_bits,
        ctrl: inst_ctrl,
        imm : inst_imm ,
    );

まず、デコーダとcoreモジュールを接続するためにinst_ctrlinst_immを定義します。次に、inst_decoderモジュールをインスタンス化します。bitsポートにinst_bitsを渡すことでフェッチした命令をデコードします。

デバッグ用のalways_ffブロックに、デコードした結果をデバッグ表示するコードを記述します(リスト3.35)。

リスト3.35: リスト3.35: デコード結果のデバッグ表示 (core.veryl)
    always_ff {
        if if_fifo_rvalid {
            $display("%h : %h", inst_pc, inst_bits);
            $display("  itype   : %b", inst_ctrl.itype);
            $display("  imm     : %h", inst_imm);
        }
    }

src/sample.hexをメモリの初期値として使い、デコード結果を確認します(リスト3.36)。

リスト3.36: リスト3.36: デコーダをテストする
$ make build
$ make sim
$ obj_dir/sim src/sample.hex 7
00000000 : 01234567
  itype   : 000010
  imm     : 00000012
00000004 : 89abcdef
  itype   : 100000
  imm     : fffbc09a
00000008 : deadbeef
  itype   : 100000
  imm     : fffdb5ea
0000000c : cafebebe
  itype   : 000000
  imm     : 00000000

例えば32'h01234567は、jalr x10, 18(x6)という命令のビット列になります。命令の種類はJALRで、命令形式はI形式、即値は10進数で18です。デコード結果を確認すると、itype32'h0000010imm32'h00000012になっており、正しくデコードできていることを確認できます。

3.8 レジスタの定義と読み込み

RV32Iには、32ビット幅のレジスタが32個用意されています。ただし、0番目のレジスタの値は常に0です。

3.8.1 レジスタファイルを定義する

coreモジュールにレジスタを定義します。レジスタの幅はXLEN(=32)ビットであるため、UIntX型のレジスタの配列を定義します(リスト3.37)。

リスト3.37: リスト3.37: レジスタの定義 (core.veryl)
    // レジスタ
    var regfile: UIntX<32>;

レジスタをまとめたもののことをレジスタファイル(register file)と呼ぶため、regfileという名前をつけています。

3.8.2 レジスタの値を読み込む

レジスタを定義したので、命令が使用するレジスタの値を取得します。

図3.3を見るとわかるように、RISC-Vの命令は形式によってソースレジスタの数が異なります。例えば、R形式はソースレジスタが2つで、2つのレジスタの値を使って実行されます。それに対して、I形式のソースレジスタは1つです。I形式の命令の実行にはソースレジスタの値と即値を利用します。

命令のビット列の中のソースレジスタの番号の場所は、命令形式が違っても共通の場所にあります。コードを簡単にするために、命令がレジスタの値を利用するかどうかに関係なく、常にレジスタの値を読み込むことにします(リスト3.38)。

リスト3.38: リスト3.38: 命令が使うレジスタの値を取得する (core.veryl)
    // レジスタ番号
    let rs1_addr: logic<5> = inst_bits[19:15];
    let rs2_addr: logic<5> = inst_bits[24:20];

    // ソースレジスタのデータ
    let rs1_data: UIntX = if rs1_addr == 0 {
        0
    } else {
        regfile[rs1_addr]
    };
    let rs2_data: UIntX = if rs2_addr == 0 {
        0
    } else {
        regfile[rs2_addr]
    };

if式を使うことで、0番目のレジスタが指定されたときは、値が常に0になるようにします。

レジスタの値を読み込めていることを確認するために、デバッグ表示にソースレジスタの値を追加します(リスト3.39)。$displayシステムタスクで、命令のレジスタ番号と値をデバッグ表示します。

リスト3.39: リスト3.39: レジスタの値をデバッグ表示する (core.veryl)
    always_ff {
        if if_fifo_rvalid {
            $display("%h : %h", inst_pc, inst_bits);
            $display("  itype   : %b", inst_ctrl.itype);
            $display("  imm     : %h", inst_imm);
            $display("  rs1[%d] : %h", rs1_addr, rs1_data);
            $display("  rs2[%d] : %h", rs2_addr, rs2_data);
        }
    }

早速動作のテストをしたいところですが、今のままだとレジスタの値が初期化されておらず、0番目のレジスタの値以外は不定値*5になってしまいます。

[*5] Verilatorはデフォルト設定では不定値に対応していないため、不定値は0になります

これではテストする意味がないため、レジスタの値を適当な値に初期化します。always_ffブロックのif_resetで、i番目(0 < i < 32)のレジスタの値をi + 100で初期化します(リスト3.40)。

リスト3.40: リスト3.40: レジスタを適当な値で初期化する (core.veryl)
    // レジスタの初期化
    always_ff {
        if_reset {
            for i: i32 in 0..32 {
                regfile[i] = i + 100;
            }
        }
    }

レジスタの値を読み込めていることを確認します(リスト3.41)。

リスト3.41: リスト3.41: レジスタ読み込みのデバッグ
$ make build
$ make sim
$ obj_dir/sim sample.hex 7
00000000 : 01234567
  itype   : 000010
  imm     : 00000012
  rs1[ 6] : 0000006a
  rs2[18] : 00000076
00000004 : 89abcdef
  itype   : 100000
  imm     : fffbc09a
  rs1[23] : 0000007b
  rs2[26] : 0000007e
00000008 : deadbeef
  itype   : 100000
  imm     : fffdb5ea
  rs1[27] : 0000007f
  rs2[10] : 0000006e
0000000c : cafebebe
  itype   : 000000
  imm     : 00000000
  rs1[29] : 00000081
  rs2[15] : 00000073

32'h01234567jalr x10, 18(x6)です。JALR命令は、ソースレジスタx6を使用します。x6はレジスタ番号が6であることを表しており、値は106に初期化しています。これは16進数で32'h0000006aです。

シミュレーションと結果が一致していることを確認してください。

3.9 ALUによる計算の実装

レジスタと即値が揃い、命令で使用するデータが手に入るようになりました。基本整数命令セットの命令では、足し算や引き算、ビット演算などの簡単な整数演算を行います。それでは、CPUの計算を行う部品であるALU(Arithmetic Logic Unit)を作成します。

3.9.1 ALUモジュールを作成する

レジスタと即値の幅はXLENです。計算には符号付き整数と符号なし整数向けの計算があります。符号付き整数を利用するために、eeiモジュールにXLENビットの符号付き整数型を定義します(リスト3.42)。

リスト3.42: リスト3.42: XLENビットの符号付き整数型を定義する (eei.veryl)
    type SIntX  = signed logic<XLEN>;
    type SInt32 = signed logic<32>  ;
    type SInt64 = signed logic<64>  ;

次に、src/alu.verylを作成し、次のように記述します(リスト3.43)。

リスト3.43: リスト3.43: alu.veryl
import eei::*;
import corectrl::*;

module alu (
    ctrl  : input  InstCtrl,
    op1   : input  UIntX   ,
    op2   : input  UIntX   ,
    result: output UIntX   ,
) {
    let add: UIntX = op1 + op2;
    let sub: UIntX = op1 - op2;

    let sll: UIntX = op1 << op2[4:0];
    let srl: UIntX = op1 >> op2[4:0];
    let sra: SIntX = $signed(op1) >>> op2[4:0];

    let slt : UIntX = {1'b0 repeat XLEN - 1, $signed(op1) <: $signed(op2)};
    let sltu: UIntX = {1'b0 repeat XLEN - 1, op1 <: op2};

    always_comb {
        if ctrl.is_aluop {
            case ctrl.funct3 {
                3'b000: result = if ctrl.itype == InstType::I | ctrl.funct7 == 0 {
                    add
                } else {
                    sub
                };
                3'b001: result = sll;
                3'b010: result = slt;
                3'b011: result = sltu;
                3'b100: result = op1 ^ op2;
                3'b101: result = if ctrl.funct7 == 0 {
                    srl
                } else {
                    sra
                };
                3'b110 : result = op1 | op2;
                3'b111 : result = op1 & op2;
                default: result = 'x;
            }
        } else {
            result = add;
        }
    }
}

aluモジュールには、次のポートを定義します (表3.3)。

表3.3: aluモジュールのポート定義

ポート名方向用途
ctrlinputInstCtrl制御用信号
op1inputUIntX1つ目のデータ
op2 inputUIntX2つ目のデータ
resultoutputUIntX結果

仕様書で整数演算命令として定義されている命令[7]は、funct3とfunct7フィールドによって計算の種類を特定できます(表3.4)。

表3.4: ALUの演算の種類

funct3演算
3'b000加算、または減算
3'b001左シフト
3'b010符号付き <=
3'b011符号なし <=
3'b100ビット単位XOR
3'b101右論理、右算術シフト
3'b110ビット単位OR
3'b111ビット単位AND

それ以外の命令は、足し算しか行いません。そのため、デコード時に整数演算命令とそれ以外の命令をInstCtrl.is_aluopで区別し、整数演算命令以外は常に足し算を行うようにしています。具体的には、opcodeがOPかOP-IMMの命令のInstCtrl.is_aluop1にしています(リスト3.33)。

always_combブロックでは、funct3のcase文によって計算を選択します。funct3だけでは選択できないとき、funct7を使用します。

3.9.2 ALUモジュールをインスタンス化する

次に、ALUに渡すデータを用意します。UIntX型の変数op1op2alu_resultを定義し、always_combブロックで値を割り当てます(リスト3.44)。

リスト3.44: リスト3.44: ALUに渡すデータの用意 (core.veryl)
    // ALU
    var op1       : UIntX;
    var op2       : UIntX;
    var alu_result: UIntX;

    always_comb {
        case inst_ctrl.itype {
            InstType::R, InstType::B: {
                                          op1 = rs1_data;
                                          op2 = rs2_data;
                                      }
            InstType::I, InstType::S: {
                                          op1 = rs1_data;
                                          op2 = inst_imm;
                                      }
            InstType::U, InstType::J: {
                                          op1 = inst_pc;
                                          op2 = inst_imm;
                                      }
            default: {
                         op1 = 'x;
                         op2 = 'x;
                     }
        }
    }

割り当てるデータは、命令形式によって次のように異なります。

R形式、B形式
R形式とB形式は、レジスタの値とレジスタの値の演算を行います。 op1op2は、レジスタの値rs1_datars2_dataになります。
I形式、S形式
I形式とS形式は、レジスタの値と即値の演算を行います。 op1op2は、それぞれレジスタの値rs1_dataと即値inst_immになります。 S形式はメモリの書き込み命令に利用されており、 レジスタの値と即値を足し合わせた値がアクセスするアドレスになります。
U形式、J形式
U形式とJ形式は、即値とPCを足した値、または即値を使う命令に使われています。 op1op2は、それぞれPCinst_pcと即値inst_immになります。 J形式はJAL命令に利用されており、PCに即値を足した値がジャンプ先になります。 U形式はAUIPC命令とLUI命令に利用されています。 AUIPC命令は、PCに即値を足した値をデスティネーションレジスタに格納します。 LUI命令は、即値をそのままデスティネーションレジスタに格納します。

ALUに渡すデータを用意したので、aluモジュールをインスタンス化します(リスト3.45)。結果を受け取る用の変数として、alu_resultを指定します。

リスト3.45: リスト3.45: ALUのインスタンス化 (core.veryl)
    inst alum: alu (
        ctrl  : inst_ctrl ,
        op1               ,
        op2               ,
        result: alu_result,
    );

3.9.3 ALUモジュールをテストする

最後にALUが正しく動くことを確認します。

always_ffブロックで、op1op2alu_resultをデバッグ表示します(リスト3.46)。

リスト3.46: リスト3.46: ALUの結果をデバッグ表示する (core.veryl)
    always_ff {
        if if_fifo_rvalid {
            $display("%h : %h", inst_pc, inst_bits);
            $display("  itype   : %b", inst_ctrl.itype);
            $display("  imm     : %h", inst_imm);
            $display("  rs1[%d] : %h", rs1_addr, rs1_data);
            $display("  rs2[%d] : %h", rs2_addr, rs2_data);
            $display("  op1     : %h", op1);
            $display("  op2     : %h", op2);
            $display("  alu res : %h", alu_result);
        }
    }

src/sample.hexを、次のように書き換えます(リスト3.47)。

リスト3.47: リスト3.47: sample.hexを書き換える
02000093 // addi x1, x0, 32
00100117 // auipc x2, 256
002081b3 // add x3, x1, x2

それぞれの命令の意味は次のとおりです(表3.5)。

表3.5: 命令の意味

アドレス命令命令形式意味
0x00000000addi x1, x0, 32I形式x1 = x0 + 32
0x00000004auipc x2, 256U形式x2 = pc + 256
0x00000008add x3, x1, x2R形式x3 = x1 + x2

シミュレータを実行し、結果を確かめます(リスト3.48)。

リスト3.48: リスト3.48: ALUのデバッグ
$ make build
$ make sim
$ obj_dir/sim src/sample.hex 6
00000000 : 02000093
  itype   : 000010
  imm     : 00000020
  rs1[ 0] : 00000000
  rs2[ 0] : 00000000
  op1     : 00000000
  op2     : 00000020
  alu res : 00000020
00000004 : 00100117
  itype   : 010000
  imm     : 00100000
  rs1[ 0] : 00000000
  rs2[ 1] : 00000065
  op1     : 00000004
  op2     : 00100000
  alu res : 00100004
00000008 : 002081b3
  itype   : 000001
  imm     : 00000000
  rs1[ 1] : 00000065
  rs2[ 2] : 00000066
  op1     : 00000065
  op2     : 00000066
  alu res : 000000cb

まだ、結果をディスティネーションレジスタに格納する処理を作成していません。そのため、命令を実行してもレジスタの値は変わらないことに注意してください

addi x1, x0, 32
op1は0番目のレジスタの値です。 0番目のレジスタの値は常に0であるため、32'h00000000と表示されています。 op2は即値です。 即値は32であるため、32'h00000020と表示されています。 ALUの計算結果として、0と32を足した結果32'h00000020が表示されています。
auipc x2, 256
op1はPCです。 op1には、命令のアドレス0x00000004が表示されています。 op2は即値です。 256を12bit左にシフトした値32'h00100000が表示されています。 ALUの計算結果として、これを足した結果32'h00100004が表示されています。
add x3, x1, x2
op1は1番目のレジスタの値です。 1番目のレジスタは101として初期化しているので、32'h00000065と表示されています。 op2は2番目のレジスタの値です。 2番目のレジスタは102として初期化しているので、32'h00000066と表示されています。 ALUの計算結果として、これを足した結果32'h000000cbが表示されています。

3.10 レジスタに結果を書き込む

CPUはレジスタから値を読み込み、計算して、レジスタに結果の値を書き戻します。レジスタに値を書き戻すことを、値をライトバック(write-back)すると呼びます。

ライトバックする値は、計算やメモリアクセスの結果です。まだメモリにアクセスする処理を実装していませんが、先にライトバック処理を実装します。

3.10.1 ライトバック処理を実装する

書き込む対象のレジスタ(デスティネーションレジスタ)は、命令のrdフィールドによって番号で指定されます。デコード時に、レジスタに結果を書き込む命令かどうかをInstCtrl.rwb_enに格納しています(リスト3.33)。

LUI命令のときは即値をそのまま、それ以外の命令のときはALUの結果をライトバックします(リスト3.49)。

リスト3.49: リスト3.49: ライトバック処理の実装 (core.veryl)
    let rd_addr: logic<5> = inst_bits[11:7];
    let wb_data: UIntX    = if inst_ctrl.is_lui {
        inst_imm
    } else {
        alu_result
    };

    always_ff {
        if_reset {
            for i: i32 in 0..32 {
                regfile[i] = i + 100;
            }
        } else {
            if if_fifo_rvalid && inst_ctrl.rwb_en {
                regfile[rd_addr] = wb_data;
            }
        }
    }

3.10.2 ライトバック処理をテストする

デバッグ表示用のalways_ffブロックで、ライトバック処理の概要をデバッグ表示します(リスト3.50)。処理している命令がライトバックする命令のときにのみ、$displayシステムタスクを呼び出します。

リスト3.50: リスト3.50: ライトバックのデバッグ表示 (core.veryl)
    if inst_ctrl.rwb_en {
        $display("  reg[%d] <= %h", rd_addr, wb_data);
    }

シミュレータを実行し、結果を確かめます(リスト3.51)。

リスト3.51: リスト3.51: ライトバックのデバッグ
$ make build
$ make sim
$ obj_dir/sim sample.hex 6
00000000 : 02000093
  itype   : 000010
  imm     : 00000020
  rs1[ 0] : 00000000
  rs2[ 0] : 00000000
  op1     : 00000000
  op2     : 00000020
  alu res : 00000020
  reg[ 1] <= 00000020
00000004 : 00100117
  itype   : 010000
  imm     : 00100000
  rs1[ 0] : 00000000
  rs2[ 1] : 00000020
  op1     : 00000004
  op2     : 00100000
  alu res : 00100004
  reg[ 2] <= 00100004
00000008 : 002081b3
  itype   : 000001
  imm     : 00000000
  rs1[ 1] : 00000020
  rs2[ 2] : 00100004
  op1     : 00000020
  op2     : 00100004
  alu res : 00100024
  reg[ 3] <= 00100024
addi x1, x0, 32
x1に、0と32を足した値(32'h00000020)を格納しています。
auipc x2, 256
x2に、256を12ビット左にシフトした値(32'h00100000)とPC(32'h00000004)を足した値(32'h00100004)を格納しています。
add x3, x1, x2
x1は1つ目の命令で32'h00000020に、 x2は2つ目の命令で32'h00100004にされています。 x3に、x1とx2を足した結果32'h00100024を格納しています。

おめでとうございます!このCPUは整数演算命令の実行ができるようになりました!

最後に、テストのためにレジスタの値を初期化していたコードを削除します(リスト3.52)。

リスト3.52: リスト3.52: レジスタの初期化をやめる (core.veryl)
    always_ff {
        if if_fifo_rvalid && inst_ctrl.rwb_en {
            regfile[rd_addr] = wb_data;
        }
    }

3.11 ロード命令とストア命令の実装

RV32Iには、メモリのデータを読み込む、書き込む命令として次の命令があります(表3.6)。データを読み込む命令のことをロード命令、データを書き込む命令のことをストア命令と呼びます。2つを合わせてロードストア命令と呼びます。

表3.6: RV32Iのロード命令、ストア命令

命令作用
LB8ビットのデータを読み込む。上位24ビットは符号拡張する
LBU8ビットのデータを読み込む。上位24ビットは0で拡張する
LH16ビットのデータを読み込む。上位16ビットは符号拡張する
LHU16ビットのデータを読み込む。上位16ビットは0で拡張する
LW32ビットのデータを読み込む
SB8ビットのデータを書き込む
SH16ビットのデータを書き込む
SW32ビットのデータを書き込む

ロード命令はI形式、ストア命令はS形式です。これらの命令で指定するメモリのアドレスは、rs1と即値の足し算です。ALUに渡すデータがrs1と即値になっていることを確認してください(リスト3.44)。ストア命令は、rs2の値をメモリに格納します。

3.11.1 LW、SW命令を実装する

8ビット、16ビット単位で読み書きを行う命令の実装は少し大変です。まず、32ビット単位で読み書きを行うLW命令とSW命令を実装します。

memunitモジュールの作成

メモリ操作を行うモジュールを、src/memunit.verylに記述します(リスト3.53)。

リスト3.53: リスト3.53: memunit.veryl
import eei::*;
import corectrl::*;

module memunit (
    clk   : input   clock                                    ,
    rst   : input   reset                                    ,
    valid : input   logic                                    ,
    is_new: input   logic                                    , // 命令が新しく供給されたかどうか
    ctrl  : input   InstCtrl                                 , // 命令のInstCtrl
    addr  : input   Addr                                     , // アクセスするアドレス
    rs2   : input   UIntX                                    , // ストア命令で書き込むデータ
    rdata : output  UIntX                                    , // ロード命令の結果 (stall = 0のときに有効)
    stall : output  logic                                    , // メモリアクセス命令が完了していない
    membus: modport membus_if::<MEM_DATA_WIDTH, XLEN>::master, // メモリとのinterface
) {

    // 命令がメモリにアクセスする命令か判別する関数
    function inst_is_memop (
        ctrl: input InstCtrl,
    ) -> logic    {
        return ctrl.itype == InstType::S || ctrl.is_load;
    }

    // 命令がストア命令か判別する関数
    function inst_is_store (
        ctrl: input InstCtrl,
    ) -> logic    {
        return inst_is_memop(ctrl) && !ctrl.is_load;
    }

    // memunitの状態を表す列挙型
    enum State: logic<2> {
        Init, // 命令を受け付ける状態
        WaitReady, // メモリが操作可能になるのを待つ状態
        WaitValid, // メモリ操作が終了するのを待つ状態
    }

    var state: State;

    var req_wen  : logic                ;
    var req_addr : Addr                 ;
    var req_wdata: logic<MEM_DATA_WIDTH>;

    always_comb {
        // メモリアクセス
        membus.valid = state == State::WaitReady;
        membus.addr  = req_addr;
        membus.wen   = req_wen;
        membus.wdata = req_wdata;
        // loadの結果
        rdata = membus.rdata;
        // stall判定
        stall = valid & case state {
            State::Init     : is_new && inst_is_memop(ctrl),
            State::WaitReady: 1,
            State::WaitValid: !membus.rvalid,
            default         : 0,
        };
    }

    always_ff {
        if_reset {
            state     = State::Init;
            req_wen   = 0;
            req_addr  = 0;
            req_wdata = 0;
        } else {
            if valid {
                case state {
                    State::Init: if is_new & inst_is_memop(ctrl) {
                        state     = State::WaitReady;
                        req_wen   = inst_is_store(ctrl);
                        req_addr  = addr;
                        req_wdata = rs2;
                    }
                    State::WaitReady: if membus.ready {
                        state = State::WaitValid;
                    }
                    State::WaitValid: if membus.rvalid {
                        state = State::Init;
                    }
                    default: {}
                }
            }
        }
    }
}

memunitモジュールでは、命令がメモリにアクセスする命令のとき、ALUから受け取ったアドレスをメモリに渡して操作を実行します。

命令がメモリにアクセスする命令かどうかはinst_is_memop関数で判定します。ストア命令のとき、命令の形式はS形式です。ロード命令のとき、デコーダはInstCtrl.is_load1にしています(リスト3.33)。

memunitモジュールには次の状態が定義されています。初期状態はState::Initです。

State::Init
memunitモジュールに新しく命令が供給されたとき、 validis_new1になっています。 新しく命令が供給されて、それがメモリにアクセスする命令のとき、 状態をState::WaitReadyに移動します。 その際、req_wenにストア命令かどうか、 req_addrにアクセスするアドレス、 req_wdatars2を格納します。
State::WaitReady
命令に応じた要求をメモリに送り続けます。 メモリが要求を受け付ける(ready)とき、 状態をState::WaitValidに移動します。
State::WaitValid
メモリの処理が終了した(rvalid)とき、 状態をState::Initに移動します。

メモリにアクセスする命令のとき、memunitモジュールはInitWaitReadyWaitValidの順で状態を移動するため、実行には少なくとも3クロックが必要です。その間、CPUはレジスタのライトバック処理やFIFOからの命令の取り出しを止める必要があります。

CPUの実行が止まることを、CPUがストール(Stall)すると呼びます。メモリアクセス中のストールを実現するために、memunitモジュールには処理中かどうかを表すstallフラグを実装しています。有効な命令が供給されているとき、stateやメモリの状態に応じて、次のようにstallの値を決定します(表3.7)。

表3.7: stallの値の決定方法

状態stallが1になる条件
Init新しく命令が供給されて、それがメモリにアクセスする命令のとき
WaitReady常に1
WaitValid処理が終了していない(!membus.rvalid)とき

アドレスが4バイトに整列されていない場合の動作

memoryモジュールはアドレスの下位2ビットを無視するため、addrの下位2ビットが00ではない、つまり、4で割り切れないアドレスに対してLW命令かSW命令を実行する場合、memunitモジュールは正しい動作をしません。この問題は後の章で対応するため、全てのロードストア命令は、アクセスするビット幅で割り切れるアドレスにしかアクセスしないということにしておきます。

memunitモジュールのインスタンス化

coreモジュール内にmemunitモジュールをインスタンス化します。

まず、命令が供給されていることを示す信号inst_validと、命令が現在のクロックで供給されたことを示す信号inst_is_newを作成します(リスト3.54)。命令が供給されているかどうかはif_fifo_rvalidと同値です。これを機に、if_fifo_rvalidを使用しているところをinst_validに置き換えましょう。

リスト3.54: リスト3.54: inst_validとinst_is_newの定義 (core.veryl)
    let inst_valid : logic    = if_fifo_rvalid;
    var inst_is_new: logic   ; // 命令が現在のクロックで供給されたかどうか

次に、inst_is_newの値を更新します(リスト3.55)。命令が現在のクロックで供給されたかどうかは、FIFOのrvalidrreadyを観測することでわかります。rvalid1のとき、rready1なら、次のクロックで供給される命令は新しく供給される命令です。rready0なら、次のクロックで供給されている命令は現在のクロックと同じ命令になります。rvalid0のとき、次のクロックで供給される命令は常に新しく供給される命令になります(次のクロックでrvalid1かどうかは考えません)。

リスト3.55: リスト3.55: inst_is_newの実装 (core.veryl)
    always_ff {
        if_reset {
            inst_is_new = 0;
        } else {
            if if_fifo_rvalid {
                inst_is_new = if_fifo_rready;
            } else {
                inst_is_new = 1;
            }
        }
    }

memunitモジュールをインスタンス化する前に、メモリとの接続方法を考える必要があります。

coreモジュールには、メモリとの接続点としてmembusポートが存在します。しかし、これは命令フェッチに使用されているため、memunitモジュールのために使用できません。また、memoryモジュールは同時に2つの操作を受け付けられません。

この問題を、coreモジュールにメモリとのインターフェースを2つ用意してtopモジュールで調停することにより回避します。

まず、coreモジュールに命令フェッチ用のポートi_membusと、ロードストア命令用のポートd_membusの2つのポートを用意します(リスト3.56)。

リスト3.56: リスト3.56: coreモジュールのポート定義 (core.veryl)
module core (
    clk     : input   clock                                    ,
    rst     : input   reset                                    ,
    i_membus: modport membus_if::<ILEN, XLEN>::master          ,
    d_membus: modport membus_if::<MEM_DATA_WIDTH, XLEN>::master,
) {

命令フェッチ用のポートがmembusからi_membusに変更されるため、既存のmembusi_membusに置き換えてください(リスト3.57)。

リスト3.57: リスト3.57: membusをi_membusに置き換える (core.veryl)
    // FIFOに2個以上空きがあるとき、命令をフェッチする
    i_membus.valid = if_fifo.wready_two;
    i_membus.addr  = if_pc;
    i_membus.wen   = 0;
    i_membus.wdata = 'x; // wdataは使用しない

次に、topモジュールでの調停を実装します(リスト3.58)。新しくi_membusd_membusをインスタンス化し、それをmembusと接続します。

リスト3.58: リスト3.58: メモリへのアクセス要求の調停 (top.veryl)
    inst membus  : membus_if::<MEM_DATA_WIDTH, MEM_ADDR_WIDTH>;
    inst i_membus: membus_if::<ILEN, XLEN>; // 命令フェッチ用
    inst d_membus: membus_if::<MEM_DATA_WIDTH, XLEN>; // ロードストア命令用

    var memarb_last_i: logic;

    // メモリアクセスを調停する
    always_ff {
        if_reset {
            memarb_last_i = 0;
        } else {
            if membus.ready {
                memarb_last_i = !d_membus.valid;
            }
        }
    }

    always_comb {
        i_membus.ready  = membus.ready && !d_membus.valid;
        i_membus.rvalid = membus.rvalid && memarb_last_i;
        i_membus.rdata  = membus.rdata;

        d_membus.ready  = membus.ready;
        d_membus.rvalid = membus.rvalid && !memarb_last_i;
        d_membus.rdata  = membus.rdata;

        membus.valid = i_membus.valid | d_membus.valid;
        if d_membus.valid {
            membus.addr  = addr_to_memaddr(d_membus.addr);
            membus.wen   = d_membus.wen;
            membus.wdata = d_membus.wdata;
        } else {
            membus.addr  = addr_to_memaddr(i_membus.addr);
            membus.wen   = 0; // 命令フェッチは常に読み込み
            membus.wdata = 'x;
        }
    }

調停の仕組みは次のとおりです。

  • i_membusd_membusの両方のvalid1のとき、d_membusを優先する
  • memarb_last_iレジスタに、受け入れた要求がi_membusからのものだったかを記録する
  • メモリが要求の結果を返すとき、memarb_last_iを見て、i_membusd_membusのどちらか片方のrvalid1にする

命令フェッチを優先しているとロードストア命令の処理が進まないため、i_membusよりもd_membusを優先します。

coreモジュールとの接続を次のように変更します(リスト3.59)。

リスト3.59: リスト3.59: membusを2つに分けて接続する (top.veryl)
    inst c: core (
        clk       ,
        rst       ,
        i_membus  ,
        d_membus  ,
    );

memoryモジュールとmemunitモジュールを接続する準備が整ったので、memunitモジュールをインスタンス化します(リスト3.60)。

リスト3.60: リスト3.60: memunitモジュールのインスタンス化 (core.veryl)
    var memu_rdata: UIntX;
    var memu_stall: logic;

    inst memu: memunit (
        clk                ,
        rst                ,
        valid : inst_valid ,
        is_new: inst_is_new,
        ctrl  : inst_ctrl  ,
        addr  : alu_result ,
        rs2   : rs2_data   ,
        rdata : memu_rdata ,
        stall : memu_stall ,
        membus: d_membus   ,
    );

memunitモジュールの処理待ちとライトバック

memunitモジュールが処理中のときは命令をFIFOから取り出すのを止める処理と、ロード命令で読み込んだデータをレジスタにライトバックする処理を実装します。

memunitモジュールが処理中のとき、FIFOから命令を取り出すのを止めます(リスト3.61)。

リスト3.61: リスト3.61: memunitモジュールの処理が終わるのを待つ (core.veryl)
    // memunitが処理中ではないとき、FIFOから命令を取り出していい
    if_fifo_rready = !memu_stall;

memunitモジュールが処理中のとき、memu_stall1になっています。そのため、memu_stall1のときはif_fifo_rready0にすることで、FIFOからの命令の取り出しを停止します。

次に、ロード命令の結果をレジスタにライトバックします(リスト3.62)。ライトバック処理では、命令がロード命令のとき(inst_ctrl.is_load)、memu_rdatawb_dataに設定します。

リスト3.62: リスト3.62: memunitモジュールの結果をライトバックする (core.veryl)
    let rd_addr: logic<5> = inst_bits[11:7];
    let wb_data: UIntX    = if inst_ctrl.is_lui {
        inst_imm
    } else if inst_ctrl.is_load {
        memu_rdata
    } else {
        alu_result
    };

ところで、現在のコードではmemunitの処理が終了していないときも値をライトバックし続けています。レジスタへのライトバックは命令の実行が終了したときのみで良いため、次のようにコードを変更します(リスト3.63)。

リスト3.63: リスト3.63: 命令の実行が終了したときにのみライトバックする (core.veryl)
    always_ff {
        if inst_valid && if_fifo_rready && inst_ctrl.rwb_en {
            regfile[rd_addr] = wb_data;
        }
    }

デバッグ表示も同様で、ライトバックするときにのみデバッグ表示します(リスト3.64)。

リスト3.64: リスト3.64: ライトバックするときにのみデバッグ表示する (core.veryl)
    if if_fifo_rready && inst_ctrl.rwb_en {
        $display("  reg[%d] <= %h", rd_addr, wb_data);
    }

LW、SW命令のテスト

LW命令とSW命令が正しく動作していることを確認するために、デバッグ表示に次のコードを追加します(リスト3.65)。

リスト3.65: リスト3.65: メモリモジュールの状態をデバッグ表示する (core.veryl)
    $display("  mem stall : %b", memu_stall);
    $display("  mem rdata : %h", memu_rdata);

ここからのテストは実行するクロック数が多くなります。そこで、ログに何クロック目かを表示することでログを読みやすくします(リスト3.66)。

リスト3.66: リスト3.66: 何クロック目かを出力する (core.veryl)
    var clock_count: u64;

    always_ff {
        if_reset {
            clock_count = 1;
        } else {
            clock_count = clock_count + 1;
            if inst_valid {
                $display("# %d", clock_count);
                $display("%h : %h", inst_pc, inst_bits);
                $display("  itype     : %b", inst_ctrl.itype);

LW、SW命令のテストのために、src/sample.hexを次のように変更します(リスト3.67)。

リスト3.67: リスト3.67: テスト用のプログラムを記述する (sample.hex)
02002503 // lw x10, 0x20(x0)
40000593 // addi x11, x0, 0x400
02b02023 // sw x11, 0x20(x0)
02002603 // lw x12, 0x20(x0)
00000000
00000000
00000000
00000000
deadbeef // 0x20

プログラムは次のようになっています(表3.8)。

表3.8: メモリに格納する命令

アドレス命令意味
0x00000000lw x10, 0x20(x0)x10に、アドレスが0x20のデータを読み込む
0x00000004addi x11, x0, 0x400x11 = 0x400
0x00000008sw x11, 0x20(x0)アドレス0x20にx11の値を書き込む
0x0000000clw x12, 0x20(x0)x12に、アドレスが0x20のデータを読み込む

アドレス0x00000020には、データ32'hdeadbeefを格納しています。1つ目の命令で32'hdeadbeefが読み込まれ、3つ目の命令で32'h00000400を書き込み、4つ目の命令で32'h00000400が読み込まれます。

シミュレータを実行し、結果を確かめます(リスト3.68)。

リスト3.68: リスト3.68: LW、SW命令のテスト
$ make build
$ make sim
$ obj_dir/sim src/sample.hex 13

#                    4
00000000 : 02002503
  itype     : 000010
  imm       : 00000020
  rs1[ 0]   : 00000000
  rs2[ 0]   : 00000000
  op1       : 00000000
  op2       : 00000020
  alu res   : 00000020
  mem stall : 1 ← LW命令でストールしている
  mem rdata : 02b02023
...
#                    6
00000000 : 02002503
  itype     : 000010
  imm       : 00000020
  rs1[ 0]   : 00000000
  rs2[ 0]   : 00000000
  op1       : 00000000
  op2       : 00000020
  alu res   : 00000020
  mem stall : 0 ← LWが終わったので0になった
  mem rdata : deadbeef
  reg[10] <= deadbeef ← 0x20の値が読み込まれた
...
#                   13
0000000c : 02002603
  itype     : 000010
  imm       : 00000020
  rs1[ 0]   : 00000000
  rs2[ 0]   : 00000000
  op1       : 00000000
  op2       : 00000020
  alu res   : 00000020
  mem stall : 0
  mem rdata : 00000400
  reg[12] <= 00000400 ← 書き込んだ値が読み込まれた

3.11.2 LB、LBU、LH、LHU命令を実装する

LBとLBUとSB命令は8ビット単位、LHとLHUとSH命令は16ビット単位でロードストアを行う命令です。まず、ロード命令を実装します。ロード命令は32ビット単位でデータを読み込み、その結果の一部を切り取ることで実装できます。

LB、LBU、LH、LHU、LW命令は、funct3の値で区別できます(表3.9)。funct3の上位1ビットが1のとき、符号拡張を行います。

表3.9: ロード命令のfunct3

funct3命令
3'b000LB
3'b100LBU
3'b001LH
3'b101LHU
3'b010LW

まず、何度も記述することになる値を短い名前(WDsext)で定義します(リスト3.69)。sextは、符号拡張を行うかどうかを示す変数です。

リスト3.69: リスト3.69: W、D、sextの定義 (memunit.veryl)
    const W   : u32                   = XLEN;
    let D   : logic<MEM_DATA_WIDTH> = membus.rdata;
    let sext: logic                 = ctrl.funct3[2] == 1'b0;

funct3をcase文で分岐し、アドレスの下位ビットを見ることで、命令とアドレスに応じた値をrdataに設定します(リスト3.70)。

リスト3.70: リスト3.70: rdataをアドレスと読み込みサイズに応じて変更する (memunit.veryl)
    // loadの結果
    rdata = case ctrl.funct3[1:0] {
        2'b00  : case addr[1:0] {
            0      : {sext & D[7] repeat W - 8, D[7:0]},
            1      : {sext & D[15] repeat W - 8, D[15:8]},
            2      : {sext & D[23] repeat W - 8, D[23:16]},
            3      : {sext & D[31] repeat W - 8, D[31:24]},
            default: 'x,
        },
        2'b01  : case addr[1:0] {
            0      : {sext & D[15] repeat W - 16, D[15:0]},
            2      : {sext & D[31] repeat W - 16, D[31:16]},
            default: 'x,
        },
        2'b10  : D,
        default: 'x,
    };

ロードした値の拡張を行うとき、値の最上位ビットとsextをAND演算した値を使って拡張します。これにより、符号拡張するときは最上位ビットの値が、ゼロで拡張するときは0が拡張に利用されます。

3.11.3 SB、SH命令を実装する

次に、SB、SH命令を実装します。

memoryモジュールで書き込みマスクをサポートする

memoryモジュールは、32ビット単位の読み書きしかサポートしておらず、一部のみの書き込みをサポートしていません。本書では、一部のみ書き込む命令をmemoryモジュールでサポートすることでSB、SH命令を実装します。

まず、membus_ifインターフェースに、書き込む場所をバイト単位で示す信号wmaskを追加します(リスト3.71,リスト3.72,リスト3.73)。

リスト3.71: リスト3.71: wmaskの定義 (membus_if.veryl)
    var wmask : logic<DATA_WIDTH / 8>;
リスト3.72: リスト3.72: modport masterにwmaskを追加する (membus_if.veryl)
    modport master {
        ...
        wmask : output,
        ...
    }
リスト3.73: リスト3.73: modport slaveにwmaskを追加する (membus_if.veryl)
    modport slave {
        ...
        wmask : input ,
        ...
    }

wmaskには、書き込む部分を1、書き込まない部分を0で指定します。このような挙動をする値を、書き込みマスクと呼びます。バイト単位で指定するため、wmaskの幅はDATA_WIDTH / 8ビットです。

次に、memoryモジュールで書き込みマスクをサポートします(リスト3.74)。

リスト3.74: リスト3.74: 書き込みマスクをサポートするmemoryモジュール (memory.veryl)
module memory::<DATA_WIDTH: const, ADDR_WIDTH: const> #(
    param FILEPATH_IS_ENV: logic  = 0 , // FILEPATHが環境変数名かどうか
    param FILEPATH       : string = "", // メモリの初期化用ファイルのパス, または環境変数名
) (
    clk   : input   clock                                     ,
    rst   : input   reset                                     ,
    membus: modport membus_if::<DATA_WIDTH, ADDR_WIDTH>::slave,
) {
    type DataType = logic<DATA_WIDTH>    ;
    type MaskType = logic<DATA_WIDTH / 8>;

    var mem: DataType [2 ** ADDR_WIDTH];

    // 書き込みマスクをDATA_WIDTHに展開した値
    var wmask_expand: DataType;
    always_comb {
        for i: u32 in 0..DATA_WIDTH {
            wmask_expand[i] = wmask_saved[i / 8];
        }
    }

    initial {
        // memを初期化する
        if FILEPATH != "" {
            if FILEPATH_IS_ENV {
                $readmemh(util::get_env(FILEPATH), mem);
            } else {
                $readmemh(FILEPATH, mem);
            }
        }
    }

    // 状態
    enum State {
        Ready,
        WriteValid,
    }
    var state: State;

    var addr_saved : logic   <ADDR_WIDTH>;
    var wdata_saved: DataType            ;
    var wmask_saved: MaskType            ;
    var rdata_saved: DataType            ;

    always_comb {
        membus.ready = state == State::Ready;
    }

    always_ff {
        if state == State::WriteValid {
            mem[addr_saved[ADDR_WIDTH - 1:0]] = wdata_saved & wmask_expand | rdata_saved & ~wmask_expand;
        }
    }

    always_ff {
        if_reset {
            state         = State::Ready;
            membus.rvalid = 0;
            membus.rdata  = 0;
            addr_saved    = 0;
            wdata_saved   = 0;
            wmask_saved   = 0;
            rdata_saved   = 0;
        } else {
            case state {
                State::Ready: {
                                  membus.rvalid = membus.valid & !membus.wen;
                                  membus.rdata  = mem[membus.addr[ADDR_WIDTH - 1:0]];
                                  addr_saved    = membus.addr[ADDR_WIDTH - 1:0];
                                  wdata_saved   = membus.wdata;
                                  wmask_saved   = membus.wmask;
                                  rdata_saved   = mem[membus.addr[ADDR_WIDTH - 1:0]];
                                  if membus.valid && membus.wen {
                                      state = State::WriteValid;
                                  }
                              }
                State::WriteValid: {
                                       state         = State::Ready;
                                       membus.rvalid = 1;
                                   }
            }
        }
    }
}

書き込みマスクをサポートするmemoryモジュールは、次の2つの状態を持ちます。

State::Ready
要求を受け付ける。 読み込み要求のとき、次のクロックで結果を返す。 書き込み要求のとき、要求の内容をレジスタに格納し、 状態をState::WriteValidに移動する。
State::WriteValid
書き込みマスクつきの書き込みを行う。 状態をState::Readyに移動する。

memoryモジュールは、書き込み要求が送られてきた場合、名前が_savedで終わるレジスタに要求の内容を格納します。また、指定されたアドレスのデータをrdata_savedに格納します。次のクロックで、書き込みマスクを使った書き込みを行い、要求の処理を終了します。

topモジュールの調停処理で、wmaskも調停します(リスト3.75)。

リスト3.75: リスト3.75: wmaskの調停 (top.veryl)
    membus.valid = i_membus.valid | d_membus.valid;
    if d_membus.valid {
        membus.addr  = addr_to_memaddr(d_membus.addr);
        membus.wen   = d_membus.wen;
        membus.wdata = d_membus.wdata;
        membus.wmask = d_membus.wmask;
    } else {
        membus.addr  = addr_to_memaddr(i_membus.addr);
        membus.wen   = 0; // 命令フェッチは常に読み込み
        membus.wdata = 'x;
        membus.wmask = 'x;
    }

memunitモジュールの実装

memoryモジュールが書き込みマスクをサポートしたので、memunitモジュールでwmaskを設定します。

req_wmaskレジスタを作成し、membus.wmaskと接続します(リスト3.76リスト3.77)。

リスト3.76: リスト3.76: req_wmaskの定義 (memunit.veryl)
    var req_wmask: logic<MEM_DATA_WIDTH / 8>;
リスト3.77: リスト3.77: membusにwmaskを設定する (memunit.veryl)
    // メモリアクセス
    membus.valid = state == State::WaitReady;
    membus.addr  = req_addr;
    membus.wen   = req_wen;
    membus.wdata = req_wdata;
    membus.wmask = req_wmask;

always_ffの中で、req_wmaskの値を設定します。それぞれの命令のとき、wmaskがどうなるかを確認してください(リスト3.78リスト3.79)。

リスト3.78: リスト3.78: if_resetでreq_wmaskを初期化する (memunit.veryl)
    if_reset {
        state     = State::Init;
        req_wen   = 0;
        req_addr  = 0;
        req_wdata = 0;
        req_wmask = 0;
    } else {
リスト3.79: リスト3.79: メモリにアクセスする命令のとき、wmaskを設定する (memunit.veryl)
    req_wmask = case ctrl.funct3[1:0] {
        2'b00  : 4'b1 << addr[1:0], ← SB命令のとき、アドレス下位2ビット分だけ1を左シフトする
        2'b01  : case addr[1:0] { ← SH命令のとき
            2      : 4'b1100, ← 上位2バイトに書き込む
            0      : 4'b0011, ← 下位2バイトに書き込む
            default: 'x,
        },
        2'b10  : 4'b1111, ← SW命令のとき、全体に書き込む
        default: 'x,
    };

3.11.4 LB、LBU、LH、LHU、SB、SH命令をテストする

簡単なテストを作成し、動作をテストします。2つテストを記載するので、正しく動いているか確認してください。

リスト3.80: リスト3.80: src/sample_lbh.hex
02000083 // lb x1, 0x20(x0)  : x1 = ffffffef
02104083 // lbu x1, 0x21(x0) : x1 = 000000be
02201083 // lh x1, 0x22(x0)  : x1 = ffffdead
02205083 // lhu x1, 0x22(x0) : x1 = 0000dead
00000000
00000000
00000000
00000000
deadbeef // 0x0
リスト3.81: リスト3.81: src/sample_sbsh.hex
12300093 // addi x1, x0, 0x123
02101023 // sh x1, 0x20(x0)
02100123 // sb x1, 0x22(x0)
02200103 // lb x2, 0x22(x0) : x2 = 00000023
02001183 // lh x3, 0x20(x0) : x3 = 00000123

3.12 ジャンプ命令、分岐命令の実装

まだ重要な命令を実装できていません。プログラムで分岐やループを実現するためにはジャンプや分岐をする命令が必要です。RV32Iには、仕様書[8]に次の命令が定義されています(表3.10)。

表3.10: ジャンプ命令、分岐命令

命令形式動作
JALJ形式PC+即値に無条件ジャンプする。rdにPC+4を格納する
JALRI形式rs1+即値に無条件ジャンプする。rdにPC+4を格納する
BEQB形式rs1とrs2が等しいとき、PC+即値にジャンプする
BNEB形式rs1とrs2が異なるとき、PC+即値にジャンプする
BLTB形式rs1(符号付き整数)がrs2(符号付き整数)より小さいとき、PC+即値にジャンプする
BLTUB形式rs1(符号なし整数)がrs2(符号なし整数)より小さいとき、PC+即値にジャンプする
BGEB形式rs1(符号付き整数)がrs2(符号付き整数)より大きいとき、PC+即値にジャンプする
BGEUB形式rs1(符号なし整数)がrs2(符号なし整数)より大きいとき、PC+即値にジャンプする

ジャンプ命令は、無条件でジャンプするため、無条件ジャンプ(Unconditional Jump)と呼びます。分岐命令は、条件付きで分岐するため、条件分岐(Conditional Branch)と呼びます。

3.12.1 JAL、JALR命令を実装する

まず、無条件ジャンプを実装します。

JAL(Jump And Link)命令は、PC+即値でジャンプ先を指定します。Linkとは、rdレジスタにPC+4を記録しておくことで、分岐元に戻れるようにしておく操作のことです。即値の幅は20ビットです。PCの下位1ビットは常に0なため、即値を1ビット左シフトして符号拡張した値をPCに加算します(即値の生成はリスト3.33を確認してください)。JAL命令でジャンプ可能な範囲は、PC±1MiBです。

JALR (Jump And Link Register)命令は、rs1+即値でジャンプ先を指定します。即値はI形式の即値です。JAL命令と同様に、rdレジスタにPC+4を格納(link)します。JALR命令でジャンプ可能な範囲は、rs1レジスタの値±4KiBです。

inst_decoderモジュールは、JAL命令かJALR命令のとき、InstCtrl.rwb_en1InstCtrl.is_aluop0InstCtrl.is_jump1としてデコードします。

無条件ジャンプであるかどうかはInstCtrl.is_jumpで確かめられます。また、InstCtrl.is_aluop0なため、ALUは常に加算を行います。加算の対象のデータが、JAL命令(J形式)ならPCと即値、JALR命令(I形式)ならrs1と即値になっていることを確認してください(リスト3.44)。

無条件ジャンプの実装

それでは、無条件ジャンプを実装します。まず、ジャンプ命令を実行するときにライトバックする値をinst_pc + 4にします(リスト3.82)。

リスト3.82: リスト3.82: pc + 4を書き込む (core.veryl)
    let wb_data: UIntX    = if inst_ctrl.is_lui {
        inst_imm
    } else if inst_ctrl.is_jump {
        inst_pc + 4
    } else if inst_ctrl.is_load {
        memu_rdata
    } else {
        alu_result
    };

次に、次にフェッチする命令をジャンプ先の命令に変更します。フェッチ先の変更が発生を示す信号control_hazardと、新しいフェッチ先を示す信号control_hazard_pc_nextを作成します(リスト3.83リスト3.84)。

リスト3.83: リスト3.83: control_hazardとcontrol_hazard_pc_nextの定義 (core.veryl)
    var control_hazard        : logic;
    var control_hazard_pc_next: Addr ;
リスト3.84: リスト3.84: control_hazardとcontrol_hazard_pc_nextの割り当て (core.veryl)
    assign control_hazard         = inst_valid && inst_ctrl.is_jump;
    assign control_hazard_pc_next = alu_result;

control_hazardを利用してif_pcを更新し、新しく命令をフェッチしなおすようにします(リスト3.85)。

リスト3.85: リスト3.85: PCをジャンプ先に変更する (core.veryl)
    always_ff {
        if_reset {
            ...
        } else {
            if control_hazard {
                if_pc           = control_hazard_pc_next;
                if_is_requested = 0;
                if_fifo_wvalid  = 0;
            } else {
                if if_is_requested {
                    ...
                }
                // IFのFIFOの制御
                if if_is_requested && i_membus.rvalid {
                    ...
                }
            }
        }
    }

ここで、新しく命令をフェッチしなおすようにしても、ジャンプ命令によって実行されることがなくなった命令がFIFOに残っていることがあることに注意する必要があります(図3.4)。

ジャンプ命令とジャンプ先の間に余計な命令が入ってしまっている

図3.4: ジャンプ命令とジャンプ先の間に余計な命令が入ってしまっている

実行するべきではない命令を実行しないようにするために、ジャンプ命令を実行するときに、FIFOをリセットします。

FIFOに、中身をリセットするための信号flushを実装します(リスト3.86)。

リスト3.86: リスト3.86: ポートにflushを追加する (fifo.veryl)
module fifo #(
    param DATA_TYPE: type = logic,
    param WIDTH    : u32  = 2    ,
) (
    clk   : input  clock    ,
    rst   : input  reset    ,
    flush : input  logic    ,
    wready: output logic    ,

flush1のとき、headtail0に初期化することでFIFOを空にします(リスト3.87)。

リスト3.87: リスト3.87: flushが1のとき、FIFOを空にする (fifo.veryl)
    always_ff {
        if_reset {
            head = 0;
            tail = 0;
        } else {
            if flush {
                head = 0;
                tail = 0;
            } else {
                if wready && wvalid {
                    mem[tail] = wdata;
                    tail      = tail + 1;
                }
                if rready && rvalid {
                    head = head + 1;
                }
            }
        }
    }

coreモジュールで、control_hazardflushを接続し、FIFOをリセットします(リスト3.88)。

リスト3.88: リスト3.88: ジャンプ命令のとき、FIFOをリセットする (core.veryl)
    inst if_fifo: fifo #(
        DATA_TYPE: if_fifo_type,
        WIDTH    : 3           ,
    ) (
        clk                   ,
        rst                   ,
        flush : control_hazard,
        ...
    );

無条件ジャンプのテスト

簡単なテストを作成し、動作をテストします(リスト3.89リスト3.90)。

リスト3.89: リスト3.89: sample_jump.hex
0100006f //  0: jal x0, 0x10 : 0x10にジャンプする
deadbeef //  4:
deadbeef //  8:
deadbeef //  c:
01800093 // 10: addi x1, x0, 0x18
00808067 // 14: jalr x0, 8(x1) : x1+8=0x20にジャンプする
deadbeef // 18:
deadbeef // 1c:
fe1ff06f // 20: jal x0, -0x20 : 0にジャンプする
リスト3.90: リスト3.90: テストの実行
$ make build
$ make sim
$ obj_dir/sim src/sample_jump.hex 17
#                    4
00000000 : 0100006f
  reg[ 0] <= 00000004 ← rd = PC + 4
#                    8
00000010 : 01800093 ← 0x00 → 0x10にジャンプしている
  reg[ 1] <= 00000018
#                    9
00000014 : 00808067
  reg[ 0] <= 00000018 ← rd = PC + 4
#                   13
00000020 : fe1ff06f ← 0x14 → 0x20にジャンプしている
  reg[ 0] <= 00000024 ← rd = PC + 4
#                   17
00000000 : 0100006f ← 0x20 → 0x00にジャンプしている
  reg[ 0] <= 00000004

3.12.2 条件分岐命令を実装する

条件分岐命令はすべてB形式で、PC+即値で分岐先を指定します。それぞれの命令は、命令のfunct3フィールドで判別できます(表3.11)。

表3.11: 条件分岐命令とfunct3

funct3命令演算
3'b000BEQ==
3'b001BNE!=
3'b100BLT符号付き <=
3'b101BGE符号付き >
3'b110BLTU符号なし <=
3'b111BGEU符号なし >

条件分岐の実装

分岐の条件が成立するかどうかを判定するモジュールを作成します。src/brunit.verylを作成し、次のように記述します(リスト3.91)。

リスト3.91: リスト3.91: brunit.veryl
import eei::*;
import corectrl::*;

module brunit (
    funct3: input  logic<3>,
    op1   : input  UIntX   ,
    op2   : input  UIntX   ,
    take  : output logic   , // 分岐が成立するか否か
) {
    let beq : logic = op1 == op2;
    let blt : logic = $signed(op1) <: $signed(op2);
    let bltu: logic = op1 <: op2;

    always_comb {
        case funct3 {
            3'b000 : take = beq;
            3'b001 : take = !beq;
            3'b100 : take = blt;
            3'b101 : take = !blt;
            3'b110 : take = bltu;
            3'b111 : take = !bltu;
            default: take = 0;
        }
    }
}

brunitモジュールは、funct3に応じてtakeの条件を切り替えます。分岐が成立するときにtake1になります。

brunitモジュールを、coreモジュールでインスタンス化します(リスト3.92)。命令がB形式のとき、op1rs1_dataop2rs2_dataになっていることを確認してください(リスト3.44)。

リスト3.92: リスト3.92: brunitモジュールのインスタンス化 (core.veryl)
    var brunit_take: logic;

    inst bru: brunit (
        funct3: inst_ctrl.funct3,
        op1                     ,
        op2                     ,
        take  : brunit_take     ,
    );

命令が条件分岐命令でbrunit_take1のとき、次のPCをPC + 即値にします(リスト3.93リスト3.94)。

リスト3.93: リスト3.93: 命令が条件分岐命令か判定する関数 (core.veryl)
    // 命令が分岐命令かどうかを判定する
    function inst_is_br (
        ctrl: input InstCtrl,
    ) -> logic    {
        return ctrl.itype == InstType::B;
    }
リスト3.94: リスト3.94: 分岐成立時のPCの設定 (core.veryl)
    assign control_hazard         = inst_valid && (inst_ctrl.is_jump || inst_is_br(inst_ctrl) && brunit_take);
    assign control_hazard_pc_next = if inst_is_br(inst_ctrl) {
        inst_pc + inst_imm
    } else {
        alu_result
    };

control_hazardは、命令が無条件ジャンプ命令か、命令が条件分岐命令かつ分岐が成立するときに1になります。control_hazard_pc_nextは、無条件ジャンプ命令のときはalu_result、条件分岐命令のときはPC + 即値になります。

条件分岐命令のテスト

条件分岐命令を実行するとき、分岐の成否をデバッグ表示します。デバッグ表示を行っているalways_ffブロック内に、次のコードを追加します(リスト3.95)。

リスト3.95: リスト3.95: 分岐判定のデバッグ表示 (core.veryl)
    if inst_is_br(inst_ctrl) {
        $display("  br take   : %b", brunit_take);
    }

簡単なテストを作成し、動作をテストします(リスト3.96, リスト3.97)。

リスト3.96: リスト3.96: sample_br.hex
00100093 //  0: addi x1, x0, 1
10100063 //  4: beq x0, x1, 0x100
00101863 //  8: bne x0, x1, 0x10
deadbeef //  c:
deadbeef // 10:
deadbeef // 14:
0000d063 // 18: bge x1, x0, 0
リスト3.97: リスト3.97: テストの実行
$ make build
$ make sim
$ obj_dir/sim src/sample_br.hex 15
#                    4
00000000 : 00100093 ← x1に1を代入
#                    5
00000004 : 10100063
  op1       : 00000000
  op2       : 00000001
  br take   : 0 ← x0 != x1なので不成立
#                    6
00000008 : 00101863
  op1       : 00000000
  op2       : 00000001
  br take   : 1 ← x0 != x1なので成立
#                   10
00000018 : 0000d063 ← 0x08 → 0x18にジャンプ
  br take   : 1 ← x1 > x0なので成立
#                   14
00000018 : 0000d063 ← 0x18 → 0x18にジャンプ
  br take   : 1

BLT、BLTU、BGEU命令は後の章で紹介するriscv-testsでテストします。

実装していないRV32Iの命令

メモリフェンス命令、ECALL命令、EBREAK命令は後の章で実装します。