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

更新


公開メモ

Verilator を使って SystemC でテストベンチを書く

SystemC はごく最近勉強し始めたので、分からないことだらけです。

忘れそうなことをここにメモろうと思います。

まだこれからです。

Verilator を使ったテストの流れ

Verilator は Verilog のモジュール(ただし合成可能なものに限る)を SystemC 互換の C++ コードに変換するためのツールです。

同じ verilog モジュールでも、テストベンチのように論理合成ができないものは 変換できないため、Verilator を使った検証では、テストベンチは SystemC で書かれるのが一般的なようです。

SystemC は、C++ を使って論理回路を表現するためのライブラリです。 フリーウェアとして開発されています。

SystemC で書かれたコードを通常の C++ コンパイラでコンパイルすることで、 実行可能なファイルを作成することができ、それ実行することで論理回路の 動作をシミュレートできます。

市販の検証ツールと違って検証可能な回路規模に制限などはありませんので、 メモリサイズの許す限り、大規模な回路の検証も高速に行うことができるそうです。

また、テストベンチのコード中では C++ の機能をすべて使うことができますので、 ソフトウェアとの整合性を検証したり、検証結果をレポートする方法を工夫したり、 非常に自由度が高いという利点もあります。

Verilator を使ったテストは、

  1. verilog モジュールを Verilator で C++ コードに変換する
  2. テストベンチコードおよび SystemC ライブラリと一緒にコンパイルする
  3. コンパイル結果を実行し、検証結果を得る

という流れで行います。

SystemC でのテストベンチ

テストベンチを書くには、最低限以下の操作が必要になります。

  • テスト対象モジュールをインスタンス化する
  • 入出力線を宣言する
  • 入出力線をモジュールに結合する
  • 入力線に値を設定する
  • 時刻を進める
  • 出力を読み取る

電気回路/HDL/SystemC の導入 で紹介した、 次のテストベンチコードを例に使って、 それぞれのやり方を書いておきます。

LANG:cpp
#include "Vbin2bcd.h"
int sc_main(int argc, char **argv) {

    Verilated::commandArgs(argc, argv);

    // モジュールのインスタンス化 
    Vbin2bcd top("top");

    // 信号ネットの定義
    sc_clock            clk ("clk", 10, SC_NS);
    sc_signal<bool>     rst, start, busy;
    sc_signal<uint32_t> bin;
    sc_signal<uint32_t> bcd;

    // モジュールに結線
    top.clk(clk);
    top.rst(rst);
    top.start(start);
    top.busy(busy);
    top.bin(bin);
    top.bcd(bcd);

    // 検証開始
    rst   = 1;
    start = 0;

    sc_start(100, SC_NS);
    rst = 0;
    sc_start(100, SC_NS);

    for(int i=0; i<1024; i++) {
        
        bin = i;

        start = 1;
        sc_start(10, SC_NS);
        start = 0;
        
        sc_start(10, SC_NS);
        while(busy)
            sc_start(10, SC_NS);

        std::ostringstream os_bin;
        std::ostringstream os_dec;
        
        os_bin << dec << i;     // 元の数値を10進数で表したもの
        os_dec << hex << bcd;   // 変換結果を16進数で表したもの
        sc_assert(os_bin.str()==os_dec.str()); // 等しいことを検証
        
        sc_start(40, SC_NS);
    }
    
    cout << "done";

    exit(0);
}

テスト対象モジュールをインスタンス化する

SystemC において、モジュールは C++ のクラスに対応しており、 そのクラスのインスタンスを作成することが、 モジュールをインスタンス化することに相当します。

(その後調べたところ、実際にはクラスではなく構造体でしたが、 大勢は変わらないので、そのままにしておきます)

Verilator を使った場合、verilog のモジュール名の先頭に大文字の V がついた名前のクラスができます。上記の例では、verilog で bin2bcd という名前のモジュールが、SystemC では Vbin2bcd という名前になっています。

クラス定義が Vbin2bcd.h というヘッダファイルに宣言されるので、 テストベンチファイルの先頭部分でこれを #include しておきます。

LANG:cpp
#include "Vbin2bcd.h"

インスタンス化する際には、 コンストラクタにインスタンス名を文字列で与えるようになっています。

C++ でクラスのインスタンスを作るには、

  1. 自動変数として作成する
  2. new で作成してポインタ変数で保持する

の2つの方法がありますが、SystemC でもどちらを使っても構いません。

自動変数として作成する

上でやっているように、

LANG:cpp
Vbin2bcd top("top");

