中級 · レッスン 3 / 3

クロックドメイン間の乗り換えで苦労しないために

別のクロックから入力されるフリップフロップがなぜ失敗しうるのか、そしてすべてのクロックドメイン間の乗り換えを安全にする少数の構造(シンクロナイザ、ハンドシェイク、グレイコードポインタのFIFO)と制約を解説します

中級AMD40 分更新 2026年11月13日無料期間 2027年1月31日

このレッスンで学ぶこと

  • 解決時間とクロック周波数からシンクロナイザの平均故障間隔を見積もり、2段目のフロップを追加すると結果が数十桁変わる理由を説明できる
  • 1ビットには2段フロップのシンクロナイザ、1ワードにはトグルハンドシェイク、ストリームにはグレイコードポインタのFIFOというように、乗り換えに適した構造を選べる
  • グレイコード化したポインタをビットごとに同期化できる理由と、そこからfullとemptyを導く方法を説明できる
  • 乗り換えを無視するのではなく上限を定める制約を書き、CDCレポートが何を示しているかを読み取れる

前提レッスン: 静的タイミング解析を正しく理解する, memories block ram and fifos

現実のFPGA設計のほとんどは複数のクロックを持ちます。100 MHzの処理パイプラインが61.44 MHzでクロックされるADCからサンプルを受け取り、125 MHzのEthernet MACにデータを送り、50 MHzのAXIバスでステータスを報告する、といった具合です。これらのクロック間の境界はそれぞれがクロックドメイン間の乗り換え(CDC)であり、静的タイミング解析(STA)が手を出せない場所でもあります。このレッスンは、STAのレッスンを終え、固定した関係を持たないクロック間でビットを移す必要が出てきた方のためのものです。乗り換えが失敗しうる物理的な理由、ほぼすべてのケースをカバーする3つの構造、そして安全にしたパスをツールに伝える方法を扱います。

フリップフロップが常に判定できるとは限らない理由

フリップフロップは、クロックエッジの前のセットアップ時間と後のホールド時間の間、入力が安定している必要があります。単一のクロック領域内では、STAがこれが常に成り立つことを証明します。無関係なクロック間ではそのような証明はありません。遅かれ早かれデータの変化がその窓の中に入り、フロップ内部のラッチは0と1の間で釣り合ったまま取り残されます。この状態を準安定状態(メタステーブル)と呼びます。出力はきれいな0でもきれいな1でもなく、どちらかに倒れるまでに予測できない時間がかかります。

この減衰は指数関数的です。クロックエッジで準安定になったフロップが時間t後もまだ未決定である確率は、e^(−t/τ)に従って減少します。ここでτ(タウ)はフロップ固有の特性で、現代のプロセスでは通常10 psから50 psです。そもそも違反が起きる頻度はT0 · f_clk · f_dataで、T0は実効的な捕捉窓(約100 ps)、f_clkはサンプリングクロック、f_dataは入力が変化する頻度です。両者を組み合わせると、平均故障間隔(MTBF)の標準的な見積もり式が得られます。

MTBF = e^(t_r / τ) / (T0 · f_clk · f_data)

ここでt_rは、1段目のフロップのクロックエッジからその出力が次に必要とされる瞬間までに設計が確保する解決時間(resolution time)です。CDC設計のすべては、このt_rを大きくすることに尽きます。

数値は驚くべきものです。τ = 25 ps、T0 = 100 ps、200 MHzの宛先クロック、毎秒5,000万回トグルする入力を考えます。

確保できる解決時間 e^(t_r/τ) MTBF
0.5 ns(出力が同一サイクル内のロジックに入る) 約5 × 10^8 約8分
4.5 ns(出力が直接2段目のフロップに入る) 約1 × 10^78 約3 × 10^70年

これらの正確な値は例示です。τとT0はベンダーがデバイスごとに特性評価しており、Vivadoではreport_synchronizer_mtbfで値を計算してくれます。結果の形は変わりません。準安定な出力をロジックに入れれば設計はデモの最中に壊れ、丸1クロック周期の整定時間を与えれば宇宙の寿命より長持ちします。

