Verylで作るCPU Star

第5章
riscv-testsによるテスト

第3章では、RV32IのCPUを実装しました。簡単なテストを作成して動作を確かめましたが、まだテストできていない命令が複数あります。そこで、riscv-testsというテストを利用することで、CPUがある程度正しく動いているらしいことを確かめます。

5.1 riscv-testsとは何か?

riscv-testsは、RISC-Vのプロセッサ向けのユニットテストやベンチマークテストの集合です。命令や機能ごとにテストが用意されており、これを利用することで簡単に実装を確かめられます。すべての命令のすべての場合を網羅するようなテストではないため、riscv-testsをパスしても、確実に実装が正しいとは言えないことに注意してください*1

[*1] 実装の正しさを完全に確かめるには形式的検証(formal verification)を行う必要があります

GitHubのriscv-software-src/riscv-testsからソースコードをダウンロードできます。

5.2 riscv-testsのビルド

riscv-testsのビルドが面倒、もしくはよく分からなくなってしまった方へ

https://github.com/nananapo/riscv-tests-bin/tree/bin4

完成品を上記のURLにおいておきます。core/testにコピーしてください。

5.2.1 riscv-testsをビルドする

まず、riscv-testsをcloneします(リスト5.1)。

リスト5.1: リスト5.1: riscv-testsのclone
$ git clone https://github.com/riscv-software-src/riscv-tests
$ cd riscv-tests
$ git submodule update --init --recursive

riscv-testsは、プログラムの実行が0x80000000から始まると仮定した設定になっています。しかし、CPUはアドレス0x00000000から実行を開始するため、リンカにわたす設定ファイルenv/p/link.ldを変更する必要があります(リスト5.2)。

リスト5.2: リスト5.2: riscv-tests/env/p/link.ld
OUTPUT_ARCH( "riscv" )
ENTRY(_start)

