同じ RTL を 3 回シミュレートする — Yosys と iverilog で、合成と配置配線で剥がれていくもの

  • fpga
  • verilog
  • yosys
  • シミュレーション
  • 電子回路

まとめ(先に結論)

  • ひとつの RTL を、RTL / 合成後 / 配置配線後(セル実遅延) の 3 段でシミュレートすると、抽象が 1 枚ずつ剥がれていくのが見えます
  • 道具はぜんぶ OSS(iverilog / yosys / nextpnr-ice40)。Ubuntu の apt 一発で揃って、ノート PC 一台、実機ナシで完結します
  • 題材は 4 bit カウンタ + CE + 非同期入力。「同期化 FF を 2 段挟むかどうか」で挙動がどう変わるか、3 段それぞれの波形と数値で並べました
  • 最後に「なぜ多くの設計で同期化 FF を 2 段にしているのか」を、MTBF(平均故障間隔)の指数則で可視化します

OSS で FPGA の "3 段" が見える時代になっていた

FPGA まわりの OSS が、ここ数年で随分大人しく成熟していました。yosys(合成)、nextpnr(配置配線)、iverilog(シミュレーション)が、Ubuntu の apt install 一発で揃います。Lattice の iCE40 系であれば、実機(評価基板)を持っていなくても、実際のセル遅延込みで波形を見るところまで、ノート PC 一台で完結する。これは少し前まで、ベンダ GUI と評価基板が前提だった世界からすると、地味にすごい変化だと感じています。

私はこれまで、合成や配置配線の "中身" を真面目に意識する機会がそれほど多くなくて、書いた RTL がベンダ GUI のブラックボックスを通って bit ファイルになる、という付き合い方をしてきました。今回は OSS のおかげで、「同じ RTL を、抽象度を 1 段ずつ降ろしながらシミュレートしていく」というのを、自分の手元で並べてみました。題材自体は教科書みたいに地味なのですが、3 段並べると 抽象が剥がれる瞬間がはっきり見えて、面白かったので書きます。

ソフトウェア寄りの方にたとえるなら、同じソースコードを「ソース」「最適化後の中間表現」「実機の命令列(キャッシュやパイプライン込みの挙動)」で見比べていく感覚に近いかもしれません。あとで出てくる RTL はソース、合成は機械語に近い部品列への変換、配置配線はその部品を実チップのどこに置いてどう配線するかを決める段階、と対応づけると掴みやすいと思います。

3 段とは何か

FPGA 開発の途中段階を、まずは大づかみに整理します。

何を見ている 道具(OSS の代表)
段 1: RTL シミュレーション 書いた Verilog をそのまま実行 iverilog, Verilator
段 2: 合成後シミュレーション 合成器がセルにマップしたもの yosysiverilog
段 3: 配置配線後 + セル実遅延 実際のチップのセル遅延を被せたもの nextpnriverilog (specify)

段 1 はクロックが理想エッジで遅延ゼロ。コードのバグだけを見ます。段 2 は、その RTL が実際にどのセルになったかを見ます。波形は段 1 とほぼ同じに見えても、中身(ネットリスト)はまったくの別物です。段 3 でようやく、シリコンの物理が許す範囲で波形が遅れたり崩れたりします。

順に見ていきます。

経験談: 量産後に "見えない" ハングに遭遇した話

個人的にこの 3 段の境目を真面目に考えるきっかけになった経験があるので、書いておきます。

2010 年代に勤めていた電子機器メーカーで、FPGA を載せた基板が、量産になってから「数百時間に一回ハングする」という、再現性の悪い不具合に遭遇したことがあります。RTL シミュではどう叩いても出ない。けれど現場では確かに出る。最終的に犯人だったのは、別クロックドメインの信号を同期化 FF を介さずに直結していた箇所で、それがたまにクロックエッジに重なると、下流の FF(フリップフロップ。1 ビットを保持する基本的な記憶素子です)が中途半端な状態に落ちて、回路が止まっていました。

当時はベンダ GUI のタイミングレポートを読み解くのに時間がかかって、原因が分かるまでだいぶ迷ったのを覚えています。あのときの "見えない敵" を、今回は OSS の道具で 3 段に分けて見えるようにしてみよう、というのが今回の動機です。

実験設計

題材はなるべく小さく、けれど 3 段の差が見える最小サイズ、ということで:

  • 4 bit カウンタ + CE (clock enable) — CE は「このクロックでカウンタを進めてよいか」を決める許可信号で、ソフトでいう if (enable) count++enable に近いものです
  • CE を駆動するのは、別クロックドメインから来る 非同期信号 ce_async(自分のクロックと無関係なタイミングで変化する入力)
  • 比較するのは 2 通り:
    • with_sync: 2 段同期化 FF を挟んでから CE に使う(教科書通り)
    • no_sync: ce_async を直接 CE に直結する(やってはいけない例)

