ISim による Verilog テストベンチ のバックアップ(No.11)
更新- バックアップ一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- 電気回路/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_FAST/2) else #(CLK_PERIOD_SLOW/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
などと書くと、仕様通りのテストベンチになっていることを確認するのが 大変ですし、後からタイミングを変更するとか、かなり考えたくないです。
ちょっと補足 †
テストベンチでノンブロッキング代入を使う理由 †
上記では、モジュールに与える信号線への代入はすべて「ノンブロッキング代入」で行っています。
- ブロッキング代入 は a = b と書く代入
- ノンブロッキング代入 は a <= b と書く代入
これらの違いは、ブロッキング代入がシミュレータがその行を評価した時点で実行されるのに対して、 ノンブロッキング代入はシミュレータがその行を評価した後、次に時刻を進める直前に (ただし $strobe を評価するのはノンブロッキング代入が行われたさらに後)実行されることです。
なぜノンブロッキング代入を使うべきなのかというと、、、
次のコードは非常に単純な物で、 dat 信号に対して dat2 信号が1クロック分遅れる動作をします。
LANG:verilog module clock_edge_test; reg clk; reg dat; reg dat2; always #5 clk = !clk; always @(posedge clk) dat2 <= dat; initial begin clk = 0; dat = 0; #100; @(posedge clk); dat = 1; @(posedge clk); dat = 0; end endmodule
ところが、clk 信号線を clk2 に渡して、
LANG:verilog wire clk2 = clk; always @(posedge clk2) dat2 <= dat;
とする、すなわち
LANG:verilog module clock_edge_test2; reg clk; reg dat; reg dat2; always #5 clk = !clk; wire clk2 = clk; always @(posedge clk2) dat2 <= dat; initial begin clk = 0; dat = 0; #100; @(posedge clk); dat = 1; @(posedge clk); dat = 0; end endmodule
とすると、今度は dat2 が dat1 と同時に変化してしまいます。
もし上記の回路をインプリメントして実機で評価したならばこのような動作にはなりませんから、 この例では正しく Behavioral シミュレーションが行えていないことになります。
テストベンチをノンブロッキング代入で書き直せば、
LANG:verilog module clock_edge_test2; reg clk; reg dat; reg dat2; always #5 clk <= !clk; wire clk2 = clk; always @(posedge clk2) dat2 <= dat; initial begin clk <= 0; dat <= 0; #100; @(posedge clk); dat <= 1; @(posedge clk); dat <= 0; end endmodule
このコードは思った通り上記前者のコードと同様の動作します。
なぜこんな事が起きるかというと、
ブロッキング代入を使ったコードをシミュレータが評価する動作は、
- ***
- 105 ns
- posedge clk
- ブロッキング代入(dat = 1)の実行
- posedge clk2
- dat2 <= dat の右辺の評価
- ノンブロッキング代入の実行
- posedge clk
- 110 ns
- ***
となって、同じ 105 ns におきるイベントの評価順が結果に影響してしまう形になっています。
ここで特に重要となるのは、
dat = 1 と
dat2 <= dat の右辺の評価と
どちらが先に起きるかという点ですね。
これをノンブロッキング代入で書けば、
- ***
- 105 ns
- posedge clk
- dat <= 1 の右辺の評価
- posedge clk2
- dat2 <= dat の右辺の評価
- ノンブロッキング代入の実行(dat2とdatが変化する)
- posedge clk
- 110 ns
- ***
として、実機と同じ動作が保証されます。
結論として、
「テストされるモジュールに渡す信号線への代入はノンブロッキング代入とすべし」
は無条件に覚えておいて良い格言なのではないかと思います。
ではブロッキング代入はどう言うときに使うのかと言えば、
- for 文のカウンタへの代入
- その他 途中計算に使う変数への代入
などは、時刻が進む前に値が変わらないと行けない場合がほとんどですので、 ブロッキング代入を使うことになります。
同一シミュレーション時刻内に終えなければならない複雑な計算はブロッキング代入を使い、 その結果を信号線に載せて被テスト対象のモジュールに渡す際には ノンブロッキング代入を使う、というのが正しい使い方になりますね。
$display と $strobe †
上記のノンブロッキング代入の話題と関連するのでここに書いておきます。
$display と $strobe との違いは以下の例を見ると一目瞭然です。
LANG:verilog reg clk = 0; always #5 begin clk <= !clk; $display("%d -------",$time); $strobe("$strobe : %d", clk); $display("$display: %d", clk); end
結果は、
LANG:console ------------- 5 $display: 0 $strobe : 1 ------------- 10 $display: 1 $strobe : 0 ------------- 15 $display: 0 $strobe : 1 ------------- 20 $display: 1 $strobe : 0
となります。
これは $display がブロッキング代入 "=" と同様に評価時点で値を出力するのに対して、
$strobe はノンブロッキング代入 "<=" の直後に値を出力するという違いに起因しています。
すなわち、上の例であれば
- #5 イベントの起動
- clk <= !clk; の「右辺を評価」 (代入はまだ行われない)
- $display("%d -------",$time); を実行
- $strobe("$strobe : %d", clk); を「予約」
- $display("$display: %d", clk); を実行 (この時点では clk は更新されていない)
- 通常実行分の処理を終了
- この時刻で行われたノンブロッキング代入の実行 (clk が更新される)
- この時刻で予約された $strobe の実行
の順で処理されます。
そもそも2つの $display に挟まれた $strobe が2つ目の $display の後で値を表示していることの理由がこれで理解できたと思います。
クロックエッジで変化する信号線の値を表示する際には、 $display を使うべきか、$strobe を使うべきかをよく考える必要がありますね。
恐らく多くの場合、信号線への代入をノンブロッキングで行い、 値の表示は $display を使って「変化する前の値」を表示するのが 実機に近く、間違いの生じにくい使い方ではないかと思います。
値の比較 †
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);
とした方が良い場合が多いかもしれません。
(シミュレータへの負荷は多少上がりそうですが、
たいていの場合は気にするほどではないでしょうね)
シミュレータへの負荷が小さいのはこんな感じかな?
LANG:verilog begin: loop forever begin @(posedge done); // done が上がるまで待つ @(posedge clk); // clk のエッジまで待って if(done) disable loop; // まだ done == 1 ならループを抜ける end end
でもこれ、記述するのが凄く面倒だし、 効果もそれほど無いだろうから、ほぼすべての場合に上の案で十分だと思う。
入力を変化させながら繰り返してテスト †
一部をタスクとして切り出して、 異なる入力を与えて繰り返し呼び出せばいい。
こういうあたり、ステートマシンで書いたら大変だけど、 ソフトウェアと同様に考えると、とても簡単。
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 == %d expected but %d found.", result_expected, result); 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 == %d expected but %d found.", result_expected, result); 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 を初期値とする乱数が得られることになるわけです。
$random の返す乱数系列は処理系に非依存で一定していますので、 同じテストベンチを繰り返し実行すれば、常に同じ結果が得られます。 信頼の置けるテストを刷るためにはこのこともとても重要ですね。
モックモジュールの 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 reg [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 last = memory_p == memory_size - 1; assign empty = memory_p > memory_size - 1; always @(posedge clk) begin if (read) memory_p <= memory_p + 1; data <= memory[read ? memory_p : memory_p + 1 ]; end /// 以下は外部からアクセスするための 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 にはそういう機能はないようなので、 そこは各自のコーディング規約で何とかするしかないですね。
疑似と言ってる理由 †
上記のようにするとそこそこオブジェクト指向っぽい書き方ができるわけです。
すなわち、オブジェクト指向の目的のうち、
- コードをデータに帰属させる
- 実装の詳細を隠蔽する
をこなせていますので、まずまずなのですが・・・
- 継承を用いたコードの再利用
ができないところがちょっと不満です。
例えば、上記 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 でも、ちょっと気を付けてコードを書くと、メンテナンスしやすくて、 なおかつ再利用のしやすいコードを(テストベンチ向けに) 書くことができるんじゃないでしょうか?という話でした。
オブジェクト同士の参照が wiring しか無いのも痛いですね †
上への追記です。
Verilog モジュールをオブジェクトと見なして通常のソフトウェア開発と比べると、 オブジェクト同士の相互参照が wiring しか無いのが苦しく感じられました。
ソフトウェアのオブジェクトであれば、自分の保有する(メンバーとなる) オブジェクト以外にも、他の様々な関連するオブジェクトへの参照を通じて メンバー関数を呼び出したり、関数の引数に渡したりができ、 そのような実装によって個々のクラスを小さく保ちつつ、 クラス間の依存関係を減少させることができます。
Verilog では自身が保有する(メンバーとして持つ)モジュールインスタンス、 あるいはグローバルなモジュールインスタンス以外を参照することができないために、 テストベンチを小さなモジュールに切り分けづらく、また、 無理に切り分けてもモジュール同士の依存関係が強すぎて、 容易に変更や再利用ができないコードになってしまいがちです。
論理合成可能なコードと同様に wire で繋げば、 相手のことを知らずに情報のやりとりができる訳なのですが、 そうするには本来論理合成が必要ない信号線について、 それ用のプロトコルを決めて、それを実装しなければならなくなります。
イベントなどをうまく利用して書く方法がないか模索中なのですが、 今のところあまり良い案がない状況です。
引数の必要ない 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;
よく分からないコンパイルエラー †
ISE Project Navigator (P.15xf) から Behavioral シミュレーションを実行したら、
LANG:console ERROR:Simulator:793 - Unable to elaborate instantiated module <モジュール名>
というエラーが出て ISim が立ち上がりませんでした。
コンパイルログをよくよく見たら、
LANG:console WARNING:HDLCompiler:604 - "ファイル名" Line <行数>: Module instantiation should have an instance name
というのが含まれていて、
LANG:verilog some_module instance_name ( .inner_wire1(wire1), .inner_wire2(wire2), ...
としなければならないところ、
LANG:verilog some_module ( .inner_wire1(wire1), .inner_wire2(wire2), ...
のように instance name を書き忘れている場所があったとのこと。
これ、Warning ではなく Error として報告すべきだと思うのだけれど???
少なくとも1年半くらい前にも同様の問題で悩んだ人がいたみたいですね。