実機より先にテストを書く — cocotb と「わざと壊した RTL」でテストを試す
目次
FPGA の案件で、評価ボードが届くより先に設計を始めることがあります。実機が無いあいだ何もしないのはもったいないので、シミュレーションで進められるところまで進めておく。僕はそれを、AI エージェントへの指示として「仕様 → テスト → RTL の順で作る」と決めています。ところが、テストが全部通っているのに、そのテストが確かめていたのは仕様書ではなく、テストを書いた側の理解だった、ということが起きました。「テストベンチは通るのに、そのテストで十分なのか自信が持てない」という人に向けて、テストの効き目を確かめる方法と、そこで踏んだ罠をまとめておきます。
仕様 → テスト → RTL の順で作る#
RTL が無いうちは、テストが落ちる#
順番はこうです。まず仕様書に、モジュールの入出力と振る舞いを書きます。次にテストを書きます。この時点では RTL が無いか、まだ機能が入っていないので、テストは落ちます。最後に RTL を書き、全件が通ったら完成です。流れを図にすると、次のようになります。
テストは2種類の道具で書いています。基本は cocotb です。cocotb は、HDL のシミュレーションを Python から動かすテストの枠組みで、WSL 上のオープンソースのシミュレータ GHDL で動かしています。シミュレーションの対象に Xilinx のモジュールが含まれていて、cocotb ではシミュレーションできないときだけ、Vivado 付属のシミュレータ xsim で動く VHDL のテストベンチを書きます。
cocotb を基本にしているのは、アサーションが書きやすいからです。確認は Python の assert で書けるので、期待値との比較も、条件がそろうのを待ってから確かめる処理も、普通のプログラムと同じ感覚で書けます。落ちたテストは FAIL として一覧に出ます。VHDL のテストベンチでは、後で書くように、assert の severity の選び方ひとつで失敗が見逃されることがあります。
最初の段階は LED とリセット回路、クロック生成だけでしたが、cocotb で13件、xsim で2本のテストが、実機の前に全部通りました。
generic を縮めて回す、その代わりに#
リセットの保持時間や LED の点滅周期は、実機では数十ミリ秒から1秒です。そのままシミュレーションすると時間がかかりすぎるので、generic(VHDL のモジュールに渡すパラメータ)を小さな値にして回しています。
その代わり、既定の値では一度もテストが走っていません。カウンタのビット幅が既定値に足りているかは、どのテストも確かめていないことになります。これはテストの限界として、記録に残してあります。照明の調光でも、PWM の周期やフェードの時間を縮めてシミュレーションすることが多いと思います。そのときも、実時間の設定値でカウンタが足りるかは別に確かめる必要があります。
テストが確かめていたのは、仕様ではなかった#
別のエージェントにレビューさせたら、10件出てきた#
テストが全部通ったあと、テストベンチのレビューだけを担当するエージェントに見てもらいました。このエージェントは RTL を読んでいません。読むのはテストそのものだけなので、RTL の書き方に引きずられずにテストを評価できます。
指摘は10件ありました。たとえば次のようなものです。
- 「出力はフリップフロップ(FF)を通す」と仕様にあるのに、テストは組合せ回路の出力でも通る形になっていた。出力を FF で受けるのは、グリッチを出さず、出力の変化をクロックにそろえるためです
- クロック生成回路の「ロック完了」信号が、最初から 1 でも通る
- 「ロック前」の確認が、ある時刻の1点だけだった
- 動作中にリセットを入れたときの振る舞いを、確かめていなかった
ただし、レビュー役が「推測」と明記した指摘が2件あり、こちらはテストベンチを読んで裏を取ると、2件とも外れていました。レビューの指摘も、そのまま受け入れずに確かめる必要があります。
仕様の空白を、テストが自分で埋めていた#
いちばん大きかったのは、10件の個別の弱さではなく、その背後にあった問題です。仕様書の記述が薄く、テストが確かめている内容の多くは、テスト自身の docstring(テスト関数の説明文)に書かれた仕様でした。つまりテストは、仕様書ではなく、テストを書いた側が自分で置いた仕様を確かめていたのです。
これでは、テストは「書いた側の理解どおりに RTL ができているか」しか確かめられません。そこで、仕様書の不足をまとめた草案を作り、僕が承認してから仕様書に反映し、テストを足しました。ただ正直に書くと、このとき足したテストの一部は、RTL がもうある状態で書いたものです。エージェントへの指示(CLAUDE.md)では、テストを先に書くことになっていました。エージェントがその指示を守らず、先に RTL を作ってしまったのです。ルールを緩めたわけではありません。このモジュールについて個別に検討し、影響は少ないと考えて、僕が許容しました。
わざと壊した RTL で、テストを試す#
通ることではなく、壊れたものが通らないことを確かめる#
ここで問題になるのが、後から足したテストです。RTL がもうあるので、一度も FAIL を経験しないまま PASS しています。それに、最初に書いたテストの FAIL も、多くは「RTL が無い」「機能が入っていない」ことによるもので、テストが壊れ方を見分けられる証明にはなっていません。
逆に考えると、テストの効き目は「正しい RTL で通ること」ではなく、「壊れた RTL で通らないこと」で確かめるものです。やり方は単純で、RTL の写しをわざと壊して、テストが落ちるかを見ます。落ちれば、そのテストはその壊れ方を検出できます。通ってしまえば、テストが弱いということです。ソフトウェアのテストでは、ミューテーションテスト(mutation testing)と呼ばれる考え方です。図にすると、次のようになります。
実際に試した壊し方は、次のとおりです。いずれもテストが FAIL し、検出できました。
| 壊し方 | 落ちたテスト |
|---|---|
| FF を通すはずの出力を、組合せ回路に変える | 出力がクロックを待たずに変化した、で FAIL(修正前のテストでは通っていた形) |
| 結合テストで、generic の接続を取り違える | リセットの保持時間の確認で FAIL |
| 本来は外部のプルアップに任せる信号を、FPGA から駆動する | 信号が駆動された、で FAIL |
| シリアル通信で、読み出し中に線を放さない | FAIL |
| 書き込み中に行う自己診断(送った値の戻りの照合)をしない | FAIL |
| その自己診断を、読み出し中にも行ってしまう(誤検出する) | FAIL |
最後の3行は、仕様 → テスト → RTL の順を最初から通して作ったモジュールです。途中で、テスト側のモデルが、仕様では正しい振る舞いを「誤り」として扱っていたことも見つかり、モデルのほうを直しました。テストもまた、誤ることがあります。
わざと失敗させる確認は、HDL のテストベンチだけで書いていた頃は手間がかかりました。cocotb を入れてからは、ずいぶんやりやすくなりました。
xsim は、失敗しても TEST PASSED と言うことがある#
壊した写しを試していて、xsim の合否の判定にも罠があると分かりました。Vivado 2025.2 の xsim を -R で起動し、最小のテストベンチで確かめた結果です。
assertをseverity failureにすると、その時刻でシミュレーションが止まるseverity errorにすると止まらず、最後の「TEST PASSED」の表示まで進んでしまう- xsim の終了コードは、失敗しても 0 だった
終了コードが合否を表さないので、判定に使えるのは「TEST PASSED」が出たかどうかだけです。そして、それが信用できるのは assert を failure にしたときだけでした。もう1つ、テストベンチに上限の無い待ちがあると、回路が出力するクロックが止まる不具合では、テストベンチ側のクロックが動き続けるので、FAIL にならずにいつまでも終わりません。全体に時間切れの監視を入れ、一定時間で failure にするようにしました。
補助のエージェントは、解釈を間違える#
レビュー役とログ解析役#
この案件では、メインのエージェントのほかに、役割を絞ったエージェントを2つ使っています。上のテストベンチのレビュー役(Sonnet)と、Vivado の合成・実装のログ解析役(Haiku)です。ログ解析を任せる主な目的は、トークンの節約です。長いログをメインのエージェントに読ませると、それだけで会話の容量を使ってしまうからです。2つの役の関係と、それぞれの結果は次の図のとおりです。
数値は正しい。足した解釈が外れる#
どちらも役に立ちましたが、共通の癖がありました。拾ってきた事実は正しいのに、自分で足した解釈が外れるのです。レビュー役の推測2件のほかに、ログ解析役も、抽出した数値は正しかったものの、解釈を足した3か所が誤っていました。たとえば、利用率レポートにある入出力パッドの種類の数を根拠に、「出力の FF は IOB(入出力ブロック)に配置済み」と書いていました。パッドの種類は、FF が IOB に入ったかどうかとは関係がありません。
そこで、補助のエージェントは「速く広く拾ってくれる係」と割り切っています。拾ってきた数値と ID はそのまま使い、解釈はメインがレポートで確かめる。この線引きをしてから、使い方が安定しました。
まとめ#
冒頭の「テストベンチは通るのに、そのテストで十分なのか自信が持てない」への答えは、「テストを書いた側とは別の目で読ませ、わざと壊した RTL で落ちることを確かめる」です。テストが通ることと、テストが効いていることは別でした。ただし、壊した写しで示せるのは「その壊れ方を検出できる」ことまでで、RTL が正しいことの証明ではありません。既定値で回していない限界も残っています。
今回の整理#
| 問題 | 何が起きていたか | 対策 |
|---|---|---|
| テストが自分の仕様を確かめていた | 仕様書が薄く、docstring が仕様になっていた | 仕様書の不足を補い、テストはそれと照合する |
| テストが弱い | 組合せ出力でも、最初から 1 でも通った | 別のエージェントにレビューさせ、指摘は裏を取る |
| テストが効いているか分からない | 一度も FAIL を経験していないテストがあった | わざと壊した RTL で FAIL を確かめる |
| xsim の判定が甘い | error だと TEST PASSED が出る。終了コードは常に 0 | failure に統一し、時間切れ監視を入れる |
| 既定値で回していない | generic を縮めてしか回していない | カウンタの幅は別に確かめる |
| 補助エージェントの誤り | 事実は正しいが、解釈が外れる | 数値と ID は使い、解釈は裏を取る |
持ち帰り#
- 実機が届く前でも、仕様 → テスト → RTL の順なら設計を進められる
- テストのレビューは、RTL を読まない別のエージェントに任せる
- 一度も FAIL を経験していないテストは、わざと壊した RTL で落ちることを確かめる
- xsim は終了コードで判定せず、
assertをseverity failureにして、全体に時間切れの監視を入れる - generic を縮めたテストとは別に、既定値でのカウンタの幅を確かめる
前の記事では、仕様書を書いた本人に検証させない話を書きました。テストでも同じで、書いた側の理解を確かめるだけのテストにしないことが大事でした。cocotb を使った検証の背景は、256階調を超えて にも書いています。