ISim による Verilog テストベンチ のバックアップソース(No.5)

更新

[[公開メモ]]

#contents

* 概要 [#lbd9dc09]

ビヘイビャーレベルのテストベンチ記述の際に自分で使っている典型的なイディオムを紹介してみます。

* クロック [#iac4f988]

周期を直に書いてしまうなら:

 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

* 逐次処理 [#e05dfa7d]

例えば、

+ 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

などと書くと、仕様通りのテストベンチになっていることを確認するのが
大変ですし、後からタイミングを変更するとか、かなり考えたくないです。

** ちょっと補足 [#p190064a]

*** 値の比較 [#ieea7e31]

 LANG:verilog
 if (result !== 8)

の部分を

 LANG:verilog
 if (result != 8)

と書くと、result に不定値 x を取るビットが含まれている場合に動作が異なります。

期待した値が確実に得られていることを確認するには === や !== を使うべきですね。

*** idle の立ち上がりを待つ記述 [#taf8433b]

ソフト開発っぽく逐次処理で書いたコードの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

でもこれ、記述するのが凄く面倒だし、
効果もそれほど無いだろうから、ほぼすべての場合に上の案で十分だと思う。

*** 逐次処理で時間を待つには #10 ではなく @(posedge clk) を使う。 [#bbfbfe2c]

clk 周期が #10 であるからと言って、10 clk 待つ処理を

 LANG:verilog
 #100; // repeat(10) @(posedge clk); のつもりで書いてみた

と書いてしまうと、よく分からないエラーに悩まされる時があります。

これは、同じ時刻に起きるイベントがどの順番で処理されるかが分からないためです。

クロックエッジに合わせた処理を行いたいのであれば、# 記法による時間指定ではなく、@ 
記法によるクロックエッジ指定で処理を行うべきです。

*** 入力を変化させながら繰り返してテスト [#pc32bea5]

一部をタスクとして切り出して、
異なる入力を与えて繰り返し呼び出せばいい。

こういうあたり、ステートマシンで書いたら大変だけど、
ソフトウェアと同様に考えると、とても簡単。

 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

*** 乱数を使う [#ncd78893]

入力データから予想される計算結果を 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 を使って疑似オブジェクト指向 [#b7c5bb17]

被テストモジュールの 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 にはそういう機能はないようなので、
そこは規約で何とかするしかないですね。

** 疑似と言ってる理由 [#te90d61e]

上記のようにするとそこそこオブジェクト指向っぽい書き方ができるわけです。

すなわち、オブジェクト指向の目的のうち、

- コードをデータに帰属させる
- 実装の詳細を隠蔽する

をこなせていますので、まずまずなのですが・・・

- 継承を用いたコードの再利用

ができないところがちょっと不満です。

例えば、上記 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 しか無いのも痛いですね [#t811032b]

上への追記です。

Verilog モジュールをオブジェクトと見なして通常のソフトウェア開発と比べると、
オブジェクト同士の相互参照が wiring しか無いのが苦しく感じられました。

ソフトウェアのオブジェクトであれば、自分の保有する(メンバーとなる)
オブジェクト以外にも、他の様々な関連するオブジェクトへの参照を通じて
メンバー関数を呼び出したり、関数の引数に渡したりができ、
そのような実装によって個々のクラスを小さく保ちつつ、
クラス間の依存関係を減少させることができます。

Verilog では自身が保有する(メンバーとして持つ)モジュールインスタンス、
あるいはグローバルなモジュールインスタンス以外を参照することができないために、
テストベンチを小さなモジュールに切り分けづらく、また、
無理に切り分けてもモジュール同士の依存関係が強すぎて、
容易に変更や再利用ができないコードになってしまいがちです。

論理合成可能なコードと同様に wire で繋げば、
相手のことを知らずに情報のやりとりができる訳なのですが、
そうするには本来論理合成が必要ない信号線について、
それ用のプロトコルを決めて、それを実装しなければならなくなります。

イベントなどをうまく利用して書く方法がないか模索中なのですが、
今のところあまり良い案がない状況です。

** 引数の必要ない function について [#q686c801]

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;

* コメント [#y83cce6f]

#article_kcaptcha
**mzFwQuIJDX [#m7ec0f91]
>[Ellie] (2011-10-21 (金) 15:28:17)~
~
I'm not quite sure how to say this; you made it etxremely easy for me!~

//

#comment_kcaptcha

Counter: 128989 (from 2010/06/03), today: 1, yesterday: 0