とすれば、そのスコープ内のみで有効な自動変数としてインスタンスが作成されます。 このインスタンスはスコープを抜けると自動的に破棄されます。

コンストラクタに与えている "top" がインスタンス名になります。

この場合、インスタンスのメンバーへのアクセスは top.foo のように行います。

new で作成してポインタ変数に代入する

ヒープ領域にインスタンスを確保するには new を使います。 new した結果はポインタが返るので、ポインタが他の変数に値を保持します。

LANG:cpp
Vbin2bcd *top = new Vbin2bcd("top");

ポインタ変数がスコープを抜けても、 new で作成したインスタンスは自動的には削除されないので、 必要なくなった時点で delete を使って明示的に破棄する必要があります。

LANG:cpp
delete top;

コンストラクタに与えている "top" がインスタンス名になります。

この場合、インスタンスのメンバーへのアクセスは top->foo のように行います。

どちらがいい?

上の例では自動変数としたほうが楽チンと思ってそうしてますが、 SystemC の世界でどちらが一般的かはまだこれから勉強するところです。

もしかするとモジュールインスタンスのサイズが大きくなる場合に備えて、 テストベンチではヒープに確保するほうが良いのかも知れません。

std::auto_ptr なんかをうまく使うことになるんですかね。

入出力ネットを定義する

信号線ネット、すなわち verilog でいうところの wire に相当するのは、 sc_signal というクラスのインスタンスです。

sc_signal はテンプレートクラスとして定義されていて、 後ろに < > で括ってビット数を指定します。

原理的には sc_signal も new で作成してポインタ変数で扱ってもよいはずですが、 面倒なので自動変数として定義されることが多いのだと思います。

LANG:cpp
sc_signal<bool>        sig1;    //  1 bit
sc_signal<sc_uint<8> > sig2;    //  8 bit unsigned
sc_signal<sc_int<8> >  sig3;    //  8 bit signed
sc_signal<uint32_t>    sig4;    // 32 bit unsigned
sc_signal<int32_t>     sig5;    // 32 bit signed
sc_signal<uint64_t>    sig6;    // 64 bit unsigned
sc_signal<int64_t>     sig7;    // 64 bit signed

sc_signal<sc_uint<8> > を sc_signal<sc_uint<8>> と書いてしまうと文法エラーになることに注意してください。

> と > との間には必ずスペースを入れるようにします。 これは、C++ では >> が独立の演算子として定義されているためです。

また、verilog で定義した複数ビットの信号線は、 verilator では必ずしも同じ幅の信号線として表現されず、 32ビットあるいは64ビットに切り上げられてしまうようです。

bin2bcd で宣言した 10 ビットの入力線 bin に sc_signal<uint32_t> として宣言した信号線を結合しているのはこのためです。

これはシミュレーション時の速度を上げるための配慮だそうですが、 オーバーフロー時の動作などに違いが出る可能性もあるため、 注意が必要です。

追記:

bool や sc_uint、uint32_t などは、0, 1 のみの2進数しか表せないのに対して、

VHDL や Verilog のビットは X や Z など、0, 1 以外の状態も取り得ます。

そこで、Verilog のビットのように 0,1,X,Z を表現できる4値ビットを表すために、

LANG:cpp
sc_signal<sc_logic>  sig_1bit;    // 1 bit
sc_signal<sc_lv<4> > sig_4bits;   // 4 bits

のように、sc_logic や sc_lv のような型も定義されているそうです。

Verilator ではこのような多値ビットを使わない形で、 なおかつ上で見たとおりビット数すらワード単位にまとめた形で、 信号線が定義されます。これはシミュレーションの速度を上げるためのようです。

追記2:

verilog では

LANG:verilog
wire [7:4] sig1;

のように、wire 定義において 0 から始まらないビット指定が可能ですが、 SystemC ではそのような機能はないようで、 最下位ビットのインデックスは常に 0 になってしまいます。

クロック線について

クロック線を簡単に定義するため、sc_clock というクラスが用意されています。

このインスタンスは、sc_signal<bool> と同じ1ビットの信号線ですが、 コンストラクタで指定された周期で自動的にクロックを刻みます。

LANG:cpp
sc_clock clk("clk", 10, SC_NS);

コンストラクタへの引数は、

  • "clk" : クロックソースのインスタンス名
  • 10 : クロック周期
  • SC_NS : クロック周期の単位 (SC_NS は ns, SC_US は us を意味します)

となっています。

このほかにもオプションの引数を渡すことでデューティー比などを変更できるそうです。

