Vengineerの妄想

人生を妄想しています。

APB I/F のSPIのテストベンチをVerilatorようにしてもらった

はじめに

先日の続き、SystemVerilog + DPI-C だと、Verilator では使えません。

そこで、Grok さんに、Verilator 用にしてもらいました。

Verilator 用への変更

Verilator を使うとどう変更すればいい

Verilatorを使う場合、VCSのような商用シミュレータとは大きくアプローチが変わります。

主な違いと変更点をまとめると以下のようになります。

Verilatorの基本的な特徴(DPI-C環境でのポイント)

  • Verilatorはシミュレータではなくコンパイラ → SystemVerilog → C++コードに変換 → g++などでネイティブ実行ファイルを作る
  • DPI-Cは両方向サポート(import/export どちらもOK)
  • VCSのように「SV側がメインでC++を動的にロード」ではなく、C++側がメインになることがほとんど
    • C++のmain()でVerilatedモデルをインスタンス化 → eval()を繰り返し呼ぶ
    • SV側のexport "DPI-C"関数はC++から普通のC関数として呼べる
  • クロックやリセットはC++側で完全に制御top->clk = 1; top->eval(); のような手動駆動)
  • 複数テストケース → C++側でテストスイートを実装するか、コマンドライン引数/環境変数で切り替えるのが自然

変更が必要な主な部分

  1. testbench.sv の大幅変更
    • VCSのようにinitialでクロック生成やリセットは不要(C++側でやる)
    • APB信号駆動部分は削除 → DPI export関数として残す
    • DUTインスタンスは残すが、Verilatorが自動でラップするのでtop module名に注意

    修正後(最小限)のtestbench.sv例

   // testbench.sv  ← Verilatorが見るトップモジュール
   // (DUTを直接インスタンスせず、DPI関数だけ定義)

   module spi_apb_tb;  // ← Verilatorのトップ名(Vspi_apb_tb になる)

       // DPI export関数(C++から呼ばれる)
       export "DPI-C" function sv_apb_write;
       export "DPI-C" function sv_apb_read;
       export "DPI-C" function sv_spi_check;

       // DUT本体(ここは元のまま)
       logic clk, rst_n;
       logic psel, penable, pwrite;
       logic [31:0] paddr, pwdata, prdata;
       logic pready, pslverr;
       logic spi_mosi, spi_miso, spi_sck, spi_cs_n;

       spi_controller dut (
           .clk       (clk),
           .rst_n     (rst_n),
           .psel      (psel),
           .penable   (penable),
           .pwrite    (pwrite),
           .paddr     (paddr),
           .pwdata    (pwdata),
           .prdata    (prdata),
           .pready    (pready),
           .pslverr   (pslverr),
           .spi_mosi  (spi_mosi),
           .spi_miso  (spi_miso),
           .spi_sck   (spi_sck),
           .spi_cs_n  (spi_cs_n)
       );

       // DPI関数実装(クロック同期が必要なものはC++側でeval()呼んだ後に使う)
       function void sv_apb_write(input int addr, input int data);
           // ここでは信号を即時セット(C++側でクロックを進める前提)
           paddr   = addr;
           pwdata  = data;
           pwrite  = 1;
           psel    = 1;
           penable = 0;
           // C++側でクロック1サイクル進めてpenable=1にする
       endfunction

       function int sv_apb_read(input int addr);
           int data;
           paddr   = addr;
           pwrite  = 0;
           psel    = 1;
           penable = 0;
           // C++側でクロック進めてprdataを取る
           data = prdata;
           return data;
       endfunction

       function void sv_spi_check();
           $display("SPI check from SV side");
           // 必要に応じて信号チェック
       endfunction

   endmodule

