はじめに
商用・OSS を問わず一般的な SystemVerilog シミュレータは、文法が通るコードに対しては、ほとんどの構文を黙ってそのまま受理します。LRM が明示的にツール警告を期待している箇所 (always_ff 内の blocking 代入など) や、Verilator のように lint 系警告を多数持つ実装もありますが、それでも logic y = a & b; も casex も input logic x も、LRM 上は合法な構文なので通常は warning なしで elaborate されます。意味論が利用者の直感とずれていても、シミュレータは「書かれた通りに」動くだけで、何も教えてくれないケースが大半です。
ただし、lint をかける習慣には 設計側と検証側で大きな差 があります。論理合成を通すデザイン RTL に対しては、Synopsys VC SpyGlass Lint や Cadence Jasper Superlint、Siemens Questa Lint といった専用 lint ツールを CI に組み込むのが一般的で、文法を越えた構造的なチェックはそちら側で済ませるのが前提になっています。
一方、検証側 (UVM テストベンチや SystemVerilog で書かれた参照モデルなど) では、シミュレータで直接走らせて挙動を追うのが中心 で、lint を別途パイプラインに通す習慣は、少なくとも筆者がこれまで関わってきたプロジェクトでは必ずしも根付いていませんでした。テストベンチには合成可能性の制約もないため、合成向け lint ルールがそのまま当てはまるわけでもなく、結局「シミュレータが文句を言わなければそのまま」になりがちです。結果として「エラーとはならないが意図と違う落とし穴」が TB 側で silent に残り、デバッグの最終局面まで気づかれないことになります。
そこで sukimasim (自作 SystemVerilog シミュレータ) では、シミュレータ本体の中に勘違いしやすい構文パターンを検出して警告を出す仕組み を入れてみました。lint 専用ツールを併用しなくても、シミュレーションの立ち上げ (parse + elaboration) の中で主要な罠は自動的に指摘されます。make や CI のシミュレーション run の副作用として勝手に警告が出るため、追加のツール導入や手順変更は不要です。
本記事は、その lint 警告として実装した 23 パターンを全部並べた記録です。一つひとつに IEEE 1800-2023 の該当条文番号、悪い例 / 良い例、そして sukimasim 側の検出ロジック (AST visitor のどこで何を見ているか) を付けています。他のシミュレータ / lint ツールを自作される方、既存の lint 設計と比較したい方、単に「こんなところで人は間違える」のリファレンスとして読む方、どの視点からも使えるように書いたつもりです。
前回までと本記事の位置づけ
本記事は Qiita の sukimaengineer シリーズ「― シミュレータ開発側の視点から」の続編です。
ここ最近は UVM の run phase や fork のプロセス寿命などランタイム寄りの話題を扱ってきましたが、今回は趣向を変えて elaboration / lint の話です。sukimasim に IEEE 1800-2023 準拠で実装した lint 警告 23 件を、カテゴリごとに全部並べていきます。
取り上げる 23 件は、本記事では読みやすさを優先して以下の 7 カテゴリに分けて紹介していきます。
| カテゴリ | 件数 | 代表項目 |
|---|---|---|
| A. 代入・宣言系 | 4 |
DECLINIT-ONCE, LOGIC-MULTIDRIVE, ALWAYS-FF-BLOCKING
|
| B. ポート系 | 4 |
PORT-INPUT-WRITE, OUTPUT-UNDRIVEN, PORT-WIDTH-MISMATCH
|
| C. case / 比較系 | 3 |
CASEX, WILDCARD-EQ, X-OPTIMISM-IF
|
| D. always_* 系 | 2 |
LATCH-INFERRED, ALWCOMB-RBW
|
| E. クラス / OOP 系 | 3 |
NULL-HANDLE, NON-VIRTUAL-OVERRIDE, RANDOMIZE-VOID-DROP
|
| F. 並行処理系 | 3 |
FORK-LOOPVAR-CAPTURE, TASK-STATIC-DEFAULT, FORCE-HIERARCHICAL-SCOPE
|
| G. データ型系 | 4 |
SIGNED-MIX, ASCRANGE, TIMESCALE-INHERIT, INPUT-KIND-SURPRISE
|
| 合計 | 23 |
本記事では、こうした 「エラーとはならないが意図と違う落とし穴」 を英語の PITFALL という語でまとめて呼ぶことにします。sukimasim 側でも警告メッセージの先頭タグとしてこの語を採用していて、[WARNING] PITFALL-<タグ名>: ... の形式で emit されるようになっています。23 件すべて sukimasim --lint <file>.sv で扱え、IEEE 1800-2023 の該当条文番号と具体的な修正案を必ず含んでいます。全タグの一覧は次の「PITFALL 一覧 (全 23 件)」の節にまとめてあります。
なぜ「エラーとはならないが意図と違う落とし穴」を別途拾うのか
商用 SystemVerilog シミュレータも、多くの場合 lint 系は別製品 (Synopsys VC SpyGlass Lint、Cadence Jasper Superlint、Siemens Questa Lint) として切り出されています。シミュレータ本体にも静的検証を持たせたい理由は 3 つあります。
-
LRM が「ツール警告を期待する」箇所がある。§9.2.2.4 (
always_ffで blocking 代入) は LRM 本文に"software tools should check and issue a warning"と書かれています。ツール側の義務扱いに近いと言えます。 -
実行時のデバッグでは拾いにくい。例として
logic y = a & b;はyが期待どおり変化しないだけで、エラーも波形の X も出ません。実行ログからの逆引きが難しいので、elaboration 時点で突き止めたいところです。 - 新規ユーザが最初に踏む落とし穴はほぼ固定されている。SNUG や DAC のチュートリアル (Cummings/Mills、LRM 付録) で指摘される定番パターンは検出コストが低く ROI が高いです。
sukimasim は AST visitor pass のなかで全部拾うという方針にしました。元から動いている elaboration/conversion の途中で symbol/expression を観察するので、追加コストはほぼゼロです。
PITFALL 一覧 (全 23 件)
| # | Tag | LRM § | 検出時期 | 要点 |
|---|---|---|---|---|
| 1 | PORT-INPUT-WRITE |
§23.3.3 | semantic | input port への代入 |
| 2 | RANDOMIZE-VOID-DROP |
§18.12 | semantic |
randomize() 戻り値の無視 |
| 3 | TASK-STATIC-DEFAULT |
§13.3 | semantic | module-scope task の default static 寿命 |
| 4 | LATCH-INFERRED |
§9.2.2.2.2 | semantic |
always_comb 内の不完全条件分岐 |
| 5 | PORT-WIDTH-MISMATCH |
§23.3.3 | semantic | port 接続のビット幅不一致 |
| 6 | WIDTH-MISMATCH |
§11.4 | semantic | 代入/式のビット幅不一致 |
| 7 | LOGIC-MULTIDRIVE |
§10.3.1 | semantic | continuous + procedural 両方で同一変数を駆動 |
| 8 | FORK-LOOPVAR-CAPTURE |
§9.3.2 | semantic | for + fork join_none / join_any のループ変数キャプチャ |
| 9 | OUTPUT-UNDRIVEN |
§23.3.3 | end-of-module | output port が無駆動 |
| 10 | NON-VIRTUAL-OVERRIDE |
§8.20 | semantic | virtual なしで同名メソッドを shadow |
| 11 | NULL-HANDLE |
§8.6 | end-of-module | class handle が new() なしで未代入 |
| 12 | FORCE-HIERARCHICAL-SCOPE |
§10.6 | semantic |
force u.a = ... など instance 越し force |
| 13 | SIGNED-MIX |
§11.8 | semantic | binary 演算で signed / unsigned 混在 |
| 14 | X-OPTIMISM-IF |
§12.5 | semantic |
always_comb if/else mux の X 楽観化 |
| 15 | ALWAYS-FF-BLOCKING |
§9.2.2.4 | semantic |
always_ff 内の blocking 代入 |
| 16 | WIRE-IMPLICIT |
§6.10 | name resolution | 暗黙宣言 wire (default_nettype wire) |
| 17 | DECLINIT-ONCE |
§10.5 | end-of-module |
logic y = a & b; が継続代入ではない件 |
| 18 | CASEX |
§12.5.1 | parse |
casex 使用 |
| 19 | WILDCARD-EQ |
§11.4.6 | semantic |
==? / !=? の非対称性 |
| 20 | ASCRANGE |
§6.9.1 | semantic | 昇順 packed range ([0:N]) |
| 21 | TIMESCALE-INHERIT |
§3.14.2.1 | elaboration | module の timescale 継承 |
| 22 | ALWCOMB-RBW |
§9.2.2.2.2 | semantic |
always_comb の self-recursive read-before-write |
| 23 | INPUT-KIND-SURPRISE |
§23.2.2.3 | semantic |
input logic x の kind 省略 |
全件、sukimasim --lint <file>.sv で取り扱えます。LRM 条文番号と理由と修正案を warning 文字列に含めています。
23 件の詳細解説
量が多いので、代入・宣言系 / ポート系 / case・比較系 / always_ 系 / クラス・OOP 系 / 並行処理系 / データ型系* の 7 カテゴリに分けてあります。各項目は「LRM 条文」「悪い例 / 良い例」「sukimasim 側の検出ロジック」を 1 セットにしています。
A. 代入・宣言系
A-1. PITFALL-DECLINIT-ONCE — logic y = a & b; は継続代入ではない
最も頻度が高いと推測している誤りがこれです。
IEEE 1800-2023 §10.5:
"A variable declaration with an initializer shall execute the
initialization only once, as if it were a blocking assignment
executed at time 0."
つまり module scope の logic y = a & b; は右辺が後で変わっても再評価されない。wire y = a & b; なら継続代入ですが、logic y = ... は t=0 で一度計算するだけで終わります。logic を「変数」だと読み換えるユーザ (Verilog の wire/reg からの移行組や、ファームウェア畑の人) はここで間違える可能性があります。
// 悪い例
module bad_decl;
logic a = 1'b1, b = 1'b1;
logic y = a & b; // t=0 だけ評価 — 以降 y は 1 のまま
initial begin
#10 a = 1'b0; // y は 1 のまま変わらない
end
endmodule
// 正しい例
module good_decl;
logic a = 1'b1, b = 1'b1;
wire y = a & b; // 継続代入: a/b が変われば y も追従
// または: logic y; assign y = a & b;
endmodule
sukimasim 側の検出は handle(InstanceBodySymbol) の末尾走査で driver map (continuous / always 駆動の集合) を見て、decl-init 以外の駆動が無く、かつ初期化式が dynamic symbol (Variable/Net/FormalArg) を参照していれば警告を出しています。slang の Expression::visitSymbolReferences(callback) が非常に便利で、これ無しでは実装が面倒でした。対象は VariableSymbol のみ で、parameter / localparam (slang では別の ParameterSymbol として扱われる) は elaboration 時定数なので本検出から外れます (LRM §6.20 の constant 規則と整合)。
A-2. PITFALL-WIDTH-MISMATCH — 代入のビット幅不一致
IEEE 1800-2023 §11.4:代入や式の両辺の幅が合っていないと、LRM は上位ビット切り捨てまたはゼロ/符号拡張を静かに行います。意図通りのケースもありますが、32'd1 を 8-bit の logic [7:0] に入れる、逆に 8'hFF を 16-bit 変数に入れて zero-extend を期待するパターンなど、silent な意味変化が頻出します。
// 悪い例: 上位 24 bit を silent drop
logic [7:0] narrow;
narrow = 32'hDEADBEEF; // narrow = 8'hEF のみ
// 良い例: 明示的にビット幅を合わせる
narrow = 8'hEF; // 意図を明示
narrow = 32'hDEADBEEF[7:0]; // ビット選択で明示
sukimasim は slang が付ける ConversionExpression (Implicit/Propagated) の getEffectiveWidth() を参照して本当に縮小/拡大が起きているケースを検出します。y = 1 のような unsized-literal は false positive 抑制のためスキップします。継続代入側 (ContinuousAssignSymbol) と 手続き代入側 (convertStatementImpl の ExpressionStatement) の両パスで同じ検出を走らせています。
A-3. PITFALL-LOGIC-MULTIDRIVE — 同一変数を continuous + procedural で駆動
IEEE 1800-2023 §10.3.1:同じ変数を assign (continuous) と always / initial (procedural) の両方で駆動すると、駆動源が競合して予測不能になります (LRM は variable について「continuous assignment 1 つか procedural assignment 1 つかのどちらか」しか許容していません)。なお、Verilator の BLKANDNBLK は blocking と non-blocking の混在 を指す別カテゴリの警告で、本項とは対象が異なります。
// 悪い例: 同一 x を 2 ヶ所で駆動
logic [7:0] x;
assign x = a + b; // continuous driver
always_comb x = c; // procedural driver → conflict
// 良い例: どちらか一方にする
logic [7:0] x;
always_comb x = sel ? (a + b) : c;
sukimasim は既存の §10.3.1 エラー経路 (exit 1 で停止) のメッセージに PITFALL-LOGIC-MULTIDRIVE / multi-driver per IEEE 1800-2023 §10.3.1 キーワードを付加するだけで、動作は変更していません。外部 lint harness は multi-driver キーワードでマッチできます (実装初期は warning に Verilator の BLKANDNBLK 文字列も併記していましたが、BLKANDNBLK は本来 blocking / non-blocking 混在用の別カテゴリなので 2026-04-25 に削除しました)。
A-4. PITFALL-ALWAYS-FF-BLOCKING (a.k.a. LRM Violation) — always_ff 内の blocking 代入
IEEE 1800-2023 §9.2.2.4 (LRM 本文に直接 "tools should issue a warning" と明記): sequential logic を表すことが期待される always_ff 内で blocking 代入 (=) を使うと、NBA との混在で race condition になり再現性を失います。
// 悪い例: q は blocking 代入、race の温床
always_ff @(posedge clk)
q = d;
// 良い例: non-blocking 代入で pipeline clean
always_ff @(posedge clk)
q <= d;
sukimasim はもともと "LRM Violation" タグで本警告を出していた既存実装をそのまま流用しており、2026-04-25 に PITFALL カタログとの統一を取るため warning text を [WARNING] PITFALL-ALWAYS-FF-BLOCKING (LRM Violation): ... という併記形に変更しました。新名 (PITFALL-ALWAYS-FF-BLOCKING) でも旧名 (LRM Violation) でも grep できるので、既存ツール / scripts は動き続けます。strict モード (SUKIMASIM_STRICT_LRM=1) では [ERROR] 扱いで exit 1 にエスカレートします。
B. ポート系
B-1. PITFALL-PORT-INPUT-WRITE — input port への書き込み
IEEE 1800-2023 §23.3.3 (Verilator の ASSIGNIN):input 方向に宣言された port に continuous assign / procedural assign / force で書き込むのは、port の contract 違反です。駆動元は外部 (caller) のはずです。
// 悪い例: input port a に書き込み
module bad(input logic a);
assign a = 1'b0; // PITFALL-PORT-INPUT-WRITE
endmodule
// 良い例: 中間信号を介する
module good(input logic a);
logic internal_a;
assign internal_a = a ? 1'b0 : 1'b1;
endmodule
sukimasim は handle(ContinuousAssignSymbol) で LHS symbol を resolve して PortSymbol::direction == In を確認します。procedural 側は ProceduralAssign (assign 文) / force 文のチェックでカバーします。
B-2. PITFALL-OUTPUT-UNDRIVEN — output port が無駆動
IEEE 1800-2023 §23.3.3 (Verilator の UNDRIVEN):output として宣言したものの、内部で一度も driver が付かない port。output logic のような 4-state 変数なら未駆動デフォルトは X、output wire のような net なら Z になります。いずれにせよ consumer 側で未定義値を読んでしまう事故の温床です。
// 悪い例: y に driver なし
module bad(output logic y);
// ... nothing drives y
endmodule
// 良い例: 少なくとも 1 つは driver
module good(output logic y);
assign y = 1'b0; // default
endmodule
sukimasim は handle(InstanceBodySymbol) の末尾で currentModule_->getSignals() を walk し、direction が Output の信号が continuouslyAssignedSignals_ / allDriverMap_ / proceduralOtherDriverMap_ のどれにも含まれていなければ emit します。
B-3. PITFALL-PORT-WIDTH-MISMATCH — port 接続のビット幅不一致
IEEE 1800-2023 §23.3.3:module instantiation で port の宣言幅と接続 signal の幅が合いません。上位ビット切捨てまたは ゼロ拡張で silent に補われる (slang の W116 に相当)。
// 悪い例: child の port は 4-bit、wide_bus は 8-bit
module child(input logic [3:0] p);
// ...
endmodule
module bad;
logic [7:0] wide_bus = 8'hFF;
child u(.p(wide_bus)); // 上位 4 bit が silent に drop
endmodule
sukimasim は slang の既存 diagnostics PortWidthExpand / PortWidthTruncate を setSeverity + setWarningOptions で Warning に昇格させ、対応する warning 分岐で PITFALL-PORT-WIDTH-MISMATCH タグを前置して emit しています。
B-4. PITFALL-WIRE-IMPLICIT (a.k.a. IMPL-WIRE-01) — 暗黙宣言 wire
IEEE 1800-2023 §6.10:default_nettype wire が効いている状態で、port connection の中に 未宣言の識別子が登場すると、1-bit wire として暗黙宣言されてしまいます。タイポが silent に通り、値は z または LSB truncate になります。
// 悪い例: `result` を declare し忘れたが、port 側で黙って 1-bit wire 化
child u(.out(resutl)); // 意図は `result`、`resutl` が 1-bit wire になる
sukimasim は handle(NetSymbol) で symbol.isImplicit が true のネットを検出して 1 回だけ emit します (key = name + offset)。Xcelium / VCS に合わせた挙動。warning text は [WARNING] PITFALL-WIRE-IMPLICIT (IMPL-WIRE-01): ... 形式の併記で、新名 (PITFALL カタログとの統一) と旧名 (sukimasim 独自の連番タグ、BUG-M19 由来) のどちらでも grep 可能です。
C. case / 比較系
C-1. PITFALL-CASEX — X を握りつぶす mux
IEEE 1800-2023 §12.5.1:casex は case 式と case item の 両側の X / Z を don't-care 扱いします。ちょっと考えれば凶悪で、シミュレーション時に式側 (opcode など) が X だと どの item にもマッチしてしまうのに、コードの見た目では気づきません。gate-level では X が正しく伝搬して未定義動作が顕在化する場合があるので、RTL シミュレーションだけ通って実機で落ちる、という最悪パターンを生みます。
// 悪い例: opcode が X になると hit=1 が常時満たされる
casex (opcode)
4'b1x??: hit = 1'b1;
default: hit = 1'b0;
endcase
// 推奨: casez か case inside
casez (opcode)
4'b1???: hit = 1'b1;
default: hit = 1'b0;
endcase
case (opcode) inside
[4'h8:4'hF]: hit = 1'b1;
default: hit = 1'b0;
endcase
検出は CaseStatement の condition フィールドが CaseStatementCondition::WildcardXOrZ だった時点で即 emit します。AST をそのまま見るだけで済むので検出コストは実質ゼロです。
C-2. PITFALL-WILDCARD-EQ — ==? / !=? の非対称性
IEEE 1800-2023 §11.4.6:wildcard compare ==? / !=? は 右辺の X/Z のみ wildcard 扱いで、左辺の X/Z は wildcard にならない。state ==? 4'b1x0? と書いて、state が X を含んでいると一致判定が壊れます。直感とずれやすい非対称仕様です。
// 悪い例: left が X のとき意図と異なる結果
logic [3:0] state;
always_comb begin
state = 4'b1x00;
hit = (state ==? 4'b1000); // 一致しない (left の X は wildcard ではない)
end
// 良い例: 左辺を 2-state で保証、または case inside を使う
always_comb
case (state) inside
4'b1000: hit = 1'b1;
default: hit = 1'b0;
endcase
sukimasim は convertBinaryOp の BinaryOperator::WildcardEquality / WildcardInequality の分岐で dedupe 付きで emit (同一 AST ポインタの再訪問をスキップ)。
C-3. PITFALL-X-OPTIMISM-IF — always_comb if/else mux の X 楽観化
IEEE 1800-2023 §12.5:always_comb if (sel) y=a; else y=b; という典型的な 2-way mux は、sel が X だと silent に else に落ちる。RTL シミュレーションでは y=b と見えるが、gate-level では X が正しく伝搬して未定義動作が顕在化します。SNUG / Cummings が「X-optimism」の代表例として指摘する pattern。
// 悪い例: sel=X のとき silent に else へ
always_comb
if (sel) y = a;
else y = b;
// 良い例: X を保存しやすい書き方
always_comb
y = sel ? a : b;
// 三項: sel=X のとき、IEEE 1800-2023 §11.4.11 によりビット毎に
// (a == b) のビットはその値、(a != b) のビットは X が伝搬する。
// if/else のように silent に b を返すことはない。
sukimasim は AlwaysComb 分岐で scanMux lambda によって、ConditionalStatement (if-else) の true branch と false branch の両方が 同じ単一 LHS に assign している場合に mux 形と判定して emit します。case 文形式の同様パターンは別ルールで扱うため、ここでは検出対象外です (unique / priority の意味づけは完全性 / 一意性チェックであって、X 伝搬を保証する機構ではない点に注意してください)。
D. always_* 系
D-1. PITFALL-LATCH-INFERRED — always_comb の不完全条件分岐
IEEE 1800-2023 §9.2.2.2.2:always_comb 内で if without else、または case without default があると、条件にマッチしない実行パスで LHS が 前回値を保持 し、実質的に latch になってしまいます。意図したラッチなら always_latch を使うべきです。
// 悪い例: else がないので latch 推論
always_comb
if (en) q = d;
// 良い例 A: else で明示
always_comb
if (en) q = d;
else q = '0;
// 良い例 B: 意図的ラッチなら always_latch
always_latch
if (en) q = d;
sukimasim は AlwaysComb 分岐で hasIncompleteConditional lambda を再帰実行し、ConditionalStatement.ifFalse または CaseStatement.defaultCase が欠落していたら emit します。
D-2. PITFALL-ALWCOMB-RBW — always_comb の自己参照
IEEE 1800-2023 §9.2.2.2.2:always_comb の暗黙感度集合は ブロック自身が書く変数を除外する。つまり LHS を RHS でも読む形 (self-recursive read-before-write) を書くと、LHS 変数が変わっても再トリガーせず、古い値を保持したまま固まる。シフトレジスタの組合せ模倣などで意図的にやる人もいるが、大半は事故です。
// 悪い例: y が y 自身を読んでいる
always_comb
y = a & y; // y が変わってもこのブロックは再評価されない
// 正しい例: 中間変数を挟む
always_comb
y_next = a & y_prev;
always_ff @(posedge clk)
y_prev <= y_next;
検出は AlwaysComb ブロックの body を再帰 walk して、AssignmentExpression の LHS (NamedValueExpression) と同じ Symbol* が RHS の visitSymbolReferences で拾われたら emit します。Statement ポインタで dedup。cross-statement な RBW (別文で read→write) は現状拾わない — そちらは false positive が多そうなので future work とします。
E. クラス / OOP 系
E-1. PITFALL-NULL-HANDLE — new() を忘れたクラスハンドル
IEEE 1800-2023 §8.6:class 型の変数は デフォルトで null。new() で初期化せずに member/method アクセスすると null deref (言語仕様上は $fatal 推奨)。initializer も assign もなく、そのまま使うと事故になります。
// 悪い例: h を new せずに呼ぶ
class my_class;
task foo(); ...
endclass
module bad;
my_class h; // null
initial h.foo(); // null deref
endmodule
// 良い例: 明示的に new
module good;
my_class h = new();
initial h.foo();
endmodule
sukimasim は handle(InstanceBodySymbol) の末尾走査で class-typed VariableSymbol を列挙し、initializer なし + 全 driver map に無いケースを emit します。
E-2. PITFALL-NON-VIRTUAL-OVERRIDE — virtual なしで同名メソッド shadow
IEEE 1800-2023 §8.20:親クラスのメソッドを子クラスで virtual 宣言なしに同名で定義すると、静的ディスパッチ になります。親型ハンドル経由で呼ぶと親版が呼ばれます。意図したオーバーライドが効かない事故を生みます。
// 悪い例: Parent::foo が non-virtual、Child が shadow
class Parent;
function int foo(); return 1; endfunction
endclass
class Child extends Parent;
function int foo(); return 2; endfunction // shadow, not override
endclass
Parent p = Child::type_id::create("c");
$display("%0d", p.foo()); // → 1 (親版)
// 良い例: 親に virtual を付ける
class Parent;
virtual function int foo(); return 1; endfunction
endclass
sukimasim は handleSubroutineImpl で、親の ClassType に同名メソッドがあるかチェック。MethodFlags::BuiltIn / Randomize / Constructor は除外 (compiler-generated は毎回 shadow されるので false positive 源)。ファイルスコープ set で dedupe します。
E-3. PITFALL-RANDOMIZE-VOID-DROP — randomize() の戻り値を無視
IEEE 1800-2023 §18.12:randomize() は function で、成功時 1、失敗 (制約解なし) 時 0 を返します。戻り値を無視すると制約不能な状況が silent になり、オブジェクトは未変化のまま残ります。デバッグが困難になります。
// 悪い例: 戻り値を無視 (失敗が見えない)
obj.randomize();
// 良い例 A: 成功チェック
if (!obj.randomize()) begin
// failure handling
end
// 良い例 B: 意図的に discard するなら void cast で明示
void'(obj.randomize());
sukimasim は ExpressionStatement 直下の CallExpression が randomize / std::randomize のときに emit します。本記事の「warning text の罠」節で触れた $fatal 事件で文言を修正した項目です。
F. 並行処理系
F-1. PITFALL-FORK-LOOPVAR-CAPTURE — for + fork join_none / join_any のループ変数キャプチャ
SNUG / Cummings & Mills canonical race: for-loop 内で fork join_none または fork join_any を使ってプロセスを立てると、ループ変数は参照キャプチャされます。fork された 4 プロセスは全員ループ終了後の最終値 (i=N) を読みます。IEEE 1800 §9.3.2 + §8.8 (automatic lifetime の話) の組み合わせで発生します。fork join (= JoinAll、各 fork が完了するまで親が待つ) は親が i を進める前に fork が完了するので、このパターンは起こりません。
// 悪い例: すべての fork が最終 i を参照
for (int i = 0; i < 4; i++)
fork
$display("i=%0d", i); // 期待: 0..3、実際: 4 4 4 4
join_none
// 良い例: automatic で capture
for (int i = 0; i < 4; i++)
fork
automatic int idx = i; // snapshot
$display("i=%0d", idx);
join_none
sukimasim は handle(ProceduralBlockSymbol) 入口で for-loop の本体に JoinAny / JoinNone の BlockStatement が含まれるかを再帰検出して emit します。
F-2. PITFALL-TASK-STATIC-DEFAULT — module-scope task の default static 寿命
IEEE 1800-2023 §13.3: module scope で宣言された task / function の lifetime は デフォルト static。同じ task を並列 fork 等から複数呼びすると、引数とローカル変数のストレージが共有 されて race になります。引数なし & ローカル変数なしの task では race vector がそもそも無いため問題は起きませんが、引数を取る task は呼び出し元から異なる値を渡しても全 frame で同じ引数 storage を上書きし合うので、典型的な race パターンになります。
// 悪い例: 引数あり static task、並列呼び出しで引数 storage が共有される
module m;
task check(int seed);
int tmp;
tmp = seed + $random;
#10 $display("seed=%0d tmp=%0d", seed, tmp);
// 期待: seed=1 と seed=2 が独立に表示される
// 実際: 後勝ちで seed が片方の値に上書きされ、両 fork が同じ値を読む
endtask
initial fork
check(1); // 引数 seed と local tmp を共有
check(2);
join
endmodule
// 良い例 A: task に automatic — 呼び出しごとに新 frame
task automatic check(int seed);
int tmp;
// ...
endtask
// 良い例 B: module 全体で automatic
module automatic m;
// ...
sukimasim は handleSubroutineImpl の Task 分岐で、module scope (class method 以外) かつ lifetime が static で、かつ少なくとも 1 つ引数を持つ task を emit します (引数こそが race vector で、引数なし task は module state を触るだけのケースが多く automatic 化しても解決にならないため意図的に対象外)。Built-in / コンパイラ生成は除外します。
F-3. PITFALL-FORCE-HIERARCHICAL-SCOPE — instance 越しの force
IEEE 1800-2023 §10.6: force u.inner.net = expr; のような cross-instance force は文法上は合法ですが、伝播範囲は階層名で書いた箇所と一致しない ため、ユーザの期待と実際の挙動がずれやすい構文です。具体的には次の 2 方向の勘違いがあります。
(A) 上に伝わってほしいのに伝わらない (input port を force したケース): 子モジュールの input port を force しても、親側 actual signal には逆流しません。port は input 方向の暗黙継続代入なので、子側で受け取った値だけが上書きされ、親側の driver はそのまま生き続けます。
// 悪い例 A: 深い階層の input port にだけ force、上位 net は変わらない
module top;
logic in_sig, out_sig;
child u(.a(in_sig), .b(out_sig));
initial force u.a = 1'b1; // a は child 内だけで 1 になる
// parent 側 in_sig は元の driver のまま
endmodule
// 良い例 A: 上位側 actual に force すれば child.a も追従する
initial force in_sig = 1'b1;
(B) ある階層以下にしか効かないと思っているのに、外まで漏れる (output port や net collapse しているケース): (A)とは逆に、子モジュールの output port を force すると、親側で同じ net を読んでいる箇所はすべて forced 値を読みます。「u.b を force したから u 内部だけが影響を受ける」という想定は誤りで、port collapse / continuous assign を経由して上位や sibling instance まで silent に伝播します。
// 悪い例 B: 子の output port にだけ force したつもりが、親の out_sig も
// 同じ net を共有しているため、外から見える値も 1 になる
module top;
logic out_sig;
child u(.b(out_sig)); // out_sig = u.b の連続代入と等価
always_comb $display("out_sig=%0b", out_sig);
initial force u.b = 1'b1; // u.b だけのつもりだが、out_sig も 1 になる
endmodule
// 良い例 B: 影響範囲を限定したいなら、子の内部 net (port でない signal) に
// force するか、そもそも force ではなく専用 mux / scan-mode 信号で
// override 経路を設計する。
つまり cross-instance force は、ユーザが書いた階層名 = 影響範囲 とは限らず、接続網 (port collapse 後の同一 net) の単位で効くため、想定より狭く効くケース (A) と想定より広く効くケース (B) の両方が起こります。テストベンチで stuck-at を再現するときなど、影響範囲を意図して使い分けてください。
sukimasim は ProceduralAssignStatement の isForce フラグが立っている assign の LHS を再帰走査し、HierarchicalValueExpression か instance-member アクセスの MemberAccessExpression を見つけたら emit します (方向 A / B どちらの誤用でも cross-instance force であれば同じ警告で拾えます)。
G. データ型系
G-1. PITFALL-SIGNED-MIX — signed × unsigned の混在
IEEE 1800-2023 §11.8:binary 算術・比較演算子で signed / unsigned オペランドを混在させると、式全体が unsigned に型プロモートされ、signed 側が意図していた符号拡張は消えます。-10 * 5 = -50 を期待して 0xFFFFFFFF...F × 5 になり、計算結果が桁あふれする典型パターンです。
// 悪い例: s * u が unsigned 扱いになる
logic signed [31:0] s = -10;
logic [31:0] u = 32'd5;
logic signed [63:0] y;
y = s * u; // 期待: -50、実際: 4294967246 * 5 (unsigned)
// 良い例: 事前に型キャスト
y = signed'({1'b0, u}) * s;
// または
localparam logic signed [31:0] us = 32'sd5;
y = s * us;
sukimasim は AssignmentExpression の RHS を再帰 walk して BinaryExpression (arithmetic / comparison のみ) を見つけ、両辺の ConversionExpression を剥がして (ユーザー宣言の signedness を復元) mismatch を検出します。file-scope set で dedupe します。
G-2. PITFALL-ASCRANGE — 昇順 packed range [0:N]
IEEE 1800-2023 §6.9.1:logic [0:7] のような昇順 packed range は合法ですが、C 由来の「index 0 == LSB」直感を 逆転させる。shift / concat / part-select と組み合わせたときにビット順を取り違えやすい。プロトコル起因で昇順が必須な箇所以外では降順 ([N-1:0]) を推奨。
// 悪い例: 昇順で混乱しやすい
logic [0:7] data = 8'hAA; // data[0] は MSB、data[7] は LSB
// 良い例: 降順の方が直感的
logic [7:0] data = 8'hAA; // data[7] は MSB、data[0] は LSB
sukimasim は handle(VariableSymbol) 先頭で canonical type を isPackedArray() で walk、各次元の getFixedRange() が left < right なら emit します。Symbol* per-symbol dedupe します。なお、Ethernet ヘッダや SPI / I2C など プロトコル仕様で昇順 ([0:N]) が指定 されている箇所は本当に昇順で書くしかなく、その場合は意図的な抑制が必要です。現状 sukimasim には行単位の // lint_off 機構が未実装なので、当面は signal 名に _be (big-endian) のような suffix を付けてレビュー側で意図を明示するか、警告を bulk で許容する CI 設定で対応してください (将来 --Wno-PITFALL-ASCRANGE のような CLI オプション追加を検討中)。
G-3. PITFALL-INPUT-KIND-SURPRISE — input logic x は net
IEEE 1800-2023 §23.2.2.3:ANSI port declaration で input / inout は kind (wire / var) を省略したら net が既定。input logic x は見た目は「logic という変数型」に見えるが、LRM 準拠なら collapsible net 扱いです。結果として force u.x = ...; の挙動や net collapse の効き方など、書き手が「変数」を期待した場合と挙動がずれることがあります。実装間でも relaxed 系オプションで net/var の解釈を揺らすことがあるので、var を明示するのが無難です。
// 意図ミスの温床
module child(input logic a); // LRM 的には net
// ...
endmodule
// 意図を明示する書き方
module child1(input var logic a); // 変数意図
module child2(input wire a); // net 意図
sukimasim は既に handle(PortSymbol) 内で source-text 逆方向スキャン (input 方向キーワードまで戻って var が出現するかを見る) をしていて、force/release の挙動補正に使っていました。この explicitVar フラグをそのまま PITFALL-INPUT-KIND-SURPRISE でも活用します。inout は LRM で net 固定なので除外します。
G-4. PITFALL-TIMESCALE-INHERIT — ファイル順でシミュレーション意味が変わる件
IEEE 1800-2023 §3.14.2.1 + §22.7:design unit に timeunit / timeprecision 宣言が無いと、`timescale directive を compilation-unit scope から継承 します。これが厄介で、+incdir / filelist の順序を変えるだけで #10 の意味が 10ns から 10ps に化けます。
// bad.sv — ファイル順依存
`timescale 1ns/1ps
// ... 他のモジュール ...
module target;
// local timeunit / timeprecision なし → 上記 `timescale を継承
initial #10 $finish; // 10 ns? 10 ps? ファイル順次第
endmodule
// good.sv — 自己完結
module target;
timeunit 1ns;
timeprecision 1ps;
initial #10 $finish; // 確実に 10 ns
endmodule
検出は少し面白い実装上の発見があって、slang は timeunit / timeprecision 宣言を SymbolKind として materialize しない。DefinitionSymbol のコンストラクタで TimeUnitsDeclarationSyntax を直接処理して timeScale フィールドを populate するだけで、symbol table には現れません。このため module.members() を walk しても見つかりません。必要なのは DefinitionSymbol::getSyntax() から ModuleDeclarationSyntax に cast して members の SyntaxKind::TimeUnitsDeclaration を探す、という syntax tree walk です。 現状は DefinitionKind::Module のみに絞っています。package / interface / program にも拡張する予定です。
実装メモ
sukimasim 側の検出パターンは 4 種類
-
AST ノード単発検出:
CASEX、WILDCARD-EQ、ASCRANGE、RANDOMIZE-VOID-DROPなど。該当ノードを見つけた瞬間に emit すれば終わりです。ほぼコスト ゼロです。 -
ProceduralBlockbody の再帰 walk:LATCH-INFERREDの incomplete conditional、X-OPTIMISM-IFの mux 検出、ALWCOMB-RBWの self-read 検出。std::function<void(const Statement&)>の lambda で再帰する pattern を何度も使います。 -
Expression::visitSymbolReferences— expression tree を再帰的に訪問して各Symbol参照を callback に渡してくれる slang API。DECLINIT-ONCEの「RHS が dynamic symbol を参照しているか」やALWCOMB-RBWの「LHS Symbol が RHS に出るか」に使います。 -
end-of-module + driver map 走査:
NULL-HANDLE、OUTPUT-UNDRIVEN、DECLINIT-ONCEは全 driver が集まり切った時点で判定したいので、handle(InstanceBodySymbol)の末尾でcurrentModuleSymbol_->members()を walk して driver map (既存のcontinuouslyAssignedSignals_/allDriverMap_/proceduralOtherDriverMap_) を引きます。
warning text の罠 — dashboard runner の stderr match
実装初期に事故った話です。PITFALL-RANDOMIZE-VOID-DROP の suggestion として Use `if (!obj.randomize()) $fatal;` or `void'(obj.randomize())`... と書いたところ、自前のダッシュボード runner が stderr に含まれる $fatal リテラルを検出して randomize 系 77 テストを全部 FAIL 扱い にしました。warning 内のコード例としての $fatal を、実際の $fatal 実行と区別できなかった。
教訓: lint warning の message 中に $fatal / $error / [error] / fatal error / simulation error / timeout in test をリテラルで書かない。テスト runner 側を直すより warning 側で辻褄を合わせる方が安全。今は自然言語で
"Either check the return value (for example, branch on
if (!obj.randomize()) ...and handle the failure), or cast to
void (void'(obj.randomize())) when the discard is intentional."
のように書き下しています。
回帰テストは CTest + manifest 両方に登録
sukimasim には 2 つの独立した test enumeration がある:
-
CTest —
tests/CMakeLists.txtのadd_test -
Manifest runner —
scripts/run_all_sv_tests.pyがtests/test_suite_manifest.jsonから列挙
この 2 つは自動同期しません。wave 1-2 で CTest だけ登録していた結果、comprehensive dashboard で 19 件の新 regression が skip され続けていた。今は両方に登録する self-check を手順化しました。
sukimasim --lint と通常実行の違い
--lint モードでは parse / elaboration 相当まで走って lint 警告を出し、シミュレーション本体はスキップします。ただし 一部の PITFALL (end-of-module 系 や symbol-binding を要するもの) は handle(InstanceBodySymbol) の完全走査後にしか判定できないため、通常実行でも emit するようにしています。CTest regression は sukimasim (通常実行) と sukimasim --lint の両方を混在させて登録しました。
残タスク (out of scope)
正直に書いておくと、deep-research-report (Claude と ChatGPT のリサーチ機能で SystemVerilog の落とし穴を網羅的に洗い出してもらった調査資料です) で挙がっていた候補のうち、sukimasim 側では未実装のものが他にもいくつか残っています。
-
UWIRE-NOT-MERGED— LRM §6.6.7、uwireで single-driver 強制が緩む -
CASEOVERLAP/CASEINCOMPLETE—caseitem 重複 / default 欠落 -
UNIQUE0-NO-DEFAULT—unique0 caseの no-match silent pass -
ALWNEVER—always @*で感度集合が空で一度も起動しないケース -
SENSITIVITY-ENTIRE-ARRAY—always @*+ part-select で配列全体が感度集合に入る -
DOTSTAR-EQUIV-MISMATCH—.*/.nameport binding 時の型等価チェック強化 -
PORT-COERCION-INOUT— port direction 宣言に反する使われ方で inout に coerce される警告
また、現行 23 件のなかでも:
-
ALWCOMB-RBWは self-read のみ 拾う簡易版で、cross-statement RBW (y = z; ... z = y;のような別文の組み合わせ) は検出していません。CFG dominance 解析を入れれば拾えますが、実装規模が跳ねるので future work です。 -
TIMESCALE-INHERITは module のみ で、interface / program / package には未対応です。 -
INPUT-KIND-SURPRISEは 宣言側のみ 検出していて、port binding 側の.a(.*)wildcard やサイレント net 生成は別話です。
上記は優先度を付けて順次足していく予定です。
まとめ
- LRM 1800-2023 上は「エラーとはならないが意図と違う落とし穴」になりやすい 23 パターン を、sukimasim に lint 警告として実装しました。
- すべて AST visitor pass 内で検出可能 で、既存 elaboration の副作用として低コストで出せます。
- 警告文には LRM 条文番号と具体的な修正例 を必ず載せて、「怒るだけで直し方が示せない警告」にならないようにしました。
- 実装上の罠:
- warning 文字列に
$fatal/[error]をリテラルで書かない (test runner が誤検知するため) - CTest と manifest 両方に regression を登録する (片方だけだと dashboard で skip されるため)
- slang の
timeunit/timeprecisionは SymbolKind として materialize されないので syntax tree を直接 walk する必要があります
- warning 文字列に
sukimasim (自作 SystemVerilog シミュレータ) 本体は、商用 EDA 領域にどんな特許が埋まっているか把握しきれず、どこで他社特許を踏むか読み切れないため、現時点では一般公開を見合わせています。23 件の lint 警告コード自体は src/parser/ast_to_ir_converter.cpp 内の AST visitor pass にまとまっているので、自作シミュレータや lint ツールに移植したい方は本記事の各項目で示した検出ロジックを参考にしてください。
次回は 検証寄りの PITFALL をさらに深掘りする予定です。今回扱った RTL 寄りの構文罠とは別に、SVA (assert / property / sequence) の意味論ずれ、covergroup / coverpoint / cross の落とし穴、そして UVM (factory / config_db / phasing / objection / sequencer-driver) で踏みやすいパターンを取り上げて、sukimasim でどこまで静的に検出できるかをまとめます。
参考
- IEEE Standard 1800-2023 SystemVerilog Language Reference Manual
- Clifford Cummings & Heath Chambers, "Nonblocking Assignments in Verilog Synthesis, Coding Styles That Kill!", SNUG 2000
- Clifford Cummings, "SystemVerilog's
priority&unique- A Solution to Verilog'sfull_case¶llel_caseEvil Twins", SNUG 2005 - Accellera UVM 1.2 Class Reference Manual