ISim による Verilog テストベンチ の履歴(No.2)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- 電気回路/HDL/ISim による Verilog テストベンチ へ行く。
概要†
テストベンチ記述の際に自分で使っている典型的なイディオムを紹介してみます。
クロック†
周期を直に書いてしまうなら:
LANG:verilog reg clk = 0; always #5 clk = !clk;
周期をパラメータにする:
LANG:verilog parameter CLK_PERIOD = 10; reg clk = 0; always #(CLK_PERIOD/2) clk = !clk;
可変周期(fast_mode で切り替え):
LANG:verilog parameter CLK_PERIOD_SLOW = 100; parameter CLK_PERIOD_FAST = 10; reg fast_mode = 0; reg clk = 0; always begin if (fast_mode) #(CLK_PERIOD_SLOW/2) else #(CLK_PERIOD_FAST/2); clk = !clk; end
逐次処理†
例えば、
- rst = 1 で 100 clk
- rst = 0 にして 10 clk
- data = 3, start = 1 で 1 clk
- start = 0 で done == 1 を待つ
- result == 8 を確認する
というような処理。
論理合成が必要な部分ではステートマシンで書くことになるけれど、 テストベンチではステートマシンを書く必要は無い。
というか、むしろステートマシンで書いてしまうと回路記述と同じくらい バグが入りやすくなってしまってテストベンチを書いている意味がない。 (回路側のコードをコピー&ペースとして、ちょこちょこっと直して テストベンチにする、とかものすごく意味がないのでダメダメです)
で、どうするかというとなるべくソフト開発っぽく逐次処理で書く。
LANG:verilog(linenumber) initial begin rst = 1; repeat(100) @(posedge clk); rst = 0; repeat(10) @(posedge clk); data = 3; start = 1; @(posedge clk); start = 0; @(posedge done); if (result !== 8) $display("result == 8 expected but ", result, " found."); $stop; end
これなら、上記の仕様通りのテストベンチになっているのが一目瞭然です。
これを、
LANG:verilog integer clk_count = 0; always @(posedge clk) begin clk_count <= clk_count + 1; if (clk_count==0) rst = 1; if (clk_count==100) rst = 0; if (clk_count==110) begin data = 3; start = 1; end if (clk_count==111) start = 0; if (clk_count > 111 && done) begin if (result !== 8) $display("result == 8 expected but ", result, " found."); $stop; end end
などと書くと、仕様通りのテストベンチになっていることを確認するのが 大変ですし、後からタイミングを変更するとか、かなり考えたくないです。
ちょっと補足†
値の比較†
LANG:verilog if (result !== 8)
の部分を
LANG:verilog if (result != 8)
と書くと、result に不定値 x を取るビットが含まれている場合に動作が異なります。
期待した値が確実に得られていることを確認するには === や !== を使うべきですね。
idle の立ち上がりを待つ記述†
ソフト開発っぽく逐次処理で書いたコードの10行目。
LANG:verilog @(posedge done);
は、clk に非同期に検査してしまっているので、 done 信号にグリッチが乗ると誤検出してしまいます。
clk 同期で検出するために、
LANG:verilog while (!done) @(posedge clk);
とした方が良い場合が多いかもしれません。
逐次処理で時間を待つには #10 ではなく @(posedge clk) を使う。†
clk 周期が #10 であるからと言って、10 clk 待つ処理を
LANG:verilog #100; // repeat(10) @(posedge clk); のつもりで書いてみた
と書いてしまうと、よく分からないエラーに悩まされる時があります。
これは、同じ時刻に起きるイベントがどの順番で処理されるかが分からないためです。
クロックエッジに合わせた処理を行いたいのであれば、# 記法による時間指定ではなく、@ 記法によるクロックエッジ指定で処理を行うべきです。
入力を変化させながら繰り返してテスト†
一部をタスクとして切り出して、 異なる入力を与えて繰り返し呼び出せばいい。
こういうあたり、ステートマシンで書いたら大変だけど、 ソフトウェアと同様に考えると、とても簡単。
LANG:verilog(linenumber) task test; input [DATA_BITS-1:0] data_input; input [RESULT_BITS-1:0] result_expected; begin repeat(10) @(posedge clk); data = data_input; start = 1; @(posedge clk); start = 0; @(posedge done); if (result !== result_expected) $display("result == 8 expected but ", result, " found."); end endtask initial begin rst = 1; repeat(100) @(posedge clk); rst = 0; test(3, 8); test(4, 12); test(5, 132); $stop; end
乱数を使う†
入力データから予想される計算結果を function で簡単に書けるのであれば、 乱数で入力データを与えることで、網羅的なテストを行うことが有効な場合もありますね。
LANG:verilog(linenumber) function [RESULT_BITS-1:0] result_expected; input [DATA_BITS-1:0] data_input; begin ... result_expected = ... end endfunction task test; input [DATA_BITS-1:0] data_input; begin repeat(10) @(posedge clk); data = data_input; start = 1; @(posedge clk); start = 0; @(posedge done); if (result !== result_expected(data_input)) $display("result == 8 expected but ", result, " found."); end endtask initial begin rst = 1; repeat(100) @(posedge clk); rst = 0; // 0 〜 2**DATA_BITS-1 の乱数を与えてテストする repeat (1000) test($random & (2**DATA_BITS-1)); $stop; end
ただし、$random の定義は普通のプログラミング言語の random とは異なるので注意が必要です。
100 以下の乱数を作るつもりで $random(100) と書くと、常に定数 が返ってきて悩むことになります。
$random への引数として 32 ビットの reg 変数を与えると、$random はその変数を一次記憶領域として 乱数を計算します。これは C/C++ の srand と同様な機能を持たせるためにある機能なのですが、 仕様をちゃんと読まない私みたいな人が必ず1度ははまる落とし穴になっています。
LANG:verilog reg [31:0] random_temp = 5; initial repeat(100) $display(random_temp, ", ", $random(random_temp), ", ", $random(5));
の結果は、
5, -2147138048, -2147138048 345346, 230383387, -2147138048 2377866395, -547878722, -2147138048 1599604512, 1492801457, -2147138048 3640284321, 1264776086, -2147138048 3412259310, 1450365868, -2147138048 3597848983, -533888320, -2147138048 1613594860, 1424505769, -2147138048 3571988733, 30899459, -2147138048 2178382746, -428945716, -2147138048
となります。
$random に 5 を与えると、必ず -2147138048 が返ってきますが、 その際に $random は random_temp の値を次々と変更するため、 引数が変数であれば、5 を初期値とする乱数が得られることになるわけです。
モックモジュールの task/function を使って疑似オブジェクト指向†
被テストモジュールの fifo 入力にデータを流し込む時、 fifo_data_provider とかいうモックモジュールをインスタンス化して、 被テストモジュールに繋いで使ったりしますよね。
LANG:verilog(linenumber) module_being_tested uut ( ... clk, // fifo とのデータやりとり用クロック data, // fifo からのデータ read, // fifo からの1データ読み出し empty, // fifo からの empty フラグ ... ); fifo_data_provider data_provider ( clk, data, read, empty ); initial begin repeat (1000) begin // ランダムな長さの、ランダムなデータで data_provider を初期化する data_provider.setup_random($random & (2^8-1)); ... // 実際のテスト処理 ... end end
で、現実的には fifo から与えるデータや、その他のパラメータを色々変えながらテストを繰り返して、 全ての場合に正しい結果が得られるかを検証したいわけですが、その度に data_provider の中身をどうやって再設定するか、が課題になります。
こういうときにお勧めなのは、data_provider の task としてデータの初期化や再設定の手順を記述しておき、 テストベンチ本体からそれらを呼び出す形です。
上記コードでは 20 行目がその呼び出しを行っている部分です。
LANG:verilog(linenumber) /// 被テストモジュールの fifo 入力にデータを流し込むためのモックモジュール module fifo_data_provider #( parameter WIDTH = 8, parameter DEPTH_BITS = 10 ) ( input wire clk, input wire read, output wire [WIDTH-1:0] data, output wire last, output wire empty ); /// ロジックの記述 parameter DEPTH = 2**DEPTH_BITS; reg [WIDTH-1:0] memory[0:DEPTH-1]; reg [DEPTH_BITS-1:0] memory_p = 0; reg [DEPTH_BITS-1:0] memory_size = 1; assign data = memory[memory_p]; assign last = memory_p == memory_size - 1; assign empty = memory_p > memory_size - 1; always @(posedge clk) if (read) memory_p = memory_p + 1; /// 以下は外部からアクセスするための public メンバー task initialize; // 始めはゼロで初期化する setup_zero(DEPTH); endtask initial initialize; // ゼロで初期化する task setup_zero; input [DEPTH_BITS-1:0] size; reg [DEPTH_BITS-1:0] p; begin for(p = 0; p < size; p = p + 1) memory[p] = 0; memory_p = 0; memory_size = size; end endtask // /// 乱数データで初期化する // task setup_random; input [DEPTH_BITS-1:0] size; reg [DEPTH_BITS-1:0] p; begin for(p = 0; p < size; p = p + 1) memory[p] = $random; memory_p = 0; memory_size = size; end endtask // 連続する数値で初期化する task setup_sequential; input [DEPTH_BITS-1:0] size; reg [DEPTH_BITS-1:0] p; begin for(p = 0; p < size; p = p + 1) memory[p] = p; memory_p = 0; memory_size = size; end endtask // 特定のデータを設定する task set_data; input [DEPTH_BITS-1:0] addr; input [WIDTH-1:0] data; begin memory[addr] = data; end endtask // データを読み出す (入出力を比べたいときなどのため) function get_data; input [DEPTH_BITS-1:0] addr; get_data = memory[addr]; endfunction // データサイズを読み出す function get_size; input dummy; get_size = memory_size; endfunction endmodule
このように初期化手順その他を data_provider 側に持たせることで、 モジュール外部に対して内部の実装を隠蔽できます。
本当なら task や function、その他の reg に private とか public とかのアクセス限定子を付けたいところですが、 古い verilog にはそういう機能はないようなので、 そこは規約で何とかするしかないですね。
引数の必要ない function について†
fifo_data_provider の function get_size は、 同モジュールの memory_size を返すだけなので、 本来なら呼び出す際に引数は必要ないのです。
しかし、verilog の言語仕様として function は最低1つの引数を持っていなければならないようで、 上記のダミー入力 dummy を書いてやらないと、
ERROR:HDLCompiler:182 A function must have at least one input
というコンパイルエラーが出てしまいます。
ただ、実際にそのような function を呼び出す際には、 dummy に具体的な入力を与えなくても問題ないようでした。
LANG:verilog // dummy に値を渡さなくても大丈夫 if (output_size !== data_provider.get_size()) $display("Error!");
それどころか、引数なしを意味する () を付けなくてもコンパイルは通ってしまうのですが、
LANG:verilog // get_size の () を省略した // → コンパイルは通るが実行時にエラーになる if (output_size !== data_provider.get_size) $display("Error!");
こちらは「コンパイル時ではなく実行時に」
ERROR: Can not find hierarchical name data_provider.get_size.
というエラーが発生してしまいました(ISim 13.1)。
引数なしの task を呼び出す際には () を付けなくても問題ないので、 このあたり、どういう設計思想なのか謎な感じです。
LANG:verilog // 引数なしの task の () は省略できる initial data_provier.initialize;
疑似と言ってる理由†
上記のようにするとそこそこオブジェクト指向っぽい書き方ができるわけです。
オブジェクト指向の目的のうち、
- コードをデータに帰属させる
- 実装の詳細を隠蔽する
はこなせていますので、まずまずなのですが・・・
- 継承を用いたコードの再利用
ができないところがちょっと不満です。
例えば、上記 fifo_data_provider と似たようなモジュールで、 udp_packet_provider というのを作って、その task として setup_arp_packet とか setup_dhcp_packet とかを実装したい場合、 古い verilog で書く場合には、
LANG:verilog module udp_packet_provider #( parameter WIDTH = 8, parameter DEPTH_BITS = 10 ) ( input wire clk, input wire read, output wire [WIDTH-1:0] data, output wire last, output wire empty ); /// ロジックの記述 fifo_data_provider #( .WIDTH(WIDTH), .DEPTH_BITS(DEPTH_BITS), ) data_provider ( clk, read, data, last, empty ); parameter DEPTH = 2**DEPTH_BITS; /// 以下は外部からアクセスするための public メンバー task initialize; data_provider.initialize; endtask initial initialize; // ARP パケットデータを準備する task setup_arp_packet; input [47:0] src_mac_addr; ... endtask /// DHCP パケットデータを準備する task setup_dhcp_packet; ... endtask // 特定のデータを設定する task set_data; input [DEPTH_BITS-1:0] addr; input [WIDTH-1:0] data; data_provider.set_data(addr, data); endtask // データを読み出す (入出力を比べたいときなどのため) function get_data; input [DEPTH_BITS-1:0] addr; get_data = data_provider.get_data(addr); endfunction // データサイズを読み出す function get_size; input dummy; get_size = data_provider.get_size(); endfunction endmodule
のようにしなければなりません。
こういう実装方法を、オブジェクト指向プログラミングの世界では「委譲」と言います。
自身の task が呼ばれたときに、そのまま子クラスの task を呼び出しているから委譲、ですね。
委譲を行う場合、実装しなければならない task / function がたくさんあると、 子クラスの task / function を呼び出すだけの無意味な実装コードがたくさん必要になってしまいます。
こういうときに「継承」が使えれば、必要な task / function のみを追加したり、 置き換えたりできて便利なのですが・・・ System Verilog ではそんな風になっていたりするんでしょうか?
ということで、「オブジェクト指向」のすべてを享受できるわけではないですが、 普通の Verilog でも、ちょっと気を付けてコードを書くと、メンテナンスしやすくて、 なおかつ再利用のしやすいコードを(テストベンチ向けに)書くことができるんじゃないでしょうか?