第3章
RV32Iの実装
本章では、RISC-Vの基本整数命令セットであるRV32Iを実装します。基本整数命令という名前の通り、整数の足し引きやビット演算、ジャンプ、分岐命令などの最小限の命令しか実装されていません。また、32ビット幅の汎用レジスタが32個定義されています。ただし、0番目のレジスタの値は常に0
です。
RISC-VのCPUは基本整数命令セットを必ず実装して、他の命令や機能は拡張として実装します。複雑な機能を持つCPUを実装する前に、まずは最小限の命令を実行できるCPUを実装しましょう。
3.1 CPUは何をやっているのか?
CPUを実装するには何が必要でしょうか?まずはCPUとはどのような動作をするものなのかを考えます。プログラム内蔵方式(stored-program computer)と呼ばれるコンピュータのCPUは、次の手順でプログラムを実行します。
- メモリ(memory, 記憶装置)からプログラムを読み込む
- プログラムを実行する
- 1、2の繰り返し
ここで、メモリから読み込まれる「プログラム」とは一体何を指しているのでしょうか?普通のプログラマが書くのはC言語やRustなどのプログラミング言語のプログラムですが、通常のCPUはそれをそのまま解釈して実行することはできません。そのため、メモリから読み込まれる「プログラム」とは、CPUが読み込んで実行できる形式のプログラムです。これはよく機械語(machine code)と呼ばれ、0
と1
で表される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)。
- PCに格納されたアドレスにある命令をフェッチする
- 命令を取得したらデコードする
- 計算で使用するデータを取得する (レジスタの値を取得したり、即値を生成する)
- 計算する命令の場合、計算を行う
- メモリにアクセスする命令の場合、メモリ操作を行う
- 計算やメモリアクセスの結果をレジスタに格納する
- PCの値を次に実行する命令のアドレスに設定する
CPUが一体どんなものなのかが分かりましたか?実装を始めましょう。
3.2 プロジェクトの作成
まず、Verylのプロジェクトを作成します(リスト3.1)。プロジェクトはcoreという名前にしています。
$ veryl new core
[INFO ] Created "core" project
すると、プロジェクト名のディレクトリと、その中にVeryl.toml
が作成されます。Veryl.toml
を次のように変更してください(リスト3.2)。
Verylのソースファイルを格納するために、プロジェクトのディレクトリ内にsrcディレクトリを作成してください(リスト3.3)。
$ cd core $ mkdir src
3.3 定数の定義
いよいよコードを記述します。まず、CPU内で何度も使用する定数や型を書いておくためのパッケージを作成します。
src/eei.veryl
を作成し、次のように記述します(リスト3.4)。
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を定義します。
ポート名 | 型 | 向き | 意味 |
---|---|---|---|
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 | 受容した読み込み命令の結果 |
src/membus_if.veryl
を作成し、次のように記述します(リスト3.5)。
membus_ifはジェネリックインターフェースです。ジェネリックパラメータとして、ADDR_WIDTH
とDATA_WIDTH
が定義されています。ADDR_WIDTH
はアドレスの幅、DATA_WIDTH
は1つのデータの幅です。
interfaceを利用することで変数の定義が不要になり、ポートの相互接続を簡潔にできます。
3.4.2 メモリモジュールを実装する
メモリを作る準備が整いました。src/memory.veryl
を作成し、次のように記述します(リスト3.6)。
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
になります。
3.4.3 メモリの初期化、環境変数の読み込み
memoryモジュールのパラメータには、FILEPATH_IS_ENV
とFILEPATH
を定義しています。memoryモジュールをインスタンス化するとき、FILEPATH
には、メモリの初期値が格納されたファイルのパスか、ファイルパスが格納されている環境変数名を指定します。初期化は$readmemh
システムタスクで行います。
FILEPATH_IS_ENV
が1
のとき、環境変数の値を取得して、初期化用のファイルのパスとして利用します。環境変数はutilパッケージのget_env関数で取得します。
utilパッケージとget_env関数を作成します。src/util.veryl
を作成し、次のように記述します(リスト3.7)。
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になります。
それでは、最上位のモジュールを作成します。src/top.veryl
を作成し、次のように記述します(リスト3.9)。
topモジュールでは、先ほど作成したmemoryモジュールと、membus_ifインターフェースをインスタンス化しています。
memoryモジュールとmembusインターフェースのジェネリックパラメータには、DATA_WIDTH
にMEM_DATA_WIDTH
、ADDR_WIDTH
にMEM_ADDR_WIDTH
を指定しています。メモリの初期化は、環境変数MEMORY_FILE_PATHで行うようにパラメータで指定しています。
3.6 命令フェッチ
メモリを作成したので、命令フェッチ処理を作れるようになりました。
いよいよ、CPUのメインの部分を作成します。
3.6.1 命令フェッチを実装する
src/core.veryl
を作成し、次のように記述します(リスト3.10)。
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
システムタスクによって出力します。
3.6.2 memoryモジュールとcoreモジュールを接続する
次に、topモジュールでcoreモジュールをインスタンス化し、membus_ifインターフェースでメモリと接続します。
coreモジュールが指定するアドレスは1バイト単位のアドレスです。それに対して、memoryモジュールは32ビット(=4バイト)単位でデータを整列しているため、データは4バイト単位のアドレスで指定する必要があります。
まず、1バイト単位のアドレスを、4バイト単位のアドレスに変換する関数を作成します(リスト3.11)。これは、1バイト単位のアドレスの下位2ビットを切り詰めることによって実現できます。
addr_to_memaddr関数は、MEM_DATA_WIDTH
(=32)をバイトに変換した値(=4)のlog2をとった値(=2)を使って、addr[17:2]
を切り取っています。範囲の選択には+:
を利用しています。
次に、coreモジュール用のmembus_ifインターフェースを作成します(リスト3.12)。ジェネリックパラメータには、coreモジュールのインターフェースのジェネリックパラメータと同じく、ILENとXLENを割り当てます。
membus
とmembus_core
を接続します。アドレスにはaddr_to_memaddr関数で変換した値を割り当てます(リスト3.13)。
最後にcoreモジュールをインスタンス化します(リスト3.14)。メモリとCPUが接続されました。
3.6.3 命令フェッチをテストする
ここまでのコードが正しく動くかを検証します。
Verylで記述されたコードはveryl build
コマンドでSystemVerilogのコードに変換できます。変換されたソースコードをオープンソースのVerilogシミュレータであるVerilatorで実行することで、命令フェッチが正しく動いていることを確認します。
まず、Verylのプロジェクトをビルドします(リスト3.15)。
$ 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)。
この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コマンドを実行し、シミュレータをビルドします(リスト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)。
値は16進数で4バイトずつ記述されています。シミュレータを実行すると、memoryモジュールは$readmemh
システムタスクでsample.hexを読み込みます。それにより、メモリは次のように初期化されます(表3.2)。
アドレス | 値 |
---|---|
0x00000000 | 01234567 |
0x00000004 | 89abcdef |
0x00000008 | deadbeef |
0x0000000c | cafebebe |
0x00000010~ | 不定 |
シミュレータの実行
生成されたシミュレータを実行し、アドレスが0
、4
、8
、c
のデータが正しくフェッチされていることを確認します(リスト3.19)。
$ obj_dir/sim src/sample.hex 5 00000000 : 01234567 00000004 : 89abcdef 00000008 : deadbeef 0000000c : cafebebe
メモリファイルのデータが、4バイトずつ読み込まれていることを確認できます。
Makefileの作成
ビルド、シミュレータのビルドのために一々コマンドを打つのは非常に面倒です。これらの作業を一つのコマンドで済ますために、Makefile
を作成し、次のように記述します(リスト3.20)。
これ以降、次のようにVerylのソースコードのビルド、シミュレータのビルド、成果物の削除ができるようになります(リスト3.21)。
$ make build ← Verylのソースコードのビルド $ make sim ← シミュレータのビルド $ make clean ← ビルドした成果物の削除
3.6.4 フェッチした命令をFIFOに格納する
フェッチした命令は次々に実行されますが、その命令が何クロックで実行されるかは分かりません。命令が常に1クロックで実行される場合は、現状の常にフェッチし続けるようなコードで問題ありませんが、例えばメモリにアクセスする命令は実行に何クロックかかるか分かりません。
複数クロックかかる命令に対応するために、命令の処理が終わってから次の命令をフェッチするように変更する場合、命令の実行の流れは次のようになります。
- 命令の処理が終わる
- 次の命令のフェッチ要求をメモリに送る
- 命令がフェッチされ、命令の処理を開始する
このとき、命令の処理が終わってから次の命令をフェッチするため、次々にフェッチするよりも多くのクロック数が必要です。これはCPUの性能を露骨に悪化させるので許容できません。
FIFOの作成
そこで、FIFO(First In First Out, ファイフォ)を作成して、フェッチした命令を格納します。FIFOとは、先に入れたデータが先に出されるデータ構造のことです(図3.2)。命令をフェッチしたらFIFOに格納(enqueue)し、命令を処理するときにFIFOから取り出し(dequeue)ます。
Verylの標準ライブラリ*3にはFIFOが用意されていますが、FIFOは簡単なデータ構造なので自分で作ってみましょう。src/fifo.veryl
を作成し、次のように記述します(リスト3.22)。
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に格納する型を定義します(リスト3.23)。if_fifo_type
には、命令のアドレス(addr
)と命令のビット列(bits
)を格納するためのフィールドを含めます。
次に、FIFOと接続するための変数を定義します(リスト3.24)。
FIFOモジュールをインスタンス化します(リスト3.25)。DATA_TYPE
パラメータにif_fifo_type
を渡すことで、アドレスと命令のペアを格納できるようにします。WIDTH
パラメータには3
を指定することで、サイズを2 ** 3 - 1 = 7
にしています。このサイズは適当です。
fifoモジュールをインスタンス化したので、メモリへデータを要求する処理を変更します(リスト3.26)。
リスト3.26では、メモリに命令フェッチを要求する条件をFIFOに2つ以上空きがあるという条件に変更しています*4。これにより、FIFOがあふれてしまうことがなくなります。また、FIFOから常にデータを取り出すようにしています。
[*4] 1つ空きがあるという条件だとあふれてしまいます。FIFOが容量いっぱいのときにどうなるか確認してください
命令をフェッチできたらFIFOに格納する処理をalways_ffブロックの中に追加します(リスト3.27)。
if_fifo_wvalid
とif_fifo_wdata
を0
に初期化します(リスト3.28)。
命令をフェッチできたとき、if_fifo_wvalid
の値を1
にして、if_fifo_wdata
にフェッチした命令とアドレスを格納します。これにより、次のクロック以降のFIFOに空きがあるタイミングでデータが追加されます。
それ以外のとき、FIFOにデータを格納しようとしていてFIFOに空きがあるとき、if_fifo_wvalid
を0
にすることでデータの追加を完了します。
命令フェッチはFIFOに2つ以上空きがあるときに行うため、まだ追加されていないデータがif_fifo_wdata
に格納されていても、別のデータに上書きされてしまうことはありません。
FIFOのテスト
FIFOをテストする前に、命令のデバッグ表示を行うコードを変更します(リスト3.29)。
シミュレータを実行します(リスト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)。
- 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)。
InstType
は、命令の形式を表すための列挙型です。InstType
の幅は6ビットで、それぞれのビットに1つの命令形式が対応しています。どの命令形式にも対応しない場合、すべてのビットが0のInstType::X
を対応させます。
InstCtrl
は、制御に使うフラグをひとまとめにした構造体です。itype
には命令の形式、funct3
とfunct7
にはそれぞれ命令のfunct3とfunct7フィールドを格納します。これ以外の構造体のフィールドは、使用するときに説明します。
命令をデコードするとき、まずopcodeを使って判別します。このために、デコードに使う定数をeeiパッケージに記述します(リスト3.32)。
これらの値とそれぞれの命令の対応は、仕様書[6]を確認してください。
3.7.2 制御フラグと即値を生成する
デコード処理を書く準備が整いました。src/inst_decoder.veryl
を作成し、次のように記述します(リスト3.33)。
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
に設定します
3.7.3 デコーダをインスタンス化する
inst_decoderモジュールを、coreモジュールでインスタンス化します(リスト3.34)。
まず、デコーダとcoreモジュールを接続するためにinst_ctrl
とinst_imm
を定義します。次に、inst_decoderモジュールをインスタンス化します。bits
ポートにinst_bits
を渡すことでフェッチした命令をデコードします。
デバッグ用のalways_ffブロックに、デコードした結果をデバッグ表示するコードを記述します(リスト3.35)。
src/sample.hex
をメモリの初期値として使い、デコード結果を確認します(リスト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です。デコード結果を確認すると、itype
が32'h0000010
、imm
が32'h00000012
になっており、正しくデコードできていることを確認できます。
3.8 レジスタの定義と読み込み
RV32Iには、32ビット幅のレジスタが32個用意されています。ただし、0番目のレジスタの値は常に0
です。
3.8.1 レジスタファイルを定義する
coreモジュールにレジスタを定義します。レジスタの幅はXLEN(=32)ビットであるため、UIntX
型のレジスタの配列を定義します(リスト3.37)。
レジスタをまとめたもののことをレジスタファイル(register file)と呼ぶため、regfile
という名前をつけています。
3.8.2 レジスタの値を読み込む
レジスタを定義したので、命令が使用するレジスタの値を取得します。
図3.3を見るとわかるように、RISC-Vの命令は形式によってソースレジスタの数が異なります。例えば、R形式はソースレジスタが2つで、2つのレジスタの値を使って実行されます。それに対して、I形式のソースレジスタは1つです。I形式の命令の実行にはソースレジスタの値と即値を利用します。
命令のビット列の中のソースレジスタの番号の場所は、命令形式が違っても共通の場所にあります。コードを簡単にするために、命令がレジスタの値を利用するかどうかに関係なく、常にレジスタの値を読み込むことにします(リスト3.38)。
if式を使うことで、0番目のレジスタが指定されたときは、値が常に0
になるようにします。
レジスタの値を読み込めていることを確認するために、デバッグ表示にソースレジスタの値を追加します(リスト3.39)。$display
システムタスクで、命令のレジスタ番号と値をデバッグ表示します。
早速動作のテストをしたいところですが、今のままだとレジスタの値が初期化されておらず、0番目のレジスタの値以外は不定値*5になってしまいます。
[*5] Verilatorはデフォルト設定では不定値に対応していないため、不定値は0になります
これではテストする意味がないため、レジスタの値を適当な値に初期化します。always_ffブロックのif_resetで、i
番目(0 < i
< 32)のレジスタの値をi + 100
で初期化します(リスト3.40)。
レジスタの値を読み込めていることを確認します(リスト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'h01234567
はjalr 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)。
次に、src/alu.veryl
を作成し、次のように記述します(リスト3.43)。
aluモジュールには、次のポートを定義します (表3.3)。
ポート名 | 方向 | 型 | 用途 |
---|---|---|---|
ctrl | input | InstCtrl | 制御用信号 |
op1 | input | UIntX | 1つ目のデータ |
op2 | input | UIntX | 2つ目のデータ |
result | output | UIntX | 結果 |
仕様書で整数演算命令として定義されている命令[7]は、funct3とfunct7フィールドによって計算の種類を特定できます(表3.4)。
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_aluop
を1
にしています(リスト3.33)。
always_combブロックでは、funct3のcase文によって計算を選択します。funct3だけでは選択できないとき、funct7を使用します。
3.9.2 ALUモジュールをインスタンス化する
次に、ALUに渡すデータを用意します。UIntX
型の変数op1
、op2
、alu_result
を定義し、always_combブロックで値を割り当てます(リスト3.44)。
割り当てるデータは、命令形式によって次のように異なります。
- 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
は、それぞれPCinst_pc
と即値inst_imm
になります。 J形式はJAL命令に利用されており、PCに即値を足した値がジャンプ先になります。 U形式はAUIPC命令とLUI命令に利用されています。 AUIPC命令は、PCに即値を足した値をデスティネーションレジスタに格納します。 LUI命令は、即値をそのままデスティネーションレジスタに格納します。
ALUに渡すデータを用意したので、aluモジュールをインスタンス化します(リスト3.45)。結果を受け取る用の変数として、alu_result
を指定します。
3.9.3 ALUモジュールをテストする
最後にALUが正しく動くことを確認します。
always_ffブロックで、op1
とop2
、alu_result
をデバッグ表示します(リスト3.46)。
src/sample.hex
を、次のように書き換えます(リスト3.47)。
それぞれの命令の意味は次のとおりです(表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.48)。
$ 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.10.2 ライトバック処理をテストする
デバッグ表示用のalways_ffブロックで、ライトバック処理の概要をデバッグ表示します(リスト3.50)。処理している命令がライトバックする命令のときにのみ、$display
システムタスクを呼び出します。
シミュレータを実行し、結果を確かめます(リスト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.11 ロード命令とストア命令の実装
RV32Iには、メモリのデータを読み込む、書き込む命令として次の命令があります(表3.6)。データを読み込む命令のことをロード命令、データを書き込む命令のことをストア命令と呼びます。2つを合わせてロードストア命令と呼びます。
命令 | 作用 |
---|---|
LB | 8ビットのデータを読み込む。上位24ビットは符号拡張する |
LBU | 8ビットのデータを読み込む。上位24ビットは0で拡張する |
LH | 16ビットのデータを読み込む。上位16ビットは符号拡張する |
LHU | 16ビットのデータを読み込む。上位16ビットは0で拡張する |
LW | 32ビットのデータを読み込む |
SB | 8ビットのデータを書き込む |
SH | 16ビットのデータを書き込む |
SW | 32ビットのデータを書き込む |
ロード命令はI形式、ストア命令はS形式です。これらの命令で指定するメモリのアドレスは、rs1と即値の足し算です。ALUに渡すデータがrs1と即値になっていることを確認してください(リスト3.44)。ストア命令は、rs2の値をメモリに格納します。
3.11.1 LW、SW命令を実装する
8ビット、16ビット単位で読み書きを行う命令の実装は少し大変です。まず、32ビット単位で読み書きを行うLW命令とSW命令を実装します。
memunitモジュールの作成
メモリ操作を行うモジュールを、src/memunit.veryl
に記述します(リスト3.53)。
memunitモジュールでは、命令がメモリにアクセスする命令のとき、ALUから受け取ったアドレスをメモリに渡して操作を実行します。
命令がメモリにアクセスする命令かどうかはinst_is_memop関数で判定します。ストア命令のとき、命令の形式はS形式です。ロード命令のとき、デコーダはInstCtrl.is_load
を1
にしています(リスト3.33)。
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
の値を決定します(表3.7)。
状態 | stallが1になる条件 |
---|---|
Init | 新しく命令が供給されて、それがメモリにアクセスする命令のとき |
WaitReady | 常に1 |
WaitValid | 処理が終了していない(!membus.rvalid )とき |
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
に置き換えましょう。
次に、inst_is_new
の値を更新します(リスト3.55)。命令が現在のクロックで供給されたかどうかは、FIFOのrvalid
とrready
を観測することでわかります。rvalid
が1
のとき、rready
が1
なら、次のクロックで供給される命令は新しく供給される命令です。rready
が0
なら、次のクロックで供給されている命令は現在のクロックと同じ命令になります。rvalid
が0
のとき、次のクロックで供給される命令は常に新しく供給される命令になります(次のクロックでrvalid
が1
かどうかは考えません)。
memunitモジュールをインスタンス化する前に、メモリとの接続方法を考える必要があります。
coreモジュールには、メモリとの接続点としてmembusポートが存在します。しかし、これは命令フェッチに使用されているため、memunitモジュールのために使用できません。また、memoryモジュールは同時に2つの操作を受け付けられません。
この問題を、coreモジュールにメモリとのインターフェースを2つ用意してtopモジュールで調停することにより回避します。
まず、coreモジュールに命令フェッチ用のポートi_membus
と、ロードストア命令用のポートd_membus
の2つのポートを用意します(リスト3.56)。
命令フェッチ用のポートがmembus
からi_membus
に変更されるため、既存のmembus
をi_membus
に置き換えてください(リスト3.57)。
次に、topモジュールでの調停を実装します(リスト3.58)。新しくi_membus
とd_membus
をインスタンス化し、それをmembus
と接続します。
調停の仕組みは次のとおりです。
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モジュールとの接続を次のように変更します(リスト3.59)。
memoryモジュールとmemunitモジュールを接続する準備が整ったので、memunitモジュールをインスタンス化します(リスト3.60)。
memunitモジュールの処理待ちとライトバック
memunitモジュールが処理中のときは命令をFIFOから取り出すのを止める処理と、ロード命令で読み込んだデータをレジスタにライトバックする処理を実装します。
memunitモジュールが処理中のとき、FIFOから命令を取り出すのを止めます(リスト3.61)。
memunitモジュールが処理中のとき、memu_stall
が1
になっています。そのため、memu_stall
が1
のときはif_fifo_rready
を0
にすることで、FIFOからの命令の取り出しを停止します。
次に、ロード命令の結果をレジスタにライトバックします(リスト3.62)。ライトバック処理では、命令がロード命令のとき(inst_ctrl.is_load
)、memu_rdata
をwb_data
に設定します。
ところで、現在のコードではmemunitの処理が終了していないときも値をライトバックし続けています。レジスタへのライトバックは命令の実行が終了したときのみで良いため、次のようにコードを変更します(リスト3.63)。
デバッグ表示も同様で、ライトバックするときにのみデバッグ表示します(リスト3.64)。
LW、SW命令のテスト
LW命令とSW命令が正しく動作していることを確認するために、デバッグ表示に次のコードを追加します(リスト3.65)。
ここからのテストは実行するクロック数が多くなります。そこで、ログに何クロック目かを表示することでログを読みやすくします(リスト3.66)。
LW、SW命令のテストのために、src/sample.hex
を次のように変更します(リスト3.67)。
プログラムは次のようになっています(表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のデータを読み込む |
アドレス0x00000020
には、データ32'hdeadbeef
を格納しています。1つ目の命令で32'hdeadbeef
が読み込まれ、3つ目の命令で32'h00000400
を書き込み、4つ目の命令で32'h00000400
が読み込まれます。
シミュレータを実行し、結果を確かめます(リスト3.68)。
$ 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
のとき、符号拡張を行います。
funct3 | 命令 |
---|---|
3'b000 | LB |
3'b100 | LBU |
3'b001 | LH |
3'b101 | LHU |
3'b010 | LW |
まず、何度も記述することになる値を短い名前(W
、D
、sext
)で定義します(リスト3.69)。sext
は、符号拡張を行うかどうかを示す変数です。
funct3をcase文で分岐し、アドレスの下位ビットを見ることで、命令とアドレスに応じた値をrdataに設定します(リスト3.70)。
ロードした値の拡張を行うとき、値の最上位ビットと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)。
wmask
には、書き込む部分を1
、書き込まない部分を0
で指定します。このような挙動をする値を、書き込みマスクと呼びます。バイト単位で指定するため、wmask
の幅はDATA_WIDTH / 8
ビットです。
次に、memoryモジュールで書き込みマスクをサポートします(リスト3.74)。
書き込みマスクをサポートするmemoryモジュールは、次の2つの状態を持ちます。
- State::Ready
-
要求を受け付ける。
読み込み要求のとき、次のクロックで結果を返す。
書き込み要求のとき、要求の内容をレジスタに格納し、
状態を
State::WriteValid
に移動する。 - State::WriteValid
-
書き込みマスクつきの書き込みを行う。
状態を
State::Ready
に移動する。
memoryモジュールは、書き込み要求が送られてきた場合、名前が_saved
で終わるレジスタに要求の内容を格納します。また、指定されたアドレスのデータをrdata_saved
に格納します。次のクロックで、書き込みマスクを使った書き込みを行い、要求の処理を終了します。
topモジュールの調停処理で、wmask
も調停します(リスト3.75)。
memunitモジュールの実装
memoryモジュールが書き込みマスクをサポートしたので、memunitモジュールでwmask
を設定します。
req_wmask
レジスタを作成し、membus.wmask
と接続します(リスト3.76、リスト3.77)。
always_ffの中で、req_wmask
の値を設定します。それぞれの命令のとき、wmask
がどうなるかを確認してください(リスト3.78、リスト3.79)。
3.11.4 LB、LBU、LH、LHU、SB、SH命令をテストする
簡単なテストを作成し、動作をテストします。2つテストを記載するので、正しく動いているか確認してください。
3.12 ジャンプ命令、分岐命令の実装
まだ重要な命令を実装できていません。プログラムで分岐やループを実現するためにはジャンプや分岐をする命令が必要です。RV32Iには、仕様書[8]に次の命令が定義されています(表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+即値にジャンプする |
ジャンプ命令は、無条件でジャンプするため、無条件ジャンプ(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_en
を1
、InstCtrl.is_aluop
を0
、InstCtrl.is_jump
を1
としてデコードします。
無条件ジャンプであるかどうかはInstCtrl.is_jump
で確かめられます。また、InstCtrl.is_aluop
が0
なため、ALUは常に加算を行います。加算の対象のデータが、JAL命令(J形式)ならPCと即値、JALR命令(I形式)ならrs1と即値になっていることを確認してください(リスト3.44)。
無条件ジャンプの実装
それでは、無条件ジャンプを実装します。まず、ジャンプ命令を実行するときにライトバックする値をinst_pc + 4
にします(リスト3.82)。
次に、次にフェッチする命令をジャンプ先の命令に変更します。フェッチ先の変更が発生を示す信号control_hazard
と、新しいフェッチ先を示す信号control_hazard_pc_next
を作成します(リスト3.83、リスト3.84)。
control_hazard
を利用してif_pc
を更新し、新しく命令をフェッチしなおすようにします(リスト3.85)。
ここで、新しく命令をフェッチしなおすようにしても、ジャンプ命令によって実行されることがなくなった命令がFIFOに残っていることがあることに注意する必要があります(図3.4)。
実行するべきではない命令を実行しないようにするために、ジャンプ命令を実行するときに、FIFOをリセットします。
FIFOに、中身をリセットするための信号flush
を実装します(リスト3.86)。
flush
が1
のとき、head
とtail
を0
に初期化することでFIFOを空にします(リスト3.87、リスト3.88)。
coreモジュールで、control_hazard
とflush
を接続し、FIFOをリセットします(リスト3.89)。
無条件ジャンプのテスト
簡単なテストを作成し、動作をテストします(リスト3.90、リスト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
3.12.2 条件分岐命令を実装する
条件分岐命令はすべてB形式で、PC+即値で分岐先を指定します。それぞれの命令は、命令のfunct3フィールドで判別できます(表3.11)。
funct3 | 命令 | 演算 |
---|---|---|
3'b000 | BEQ | == |
3'b001 | BNE | != |
3'b100 | BLT | 符号付き <= |
3'b101 | BGE | 符号付き > |
3'b110 | BLTU | 符号なし <= |
3'b111 | BGEU | 符号なし > |
条件分岐の実装
分岐の条件が成立するかどうかを判定するモジュールを作成します。src/brunit.veryl
を作成し、次のように記述します(リスト3.92)。
brunitモジュールは、funct3
に応じてtake
の条件を切り替えます。分岐が成立するときにtake
が1
になります。
brunitモジュールを、coreモジュールでインスタンス化します(リスト3.93)。命令がB形式のとき、op1
はrs1_data
、op2
はrs2_data
になっていることを確認してください(リスト3.44)。
命令が条件分岐命令でbrunit_take
が1
のとき、次のPCをPC + 即値にします(リスト3.94、リスト3.95)。
control_hazard
は、命令が無条件ジャンプ命令か、命令が条件分岐命令かつ分岐が成立するときに1
になります。control_hazard_pc_next
は、無条件ジャンプ命令のときはalu_result
、条件分岐命令のときはPC + 即値になります。
条件分岐命令のテスト
条件分岐命令を実行するとき、分岐の成否をデバッグ表示します。デバッグ表示を行っているalways_ffブロック内に、次のコードを追加します(リスト3.96)。
簡単なテストを作成し、動作をテストします(リスト3.97, リスト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でテストします。
メモリフェンス命令、ECALL命令、EBREAK命令は後の章で実装します。