重要: 上記のsv_apb_writeなどは「信号をセットするだけ」。実際のタイミング(SETUP → ENABLE)はC++側でtop->eval()を挟んで制御します。

  1. C++側がメインになる(test_program.cpp → sim_main.cpp など)
   // sim_main.cpp (複数テスト対応版の例)
   #include "Vspi_apb_tb.h"         // Verilatorが生成
   #include "verilated.h"
   #include "verilated_vcd_c.h"     // 波形ダンプしたい場合

   #include <iostream>
   #include <string>

   extern "C" {
       // SV側export関数(DPI-Cで自動リンク)
       extern void sv_apb_write(int addr, int data);
       extern int  sv_apb_read (int addr);
       extern void sv_spi_check();
   }

   static Vspi_apb_tb* top = nullptr;
   static VerilatedContext* contextp = nullptr;

   // APBトランザクションをC++で安全に実行するヘルパー
   void apb_write(uint32_t addr, uint32_t data) {
       top->paddr   = addr;
       top->pwdata  = data;
       top->pwrite  = 1;
       top->psel    = 1;
       top->penable = 0;
       top->eval();  // SETUP相

       top->penable = 1;
       top->eval();  // ACCESS相(preadyを待つループ推奨)

       while (!top->pready) {
           top->clk = !top->clk;
           top->eval();
       }
       top->psel = 0;
       top->penable = 0;
   }

   uint32_t apb_read(uint32_t addr) {
       top->paddr   = addr;
       top->pwrite  = 0;
       top->psel    = 1;
       top->penable = 0;
       top->eval();

       top->penable = 1;
       top->eval();

       while (!top->pready) {
           top->clk = !top->clk;
           top->eval();
       }
       uint32_t data = top->prdata;
       top->psel = 0;
       top->penable = 0;
       return data;
   }

   // 各テスト関数(複数対応)
   void run_basic_test() {
       std::cout << "[BASIC] Starting...\n";
       apb_write(0x00, 0x00000001);  // SPI enable
       apb_write(0x04, 0x000000A5);  // TX data
       // ... 待機やチェック
       sv_spi_check();
       std::cout << "[BASIC] Done.\n";
   }

   void run_stress_test() {
       std::cout << "[STRESS] Starting...\n";
       for (int i = 0; i < 1000; ++i) {
           apb_write(0x04, i & 0xFF);
           // ...
       }
       std::cout << "[STRESS] Done.\n";
   }

   int main(int argc, char** argv) {
       std::string test_name = "basic";
       if (argc > 1) test_name = argv[1];

       contextp = new VerilatedContext;
       contextp->commandArgs(argc, argv);
       top = new Vspi_apb_tb{contextp};

       top->rst_n = 0;
       top->clk   = 0;
       top->eval();
       top->rst_n = 1;
       top->eval();

       // テスト選択
       if (test_name == "basic")      run_basic_test();
       else if (test_name == "stress") run_stress_test();
       else {
           std::cerr << "Unknown test: " << test_name << "\n";
           return 1;
       }

       delete top;
       delete contextp;
       return 0;
   }
  1. Makefile をVerilator向けに完全書き換え
   # Makefile for Verilator + DPI-C multiple tests

   VERILATOR = verilator
   V_FLAGS   = --cc --exe --build -j 0 --timing --trace --assert \
               -Wall -Wno-fatal -CFLAGS "-std=c++17 -O2 -g"

   TOP_MODULE   = spi_apb_tb
   SV_FILES     = testbench.sv spi_controller.sv
   CPP_MAIN     = sim_main.cpp
   EXE          = V$(TOP_MODULE)

   # テスト名(make run TEST=stress など)
   TEST ?= basic

   all: $(EXE)

   $(EXE): $(SV_FILES) $(CPP_MAIN)
    $(VERILATOR) $(V_FLAGS) \
        --top-module $(TOP_MODULE) \
        $(SV_FILES) $(CPP_MAIN) \
        -o $(EXE)

   run: $(EXE)
    @echo "Running $(TEST) test..."
    ./$(EXE) $(TEST)

   clean:
    rm -rf obj_dir $(EXE) *.vcd *.log

   .PHONY: all run clean

実行例: make make run TEST=basic make run TEST=stress

まとめ:VCS → Verilator 移行のポイント

項目 VCS時代 Verilator時代
メインループ SVのinitial / always C++のwhileループ + eval()
クロック生成 SVで#delayやalways C++で手動 clk = !clk; eval();
DPI export呼び出し SVがC++を呼ぶ C++がSV export関数を普通に呼ぶ
複数テスト 別.so をロード C++側でif/switch または引数で分岐
波形ダンプ -gui / fsdb --trace + VCD / fst
ビルド時間 短め 初回長め(C++コンパイル)
シミュレーション速度 商用並み~遅め 非常に速い(ネイティブコード)