入出力線をモジュールに結合する

モジュールへの結合は、 モジュールのメンバとして定義されている入出力ピンに信号線ネットを渡すことで行われます。

LANG:cpp
    // モジュールに結線
    top.clk(clk);
    top.rst(rst);
    top.start(start);
    top.busy(busy);
    top.bin(bin);
    top.bcd(bcd);

top.clk などの .clk の部分がモジュールピンの名前、カッコ内が信号ネットのインスタンスです。

top をポインタ変数で保持している場合には、top.clk(clk) の代わりに top->clk(clk) となります。

入力線に値を設定する

単純に sc_sig のインスタンスに数値を代入します。

LANG:cpp
    rst   = 1;
    start = 0;

代入する値を計算するためには C++ で利用可能な任意の関数や演算子を使えますので、 verilog などでテストベンチを書く際に苦労するちょっとした計算などを手短に書くことができます。

時刻を進める

他にも色々あるのですが、もっとも手軽な方法として、 verilog の #123 のように一定時間だけ時刻を進める方法として、 sc_start に数値を指定するという方法があります。

LANG:cpp
   sc_start(10.0, SC_NS);

SC_NS は与える数値に付ける単位で、SC_NS ならば ns を表します。

したがって、この例では 10ns 時刻を進めるという意味になります。

数値部分は必ずしも整数でなくても、任意の浮動小数点数を指定できるようです。

出力を確認する

sc_sig は暗黙的に整数値に変換されるようなので、 任意の整数と比較したり、整数値を要求する関数の引数として与えることが可能です。

注意点

ちょっとだけ注意が必要なのは、 assign で定数が割り当てられた wire であっても、 その値は初めて sc_start が呼ばれるまで決定されないことです。

LANG:verilog
module test ( output wire [15:0] output_value );
    assign output_value = 1234;
endmodule // test

に対して、テストベンチを

LANG:cpp
#include "Vtest.h"

int sc_main(int argc, char **argv)
{
  Verilated::commandArgs(argc, argv);

  Vtest test("top");

  sc_signal<uint32_t> output_value;

  test.output_value(output_value);

  cout << "sc_start 前 : " << output_value << endl;
  sc_start(0, SC_NS);
  cout << "sc_start 後 : " << output_value << endl;

  exit(0);
}

とすると、結果は

LANG:console
             SystemC 2.2.0 --- Oct 29 2010 15:47:29
        Copyright (c) 1996-2006 by all Contributors
                    ALL RIGHTS RESERVED
sc_start 前 : 0
sc_start 後 : 1234

となります。

追記 : sc_initialize() を呼べばよい

上記の件、シミュレーションの開始前に sc_initialize() 呼べば初期化されるようでした。

LANG:cpp
  cout << "sc_initialize 前 : " << output_value << endl;
  sc_initialize(); 
  cout << "sc_initialize 後 : " << output_value << endl;
  sc_start(0, SC_NS);
  cout << "sc_start 後 : " << output_value << endl;

とすれば、結果は

LANG:console
             SystemC 2.2.0 --- Oct 29 2010 15:47:29
        Copyright (c) 1996-2006 by all Contributors
                    ALL RIGHTS RESERVED
sc_initialize 前 : 0
sc_initialize 後 : 1234
sc_start 後 : 1234

となります。

このあたりは SystemC の dont_initialize() 関数とも関係してそうなので、 たぶん後でまた勉強することになりそうです。

基本は以上で OK ?

と、ここまで分かっていれば、上記のような簡単なテストベンチを書くことができるようになります。

が、より複雑なテストをするには SystemC のモジュールを書いたり、 イベントを使ったりしたくなるので、以下ではそのあたりを勉強しようと思います。

情報源

ここは非常によくまとまっていて、短時間で勉強できそうです。

以下、これをなぞる形で勉強してみます。

always @(posedge clk) を SystemC で記述する

シンプルなフリップフロップ回路を verilog で書いてみます。

dff.v

LANG:verilog
module dff (
    input  wire clk,
    input  wire din,
    output reg  dout
);
    always @(posedge clk)
        dout <= din;
endmodule

これと同じ回路を SystemC で書くと、次のようになります。

dff.h

LANG:cpp
#ifndef __DFF_H
#define __DFF_H

#include "systemc.h"

SC_MODULE(dff) {
    sc_in <bool> clk;
    sc_in <bool> din;
    sc_out<bool> dout;
 
    // sensitive << clk.pos()
    void assign();
 
    SC_CTOR(dff) {
        SC_METHOD(assign);
          sensitive << clk.pos();
    }
};