エッジ付近で変化する入力は、1段目のフロップをサイクルの一部の間、未決定のままにします。2段目のフロップはそのサイクルが終わった後にだけサンプリングするため、きれいな値を見ます。clk_dstd_asyncq1 (first flop)q2 (second flop)
エッジ付近で変化する入力は、1段目のフロップをサイクルの一部の間、未決定のままにします。2段目のフロップはそのサイクルが終わった後にだけサンプリングするため、きれいな値を見ます。

ルール1:1ビットは2段のフロップを通して渡す

2段フロップのシンクロナイザは、前節の内容をそのままハードウェアにしたものです。1段目のフロップが準安定状態を吸収します。その出力を見るのは2段目のフロップだけなので、解決時間は丸1クロック周期から2段目のフロップのセットアップ時間と両者の間の配線遅延を引いたものになります。この配線が、2つのフロップを隣接して配置しなければならない理由です。AMDデバイスではASYNC_REG属性によって、Vivadoに両者を同じスライスに置き、1段目のフロップを正当なCDCの到達点として扱うよう指示します。他のベンダーにも同等の仕組みがあり、リセットとCDCパターンのリファレンスにまとめています。

module sync_bit #(
    parameter integer STAGES = 2
) (
    input  wire clk,
    input  wire d_async,
    output wire q
);
    (* ASYNC_REG = "TRUE" *) reg [STAGES-1:0] shift;

    always @(posedge clk) begin
        shift <= {shift[STAGES-2:0], d_async};
    end

    assign q = shift[STAGES-1];
endmodule

VHDL-2008で同じものを書くと次のようになります。

library ieee;
use ieee.std_logic_1164.all;

entity sync_bit is
  generic (
    stages : positive := 2
  );
  port (
    clk     : in  std_logic;
    d_async : in  std_logic;
    q       : out std_logic
  );
end entity;

architecture rtl of sync_bit is
  signal shift : std_logic_vector(stages - 1 downto 0) := (others => '0');
  attribute async_reg : string;
  attribute async_reg of shift : signal is "TRUE";
begin
  process (clk)
  begin
    if rising_edge(clk) then
      shift <= shift(stages - 2 downto 0) & d_async;
    end if;
  end process;

  q <= shift(stages - 1);
end architecture;

このモジュールには、その小ささに似合わないほど重要な点が3つあります。

  • リセットを持たない。 シンクロナイザにリセットを付けると、マルチプレクサやリセットピンが追加され、それ自体がCDCの問題になりえます。そもそもフロップは2サイクル以内に正しい値に落ち着きます。コーディング規約でリセットが必須なら、宛先領域の同期リセットにしてください。
  • 入力はソース領域のフロップから直接来なければならない。 入力が複数のソースフロップの組み合わせ論理関数だと、それらの間でグリッチが生じえます。シンクロナイザがグリッチをサンプリングすると、どんな2段目のフロップでも取り除けない偽のパルスになります。
  • 動かない信号にしか使えない。 宛先クロックで約3サイクルより遅く変化するレベル信号は必ず通ります。より速いクロックからの1サイクルのパルスは完全に取りこぼされることがあります。パルスやデータワードにはプロトコルが必要です。

ルール2:1ワードはハンドシェイクで渡す

複数ビットの値を安全に移す最も単純な方法は、ソース領域で値を凍結し、1ビットのフラグを向こうに振り、宛先が凍結された値をコピーしてからフラグを振り返す、というものです。フラグはパルスではなくトグルにすることで、どんなクロック周波数比でも生き残ります。下のモジュールは任意の幅のワードをこの方法で運びます。ファイル単体で完結するよう、2つのシンクロナイザはインラインで書いてあります。