Verilator では、export task (時間が進む) は、使えない

SystemVerilog 側のDPI-C関数は、使っていないよね。

はい、その通りです。Verilatorを使った構成では、SystemVerilog側のDPI-C export関数(export "DPI-C" function sv_apb_write; など)は実際には使われていません(少なくとも前回のサンプルコードでは完全に無駄になっています)。

なぜ使われていないのか?(VCS vs Verilatorの決定的な違い)

  • VCS(商用シミュレータ) の場合
    → SV側がシミュレーションのメインループを持ち、C++はDPI経由で呼ばれたり呼んだりする(動的リンク)。
    export "DPI-C" でSV関数をC++から呼べるようにするのが標準。

  • Verilator の場合
    → SV → C++コードに変換 → C++のmain()が完全にコントロールする。
    → SVのDPI export関数は、C++側から普通のextern関数として直接リンク・呼び出し可能になる。
    → しかし、SV側でsv_apb_write()のような関数本体を実装しても、Verilatorはそれを「自動でC++側にエクスポート」するが、タイミング制御(クロック同期、wait(pready)など)ができない
    → そのため、APBのようなバスプロトコル(複数クロックサイクルにまたがる処理)はC++側で直接信号を操作 + eval()を呼ぶのが最も自然で高速・正確。

結果として、前回の例ではSV側のsv_apb_write関数は宣言だけ残って中身が空っぽor不完全で、C++側はDPI exportを介さず直接top->paddr = ...; top->eval(); と信号を触っているため、export関数は死んでいます

修正方針:本当にDPI exportを使うか?使わないか?

パターン1: DPI exportを使わない(おすすめ・現在の主流)

SV側ではDPI関数を一切宣言せず、C++側でVerilatedモデルのpublicポート/変数を直接操作する。これがVerilatorの最も速くてシンプルなスタイル。

// testbench.sv (DPI関連を全部削除)
module spi_apb_tb;

    logic clk, rst_n;
    logic psel, penable, pwrite;
    logic [31:0] paddr, pwdata, prdata;
    logic pready, pslverr;
    // ... SPI信号も

    spi_controller dut ( /* ポート接続 */ );

    // DPI export なし! C++が直接 clk, psel などを触る
endmodule

C++側(sim_main.cpp)は前回のままでOK(top->psel = 1; など)。

パターン2: どうしてもDPI exportを使いたい場合(例: 既存SVコード再利用時)

SV側でcontext付きのexport task/functionを作り、C++から呼ぶ。ただし、時間消費(wait, #delay)は禁止(Verilatorではサポート外or制限大)。

export "DPI-C" context task sv_apb_write_task;
task sv_apb_write_task(input int addr, input int data);
    paddr   = addr;
    pwdata  = data;
    pwrite  = 1;
    psel    = 1;
    penable = 0;
    // wait(pready);  ← これがダメ! Verilatorでは時間進まない
endtask

C++側:

#include "Vspi_apb_tb__Dpi.h"  // Verilatorが生成
extern "C" void sv_apb_write_task(int addr, int data);

しかし、preadyを待つ部分がC++側に残るので、結局C++でeval()ループを書く必要があり、DPIのメリットが薄れる

→ 結論:VerilatorではDPI exportは最小限(純粋関数のみ)に留め、バスプロトコルはC++で直接駆動するのがベスト。

最終的なおすすめ構成(DPI exportを完全に削除)

  • testbench.sv:DPI関連宣言を全部削除。ただのDUTラッパーにする。
  • sim_main.cpp:前回のヘルパー関数(apb_write(), apb_read())をそのまま使い、テスト関数内で呼ぶ。
  • Makefile:変更なし(VerilatorはSVファイルを解析して自動でpublicインターフェース生成)。

これでコードがクリーンになり、シミュレーションも高速です。

必要なら、DPI exportを残して「ログ出力専用関数($displayラッパー)」だけにする、などのハイブリッドも可能です。どういう方向で進めたいか教えてください!

おわりに

まー、一瞬で出てきます。もう、最初の一歩は、AIさんにお願いして、OKです!

とは言え、生成されたコードの中身を理解できないと、いずれは辛くなると思います。