#endif // __DFF_H

dff.cpp

LANG:cpp
#include "dff.h"

// sensitive << clk.pos()
void dff::assign() {
   dout.write( din.read() );
}

ヘッダファイルの慣用句

#ifndef / #define / #endif は dff.h を多重インクルードしても 問題が起きないようにする慣用句です。

モジュールの宣言

モジュールを表すクラスを宣言するには、SC_MODULE マクロを用います。

正確にはクラスではなく構造体 (struct) として宣言されるので、 デフォルトのアクセス指定子は public です。したがって、 わざわざ private を指定しない限り、宣言されたすべてのメンバーは 外からアクセス可能になります。

入出力ピンの定義

入出力ピンの定義には、上で見た sc_signal の代わりに sc_in / sc_out / sc_inout を使います。

コンストラクタ

モジュールのコンストラクタは SC_CTOR マクロを使って定義します。 コンストラクタでは下位モジュールなど、モジュールクラスのメンバーを初期化するとともに (ここでは初期化の必要なメンバーはありません)、 verilog における always 等を実現するためにイベントリストの構築を行います。

always を記述するには、

  1. 中身をメンバ関数として定義し (上では assign() 関数)、
  2. モジュールのコンストラクタにおいて
  3. センシティビティリストと共に SC_METHOD マクロを使ってイベントリストに登録します

sensitive

SC_METHOD の次にある sensitive がセンシティビティリストの定義です。

動作としては、sensitive に追加したイベントが起きると、 SC_METHOD で指定したメソッドが呼び出される、という仕掛けです。

上記のように .pos() や .neg() を付けたエッジ検出の他に、

LANG:cpp
sensitive << clk;

のように書けば、両エッジを検出することもできます。 また、

LANG:cpp
sensitive << sig1 << sig2.pos();

のように、複数個の信号を並べることができます。 あるいは、

LANG:cpp
sensitive << sig1;
sensitive << sig2.pos(); 

のように2つの分に分けて書いても同じ意味になります。

sensitive << clk としておいて、assign() の中で
if (clk.posedge()) dout = din; と書いても上記と同じ動作になるようですが、 メソッドが呼び出されてから判別するよりも、 始めから sensitive << clk.pos() とした方がシミュレーションは速くなると思います。

sensitive 指定は直前の SC_METHOD に対して有効なので、 1つのコンストラクタの中で複数の SC_METHOD とセンシティビティリストを 登録することができます。

LANG:cpp
SC_METHOD(method1);
  sensitive << sig1 << sig2.pos();
SC_METHOD(method2);
  sensitive << sig3;
  sensitive << sig4.pos();
SC_METHOD(method3);
  sensitive << sig5;

assign() の定義

dff.cpp では、dff.h で宣言した assign() 関数を定義します。 (C/C++ では「宣言」と「定義」は専門用語として使い分けられていて、 実体を伴わずインターフェースだけを記述する「宣言」と、 実体を記述する「定義」という使い方になります。)

宣言(実体なし):

LANG:cpp
void assign();

定義(実体あり):

LANG:cpp
void dff::assign()
{ ... }

メンバー関数の定義では、その関数がどのクラスのメンバーであるかを指定するため、 関数名の前に :: で区切ってクラス名を記述します。

コメントの重要性

気になるのは、上記のように .h と .cpp ファイルを分けてしまうと、 センシティビティリストとメソッド内容とが視覚的に離れてしまう点です。

