はじめに
先日の続き、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++の
- クロックやリセットはC++側で完全に制御(
top->clk = 1; top->eval();のような手動駆動) - 複数テストケース → C++側でテストスイートを実装するか、コマンドライン引数/環境変数で切り替えるのが自然
変更が必要な主な部分
- testbench.sv の大幅変更
- VCSのように
initialでクロック生成やリセットは不要(C++側でやる) - APB信号駆動部分は削除 → DPI export関数として残す
- DUTインスタンスは残すが、Verilatorが自動でラップするのでtop module名に注意
修正後(最小限)のtestbench.sv例
- VCSのように
// 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()を挟んで制御します。
- 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;
}
- 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です!
とは言え、生成されたコードの中身を理解できないと、いずれは辛くなると思います。