module cdc_handshake #(
    parameter integer WIDTH = 16
) (
    input  wire             clk_src,
    input  wire             rst_src,     // synchronous to clk_src
    input  wire [WIDTH-1:0] data_src,
    input  wire             valid_src,
    output wire             ready_src,

    input  wire             clk_dst,
    input  wire             rst_dst,     // synchronous to clk_dst
    output reg  [WIDTH-1:0] data_dst,
    output reg              valid_dst    // one-cycle pulse per word
);
    reg              req;        // clk_src, toggles once per word
    reg  [WIDTH-1:0] data_hold;  // clk_src, frozen while a word is in flight
    reg              ack;        // clk_dst, copies req once the word is taken

    (* ASYNC_REG = "TRUE" *) reg [1:0] ack_meta;   // ack crossing into clk_src
    (* ASYNC_REG = "TRUE" *) reg [1:0] req_meta;   // req crossing into clk_dst

    wire ack_sync = ack_meta[1];
    wire req_sync = req_meta[1];

    assign ready_src = (req == ack_sync);

    always @(posedge clk_src) begin
        if (rst_src) begin
            req       <= 1'b0;
            data_hold <= {WIDTH{1'b0}};
            ack_meta  <= 2'b00;
        end else begin
            ack_meta <= {ack_meta[0], ack};
            if (valid_src && ready_src) begin
                data_hold <= data_src;
                req       <= ~req;
            end
        end
    end

    always @(posedge clk_dst) begin
        if (rst_dst) begin
            ack       <= 1'b0;
            req_meta  <= 2'b00;
            data_dst  <= {WIDTH{1'b0}};
            valid_dst <= 1'b0;
        end else begin
            req_meta  <= {req_meta[0], req};
            valid_dst <= 1'b0;
            if (req_sync != ack) begin
                data_dst  <= data_hold;
                ack       <= req_sync;
                valid_dst <= 1'b1;
            end
        end
    end
endmodule

1回の転送を追ってみましょう。ソースはワードをdata_holdに取り込み、同じエッジでreqを反転します。このトグルがreq_metaから出てくるまでに、宛先クロックで2〜3サイクルかかります。出てきたら、宛先はdata_holdをコピーし、valid_dstをパルスし、ackを新しいreqと等しくします。このトグルが戻るまでにソースクロックで2〜3サイクルかかり、それでようやくready_srcが再び立ちます。ワードはやり取りの間ずっと凍結されていたので、宛先は安定した値をコピーしました。代償はスループットです。1ワードあたりおよそ宛先2サイクルとソース3サイクルかかるため、クロックが近い場合、ハンドシェイクは5〜6サイクルに1ワードしか運べません。制御レジスタやステータス値にはそれで十分です。サンプルストリームには不十分であり、だからこそ3つ目の構造があります。

この説明には1つ微妙な点が隠れています。ワードとトグルは同じエッジでソースを出発しますが、トグルがシンクロナイザから出てくる前に、ワードは宛先のフロップに到着していなければなりません。ロジックには、それを保証するものは何もありません。保証するのは配線です。制約の節で、この保証が実際に成り立つようそのパスに上限を定める方法を示します。

ルール3:ストリームは非同期FIFOを通して渡す

非同期FIFOは、一方のクロックに属する書き込みポインタと、もう一方のクロックに属する読み出しポインタを持つデュアルポートメモリです。メモリ自体は問題になりません。ブロックRAM(BRAM)は独立したポートクロックを持ち、フラグが正しければ同じ場所が同時に書き込まれかつ読み出されることはありません。問題はフラグです。書き込み側はfullかどうかを判断するために読み出しポインタを知る必要があり、読み出し側はemptyかどうかを判断するために書き込みポインタが必要です。各ポインタはクロック境界を越えなければならない複数ビットのバスであり、先ほどのコールアウトで述べたとおり、バスはビットごとに渡せません。

グレイコードがこれを解決します。グレイコードでは、連続する値はちょうど1ビットだけ異なります。ポインタが常に1ずつしか増えないなら、バスは常に一度に1ビットしか変化しません。独立したシンクロナイザの集まりは、古い値か新しい値のどちらかには落ち着きますが、それ以外にはなりえず、どちらも正当なポインタ位置です。乗り換えた値は数サイクル古いことがあり、その結果のフラグは少し早めにfullやemptyを示すかもしれませんが、真実と逆のことを言うことは決してありません。残り1スロットでfullを報告するFIFOは安全です。満杯なのにfullでないと報告するFIFOはデータを壊します。

clk_wr領域clk_rd領域書き込みポインタwr_bin, wr_graydual-port RAM16 x 8 bit読み出しポインタrd_bin, rd_graywr addrrd addr2段フロップ同期1ビット1個、clk_rd2段フロップ同期1ビット1個、clk_wrwr_gray(5ビット)rd_gray(5ビット)full = 比較empty = 比較
非同期FIFO。各ポインタは自身の領域にとどまります。乗り換えるのはグレイコード化したコピーだけで、ビットごとの2段フロップシンクロナイザを通り、各側は相手ポインタの古いコピーと比較します。

さらに2つの決定がフラグ論理をきれいにします。第一に、ポインタはアドレスに必要なビット数より1ビット多く持ちます。16ワードのメモリではアドレスは4ビットですが、ポインタは5ビットです。余分な1ビットはポインタが何回折り返したかを記録するので、「ポインタが等しい」がemptyを、「折り返しビット以外が等しい」がfullを意味し、両者の間に曖昧さはありません。第二に、グレイコードでは「折り返しビット以外が等しい」は単一ビットの反転ではありません。上位2ビットが反転し残りが等しい、という形になり、下のfull式が検査しているのはまさにそれです。

module async_fifo #(
    parameter integer WIDTH     = 8,
    parameter integer ADDR_BITS = 4      // depth = 16 words
) (
    input  wire             clk_wr,
    input  wire             rst_wr,      // synchronous to clk_wr
    input  wire             wr_en,
    input  wire [WIDTH-1:0] wr_data,
    output wire             full,

    input  wire             clk_rd,
    input  wire             rst_rd,      // synchronous to clk_rd
    input  wire             rd_en,
    output reg  [WIDTH-1:0] rd_data,
    output wire             empty
);
    localparam integer P = ADDR_BITS + 1;      // pointer carries one wrap bit

    reg [WIDTH-1:0] mem [0:(1 << ADDR_BITS) - 1];

    // Write side: binary pointer for addressing, Gray copy for crossing.
    reg  [P-1:0] wr_bin, wr_gray;
    wire         do_wr        = wr_en && !full;
    wire [P-1:0] wr_bin_next  = wr_bin + {{(P-1){1'b0}}, do_wr};
    wire [P-1:0] wr_gray_next = wr_bin_next ^ (wr_bin_next >> 1);

    // Read side, the same shape.
    reg  [P-1:0] rd_bin, rd_gray;
    wire         do_rd        = rd_en && !empty;
    wire [P-1:0] rd_bin_next  = rd_bin + {{(P-1){1'b0}}, do_rd};
    wire [P-1:0] rd_gray_next = rd_bin_next ^ (rd_bin_next >> 1);

    // Each Gray pointer crosses through its own two-flop synchroniser.
    (* ASYNC_REG = "TRUE" *) reg [P-1:0] rd_gray_w1, rd_gray_w2;   // rd pointer seen in clk_wr
    (* ASYNC_REG = "TRUE" *) reg [P-1:0] wr_gray_r1, wr_gray_r2;   // wr pointer seen in clk_rd

    always @(posedge clk_wr) begin
        if (rst_wr) begin
            wr_bin     <= {P{1'b0}};
            wr_gray    <= {P{1'b0}};
            rd_gray_w1 <= {P{1'b0}};
            rd_gray_w2 <= {P{1'b0}};
        end else begin
            wr_bin     <= wr_bin_next;
            wr_gray    <= wr_gray_next;
            rd_gray_w1 <= rd_gray;
            rd_gray_w2 <= rd_gray_w1;
            if (do_wr) mem[wr_bin[ADDR_BITS-1:0]] <= wr_data;
        end
    end

    always @(posedge clk_rd) begin
        if (rst_rd) begin
            rd_bin     <= {P{1'b0}};
            rd_gray    <= {P{1'b0}};
            wr_gray_r1 <= {P{1'b0}};
            wr_gray_r2 <= {P{1'b0}};
            rd_data    <= {WIDTH{1'b0}};
        end else begin
            rd_bin     <= rd_bin_next;
            rd_gray    <= rd_gray_next;
            wr_gray_r1 <= wr_gray;
            wr_gray_r2 <= wr_gray_r1;
            if (do_rd) rd_data <= mem[rd_bin[ADDR_BITS-1:0]];
        end
    end

    // Empty: the read pointer has caught up with the (stale) write pointer.
    assign empty = (rd_gray == wr_gray_r2);

    // Full: pointers differ only by one wrap. In Gray code that is the top
    // two bits inverted and everything below them equal.
    assign full = (wr_gray == {~rd_gray_w2[P-1:P-2], rd_gray_w2[P-3:0]});
endmodule

wr_grayとrd_grayが、バイナリポインタの組み合わせ論理による変換ではなく、レジスタであることに注目してください。乗り換えるグレイ値は、ルール1と同じグリッチの理由から、フロップから直接来なければなりません。バイナリポインタも依然として存在するのは、それがメモリのアドレスとなり、インクリメントの対象となるからです。向こう側でグレイからバイナリに戻す変換はフラグには不要であり、だからこそ比較はグレイのまま行っています。

フラグは、相手側クロックで約2サイクル分、保守的です。書き込み直後、読み出し側はまだ古い書き込みポインタを見ており、あと2読み出しサイクルの間emptyを報告するかもしれません。その代償はわずかなレイテンシと実効深さの1〜2スロットだけで、それ以外にはありません。メモリのレッスンでのFIFOサイズ設計の詳しい扱いは、そのまま当てはまります。

行った対策をツールに伝える

デフォルトでは、VivadoもQuartus Primeも、無関係なものを含むすべてのクロック対の間のすべてのパスをタイミング解析します。200 MHzと61.44 MHzの間の乗り換えのレポートには、数百ピコ秒という無意味な要求と巨大な違反が表示されます。初心者はこれを一括のset_false_pathで片付けて先に進みます。それでエラーは消えますが、乗り換えに対するすべての検査も消えてしまいます。本当に重要な2つの検査、すなわちハンドシェイクのデータワードは自身のフラグより先に着かなければならないこと、グレイポインタの各ビットは1サイクル以上ばらついてはならないこと、も含めてです。XDCとSDCのチートシートにある制約を使えば、これらの検査を残せます。

create_clock -name clk_src -period 5.000 [get_ports clk_src]
create_clock -name clk_dst -period 16.276 [get_ports clk_dst]

# Single-bit synchroniser inputs: no timing relationship, and ASYNC_REG keeps the flops adjacent.
set_false_path -to [get_cells -hierarchical -filter {ASYNC_REG == TRUE && PRIMITIVE_SUBGROUP == SDR}]

# Handshake data: must arrive within one destination period so it beats the toggle.
set_max_delay -datapath_only 16.276 \
    -from [get_cells u_hs/data_hold_reg[*]] \
    -to   [get_cells u_hs/data_dst_reg[*]]

# Gray pointer crossing: bound the delay and the skew between bits.
set_max_delay -datapath_only 16.276 \
    -from [get_cells u_fifo/wr_gray_reg[*]] \
    -to   [get_cells u_fifo/wr_gray_r1_reg[*]]
set_bus_skew 16.276 \
    -from [get_cells u_fifo/wr_gray_reg[*]] \
    -to   [get_cells u_fifo/wr_gray_r1_reg[*]]

鍵となる語句は-datapath_onlyです。これはアナライザに、クロック関係を無視し、起点フロップから捕捉フロップまでのセルと配線の遅延だけを検査するよう指示します。それこそが気にすべき物理量そのものです。Vivado 2017.1以降で使えるset_bus_skew制約は、バスの最も速いビットと最も遅いビットの差が1周期未満であるという第二の検査を加えます。これがグレイコードの仕掛けが成り立つ条件です。

1つ落とし穴があります。Vivadoではset_clock_groups -asynchronousがset_max_delayより優先されるため、2つのクロックを非同期グループとしても宣言すると、上限を定めたパスが再び無制限になります。戦略は1つに絞ってください。ハンドシェイクとFIFOを持つ設計では、上記のパスごとの制約の方が安全な選択です。Quartus Primeも同じ例外を同様に扱いますが、優先順位の規則は独自のものなので、使用しているバージョンのTiming Analyzerのドキュメントを確認してください。

CDCリント:見落としをツールに見つけさせる

構造上のルールは述べるのは簡単ですが、40,000行の設計では破るのも簡単です。主要なベンダーのフローには、いまやどれもCDC検査が備わっています。Vivadoでは、配置後にreport_cdcを実行するとすべての乗り換えが列挙され、分類(2段フロップシンクロナイザ、ハンドシェイク、グレイコード化バス、FIFOプリミティブ、未制約)と深刻度が付けられます。追いかけるべき重大なものは、グレイコード化されていないのに独立したシンクロナイザで渡しているバス、入力が組み合わせ論理から来ているシンクロナイザ、同期化せずに渡っているリセットです。Quartus Prime ProにはTiming Analyzer内にCDCビューアがあり、同じ役割を果たします。ベンダーツールの外では、Siemens Questa CDCとSynopsys VC SpyGlass CDCが形式検証を行い、アクノリッジが戻る前にソースがデータを変えてしまうハンドシェイクのようなプロトコル誤りも検出します。これらは高価で、本稿執筆時点では、構造的なパターン照合を超えるオープンソースの同等品はありません。

レポートはコンパイラの警告一覧のように扱ってください。重大な指摘がゼロであることが基準であり、すべての警告は修正するか、理由を書き残した上で免除するかのどちらかにすべきです。

演習

テストベンチのレッスンの構成に従って、200 MHzの書き込みクロックと61.44 MHzの読み出しクロックでasync_fifoのテストベンチを書いてください。書き込み側を全速で動かして64個のインクリメントするバイトを投入し、emptyが許す限り速く読み出して、出力の順序を確認します。次にfullフラグを観察し、書き込み側が止まるまでに16スロットのうち実際に何個使えるかを数えてください。その数を、シンクロナイザの2サイクルのレイテンシから説明します。最後にADDR_BITSを1に減らし、モジュールがコンパイルできなくなる理由をfull式から説明してください。

まとめ

  • 無関係なクロックをサンプリングするフロップは準安定状態になります。まだ未決定である確率はe^(−t/τ)に従って減るため、丸1サイクルの整定時間を与えると、故障率は数分に1回から宇宙の年齢を超える間隔になります。
  • 1ビットは、ソースのフロップから直接入力され、間にロジックを挟まない、隣接した2段のフロップを通して渡します。
  • 1ワードは、データを凍結してトグルを往復させることで渡します。ストリームは、1ステップで1ビットしか変わらないようポインタをグレイコード化したFIFOで渡します。
  • 乗り換えに一括のフォルスパスを使ってはいけません。データパスはset_max_delay -datapath_onlyで、バスはset_bus_skewで上限を定め、Vivadoでは非同期クロックグループがその両方を上書きすることを忘れないでください。
  • CDCレポートを毎回実行し、重大な指摘をすべて解消してください。構造は単純ですが、誤用の仕方は単純ではありません。

さらに読む