あまり気は乗らないのですが、上記のように dff::assign() の定義付近にコメントとしてセンシティビティリストを 書いておくと、後から読みやすいかもしれません。 ( // sensitive << clk.pos() のコメント)

#気が乗らない理由は、こういうコメントと、実際のコンストラクタ中の
#記述とが食い違うと、非常に見つけにくいバグになってしまうためです。

assign() の定義をインラインで書いてしまう

一方、C++ ではメンバ関数の宣言中に定義も書いてしまうこともできて、

dff.h

LANG:cpp
#ifndef __DFF_H
#define __DFF_H

#include "systemc.h"

SC_MODULE(dff) {
    sc_in <bool> clk;
    sc_in <bool> din;
    sc_out<bool> dout;
 
    // sensitive << clk.pos()
    void assign() {
       dout = din;
    }
 
    SC_CTOR(dff) {
        SC_METHOD(assign);
          sensitive << clk.pos();
    }

};

#endif // __DFF_H

これならセンシティビティリストと中身は少なくとも1つのファイルの中で見渡せます。 この場合、dff.cpp の中身は #include "dff.h" のみでOKです。

C++ の文法的には、これだと assign() 関数をインライン関数として宣言することになります。 実際には SC_METHOD で assign の関数ポインタを使うことになるので、 動作が食い違うのですが、g++ では問題なくコンパイルできるようでした。

ただ、この形だと、assign() 内を編集すると、 dff.h をインクルードするすべての .cpp ファイルをコンパイルしなおさなければなりません。 これは大きな負担となるため、 規模の大きなプロジェクトでは、やはりメソッド定義を .cpp に切り出しておくほうがよいのだと思います。

値の代入

LANG:cpp
dout.write( din.read() );

の部分は、

LANG:cpp
dout = din;

と書いても同じ動作になるのですが、read / write を使う書き方が推奨されているそうです。

というのも、verilog と異なり C++ では通常 dout = din の形は変数内容の書き換えを表すもので、 信号線から信号線への値の受け渡しを想起しないためです。

ちょっと面倒なので、

LANG:cpp
dout.write( din );

でもいいように思うのですが、.read() の必要な理由は後で出てくるのかもしれません。

assign で書くには

no_ff.v

LANG:verilog
module no_ff (
    input  wire din,
    output reg  dout
);
    assign dout = din;
endmodule

ならば、

no_ff.h

LANG:cpp
#ifndef __DFF_H
#define __DFF_H
#include "systemc.h"

SC_MODULE(no_ff) {
  sc_in <bool> din;
  sc_out<bool> dout;

  sc_signal<bool> din_copy;

  SC_CTOR(dff)
  {
    din(din_copy);
    dout(din_copy);
  }
};
#endif // __DFF_H

となるのでしょうか?

ちょっと面倒ですね。

LANG:cpp
dout(din);  // エラーになる

ではうまくいきませんでした。

コンパイル方法

上記 SystemC コード dff.c をコンパイルしてオブジェクトファイル dff.o を得るには、

LANG:console
g++ -c -I$SYSTEMC/include dff.cpp

とします。

注意点

最後に、C++ の初心者の方が1回ははまるミスとして、 SC_MODULE(dff) { }; のブロックの後ろにある ; を忘れてしまうというものがあります。

通常 C++ では } の後ろに ; が必要になることはそれほど多くなくて、 例外と言えるのが、この型定義の後の ; になります。

これは、SC_MODULE(dff) { } a_variable_of_dff_type; の形で、 dff 型の変数を定義できるという C++ の文法のせいなのですが、 この間違いは犯しやすいというだけでなく、 コンパイルエラーが出たときにその理由を探しにくいという点でも 犯罪的です。

どうなるかというと、dff.cpp で include された後のコードにおいて、 SC_MODULE(dff) { } void dff::assign() { } の形が現れるため、 エラーは dff.h ではなく dff.cpp の void dff::assign() の行に現れることになります。

LANG:console
$ g++ -c -I$SYSTEMC/include dff.cpp
dff.cpp:4: error: new types may not be defined in a return type
dff.cpp:4: note: (perhaps a semicolon is missing after the definition of ?dff?)
dff.cpp:4: error: two or more data types in declaration of ?assign?

あら、最近の g++ は親切ですね。ちゃんと dff の定義が悪いことを指摘してくれています。

インクルードファイルが複数あるときは、 次のインクルードファイルの先頭でエラーになったりもしますので、 その際は、直前のインクルードファイルの末尾を見直すという ひらめきが重要になります。

気になっている点

verilog モジュールにパラメータを指定する

パラメータを含む verilog モジュールを verilator + SystemC で検証するのは難しそう?

http://www.veripool.org/boards/2/topics/show/276-Verilator-Efficient-Usage-of-Verilog-Parameters

この記事の 2010-04-14 の時点では、

  • parameter を verilator から設定する方法は無い
  • 今後も実装する気はない

との回答でした。

これは結構困ったことで、parameter を含む verilog モジュールをテストするには、 ラッパーとなる verilog モジュールをテストしたいパラメータの数だけ作り、 それらを verilate して SystemC とリンクしなければならないということになるのだと思います。

1つ後で思いついた対応策として、 verilator の +define+ オプションでプリプロセッサー定数を定義することはできるので、

`ifdef や `SOME_PARAMETER でうまくパラメータを変更する手だてを考える余地はありそうですね。

コメント





Counter: 26084 (from 2010/06/03), today: 4, yesterday: 4