RTL はこれだけです:

// counter_ce.v
module counter_ce (
    input  wire       clk, rst_n, ce,
    output reg  [3:0] q
);
    always @(posedge clk) begin
        if (!rst_n)      q <= 4'b0;
        else if (ce)     q <= q + 1'b1;
    end
endmodule

// sync_ff.v
module sync_ff (
    input  wire clk, d_async,
    output wire q_sync
);
    reg [1:0] sync_r;
    always @(posedge clk) sync_r <= {sync_r[0], d_async};
    assign q_sync = sync_r[1];
endmodule

クロックは 100 MHz、非同期信号は周期 17 ns(クロックと非整数比)で揺らして、エッジがズレながら当たるようにしてあります。

段 1: RTL ─ 嘘をつかない、けれど何も教えてくれない

iverilog で RTL をそのまま叩きます。クロックは数学的に完全な矩形波、ゲートは遅延ゼロ。

Stage 1: RTL simulation

ce_async が 2 段同期化 FF (sync_r[1:0]) を通って ce_s になり、カウンタが 0 → 1 → 2 … と一つずつ増えていきます(図は最初の 50 ns 分を拡大しています)。波形そのものは穏やかで、書いた通りに動いている。段 1 は嘘をつきません。けれど、現実とのギャップも何も教えてくれない。

参考までに「悪い例」(no_sync) の段 1 も載せておきます:

Stage 1: no_sync (BAD)

ce_async を CE に直結しても、段 1 ではやはり何事もなく動いて見える。これがクセモノで、段 1 だけで判断するとここで安心してしまう。私が量産後に遭遇したあの不具合は、ちょうどこのトラップに足を取られた形でした。

段 2: 合成後 ─ 同じ波形、別の中身

yosyssynth_ice40 で iCE40 ターゲット合成を頼みます。with_sync 版のセル統計はこうなりました:

=== top ===
   Number of cells:                 14
     SB_CARRY                        2
     SB_DFF                          2
     SB_DFFESR                       4
     SB_LUT4                         6

まず、ここに出てくる SB_* は iCE40 の中に実在する基本部品の名前です。DFF はフリップフロップ、LUT4 は 4 入力ぶんの小さな論理表(真理値表をそのまま焼いたもの)、CARRY は加算用の専用回路だと思ってください。

SB_DFFESR は CE + 同期リセット付きの FF。カウンタの 4 bit ぶんが綺麗にここにマップされています。SB_DFF は素の FF が 2 個 — これが同期化 FF の正体です(CE 不要なので素のセルが選ばれた)。ちなみに同期化 FF に CE が付いていないのにも理由があって、CE で間引くと「毎クロック必ずサンプルし続ける」という同期化器の前提が崩れ、メタステーブルが解消するための時間まで削られてしまいます。素の SB_DFF が選ばれているのは、その意味でも理にかなっています。

no_sync 版を同じく合成すると、SB_DFF が消えて 12 セルに減ります。差分はまさに SB_DFF × 2 だけ。たったセル 2 個 — これが、量産で数百時間に一回のハングを防ぐ "原価" になります。

波形 (with_sync):

Stage 2: post-synthesis

見た目はほぼ段 1 と同じです。でもそれもそのはずで、まだ物理を被せていないから。違うのは「書類上の中身」だけ。

段 3: P&R + 実遅延 ─ 物理が顔を出す

nextpnr-ice40 で配置配線まで実際に走らせて、まず Fmax や経路遅延のタイミングレポートを取ります。波形のほうは、cells_sim.v に icestorm プロジェクトが組み込んでくれている iCE40-HX のセル実遅延((posedge C => (Q : D)) のクロック→出力遅延など)を iverilog -gspecify で効かせて、もう一度シミュレートします。

ひとつ正直に補足しておくと、今回は配線遅延の SDF 逆アノテーション(back-annotation。配置配線で分かった遅延情報をシミュレータへ戻して、より実物に近い波形にする手順)は外しています。nextpnr が吐く SDF と iverilog のインスタンス命名が、手元のツールのバージョン間で噛み合わず、素直に食べてくれなかったためです。なので段 3 の波形に効いているのは、配線の遅れではなく セル単体の遅延(FF のクロック→出力など)だけ、という点はお断りしておきます。配置配線そのものは実際に走っていて、Fmax と経路遅延の数字はそのレポートから取っています。「物理が一気に全部見える」とまでは言えませんが、セル遅延ぶんだけでも、理想エッジが現実へ寄っていく最初の一歩は十分に見えます。

