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

更新


公開メモ

概要

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

クロック

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

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

逐次処理

例えば、

  1. rst = 1 で 100 clk
  2. rst = 0 にして 10 clk
  3. data = 3, start = 1 で 1 clk
  4. start = 0 で done == 1 を待つ
  5. 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 でも、ちょっと気を付けてコードを書くと、メンテナンスしやすくて、 なおかつ再利用のしやすいコードを(テストベンチ向けに)書くことができるんじゃないでしょうか?

コメント





Counter: 129409 (from 2010/06/03), today: 12, yesterday: 0