RV32Iの実装
本章では、RISC-Vの基本整数命令セットであるRV32Iを実装します。 基本整数命令という名前の通り、 整数の足し引きやビット演算、 ジャンプ、分岐命令などの最小限の命令しか実装されていません。 また、32ビット幅の汎用レジスタが32個定義されています。 ただし、0番目のレジスタの値は常に0
です。
RISC-VのCPUは基本整数命令セットを必ず実装して、 他の命令や機能は拡張として実装します。 複雑な機能を持つCPUを実装する前に、 まずは最小限の命令を実行できるCPUを実装しましょう。
CPUは何をやっているのか?
CPUを実装するには何が必要でしょうか? まずはCPUとはどのような動作をするものなのかを考えます。 プログラム内蔵方式(stored-program computer)と呼ばれるコンピュータのCPUは、 次の手順でプログラムを実行します。
- メモリ(memory, 記憶装置)からプログラムを読み込む
- プログラムを実行する
- 1、2の繰り返し
ここで、メモリから読み込まれる「プログラム」とは一体何を指しているのでしょうか? 普通のプログラマが書くのはC言語やRustなどのプログラミング言語のプログラムですが、 通常のCPUはそれをそのまま解釈して実行することはできません。 そのため、メモリから読み込まれる「プログラム」とは、 CPUが読み込んで実行できる形式のプログラムです。 これはよく機械語(machine code)と呼ばれ、0
と1
で表される2進数のビット列[1]で記述されています。
メモリから機械語を読み込んで実行するのがCPUの仕事ということが分かりました。 これをもう少し掘り下げます。
まず、機械語をメモリから読み込むためには、 メモリのどこを読み込みたいのかという情報(アドレス, address)をメモリに与える必要があります。 また、当然ながらメモリが必要です。
CPUは機械語を実行しますが、 一気にすべての機械語を読み込んだり実行するわけではなく、 機械語の最小単位である命令(instruction)を一つずつ読み込んで実行します。 命令をメモリに要求、取得することを、命令をフェッチすると呼びます。
命令がCPUに供給されると、 CPUは命令のビット列がどのような意味を持っていて、 何をすればいいかを判定します。 このことを、命令をデコードすると呼びます。
命令をデコードすると、いよいよ計算やメモリの読み書きを行います。 しかし、例えば足し算を計算するにも、 何と何を足し合わせればいいのか分かりません。 この計算に使うデータは、次のいずれかで指定されます。
- レジスタ(= CPU内に存在する計算データ用のレジスタ列)の番号
- 即値(= 命令のビット列から生成される数値)
計算対象のデータにレジスタと即値のどちらを使うかは命令によって異なります。 レジスタの番号は命令のビット列の中に含まれています。
フォンノイマン型アーキテクチャ(von Neumann architecture)と呼ばれるコンピュータの構成方式では、 メモリのデータの読み書きを、機械語が格納されているメモリと同じメモリに対して行います。
計算やメモリの読み書きが終わると、その結果をレジスタに格納します。 例えば、足し算を行う命令なら足し算の結果、 メモリから値を読み込む命令なら読み込まれた値を格納します。
これで命令の実行は終わりですが、CPUは次の命令を実行する必要があります。 今現在実行している命令のアドレスを格納しているレジスタのことをプログラムカウンタ(program counter, PC)と呼びます。 CPUはPCの値をメモリに渡すことで命令をフェッチしています。
CPUは次の命令を実行するために、 PCの値を次の命令のアドレスに設定します。 ジャンプ命令の場合はPCの値をジャンプ先のアドレスに設定します。 分岐命令の場合は、まず、分岐の成否を判定します。 分岐が成立する場合はPCの値を分岐先のアドレスに設定します。 分岐が成立しない場合は通常の命令と同じです。
ここまでの話をまとめると、CPUの動作は次のようになります(図1)。
- PCに格納されたアドレスにある命令をフェッチする
- 命令を取得したらデコードする
- 計算で使用するデータを取得する (レジスタの値を取得したり、即値を生成する)
- 計算する命令の場合、計算を行う
- メモリにアクセスする命令の場合、メモリ操作を行う
- 計算やメモリアクセスの結果をレジスタに格納する
- PCの値を次に実行する命令のアドレスに設定する
CPUが一体どんなものなのかが分かりましたか? 実装を始めましょう。
プロジェクトの作成
まず、Verylのプロジェクトを作成します(リスト1)。 プロジェクトはcoreという名前にしています。
▼リスト3.1: 新規プロジェクトの作成
$ veryl new core
[INFO ] Created "core" project
すると、プロジェクト名のディレクトリと、その中にVeryl.toml
、srcディレクトリが作成されます。 Verylのソースファイルはsrcディレクトリに作成します。
Veryl.toml
にはプロジェクトの設定を記述します。 デフォルトの状態だとソースマップファイルが生成されますが、使用しない場合はVeryl.toml
を次のように変更してください(リスト2)。
▼リスト3.2: Veryl.toml
[project]
name = "core"
version = "0.1.0"
[build]
source = "src"
sourcemap_target = {type ="none"}
target = {type = "directory", path = "target"}
定数の定義
いよいよコードを記述します。 まず、CPU内で何度も使用する定数や型を書いておくためのパッケージを作成します。
src/eei.veryl
を作成し、次のように記述します(リスト3)。
▼リスト3.3: 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`でもいいですが、 アドレスであることを明示するための別名を定義しています。
メモリ
CPUはメモリに格納された命令を実行します。 そのため、CPUの実装のためにはメモリの実装が必要です。 RV32Iにおいて命令の幅は32ビット(ILEN)です。 また、メモリからの読み込み命令、書き込み命令の最大の幅も32ビットです。
これを実現するために、次のような要件のメモリを実装します。
- 読み書きの単位は32ビット
- クロックに同期してメモリアクセスの要求を受け取る
- 要求を受け取った次のクロックで結果を返す
メモリのインターフェースを定義する
このメモリモジュールには、クロックとリセット信号の他に表1のようなポートを定義する必要があります。 これを一つ一つ定義して接続するのは面倒なため、interfaceを定義します。
表3.1: メモリモジュールに必要なポート
ポート名 | 型 | 向き | 意味 |
---|---|---|---|
valid | logic | input | メモリアクセスを要求しているかどうか |
ready | logic | output | メモリアクセス要求を受容するかどうか |
addr | logic<ADDR_WIDTH> | input | アクセス先のアドレス |
wen | logic | input | 書き込みかどうか (1なら書き込み) |
wdata | logic<DATA_WIDTH> | input | 書き込むデータ |
rvalid | logic | output | 受容した要求の処理が終了したかどうか |
rdata | logic<DATA_WIDTH> | output | 受容した読み込み命令の結果 |
▼リスト3.4: インターフェースの定義 (membus_if.veryl)
interface membus_if::<DATA_WIDTH: u32, ADDR_WIDTH: u32> {
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 {
..converse(master)
}
}
membus_ifはジェネリックインターフェースです。 ジェネリックパラメータとして、 ADDR_WIDTH
とDATA_WIDTH
が定義されています。 ADDR_WIDTH
はアドレスの幅、 DATA_WIDTH
は1つのデータの幅です。
interfaceを利用することで変数の定義が不要になり、 ポートの相互接続を簡潔にできます。
メモリモジュールを実装する
メモリを作る準備が整いました。 src/memory.veryl
を作成し、次のように記述します(リスト5)。
▼リスト3.5: メモリモジュールの定義 (memory.veryl)
module memory::<DATA_WIDTH: u32, ADDR_WIDTH: u32> #(
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.valid`が`1`、 `membus.wen`が`0`、 `membus.addr`が対象アドレスになっています。 次のクロックで、`membus.rvalid`が`1`になり、 `membus.rdata`は対象アドレスのデータになります。
- 書き込み
- 書き込みが要求されるとき、 `membus.valid`が`1`、 `membus.wen`が`1`、 `membus.addr`が対象アドレスになっています。 always_ffブロックでは、 `membus.wen`が`1`であることを確認し、 `1`の場合は対象アドレスに`membus.wdata`を書き込みます。 次のクロックで`membus.rvalid`が`1`になります。
メモリの初期化、環境変数の読み込み
memoryモジュールのパラメータには、FILEPATH_IS_ENV
とFILEPATH
を定義しています。 memoryモジュールをインスタンス化するとき、 FILEPATH
には、 メモリの初期値が格納されたファイルのパスか、 ファイルパスが格納されている環境変数名を指定します。 初期化は$readmemh
システムタスクで行います。
FILEPATH_IS_ENV
が1
のとき、 環境変数の値を取得して、初期化用のファイルのパスとして利用します。 環境変数はutilパッケージのget_env関数で取得します。
utilパッケージとget_env関数を作成します。 src/util.veryl
を作成し、次のように記述します(リスト6)。
▼リスト3.6: 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関数は後で定義します。
最上位モジュールの作成
次に、 最上位のモジュール(Top Module)を作成して、 memoryモジュールをインスタンス化します。
最上位のモジュールとは、 設計の階層の最上位に位置するモジュールのことです。 論理設計では、最上位モジュールの中に、 あらゆるモジュールやレジスタなどをインスタンス化します。
memoryモジュールはジェネリックモジュールであるため、 1つのデータのビット幅とメモリのサイズを指定する必要があります。 これらを示す定数をeeiパッケージに定義します(リスト7)。 メモリのアドレス幅(サイズ)には、適当に16を設定しています。 これによりメモリ容量は32ビット * (2 ** 16) = 256KiBになります。
▼リスト3.7: メモリのデータ幅とアドレスの幅の定数を定義する (eei.veryl)
// メモリのデータ幅
const MEM_DATA_WIDTH: u32 = 32;
// メモリのアドレス幅
const MEM_ADDR_WIDTH: u32 = 16;
それでは、最上位のモジュールを作成します。 src/top.veryl
を作成し、次のように記述します(リスト8)。
▼リスト3.8: 最上位モジュールの定義 (top.veryl)
import eei::*;
module top #(
param MEMORY_FILEPATH_IS_ENV: bit = 1 ,
param MEMORY_FILEPATH : string = "MEMORY_FILE_PATH",
) (
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: MEMORY_FILEPATH_IS_ENV,
FILEPATH : MEMORY_FILEPATH ,
) (
clk ,
rst ,
membus ,
);
}
topモジュールでは、先ほど作成したmemoryモジュールと、 membus_ifインターフェースをインスタンス化しています。
memoryモジュールとmembusインターフェースのジェネリックパラメータには、 DATA_WIDTH
にMEM_DATA_WIDTH
、 ADDR_WIDTH
にMEM_ADDR_WIDTH
を指定しています。 メモリの初期化は、環境変数MEMORY_FILE_PATHで行うようにパラメータで指定しています。
命令フェッチ
メモリを作成したので、命令フェッチ処理を作れるようになりました。
いよいよ、CPUのメインの部分を作成します。
命令フェッチを実装する
src/core.veryl
を作成し、 次のように記述します(リスト9)。
▼リスト3.9: 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.wen
は0
にしています。
上から1つめのalways_ffブロックでは、 フェッチ中かどうかとメモリがready(要求を受け入れる)状態かどうかによって、 if_pc
とif_is_requested
、if_pc_requested
の値を変更しています。
メモリにデータを要求するとき、 if_pc
を次の命令のアドレス(4
を足したアドレス)に変更して、 if_is_requested
を1
に変更しています。 フェッチ中かつmembus.rvalid
が1
のとき、 命令フェッチが完了し、データがmembus.rdata
に供給されています。 メモリがready状態なら、 すぐに次の命令フェッチを開始します。 この状態遷移を繰り返すことによって、 アドレス0
→4
→8
→c
→10
...の命令を 次々にフェッチします。
上から2つめのalways_ffブロックは、 デバッグ用の表示を行うコードです。 命令フェッチが完了したとき、 その結果を$display
システムタスクによって出力します。
memoryモジュールとcoreモジュールを接続する
次に、 topモジュールでcoreモジュールをインスタンス化し、 membus_ifインターフェースでメモリと接続します。
coreモジュールが指定するアドレスは1バイト単位のアドレスです。 それに対して、 memoryモジュールは32ビット(=4バイト)単位でデータを整列しているため、 データは4バイト単位のアドレスで指定する必要があります。
まず、1バイト単位のアドレスを、 4バイト単位のアドレスに変換する関数を作成します (リスト10)。 これは、1バイト単位のアドレスの下位2ビットを切り詰めることによって実現できます。
▼リスト3.10: アドレスを変換する関数を作成する (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インターフェースを作成します(リスト11)。 ジェネリックパラメータには、 coreモジュールのインターフェースのジェネリックパラメータと同じく、 ILENとXLENを割り当てます。
▼リスト3.11: coreモジュール用のmembus_ifインターフェースをインスタンス化する (top.veryl)
inst membus : membus_if::<MEM_DATA_WIDTH, MEM_ADDR_WIDTH>;
inst membus_core: membus_if::<ILEN, XLEN>;
membus
とmembus_core
を接続します。 アドレスにはaddr_to_memaddr関数で変換した値を割り当てます (リスト12)。
▼リスト3.12: 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モジュールをインスタンス化します (リスト13)。 メモリとCPUが接続されました。
▼リスト3.13: coreモジュールをインスタンス化する (top.veryl)
inst c: core (
clk ,
rst ,
membus: membus_core,
);
命令フェッチをテストする
ここまでのコードが正しく動くかを検証します。
Verylで記述されたコードはveryl build
コマンドでSystemVerilogのコードに変換できます。 変換されたソースコードをオープンソースのVerilogシミュレータであるVerilatorで実行することで、 命令フェッチが正しく動いていることを確認します。
まず、Verylのプロジェクトをビルドします(リスト14)。
▼リスト3.14: Verylのプロジェクトのビルド
$ veryl fmt ← フォーマットする
$ veryl build ← ビルドする
上記のコマンドを実行すると、 verylファイルと同名のsv
ファイルとcore.f
ファイルが生成されます。 拡張子がsv
のファイルはSystemVerilogのファイルで、 core.f
には生成されたSystemVerilogのファイルのリストが記載されています。 これをシミュレータのビルドに利用します。
シミュレータのビルドにはVerilatorを利用します。 Verilatorは、与えられたSystemVerilogのコードをC++プログラムに変換することでシミュレータを生成します。 Verilatorを利用するために、次のようなC++プログラムを書きます[2]。
src/tb_verilator.cpp
を作成し、次のように記述します(リスト15)。
▼リスト3.15: 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モジュールのrst
を1
にしてからeval
を実行し、 rst
を0
にしてまたeval
を実行し、 rst
を1
にもどしてからclk
を反転しています。
シミュレータのビルド
verilatorコマンドを実行し、 シミュレータをビルドします(リスト16)。
▼リスト3.16: シミュレータのビルド
$ 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`ディレクトリに指定しています。
リスト16のコマンドの実行により、 シミュレータがobj_dir/sim
に生成されました。
メモリの初期化用ファイルの作成
シミュレータを実行する前にメモリの初期値となるファイルを作成します。 src/sample.hex
を作成し、次のように記述します(リスト17)。
▼リスト3.17: sample.hex
01234567
89abcdef
deadbeef
cafebebe
← 必ず末尾に改行をいれてください
値は16進数で4バイトずつ記述されています。 シミュレータを実行すると、 memoryモジュールは$readmemh
システムタスクでsample.hexを読み込みます。 それにより、メモリは次のように初期化されます(表2)。
表3.2: sample.hexによって設定されるメモリの初期値
アドレス | 値 |
---|---|
0x00000000 | 01234567 |
0x00000004 | 89abcdef |
0x00000008 | deadbeef |
0x0000000c | cafebebe |
0x00000010~ | 不定 |
シミュレータの実行
生成されたシミュレータを実行し、 アドレスが0
、4
、8
、c
のデータが正しくフェッチされていることを確認します(リスト18)。
▼リスト3.18: 命令フェッチの動作チェック
$ obj_dir/sim src/sample.hex 5
00000000 : 01234567
00000004 : 89abcdef
00000008 : deadbeef
0000000c : cafebebe
メモリファイルのデータが、 4バイトずつ読み込まれていることを確認できます。
Makefileの作成
ビルド、シミュレータのビルドのために一々コマンドを打つのは非常に面倒です。 これらの作業を一つのコマンドで済ますために、 Makefile
を作成し、 次のように記述します(リスト19)。
▼リスト3.19: 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のソースコードのビルド、 シミュレータのビルド、 成果物の削除ができるようになります(リスト20)。
▼リスト3.20: Makefileによって追加されたコマンド
$ make build ← Verylのソースコードのビルド
$ make sim ← シミュレータのビルド
$ make clean ← ビルドした成果物の削除
フェッチした命令をFIFOに格納する
フェッチした命令は次々に実行されますが、 その命令が何クロックで実行されるかは分かりません。 命令が常に1クロックで実行される場合は、 現状の常にフェッチし続けるようなコードで問題ありませんが、 例えばメモリにアクセスする命令は実行に何クロックかかるか分かりません。
複数クロックかかる命令に対応するために、 命令の処理が終わってから次の命令をフェッチするように変更する場合、 命令の実行の流れは次のようになります。
- 命令の処理が終わる
- 次の命令のフェッチ要求をメモリに送る
- 命令がフェッチされ、命令の処理を開始する
このとき、 命令の処理が終わってから次の命令をフェッチするため、 次々にフェッチするよりも多くのクロック数が必要です。 これはCPUの性能を露骨に悪化させるので許容できません。
FIFOの作成
そこで、 FIFO(First In First Out, ファイフォ)を作成して、 フェッチした命令を格納します。 FIFOとは、先に入れたデータが先に出されるデータ構造のことです(図2)。 命令をフェッチしたらFIFOに格納(enqueue)し、 命令を処理するときにFIFOから取り出し(dequeue)ます。
Verylの標準ライブラリ[3]にはFIFOが用意されていますが、 FIFOは簡単なデータ構造なので自分で作ってみましょう。 src/fifo.veryl
を作成し、次のように記述します(リスト21)。
▼リスト3.21: FIFOモジュールの実装 (fifo.veryl)
module fifo #(
param DATA_TYPE: type = logic,
param WIDTH : u32 = 2 ,
) (
clk : input clock ,
rst : input reset ,
wready : output logic ,
wready_two: output logic = _,
wvalid : input logic ,
wdata : input DATA_TYPE ,
rready : input logic ,
rvalid : output logic ,
rdata : output DATA_TYPE ,
) {
if WIDTH == 1 :width_one {
always_comb {
wready = !rvalid || rready;
wready_two = 0;
}
always_ff {
if_reset {
rdata = 0;
rvalid = 0;
} else {
if wready && wvalid {
rdata = wdata;
rvalid = 1;
} else if rready {
rvalid = 0;
}
}
}
} else {
type Ptr = logic<WIDTH>;
var head : Ptr;
var tail : Ptr;
let tail_plus1: Ptr = tail + 1 as Ptr;
let tail_plus2: Ptr = tail + 2 as Ptr;
var mem: DATA_TYPE [2 ** WIDTH];
always_comb {
wready = tail_plus1 != head;
wready_two = wready && tail_plus2 != head;
rvalid = head != tail;
rdata = mem[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です。 操作は次のように行います。
- データを追加する
- `wready`が`1`のとき、データを追加できます。**** データを追加するためには、追加したいデータを`wdata`に格納し、`wvalid`を`1`にします。**** 追加したデータは次のクロック以降に取り出せます。
- データを取り出す
- `rvalid`が`1`のとき、データを取り出せます。**** データを取り出せるとき、`rdata`にデータが供給されています。**** `rready`を`1`にすることで、FIFOにデータを取り出したことを通知できます。
データの格納状況は、head
レジスタとtail
レジスタで管理します。 データを追加するとき、つまりwready && wvalid
のとき、tail = tail + 1
しています。 データを取り出すとき、つまりrready && rvalid
のとき、head = head + 1
しています。
データを追加できる状況とは、 tail
に1
を足してもhead
を超えないとき、 つまり、tail
が指す場所が一周してしまわないときです。 この制限から、FIFOには最大でも2 ** WIDTH - 1
個しかデータを格納できません。 データを取り出せる状況とは、head
とtail
の指す場所が違うときです。 WIDTH
が1
のときは特別で、既にデータが1つ入っていても、 rready
が1
のときはデータを追加できるようにしています。
命令フェッチ処理の変更
fifoモジュールを使って、命令フェッチ処理を変更します。
まず、FIFOに格納する型を定義します(リスト22)。 if_fifo_type
には、 命令のアドレス(addr
)と命令のビット列(bits
)を格納するためのフィールドを含めます。
▼リスト3.22: FIFOで格納する型を定義する (core.veryl)
// ifのFIFOのデータ型
struct if_fifo_type {
addr: Addr,
bits: Inst,
}
次に、FIFOと接続するための変数を定義します(リスト23)。
▼リスト3.23: FIFOと接続するための変数を定義する (core.veryl)
// FIFOの制御用レジスタ
var if_fifo_wready : logic ;
var if_fifo_wready_two: 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モジュールをインスタンス化します(リスト24)。 DATA_TYPE
パラメータにif_fifo_type
を渡すことで、 アドレスと命令のペアを格納できるようにします。 WIDTH
パラメータには3
を指定することで、 サイズを2 ** 3 - 1 = 7
にしています。 このサイズは適当です。
▼リスト3.24: FIFOをインスタンス化する (core.veryl)
// フェッチした命令を格納するFIFO
inst if_fifo: fifo #(
DATA_TYPE: if_fifo_type,
WIDTH : 3 ,
) (
clk ,
rst ,
wready : if_fifo_wready ,
wready_two: if_fifo_wready_two,
wvalid : if_fifo_wvalid ,
wdata : if_fifo_wdata ,
rready : if_fifo_rready ,
rvalid : if_fifo_rvalid ,
rdata : if_fifo_rdata ,
);
fifoモジュールをインスタンス化したので、 メモリへデータを要求する処理を変更します(リスト25)。
▼リスト3.25: フェッチ処理の変更 (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;
}
リスト25では、 メモリに命令フェッチを要求する条件を FIFOに2つ以上空きがあるという条件に変更しています[4]。 これにより、FIFOがあふれてしまうことがなくなります。 また、FIFOから常にデータを取り出すようにしています。
命令をフェッチできたらFIFOに格納する処理をalways_ffブロックの中に追加します(リスト26)。
▼リスト3.26: 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_wvalid
とif_fifo_wdata
を0
に初期化します(リスト27)。
▼リスト3.27: 変数の初期化 (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_wvalid
を0
にすることでデータの追加を完了します。
命令フェッチはFIFOに2つ以上空きがあるときに行うため、 まだ追加されていないデータがif_fifo_wdata
に格納されていても、 別のデータに上書きされてしまうことはありません。
FIFOのテスト
FIFOをテストする前に、命令のデバッグ表示を行うコードを変更します(リスト28)。
▼リスト3.28: 命令のデバッグ表示を変更する (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);
}
}
シミュレータを実行します(リスト29)。 命令がフェッチされて表示されるまでに、 FIFOに格納してから取り出すクロック分だけ遅延があることに注意してください。
▼リスト3.29: FIFOをテストする
$ make build
$ make sim
$ obj_dir/sim src/sample.hex 7
00000000 : 01234567
00000004 : 89abcdef
00000008 : deadbeef
0000000c : cafebebe
命令のデコードと即値の生成
命令をフェッチできたら、 フェッチした命令がどのような意味を持つかをチェックし、 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)。
- 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を利用します。
デコード用の定数と型を定義する
デコード処理を書く前に、 デコードに利用する定数と型を定義します。 src/corectrl.veryl
を作成し、 次のように記述します(リスト30)。
▼リスト3.30: 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
には命令の形式、 funct3
とfunct7
にはそれぞれ命令のfunct3とfunct7フィールドを格納します。 これ以外の構造体のフィールドは、使用するときに説明します。
命令をデコードするとき、まずopcodeを使って判別します。 このために、デコードに使う定数をeeiパッケージに記述します(リスト31)。
▼リスト3.31: 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;
これらの値とそれぞれの命令の対応は、仕様書を確認してください。
制御フラグと即値を生成する
デコード処理を書く準備が整いました。 src/inst_decoder.veryl
を作成し、 次のように記述します(リスト32)。
▼リスト3.32: 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'b0
と1'b1
を入力する手間を省くために、 F
とT
という定数を用意していることに注意してください。
- 命令形式
itype
をInstType::I
に設定します - 結果をレジスタに書き込むため、
rwb_en
を1
に設定します - ALU(計算を実行する部品)を利用するため、
is_aluop
を1
に設定します funct3
、funct7
に命令中のビットをそのまま設定します- それ以外のフィールドは
0
に設定します
デコーダをインスタンス化する
inst_decoderモジュールを、 coreモジュールでインスタンス化します(リスト33)。
▼リスト3.33: 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_ctrl
とinst_imm
を定義します。 次に、inst_decoderモジュールをインスタンス化します。 bits
ポートにinst_bits
を渡すことでフェッチした命令をデコードします。
デバッグ用のalways_ffブロックに、 デコードした結果をデバッグ表示するコードを記述します(リスト34)。
▼リスト3.34: デコード結果のデバッグ表示 (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
をメモリの初期値として使い、 デコード結果を確認します(リスト35)。
▼リスト3.35: デコーダをテストする
$ 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です。 デコード結果を確認すると、 itype
が32'h0000010
、 imm
が32'h00000012
になっており、 正しくデコードできていることを確認できます。
レジスタの定義と読み込み
RV32Iには、32ビット幅のレジスタが32個用意されています。 ただし、0番目のレジスタの値は常に0
です。
レジスタファイルを定義する
coreモジュールにレジスタを定義します。 レジスタの幅はXLEN(=32)ビットであるため、 UIntX
型のレジスタの配列を定義します(リスト36)。
▼リスト3.36: レジスタの定義 (core.veryl)
// レジスタ
var regfile: UIntX<32>;
レジスタをまとめたもののことをレジスタファイル(register file)と呼ぶため、 regfile
という名前をつけています。
レジスタの値を読み込む
レジスタを定義したので、命令が使用するレジスタの値を取得します。
図3を見るとわかるように、 RISC-Vの命令は形式によってソースレジスタの数が異なります。 例えば、R形式はソースレジスタが2つで、2つのレジスタの値を使って実行されます。 それに対して、I形式のソースレジスタは1つです。 I形式の命令の実行にはソースレジスタの値と即値を利用します。
命令のビット列の中のソースレジスタの番号の場所は、 命令形式が違っても共通の場所にあります。 コードを簡単にするために、 命令がレジスタの値を利用するかどうかに関係なく、 常にレジスタの値を読み込むことにします(リスト37)。
▼リスト3.37: 命令が使うレジスタの値を取得する (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 : regfile[rs1_addr];
let rs2_data: UIntX = if rs2_addr == 0 ? 0 : regfile[rs2_addr];
ifを使うことで、 0番目のレジスタが指定されたときは、 値が常に0
になるようにします。
レジスタの値を読み込めていることを確認するために、 デバッグ表示にソースレジスタの値を追加します(リスト38)。 $display
システムタスクで、 命令のレジスタ番号と値をデバッグ表示します。
▼リスト3.38: レジスタの値をデバッグ表示する (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]になってしまいます。
これではテストする意味がないため、 レジスタの値を適当な値に初期化します。 always_ffブロックのif_resetで、 i
番目(0 < i
< 32)のレジスタの値をi
で初期化します(リスト39)。
▼リスト3.39: レジスタを適当な値で初期化する (core.veryl)
// レジスタの初期化
always_ff {
if_reset {
for i: i32 in 0..32 {
regfile[i] = i;
}
}
}
レジスタの値を読み込めていることを確認します(リスト40)。
▼リスト3.40: レジスタ読み込みのデバッグ
$ make build
$ make sim
$ obj_dir/sim sample.hex 7
00000000 : 01234567
itype : 000010
imm : 00000012
rs1[ 6] : 00000006
rs2[18] : 00000012
00000004 : 89abcdef
itype : 100000
imm : fffbc09a
rs1[23] : 00000017
rs2[26] : 0000001a
00000008 : deadbeef
itype : 100000
imm : fffdb5ea
rs1[27] : 0000001b
rs2[10] : 0000000a
0000000c : cafebebe
itype : 000000
imm : 00000000
rs1[29] : 0000001d
rs2[15] : 0000000f
32'h01234567
はjalr x10, 18(x6)
です。 JALR命令は、ソースレジスタx6
を使用します。 x6
は6番目のレジスタです。
シミュレーションと結果が一致していることを確認してください。
ALUによる計算の実装
レジスタと即値が揃い、命令で使用するデータが手に入るようになりました。 基本整数命令セットの命令では、 足し算や引き算、ビット演算などの簡単な整数演算を行います。 それでは、CPUの計算を行う部品であるALU(Arithmetic Logic Unit)を作成します。
ALUモジュールを作成する
レジスタと即値の幅はXLENです。 計算には符号付き整数と符号なし整数向けの計算があります。 符号付き整数を利用するために、 eeiモジュールにXLENビットの符号付き整数型を定義します(リスト41)。
▼リスト3.41: XLENビットの符号付き整数型を定義する (eei.veryl)
type SIntX = signed logic<XLEN>;
type SInt32 = signed logic<32> ;
type SInt64 = signed logic<64> ;
次に、src/alu.veryl
を作成し、次のように記述します(リスト42)。
▼リスト3.42: 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 : sub;
3'b001 : result = sll;
3'b010 : result = slt;
3'b011 : result = sltu;
3'b100 : result = op1 ^ op2;
3'b101 : result = if ctrl.funct7[5] == 0 ? srl : sra;
3'b110 : result = op1 | op2;
3'b111 : result = op1 & op2;
default: result = 'x;
}
} else {
result = add;
}
}
}
aluモジュールには、次のポートを定義します (表3)。
表3.3: aluモジュールのポート定義
ポート名 | 方向 | 型 | 用途 |
---|---|---|---|
ctrl | input | InstCtrl | 制御用信号 |
op1 | input | UIntX | 1つ目のデータ |
op2 | input | UIntX | 2つ目のデータ |
result | output | UIntX | 結果 |
表3.4: ALUの演算の種類
funct3 | 演算 |
---|---|
3'b000 | 加算、または減算 |
3'b001 | 左シフト |
3'b010 | 符号付き <= |
3'b011 | 符号なし <= |
3'b100 | ビット単位XOR |
3'b101 | 右論理、右算術シフト |
3'b110 | ビット単位OR |
3'b111 | ビット単位AND |
always_combブロックでは、 funct3のcase文によって計算を選択します。 funct3だけでは選択できないとき、funct7を使用します。
ALUモジュールをインスタンス化する
次に、ALUに渡すデータを用意します。 UIntX
型の変数op1
、 op2
、 alu_result
を定義し、 always_combブロックで値を割り当てます (リスト43)。
▼リスト3.43: 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形式は、レジスタの値とレジスタの値の演算を行います。 `op1`と`op2`は、レジスタの値`rs1_data`と`rs2_data`になります。
- I形式、S形式
- I形式とS形式は、レジスタの値と即値の演算を行います。 `op1`と`op2`は、それぞれレジスタの値`rs1_data`と即値`inst_imm`になります。 S形式はメモリの書き込み命令に利用されており、 レジスタの値と即値を足し合わせた値がアクセスするアドレスになります。
- U形式、J形式
- U形式とJ形式は、即値とPCを足した値、または即値を使う命令に使われています。 `op1`と`op2`は、それぞれPC`inst_pc`と即値`inst_imm`になります。 J形式はJAL命令に利用されており、PCに即値を足した値がジャンプ先になります。 U形式はAUIPC命令とLUI命令に利用されています。 AUIPC命令は、PCに即値を足した値をデスティネーションレジスタに格納します。 LUI命令は、即値をそのままデスティネーションレジスタに格納します。
ALUに渡すデータを用意したので、aluモジュールをインスタンス化します(リスト44)。 結果を受け取る用の変数として、alu_result
を指定します。
▼リスト3.44: ALUのインスタンス化 (core.veryl)
inst alum: alu (
ctrl : inst_ctrl ,
op1 ,
op2 ,
result: alu_result,
);
ALUモジュールをテストする
最後にALUが正しく動くことを確認します。
always_ffブロックで、 op1
とop2
、alu_result
をデバッグ表示します(リスト45)。
▼リスト3.45: 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
を、次のように書き換えます(リスト46)。
▼リスト3.46: sample.hexを書き換える
02000093 // addi x1, x0, 32
00100117 // auipc x2, 256
002081b3 // add x3, x1, x2
それぞれの命令の意味は次のとおりです(表5)。
表3.5: 命令の意味
アドレス | 命令 | 命令形式 | 意味 |
---|---|---|---|
0x00000000 | addi x1, x0, 32 | I形式 | x1 = x0 + 32 |
0x00000004 | auipc x2, 256 | U形式 | x2 = pc + 256 |
0x00000008 | add x3, x1, x2 | R形式 | x3 = x1 + x2 |
▼リスト3.47: 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 : 00000001
op2 : 00000002
alu res : 00000003
まだ、結果をディスティネーションレジスタに格納する処理を作成していません。 そのため、命令を実行してもレジスタの値は変わらないことに注意してください
- 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`、`op2`は1、2番目のレジスタの値です。 ALUの計算結果として、それぞれの初期値`1`と`2`を足した結果`32'h00000003`が表示されています。
レジスタに結果を書き込む
CPUはレジスタから値を読み込み、計算して、 レジスタに結果の値を書き戻します。 レジスタに値を書き戻すことを、 値をライトバック(write-back)すると呼びます。
ライトバックする値は、計算やメモリアクセスの結果です。 まだメモリにアクセスする処理を実装していませんが、 先にライトバック処理を実装します。
ライトバック処理を実装する
書き込む対象のレジスタ(デスティネーションレジスタ)は、 命令のrdフィールドによって番号で指定されます。 デコード時に、 レジスタに結果を書き込む命令かどうかをInstCtrl.rwb_en
に格納しています(リスト32)。
LUI命令のときは即値をそのまま、 それ以外の命令のときはALUの結果をライトバックします(リスト48)。
▼リスト3.48: ライトバック処理の実装 (core.veryl)
let rd_addr: logic<5> = inst_bits[11:7];
let wb_data: UIntX = if inst_ctrl.is_lui ? inst_imm : alu_result;
always_ff {
if_reset {
for i: i32 in 0..32 {
regfile[i] = i;
}
} else {
if if_fifo_rvalid && inst_ctrl.rwb_en {
regfile[rd_addr] = wb_data;
}
}
}
ライトバック処理をテストする
デバッグ表示用のalways_ffブロックで、 ライトバック処理の概要をデバッグ表示します(リスト49)。 処理している命令がライトバックする命令のときにのみ、 $display
システムタスクを呼び出します。
▼リスト3.49: ライトバックのデバッグ表示 (core.veryl)
if inst_ctrl.rwb_en {
$display(" reg[%d] <= %h", rd_addr, wb_data);
}
シミュレータを実行し、結果を確かめます(リスト50)。
▼リスト3.50: ライトバックのデバッグ
$ 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は整数演算命令の実行ができるようになりました!
最後に、テストのためにレジスタの値を初期化していたコードを削除します(リスト51)。
▼リスト3.51: レジスタの初期化をやめる (core.veryl)
always_ff {
if if_fifo_rvalid && inst_ctrl.rwb_en {
regfile[rd_addr] = wb_data;
}
}
ロード命令とストア命令の実装
RV32Iには、 メモリのデータを読み込む、 書き込む命令として次の命令があります(表6)。 データを読み込む命令のことをロード命令、 データを書き込む命令のことをストア命令と呼びます。 2つを合わせてロードストア命令と呼びます。
表3.6: RV32Iのロード命令、ストア命令
命令 | 作用 |
---|---|
LB | 8ビットのデータを読み込む。上位24ビットは符号拡張する |
LBU | 8ビットのデータを読み込む。上位24ビットは0で拡張する |
LH | 16ビットのデータを読み込む。上位16ビットは符号拡張する |
LHU | 16ビットのデータを読み込む。上位16ビットは0で拡張する |
LW | 32ビットのデータを読み込む |
SB | 8ビットのデータを書き込む |
SH | 16ビットのデータを書き込む |
SW | 32ビットのデータを書き込む |
LW、SW命令を実装する
8ビット、16ビット単位で読み書きを行う命令の実装は少し大変です。 まず、32ビット単位で読み書きを行うLW命令とSW命令を実装します。
memunitモジュールの作成
メモリ操作を行うモジュールを、 src/memunit.veryl
に記述します(リスト52)。
▼リスト3.52: 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
) {
// 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_load
を1
にしています(リスト32)。
memunitモジュールには次の状態が定義されています。 初期状態はState::Init
です。
- State::Init
- memunitモジュールに新しく命令が供給されたとき、 `valid`と`is_new`は`1`になっています。 新しく命令が供給されて、それがメモリにアクセスする命令のとき、 状態を`State::WaitReady`に移動します。 その際、`req_wen`にストア命令かどうか、 `req_addr`にアクセスするアドレス、 `req_wdata`に`rs2`を格納します。
- State::WaitReady
- 命令に応じた要求をメモリに送り続けます。 メモリが要求を受け付ける(`ready`)とき、 状態を`State::WaitValid`に移動します。
- State::WaitValid
- メモリの処理が終了した(`rvalid`)とき、 状態を`State::Init`に移動します。
メモリにアクセスする命令のとき、 memunitモジュールはInit
→WaitReady
→WaitValid
の順で状態を移動するため、 実行には少なくとも3クロックが必要です。 その間、CPUはレジスタのライトバック処理やFIFOからの命令の取り出しを止める必要があります。
CPUの実行が止まることを、CPUがストール(Stall)すると呼びます。 メモリアクセス中のストールを実現するために、 memunitモジュールには処理中かどうかを表すstall
フラグを実装しています。 有効な命令が供給されているとき、state
やメモリの状態に応じて、 次のようにstall
の値を決定します(表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
を作成します(リスト53)。 命令が供給されているかどうかはif_fifo_rvalid
と同値です。 これを機に、if_fifo_rvalid
を使用しているところをinst_valid
に置き換えましょう。
▼リスト3.53: inst_validとinst_is_newの定義 (core.veryl)
let inst_valid : logic = if_fifo_rvalid;
var inst_is_new: logic ; // 命令が現在のクロックで供給されたかどうか
次に、inst_is_new
の値を更新します(リスト54)。 命令が現在のクロックで供給されたかどうかは、 FIFOのrvalid
とrready
を観測することでわかります。 rvalid
が1
のとき、 rready
が1
なら、 次のクロックで供給される命令は新しく供給される命令です。 rready
が0
なら、 次のクロックで供給されている命令は現在のクロックと同じ命令になります。 rvalid
が0
のとき、 次のクロックで供給される命令は常に新しく供給される命令になります (次のクロックでrvalid
が1
かどうかは考えません)。
▼リスト3.54: 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つのポートを用意します(リスト55)。
▼リスト3.55: 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
に変更されるため、 既存のmembus
をi_membus
に置き換えてください(リスト56)。
▼リスト3.56: 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モジュールでの調停を実装します(リスト57)。 新しくi_membus
とd_membus
をインスタンス化し、 それをmembus
と接続します。
▼リスト3.57: メモリへのアクセス要求の調停 (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_membus
とd_membus
の両方のvalid
が1
のとき、d_membus
を優先するmemarb_last_i
レジスタに、受け入れた要求がi_membus
からのものだったかを記録する- メモリが要求の結果を返すとき、
memarb_last_i
を見て、i_membus
とd_membus
のどちらか片方のrvalid
を1
にする
命令フェッチを優先しているとロードストア命令の処理が進まないため、 i_membus
よりもd_membus
を優先します。
coreモジュールとの接続を次のように変更します(リスト58)。
▼リスト3.58: membusを2つに分けて接続する (top.veryl)
inst c: core (
clk ,
rst ,
i_membus ,
d_membus ,
);
memoryモジュールとmemunitモジュールを接続する準備が整ったので、memunitモジュールをインスタンス化します(リスト59)。
▼リスト3.59: 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から命令を取り出すのを止めます(リスト60)。
▼リスト3.60: memunitモジュールの処理が終わるのを待つ (core.veryl)
// memunitが処理中ではないとき、FIFOから命令を取り出していい
if_fifo_rready = !memu_stall;
memunitモジュールが処理中のとき、memu_stall
が1
になっています。 そのため、memu_stall
が1
のときはif_fifo_rready
を0
にすることで、 FIFOからの命令の取り出しを停止します。
次に、ロード命令の結果をレジスタにライトバックします(リスト61)。 ライトバック処理では、命令がロード命令のとき(inst_ctrl.is_load
)、 memu_rdata
をwb_data
に設定します。
▼リスト3.61: memunitモジュールの結果をライトバックする (core.veryl)
let rd_addr: logic<5> = inst_bits[11:7];
let wb_data: UIntX = switch {
inst_ctrl.is_lui : inst_imm,
inst_ctrl.is_load: memu_rdata,
default : alu_result
};
ところで、現在のコードではmemunitの処理が終了していないときも値をライトバックし続けています。 レジスタへのライトバックは命令の実行が終了したときのみで良いため、 次のようにコードを変更します(リスト62)。
▼リスト3.62: 命令の実行が終了したときにのみライトバックする (core.veryl)
always_ff {
if inst_valid && if_fifo_rready && inst_ctrl.rwb_en {
regfile[rd_addr] = wb_data;
}
}
デバッグ表示も同様で、 ライトバックするときにのみデバッグ表示します(リスト63)。
▼リスト3.63: ライトバックするときにのみデバッグ表示する (core.veryl)
if if_fifo_rready && inst_ctrl.rwb_en {
$display(" reg[%d] <= %h", rd_addr, wb_data);
}
LW、SW命令のテスト
LW命令とSW命令が正しく動作していることを確認するために、 デバッグ表示に次のコードを追加します(リスト64)。
▼リスト3.64: メモリモジュールの状態をデバッグ表示する (core.veryl)
$display(" mem stall : %b", memu_stall);
$display(" mem rdata : %h", memu_rdata);
ここからのテストは実行するクロック数が多くなります。 そこで、ログに何クロック目かを表示することでログを読みやすくします(リスト65)。
▼リスト3.65: 何クロック目かを出力する (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
を次のように変更します(リスト66)。
▼リスト3.66: テスト用のプログラムを記述する (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
プログラムは次のようになっています(表8)。
表3.8: メモリに格納する命令
アドレス | 命令 | 意味 |
---|---|---|
0x00000000 | lw x10, 0x20(x0) | x10に、アドレスが0x20のデータを読み込む |
0x00000004 | addi x11, x0, 0x400 | x11 = 0x400 |
0x00000008 | sw x11, 0x20(x0) | アドレス0x20にx11の値を書き込む |
0x0000000c | lw x12, 0x20(x0) | x12に、アドレスが0x20のデータを読み込む |
シミュレータを実行し、結果を確かめます(リスト67)。
▼リスト3.67: 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 ← 書き込んだ値が読み込まれた
LB、LBU、LH、LHU命令を実装する
LBとLBUとSB命令は8ビット単位、LHとLHUとSH命令は16ビット単位でロードストアを行う命令です。 まず、ロード命令を実装します。 ロード命令は32ビット単位でデータを読み込み、 その結果の一部を切り取ることで実装できます。
LB、LBU、LH、LHU、LW命令は、funct3の値で区別できます(表9)。 funct3の上位1ビットが1
のとき、符号拡張を行います。
表3.9: ロード命令のfunct3
funct3 | 命令 |
---|---|
3'b000 | LB |
3'b100 | LBU |
3'b001 | LH |
3'b101 | LHU |
3'b010 | LW |
▼リスト3.68: 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に設定します(リスト69)。
▼リスト3.69: 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
が拡張に利用されます。
SB、SH命令を実装する
次に、SB、SH命令を実装します。
memoryモジュールで書き込みマスクをサポートする
memoryモジュールは、32ビット単位の読み書きしかサポートしておらず、 一部のみの書き込みをサポートしていません。 本書では、一部のみ書き込む命令をmemoryモジュールでサポートすることでSB、SH命令を実装します。
まず、membus_ifインターフェースに、 書き込む場所をバイト単位で示す信号wmask
を追加します ( リスト70 )。
▼リスト3.70: wmaskの定義 (membus_if.veryl)
var wmask : logic<DATA_WIDTH / 8>;
後でwmask
をDATA_WIDTH
ビットに展開して使うので、wmaskを展開するwmask_expand関数を定義します ( リスト72 )。
▼リスト3.71: wmask_expand関数の定義 (membus_if.veryl)
// get DATA_WIDTH-bit expanded wmask
function wmask_expand () -> logic<DATA_WIDTH> {
var result: logic<DATA_WIDTH>;
for i: u32 in 0..DATA_WIDTH {
result[i] = wmask[i / 8];
}
return result;
}
wmask
、wmask_expand関数をmodportに追加します ( リスト72 )。
▼リスト3.72: modport masterとslaveにwmask、wmask_expand関数を追加する (membus_if.veryl)
modport master {
valid : output,
ready : input ,
addr : output,
wen : output,
wdata : output,
wmask : output,
rvalid : input ,
rdata : input ,
wmask_expand: import,
}
modport slave {
wmask_expand: import,
..converse(master)
}
wmask
には、書き込む部分を1
、書き込まない部分を0
で指定します。 このような挙動をする値を、書き込みマスクと呼びます。 バイト単位で指定するため、wmask
の幅はDATA_WIDTH / 8
ビットです。
次に、memoryモジュールで書き込みマスクをサポートします(リスト73)。
▼リスト3.73: 書き込みマスクをサポートするmemoryモジュール (memory.veryl)
module memory::<DATA_WIDTH: u32, ADDR_WIDTH: u32> #(
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];
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 {
let wmask: logic<DATA_WIDTH> = membus.wmask_expand();
if state == State::WriteValid {
mem[addr_saved[ADDR_WIDTH - 1:0]] = wdata_saved & wmask | rdata_saved & ~wmask;
}
}
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
も調停します(リスト74)。
▼リスト3.74: 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
と接続します ( リスト75、 リスト76 )。
▼リスト3.75: req_wmaskの定義 (memunit.veryl)
var req_wmask: logic<MEM_DATA_WIDTH / 8>;
▼リスト3.76: 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
がどうなるかを確認してください( リスト77、 リスト78 )。
▼リスト3.77: 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.78: メモリにアクセスする命令のとき、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,
};
ソースレジスタの値はLSB側(右)に寄せられているため、アドレスを4で割った値が1, 2, 3のとき、アドレスに合わせてSB命令で書き込む値の左シフトが必要です ( リスト79 )。
▼リスト3.79: 書き込みデータをシフトする (memunit.veryl)
req_wdata = rs2 << {addr[1:0], 3'b0};
LB、LBU、LH、LHU、SB、SH命令をテストする
簡単なテストを作成し、動作をテストします。 2つテストを記載するので、正しく動いているか確認してください。
▼リスト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: 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
ジャンプ命令、分岐命令の実装
まだ重要な命令を実装できていません。 プログラムで分岐やループを実現するためにはジャンプや分岐をする命令が必要です。 RV32Iには、次のジャンプ、分岐命令が定義されています(表10)。
表3.10: ジャンプ命令、分岐命令
命令 | 形式 | 動作 |
---|---|---|
JAL | J形式 | PC+即値に無条件ジャンプする。rdにPC+4を格納する |
JALR | I形式 | rs1+即値に無条件ジャンプする。rdにPC+4を格納する |
BEQ | B形式 | rs1とrs2が等しいとき、PC+即値にジャンプする |
BNE | B形式 | rs1とrs2が異なるとき、PC+即値にジャンプする |
BLT | B形式 | rs1(符号付き整数)がrs2(符号付き整数)より小さいとき、PC+即値にジャンプする |
BLTU | B形式 | rs1(符号なし整数)がrs2(符号なし整数)より小さいとき、PC+即値にジャンプする |
BGE | B形式 | rs1(符号付き整数)がrs2(符号付き整数)より大きいとき、PC+即値にジャンプする |
BGEU | B形式 | rs1(符号なし整数)がrs2(符号なし整数)より大きいとき、PC+即値にジャンプする |
JAL、JALR命令を実装する
まず、無条件ジャンプを実装します。
JAL(Jump And Link)命令は、PC+即値でジャンプ先を指定します。 Linkとは、rdレジスタにPC+4を記録しておくことで、分岐元に戻れるようにしておく操作のことです。 即値の幅は20ビットです。 PCの下位1ビットは常に0
なため、即値を1ビット左シフトして符号拡張した値をPCに加算します (即値の生成はリスト32を確認してください)。 JAL命令でジャンプ可能な範囲は、PC±1MiBです。
JALR (Jump And Link Register)命令は、rs1+即値でジャンプ先を指定します。 即値はI形式の即値です。 JAL命令と同様に、rdレジスタにPC+4を格納(link)します。 JALR命令でジャンプ可能な範囲は、rs1レジスタの値±4KiBです。
inst_decoderモジュールは、 JAL命令かJALR命令のとき、 InstCtrl.rwb_en
を1
、 InstCtrl.is_aluop
を0
、 InstCtrl.is_jump
を1
としてデコードします。
無条件ジャンプであるかどうかはInstCtrl.is_jump
で確かめられます。 また、InstCtrl.is_aluop
が0
なため、ALUは常に加算を行います。 加算の対象のデータが、 JAL命令(J形式)ならPCと即値、 JALR命令(I形式)ならrs1と即値になっていることを確認してください(リスト43)。
無条件ジャンプの実装
それでは、無条件ジャンプを実装します。 まず、ジャンプ命令を実行するときにライトバックする値をinst_pc + 4
にします(リスト82)。
▼リスト3.82: pc + 4を書き込む (core.veryl)
let wb_data: UIntX = switch {
inst_ctrl.is_lui : inst_imm,
inst_ctrl.is_jump: inst_pc + 4,
inst_ctrl.is_load: memu_rdata,
default : alu_result
};
次に、次にフェッチする命令をジャンプ先の命令に変更します。 フェッチ先の変更が発生を示す信号control_hazard
と、 新しいフェッチ先を示す信号control_hazard_pc_next
を作成します ( リスト83、 リスト84 )。
▼リスト3.83: control_hazardとcontrol_hazard_pc_nextの定義 (core.veryl)
var control_hazard : logic;
var control_hazard_pc_next: Addr ;
▼リスト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 & ~1;
control_hazard
を利用してif_pc
を更新し、 新しく命令をフェッチしなおすようにします(リスト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に残っていることがあることに注意する必要があります(図4)。
実行するべきではない命令を実行しないようにするために、 ジャンプ命令を実行するときに、FIFOをリセットします。
FIFOに、中身をリセットするための信号flush
を実装します(リスト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 ,
flush
が1
のとき、 head
とtail
を0
に初期化することでFIFOを空にします(リスト87、リスト88)。
▼リスト3.87: flushが1のとき、FIFOを空にする (fifo.veryl、WIDTH==1)
always_ff {
if_reset {
rdata = 0;
rvalid = 0;
} else {
if flush {
rvalid = 0;
} else {
if wready && wvalid {
rdata = wdata;
rvalid = 1;
} else if rready {
rvalid = 0;
}
}
}
}
▼リスト3.88: flushが1のとき、FIFOを空にする (fifo.veryl、WIDTH!=1)
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_hazard
とflush
を接続し、 FIFOをリセットします(リスト89)。
▼リスト3.89: ジャンプ命令のとき、FIFOをリセットする (core.veryl)
inst if_fifo: fifo #(
DATA_TYPE: if_fifo_type,
WIDTH : 3 ,
) (
clk ,
rst ,
flush : control_hazard ,
...
);
無条件ジャンプのテスト
簡単なテストを作成し、動作をテストします(リスト90、リスト91)。
▼リスト3.90: 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.91: テストの実行
$ 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
条件分岐命令を実装する
条件分岐命令はすべてB形式で、PC+即値で分岐先を指定します。 それぞれの命令は、命令のfunct3フィールドで判別できます (表11)。
表3.11: 条件分岐命令とfunct3
funct3 | 命令 | 演算 |
---|---|---|
3'b000 | BEQ | == |
3'b001 | BNE | != |
3'b100 | BLT | 符号付き <= |
3'b101 | BGE | 符号付き > |
3'b110 | BLTU | 符号なし <= |
3'b111 | BGEU | 符号なし > |
条件分岐の実装
分岐の条件が成立するかどうかを判定するモジュールを作成します。 src/brunit.veryl
を作成し、次のように記述します(リスト92)。
▼リスト3.92: 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
の条件を切り替えます。 分岐が成立するときにtake
が1
になります。
brunitモジュールを、 coreモジュールでインスタンス化します(リスト93)。 命令がB形式のとき、 op1
はrs1_data
、 op2
はrs2_data
になっていることを確認してください(リスト43)。
▼リスト3.93: brunitモジュールのインスタンス化 (core.veryl)
var brunit_take: logic;
inst bru: brunit (
funct3: inst_ctrl.funct3,
op1 ,
op2 ,
take : brunit_take ,
);
命令が条件分岐命令でbrunit_take
が1
のとき、 次のPCをPC + 即値にします ( リスト94、 リスト95 )。
▼リスト3.94: 命令が条件分岐命令か判定する関数 (core.veryl)
// 命令が分岐命令かどうかを判定する
function inst_is_br (
ctrl: input InstCtrl,
) -> logic {
return ctrl.itype == InstType::B;
}
▼リスト3.95: 分岐成立時の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 & ~1
};
control_hazard
は、命令が無条件ジャンプ命令か、命令が条件分岐命令かつ分岐が成立するときに1
になります。 control_hazard_pc_next
は、無条件ジャンプ命令のときはalu_result
、条件分岐命令のときはPC + 即値になります。
条件分岐命令のテスト
条件分岐命令を実行するとき、分岐の成否をデバッグ表示します。 デバッグ表示を行っているalways_ffブロック内に、次のコードを追加します(リスト96)。
▼リスト3.96: 分岐判定のデバッグ表示 (core.veryl)
if inst_is_br(inst_ctrl) {
$display(" br take : %b", brunit_take);
}
簡単なテストを作成し、動作をテストします(リスト97, リスト98)。
▼リスト3.97: 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.98: テストの実行
$ 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命令は後の章で実装します。