段 1 と段 3 を、同じ時間軸で並べたものがこちらです(黒背景は logic analyzer 風に、段 3 の差を強調しています):

Stage 1 vs Stage 3

クロックエッジの位置(45.00 ns)と、カウンタ q[3:0] が更新される位置(45.54 ns)に、540 ps のずれが出ています。これは FF のクロック→出力(clk→q)のセル遅延ぶんです。波形だけ見ると小さな差ですが、ここで起きていることはひとつ重要で、理想エッジで動いていたものに、現実のセル伝搬遅延が乗った — つまり、シリコンとして動かしたときの "リアル" がやっと顔を出した、ということです。

nextpnr のタイミングレポート(Fmax=その回路が正しく動ける最大クロック周波数)も、いっしょに見ておきます:

Fmax 非同期 → clk 最大遅延
with_sync 350 MHz 3.33 ns
no_sync 448 MHz 3.45 ns

(なお、この「非同期 → clk 最大遅延」は nextpnr が報告した経路の遅延で、さきほど MTBF で出てきた解消の猶予 trt_r とは別物です。混同しないようにだけ書いておきます。)

with_sync の方が Fmax が低めに出ましたが、これを「同期化 FF を直列に挟んだぶん経路が延びたから」と単純化はできなさそうです。これくらい小さい回路だと、配置・最適化のばらつきのほうが効きますし、critical path(回路の中で一番遅延の厳しい経路)も両者で別の場所に出ています。確実に言えるのは、どちらも 100 MHz 設計に対して 3.5 倍前後のマージンがあるということで、この規模では Fmax の絶対差を気にする段階ではない、というのが無難な読み方だと思います。

なぜ FF 2 段か ─ コインの話

ここまで来ると、ようやく本題に入れます。「非同期信号を取り込むときは FF を 2 段にしなさい」というのが、なぜ多くの設計でほぼ定番になっているのか。

たとえ話で言うと、コインを投げて、まだ斜めに転がっている状態で結果を見ようとするようなものです。最終的には表か裏に必ず落ちます。けれど、落ちきる前に「これは表!」と判定してしまうと、毎回違う答えが返ってくる。これが FF にとっての 準安定(メタステーブル)状態です。クロックエッジの瞬間に入力が変化すると、FF は一瞬だけ "中間電圧でブルブル震えている" 状態に落ちます。

1 段だけの FF で取り込むと、下流ロジックがその「転がっている途中のコイン」をそのまま見てしまう。出力は不定のまま回路に伝わって、たまにビットが化ける。回路全体が、たまに、原因不明にハングする。先に書いた、量産後の "数百時間に一回" の正体は、たぶんこの形でした。

ソフトウェアの言葉でいうと、別々のスレッドがロック(排他制御)なしで同じ変数を触ってしまう レースコンディション に似ています。あちらが「タイミング次第でデータが壊れる」なら、こちらは「クロックエッジと信号の変化が物理的に衝突して、FF が中間状態に落ちる」。現れ方は違っても、"たまに・再現性なく・原因不明に" というハマり方はそっくりだと、私はいつも感じます。

そこに 2 段目の FF を入れる。1 段目が転がっている間、2 段目はおとなしく待っている。次のクロックエッジまでの 1 クロック分の "待ち時間" を与えれば、その間にコインはちゃんと表か裏に落ちている。落ちきった値を 2 段目が拾うので、下流に化けたビットが流れる確率はぐっと小さくなります(ゼロにはなりません ── それが、次に出てくる MTBF の話です)。

ここで本当に効いてくるのは、準安定が解消する確率は時間に対して指数関数で減衰するという点です。1 クロック余計に待つだけで、確率が桁外れに小さくなる。

教科書の MTBF 式はこうです(暗記する必要はありません。見てほしいのは「待ち時間が指数関数の肩に乗っている」という形だけです):

MTBF=exp(tr/τ)TWfclkfdata\text{MTBF} = \frac{\exp(t_r / \tau)}{T_W \cdot f_{clk} \cdot f_{data}}

ここで τ\tau は FF がメタステーブルから抜け出す時定数、trt_r は「メタステーブルになった 1 段目の出力が、最終的に下流へ渡るまでに与えられる解消の猶予時間」です。同期化 FF を N 段にすると、1 段目で生じた揺らぎが最終段に拾われるまでに (N−1) クロックぶんの猶予ができるので、ざっくり tr(N1)Tclkt_r \approx (N-1)\,T_{clk} と置けます(実際の設計では、ここから clk→Q やセル・配線の遅延、次段の setup ぶんが差し引かれます)。ここがポイントで、1 段だけだと猶予はほぼゼロ=ほとんど守られていません。