SECTIONS
{
  . = 0x00000000; ← 先頭を0x00000000に変更する

riscv-testsをビルドします。必要なソフトウェアがインストールされていない場合、適宜インストールしてください(リスト5.3)。

リスト5.3: リスト5.3: riscv-testsのビルド
$ cd riscv-testsをcloneしたディレクトリ
$ autoconf
$ ./configure --prefix=core/testへのパス
$ make
$ make install

core/testにshareディレクトリが作成されます。

5.2.2 成果物を$readmemhで読み込める形式に変換する

riscv-testsをビルドできましたが、これは$readmemhシステムタスクで読み込める形式(以降HEX形式と呼びます)ではありません。これをCPUでテストを実行できるように、ビルドしたテストのバイナリファイルをHEX形式に変換します。

まず、バイナリファイルをHEX形式に変換するPythonプログラムtest/bin2hex.pyを作成します(リスト5.4)。

リスト5.4: リスト5.4: test/bin2hex.py
import sys

# 使い方を表示する
def print_usage():
    print(sys.argv[1])
    print("Usage:", sys.argv[0], "[bytes per line] [filename]")
    exit()

# コマンドライン引数を受け取る
args = sys.argv[1:]
if len(args) != 2:
    print_usage()
BYTES_PER_LINE = None
try:
    BYTES_PER_LINE = int(args[0])
except:
    print_usage()
FILE_NAME = args[1]

# バイナリファイルを読み込む
allbytes = []
with open(FILE_NAME, "rb") as f:
    allbytes = f.read()

# 値を文字列に変換する
bytestrs = []
for b in allbytes:
    bytestrs.append(format(b, '02x'))

# 00を足すことでBYTES_PER_LINEの倍数に揃える
bytestrs += ["00"] * (BYTES_PER_LINE - len(bytestrs) % BYTES_PER_LINE)

# 出力
results = []
for i in range(0, len(bytestrs), BYTES_PER_LINE):
    s = ""
    for j in range(BYTES_PER_LINE):
        s += bytestrs[i + BYTES_PER_LINE - j - 1]
    results.append(s)
print("\n".join(results))

このプログラムは、第二引数に指定されるバイナリファイルを、第一引数に与えられた数のバイト毎に区切り、16進数のテキストで出力します。

HEXファイルに変換する前に、ビルドした成果物を確認する必要があります。例えばtest/share/riscv-tests/isa/rv32ui-p-addはELFファイル*2です。CPUはELFを直接に実行する機能を持っていないため、riscv64-unknown-elf-objcopyを利用して、ELFファイルを余計な情報を取り除いたバイナリファイルに変換します(リスト5.5)。

[*2] ELF(Executable and Linkable Format)とは実行可能ファイルの形式です

リスト5.5: リスト5.5: ELFファイルをバイナリファイルに変換する
$ find share/ -type f -not -name "*.dump" -exec riscv32-unknown-elf-objcopy -O binary {} {}.bin \;

最後に、objcopyで生成されたバイナリファイルを、PythonプログラムでHEXファイルに変換します(リスト5.6)。

リスト5.6: リスト5.6: バイナリファイルをHEXファイルに変換する
$ find share/ -type f -name "*.bin" -exec sh -c "python3 bin2hex.py 4 {} > {}.hex" \;

5.3 テスト内容の確認

riscv-testsには複数のテストが用意されていますが、本章では、名前がrv32ui-p-から始まるRV32I向けのテストを利用します。

例えば、ADD命令のテストであるtest/share/riscv-tests/isa/rv32ui-p-add.dumpを読んでみます(リスト5.7)。rv32ui-p-add.dumpは、rv32ui-p-addのダンプファイルです。

リスト5.7: リスト5.7: rv32ui-p-add.dump
Disassembly of section .text.init:

00000000 <_start>:
   0:   0500006f                j       50 <reset_vector>

00000004 <trap_vector>:
   4:   34202f73                csrr    t5,mcause ← t5 = mcause
  ...
  18:   00b00f93                li      t6,11
  1c:   03ff0063                beq     t5,t6,3c <write_tohost>
  ...

0000003c <write_tohost>: ← 0x1000にテスト結果を書き込む
  3c:   00001f17                auipc   t5,0x1
  40:   fc3f2223                sw      gp,-60(t5) # 1000 <tohost>
  ...

00000050 <reset_vector>:
  50:   00000093                li      ra,0
 ...    ← レジスタ値のゼロ初期化
  c8:   00000f93                li      t6,0
 ...    
 130:   00000297                auipc   t0,0x0
 134:   ed428293                addi    t0,t0,-300 # 4 <trap_vector>
 138:   30529073                csrw    mtvec,t0 ← mtvecにtrap_vectorのアドレスを書き込む
 ...    
 178:   00000297                auipc   t0,0x0
 17c:   01428293                addi    t0,t0,20 # 18c <test_2>
 180:   34129073                csrw    mepc,t0 ← mepcにtest_2のアドレスを書き込む
 ...    
 188:   30200073                mret ← mepcのアドレス=test_2にジャンプする

0000018c <test_2>: ← 0 + 0 = 0のテスト
 18c:   00200193                li      gp,2 ← gp = 2
 190:   00000593                li      a1,0
 194:   00000613                li      a2,0
 198:   00c58733                add     a4,a1,a2
 19c:   00000393                li      t2,0
 1a0:   4c771663                bne     a4,t2,66c <fail>
 ...
0000066c <fail>: ← 失敗したときのジャンプ先
 ...
 674:   00119193                sll     gp,gp,0x1 ← gpを1ビット左シフトする
 678:   0011e193                or      gp,gp,1 ← gpのLSBを1にする
 ...
 684:   00000073                ecall

00000688 <pass>: ← すべてのテストに成功したときのジャンプ先
 ...
 68c:   00100193                li      gp,1 ← gp = 1
 690:   05d00893                li      a7,93
 694:   00000513                li      a0,0
 698:   00000073                ecall
 69c:   c0001073                unimp

命令のテストは次の流れで実行されます。

  1. _start : reset_vectorにジャンプする。
  2. reset_vector : 各種状態を初期化する。
  3. test_* : テストを実行する。命令の結果がおかしかったらfailにジャンプする。最後まで正常に実行できたらpassにジャンプする。
  4. fail、pass : テストの成否をレジスタに書き込み、trap_vectorにジャンプする。
  5. trap_vector : write_tohostにジャンプする。
  6. write_tohost : テスト結果をメモリに書き込む。ここでループする。

_startから実行を開始し、最終的にwrite_tohostに移動します。テスト結果はメモリの.tohostに書き込まれます。.tohostのアドレスは、リンカの設定ファイルに記述されています(リスト5.8)。プログラムのサイズは0x1000よりも小さいため、.tohostのアドレスは0x1000になります。

リスト5.8: リスト5.8: riscv-tests/env/p/link.ld
OUTPUT_ARCH( "riscv" )
ENTRY(_start)

SECTIONS
{
  . = 0x00000000;
  .text.init : { *(.text.init) }
  . = ALIGN(0x1000);
  .tohost : { *(.tohost) }

5.4 テストの終了検知

テストを実行するとき、テストの終了を検知して、成功か失敗かを報告する必要があります。

riscv-testsはテストの終了を示すために、.tohostにLSBが1な値を書き込みます。書き込まれた値が32'h1のとき、テストが正常に終了したことを表しています。それ以外のときは、テストが失敗したことを表しています。

riscv-testsが終了したことを検知する処理をtopモジュールに記述します。topモジュールでメモリへのアクセスを監視し、.tohostにLSBが1な値が書き込まれたら、test_successに結果を書き込んでテストを終了します。(リスト5.9)。

リスト5.9: リスト5.9: メモリアクセスを監視して終了を検知する (top.veryl)
    // riscv-testsの終了を検知する
    #[ifdef(TEST_MODE)]
    always_ff {
        let RISCVTESTS_TOHOST_ADDR: Addr = 'h1000 as Addr;
        if d_membus.valid && d_membus.ready && d_membus.wen == 1 && d_membus.addr == RISCVTESTS_TOHOST_ADDR && d_membus.wdata[lsb] == 1'b1 {
            test_success = d_membus.wdata == 1;
            if d_membus.wdata == 1 {
                $display("riscv-tests success!");
            } else {
                $display("riscv-tests failed!");
                $error  ("wdata : %h", d_membus.wdata);
            }
            $finish();
        }
    }

test_successはポートとして定義します(リスト5.10)。

リスト5.10: リスト5.10: テスト結果を報告するためのポートを宣言する (top.veryl)
module top (
    clk: input clock,
    rst: input reset,
    #[ifdef(TEST_MODE)]
    test_success: output bit,
) {

アトリビュートによって、終了検知のコードとtest_successポートはTEST_MODEマクロが定義されているときにのみ存在するようになっています。

5.5 テストの実行

試しにADD命令のテストを実行してみましょう。ADD命令のテストのHEXファイルはtest/share/riscv-tests/isa/rv32ui-p-add.bin.hexです。

TEST_MODEマクロを定義してシミュレータをビルドし、正常に動くことを確認します(リスト5.11)。

リスト5.11: リスト5.11: ADD命令のriscv-testsを実行する
$ make build
$ make sim VERILATOR_FLAGS="-DTEST_MODE" ← TEST_MODEマクロを定義してビルドする
$ ./obj_dir/sim test/share/riscv-tests/isa/rv32ui-p-add.bin.hex 0
#                    4
00000000 : 0500006f
#                    8
00000050 : 00000093
...
#                  593
00000040 : fc3f2223
  itype     : 000100
  imm       : ffffffc4
  rs1[30]   : 0000103c
  rs2[ 3]   : 00000001
  op1       : 0000103c
  op2       : ffffffc4
  alu res   : 00001000
  mem stall : 1
  mem rdata : ff1ff06f
riscv-tests success!
- ~/core/src/top.sv:26: Verilog $finish

riscv-tests success!と表示され、テストが正常終了しました*3

[*3] 実行が終了しない場合はどこかしらにバグがあります。rv32ui-p-add.dumpと実行ログを見比べて、頑張って原因を探してください

5.6 複数のテストの自動実行

ADD命令以外の命令もテストしたいですが、わざわざコマンドを手打ちしたくありません。自動でテストを実行して、その結果を報告するプログラムを作成しましょう。

test/test.pyを作成し、次のように記述します(リスト5.12)。

リスト5.12: リスト5.12: test.py
import argparse
import os
import subprocess

parser = argparse.ArgumentParser()
parser.add_argument("sim_path", help="path to simlator")
parser.add_argument("dir", help="directory includes test")
parser.add_argument("files", nargs='*', help="test hex file names")
parser.add_argument("-r", "--recursive", action='store_true', help="search file recursively")
parser.add_argument("-e", "--extension", default="hex", help="test file extension")
parser.add_argument("-o", "--output_dir", default="results", help="result output directory")
parser.add_argument("-t", "--time_limit", type=float, default=10, help="limit of execution time. set 0 to nolimit")
args = parser.parse_args()

# run test
def test(file_name):
    result_file_path = os.path.join(args.output_dir, file_name.replace(os.sep, "_") + ".txt")
    cmd = args.sim_path + " " + file_name + " 0"
    success = False
    with open(result_file_path, "w") as f:
        no = f.fileno()
        p = subprocess.Popen("exec " + cmd, shell=True, stdout=no, stderr=no)
        try:
            p.wait(None if args.time_limit == 0 else args.time_limit)
            success = p.returncode == 0
        except: pass
        finally:
            p.terminate()
            p.kill()
    print(("PASS" if success else "FAIL") + " : "+ file_name)
    return (file_name, success)

# search files
def dir_walk(dir):
    for entry in os.scandir(dir):
        if entry.is_dir():
            if args.recursive:
                for e in dir_walk(entry.path):
                    yield e
            continue
        if entry.is_file():
            if not entry.name.endswith(args.extension):
                continue
            if len(args.files) == 0:
                yield entry.path
            for f in args.files:
                if entry.name.find(f) != -1:
                    yield entry.path
                    break

if __name__ == '__main__':
    os.makedirs(args.output_dir, exist_ok=True)

    res_strs = []
    res_statuses = []

    for hexpath in dir_walk(args.dir):
        f, s = test(os.path.abspath(hexpath))
        res_strs.append(("PASS" if s else "FAIL") + " : " + f)
        res_statuses.append(s)

    res_strs = sorted(res_strs)
    statusText = "Test Result : " + str(sum(res_statuses)) + " / " + str(len(res_statuses))

    with open(os.path.join(args.output_dir, "result.txt"), "w", encoding='utf-8') as f:
        f.write(statusText + "\n")
        f.write("\n".join(res_strs))

    print(statusText)

    if sum(res_statuses) != len(res_statuses):
        exit(1)

このPythonプログラムは、第2引数で指定したディレクトリに存在する、第3引数で指定した文字列を名前に含むファイルを、第1引数で指定したシミュレータで実行し、その結果を報告します。

次のオプションの引数が存在します。

-r
第2引数で指定されたディレクトリの中にあるディレクトリも走査します。 デフォルトでは走査しません。
-e 拡張子
指定した拡張子のファイルのみを対象にテストします。 HEXファイルをテストしたい場合は、-e hexにします。 デフォルトではhexが指定されています。
-o ディレクトリ
指定したディレクトリにテスト結果を格納します。 デフォルトではresultディレクトリに格納します。
-t 時間
テストに時間制限を設けます。 0を指定すると時間制限はなくなります。 デフォルト値は10(秒)です。

テストが成功したか失敗したかの判定には、シミュレータの終了コードを利用しています。テストが失敗したときに終了コードが1になるように、Verilatorに渡しているC++プログラムを変更します(リスト5.13)。

リスト5.13: リスト5.13: tb_verilator.cpp
    #ifdef TEST_MODE
        return dut->test_success != 1;
    #endif

それでは、RV32Iのテストを実行しましょう。riscv-testsのRV32I向けのテストの接頭辞であるrv32ui-p-を引数に指定します(リスト5.14)。

リスト5.14: リスト5.14: rv32ui-pから始まるテストを実行する
$ make build
$ make sim VERILATOR_FLAGS="-DTEST_MODE"
$ python3 test/test.py -r obj_dir/sim test/share rv32ui-p-
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lh.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sb.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sltiu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sh.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-bltu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-or.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sra.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-xor.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-addi.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-srai.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-srli.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-auipc.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-slli.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-slti.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lb.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lw.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-bge.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sub.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-xori.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sw.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-beq.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-fence_i.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-jal.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-and.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lui.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-bgeu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-slt.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sll.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-jalr.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-add.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-simple.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-andi.bin.hex
FAIL : ~/core/test/share/riscv-tests/isa/rv32ui-p-ma_data.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lhu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-lbu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-sltu.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-ori.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-blt.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-bne.bin.hex
PASS : ~/core/test/share/riscv-tests/isa/rv32ui-p-srl.bin.hex
Test Result : 39 / 40

rv32ui-p-から始まる40個のテストの内、39個のテストに成功しました。テストの詳細な結果はresultsディレクトリに格納されています。

rv32ui-p-ma_dataは、ロードストアするサイズに整列されていないアドレスへのロードストア命令のテストです。これは後の章で例外として対処するため、今は無視します。