τ\tau は実測したものではなく、教育用に悲観側へ振った仮の値(τ\tau = 0.25 ns)を置いています。TWT_W(メタステーブルを起こしやすい危険な時間窓)= 0.5 ns、fclkf_{clk} = 100 MHz、fdataf_{data}(非同期入力の遷移頻度)= 50 MHz として、段数 N でプロットすると、こうなります:

MTBF vs synchronizer stages

  • 1 段だけ: 解消の猶予がほぼゼロなので、マイクロ秒オーダーで化け続ける(実質ノーガード)
  • 2 段にする: 約 3,000 年に 1 回(人間の感覚では「起きない」)
  • 3 段にする: もう人が普段思い浮かべる時間の物差しでは測れないくらいで、実用上は「まず起きない」と考えて差し支えありません(やりすぎなくらい安全)

念のため補足すると、この「1 段=マイクロ秒」は悲観側に振った仮定での値です。実際の製品が冒頭の私の話のように「数百時間に一回」くらいの頻度でしか表面化しないのは、本物の τ\tau がこれよりずっと小さく、しかも危ないタイミングが実際に重なること自体がまれだからです。それでも「いつか必ず、忘れた頃に出る」というのが、この手の不具合の本当に厄介なところでした。

FF を 1 個足すだけで、解消の猶予が 1 クロック増え、MTBF が指数で跳ね上がる。1 段から 2 段に増やした瞬間、マイクロ秒が数千年に化けます。この効果の大きさが、2 段同期化が定番になっている、大きな理由なのだと思います。

ただし、これはあくまで「絶対値は τ\tau 次第で大きく動く」という前提つきの話です。ここで本当に効いているのは「段を 1 つ足すと指数で伸びる」という関係のほうで、実際に何段あれば十分かは、クロック周波数・非同期信号の遷移頻度・要求する信頼性(FIT=10 億時間あたりの故障回数)・そしてデバイスの τ\tau で変わります。典型条件では τ\tau はもっと小さく(0.1 ns 台とも言われます)、その場合 2 段でも、人の一生をはるかに超えて十分すぎるほど安全になりますが、τ\tau は温度・電源電圧・プロセスばらつきで悪化するので、一番厳しい条件で見ても困らないように 2 段を標準にしておく、という保険の意味合いが強いです。設計の世界では、最悪条件で見て困らないことのほうが、典型条件でラクなことより圧倒的に大事だと、私は何度か痛い目に遭うたびに思い直しています。

まとめ

1 つの RTL を、抽象を 1 段ずつ降ろしながら見ました:

  • 段 1 (RTL): 書いた通り。動くべきところは動く。けれど、現実の遅延も、たまに化けるビットも見えない。
  • 段 2 (合成後): 同じ波形でも、中身は別物(SB_DFFESRSB_LUT4 の塊)。同期化 FF の "原価" がここで初めて見える(=セル 2 個)。
  • 段 3 (P&R + セル実遅延): 物理が顔を出す。クロックから 540 ps 遅れて値が出る。この条件での Fmax が、ここでようやく見えてくる。

OSS の道具立て一式で、ここまでぜんぶ実機なしで見えるのが、地味にすごい時代だなと思いました。20 年くらい前の感覚だと、こういうことを試すには評価基板と Quartus / Vivado と、何より自由になる時間と権限が要りました。それが apt 一発で揃って、自宅のノート PC で完結する。一人の電子機器エンジニアとして、これは何かを変えてくれそうな道具立てだと思っています。

次の宿題

今回扱ったのは、「単一のクロックドメインに非同期信号 1 本を取り込む」という、もっとも基本のケースです。実際の現場では、別クロックのバスを渡したい場面がよく出てきて、そうなると FF 2 段だけでは足りません。Gray code、handshake、非同期 FIFO といった "もう一段上" の仕組みが要ります。これは別の記事で、また 3 段論法に乗せて書いてみたいと思います。

ここで使った RTL / Verilog / 描画 Python は、公開リポジトリ lab-hdl-cosim(MIT)に置いてあります。同じ環境(Ubuntu 24.04 + sudo apt install iverilog yosys nextpnr-ice40 nextpnr-ice40-chipdb fpga-icestorm fpga-icestorm-chipdb だけ)で、手元で同じ 3 段の波形が出るはずです。「自分の回路で同じことをやってみたい」という方の足場になれば嬉しいです。

この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る