CDC and RDC with Formal

Separate what a structural crossing check proves from what a property proves, model a racing synchroniser bit as a free choice, and see why a two-clock formal model needs that model to find anything.

Lesson
F5.6
Time
18 min
Runs on
SymbiYosys
Check
4 questions

A FIFO's write pointer crossed into the read domain through two flops, and a refactor replaced its gray converter with the binary count. The structural CDC check still saw a two-flop synchroniser and passed it. In silicon a pointer change caught mid-flight produced a value the writer never held, and the reader ran past the data.

The binary pointer moves two bits in one edge
01234clkfreerstfreeincptr000000010010ptr_q0000000100100001 to 0010SBY reports FAIL

Measured with smtbmc yices at depth 20. The shortest failure is the first count with two changing bits, 1 to 2, not the famous 7 to 8. Sampled at step 3, reported at 4.

Structure Finds the Synchroniser; a Property Checks the Value

What Each Check Can SeeThe write-domain pointer crosses through two flops into the read domain. The dashed boundary is where a structural check looks. Arrows mean "drives".
call: who calls whom
What each check can seeThe write-domain pointer crosses through two flops into the read domain. The dashed boundary is where a structural check looks. Arrows mean "drives". component bin counter(wclk) counts writes component gray register(wclk) bin ^ (bin >> 1), froma flop component sync1 (rclk) may go metastablewhen ptr moves nearthe edge component sync2 (rclk) settled copy used bythe read side component Structural check is there a recognisedsynchroniser? yes component Formal property does at most one bitchange per sourceedge? crosses
Read the diagram as text
  • bin counter (wclk): counts writes
  • gray register (wclk): bin ^ (bin >> 1), from a flop
  • sync1 (rclk): may go metastable when ptr moves near the edge
  • sync2 (rclk): settled copy used by the read side
  • Structural check: is there a recognised synchroniser? yes
  • Formal property: does at most one bit change per source edge?
  • bin counter (wclk) → gray register (wclk)
  • gray register (wclk) → sync1 (rclk): crosses
  • sync1 (rclk) → sync2 (rclk)
  • Structural check → sync1 (rclk)
  • Formal property → gray register (wclk)

Replace the gray register with the binary count and the structure is unchanged: same two flops, same pass. Only the property sees the difference.

A structural CDC check reads the netlist: every flop in one domain whose input comes from another must pass through a recognised synchroniser. That catches a missing synchroniser. It cannot know whether the value sent through a correct one is safe to send.

That is a property. On the source clock: $countones(ptr ^ $past(ptr)) <= 1. The gray pointer gives BMC PASS to depth 20 and Prove PASS (k = 4); the binary count gives FAIL at reported step 4.

Why One Bit Is the Rule

StepBinaryBits movingMixes a racing sample can produceGrayMixes
1 to 20001 to 001020000, 0011: neither old nor new0001 to 00110001 or 0011 only
7 to 80111 to 10004any of 16 values0100 to 11000100 or 1100 only

With one bit moving, every mix of old and new bits is the old or the new value. That is the whole reason for gray code.

A flop that samples a bit as it changes may settle either way. If several bits move at once, each may settle differently. On this engine a flop never goes metastable, so the lab adds a free input, meta, that chooses old or new for every moving bit (F1.6).

Racing bits settle to a value the source never held
012345clkfreerstfreeincptr000000010010freemeta000000100000sync1000000110010ptr 0001 to 0010, sampled mid-changesync1 = 0011SBY reports FAIL

Measured with smtbmc yices at depth 20, binary pointer. Old 1, new 2, caught 3: one bit from each. Reported at 5.

The binary pointer fails, FAIL at reported step 5: the reader sees 3. The gray pointer gives BMC PASS to depth 20 and Prove PASS (k = 4).

Two Real Clocks

Two-clock run (SBY multiclock on)ResultTime
Binary pointer, plain flopsBMC PASS to depth 40293 ms
Binary pointer, racing bits modelledFAIL at reported step 5189 ms
Gray pointer, racing bits modelledBMC PASS to depth 40331 ms
Gray pointer, racing bits, cover sync2 == 9Cover reached, reported step 31323 ms

Both clocks come from phase accumulators with free, fixed steps; the read clock is at least as fast as the write clock. Run straight from the pinned suite: the site's runner accepts one clock only.

With two clocks and plain flops, the binary pointer passes: in the model every capture is clean. Only with racing bits modelled does it fail, and the gray pointer still passes. The cover shows that run moved data across, so its pass is not empty.

The lesson for any CDC proof: the model decides what can go wrong. Commercial CDC tools add such metastability models or ask for them; check which your flow uses before trusting a pass.

Reset Domain Crossings

The hazard

rst_a asserted (async to clk_b)
  -> flop in domain A changes now
  -> domain-B flop samples it mid-change

A reset edge is one more asynchronous data change.

The check

structural: which crossings see rst_a?
property:   while rst_a is asserted,
            the domain-B consumer is held

Same split as CDC: structure first, then a protocol property.

An asynchronous reset in one domain changes the crossing flop's output at a time the other domain did not choose. This engine refuses asynchronous resets, so the page draws the check and leaves the run to a full two-clock flow.

Asked in Interviews

QuestionAnswer
What does formal CDC check that structural CDC does not?The protocol on the crossing: one bit per edge for a gray pointer, pulse width for a pulse synchroniser, the request/acknowledge rule for a handshake.
Why gray code?One bit changes per step, so any mix of old and new bits is still the old or the new value.
Does a two-clock formal model find metastability bugs?Not with plain flops: every capture is clean. You add a model in which a changing bit settles either way.
What is RDC?A reset in one domain changing a value that crosses into a running domain: the same hazard, with reset as the cause.

Try It

The rules in SVA

assert property (@(posedge clk) disable iff (rst) $countones(ptr ^ $past(ptr)) <= 1);
assert property (@(posedge clk) disable iff (rst) sync1 == $past(ptr) || sync1 == $past(ptr, 2));

One rule on the source pointer, one on the first synchroniser flop.

gray_sync as this engine runs it

  always @(posedge clk) begin
    if ($initstate) begin assume(rst); end else begin assume(!rst); end
  end
  reg [3:0] ptr_q2=0;   // ptr two edges ago
  reg live=0;
  always @(posedge clk) begin ptr_q2 <= ptr_q; live <= !rst; end
  always @(posedge clk) if (!rst && live) assert(sync1 == ptr_q || sync1 == ptr_q2);

$past(ptr, 2) becomes the copy ptr_q2.

The whole task for gray4. gray_sync uses the same task with prep -top gray_sync.
[options]
mode bmc
depth 20
[engines]
smtbmc yices
[script]
read_verilog -formal -sv design.sv
prep -top gray4
[files]
design.sv
The two-clock design and its .sby file
Racing bits modelled on the global formal clock.
module cdc2(input winc, input [3:0] meta);
  (* anyconst *) reg [3:0] wstep, rstep;   // clock model: two phase accumulators
  reg [3:0] wph=0, rph=0;
  always @(*) assume(wstep != 0 && wstep <= 4'd8 && rstep >= wstep && rstep <= 4'd8);
  always @($global_clock) begin wph <= wph + wstep; rph <= rph + rstep; end
  wire wclk = wph[3], rclk = rph[3];
  reg [3:0] bin=0, ptr=0;                  // write domain
  wire [3:0] bin_next = bin + winc;
  always @(posedge wclk) begin bin <= bin_next; ptr <= bin_next; end
  reg [3:0] ptr_g=0, ptr_prev=0;           // ptr one global tick ago, and before its last change
  always @($global_clock) begin ptr_g <= ptr; if (ptr != ptr_g) ptr_prev <= ptr_g; end
  wire [3:0] moving = ptr ^ ptr_g;
  reg [3:0] sync1=0, sync2=0;              // read domain: two-flop synchroniser
  always @(posedge rclk) begin
    sync1 <= (ptr & ~moving) | (moving & ((meta & ptr) | (~meta & ptr_g)));
    sync2 <= sync1;
  end
  always @($global_clock) assert(sync1 == ptr || sync1 == ptr_g || sync1 == ptr_prev);
endmodule
Run with the pinned suite: sby -f cdc2.sby.
[options]
mode bmc
depth 40
multiclock on
[engines]
smtbmc yices
[script]
read_verilog -formal -sv cdc2.sv
prep -top cdc2
[files]
cdc2.sv
  1. Break the converter

    Run as it is: FAIL at reported step 4. Which count does the trace stop at, and why not 7 to 8?

  2. Restore gray

    Put the converter back: BMC PASS to depth 20. Switch to mode prove, depth 4: Prove PASS (k = 4).

  3. Race the bits

    Run gray_sync with the binary pointer: FAIL at reported step 5. Read meta at the failing step.

  4. Race gray bits

    Restore the gray pointer in gray_sync: BMC PASS to depth 20.

design.sv

props.sv

Use assert property (@(posedge clk) ...);. Leave blank for immediate assertions in the design.

Inputs are free unless constrained. Run to find a counterexample or check a proof.

Model: one positive-edge clock, binary data. Sampled history requires declaration-initialized variables; primary-input history is unsupported.

SBY summary and log

Check Yourself

Predict the outputgray4 with the binary pointer, mode bmc, default depth 20, smtbmc yices, reset assumed in step 0, inc free, assert($countones(ptr ^ ptr_q) <= 1). At which reported step does it fail?
module gray4(input clk, rst, inc, output reg [3:0] ptr=0);
  reg [3:0] bin=0;
  wire [3:0] bin_next = bin + inc;
  always @(posedge clk)
    if (rst) begin bin <= 0; ptr <= 0; end
    else begin
      bin <= bin_next;
      ptr <= bin_next;   // bug: the binary count crosses
    end
`ifdef FORMAL
  always @(posedge clk) begin
    if ($initstate) begin assume(rst); end else begin assume(!rst); end
  end
  reg [3:0] ptr_q=0;   // last cycle's ptr
  always @(posedge clk) ptr_q <= ptr;
  always @(posedge clk) if (!rst) assert($countones(ptr ^ ptr_q) <= 1);
`endif
endmodule
ConceptWhich claim about the crossing is wrong?
ConceptUnder SBY multiclock on with plain flops, the binary pointer passes. Why?
Read the waveformIn the racing-bits trace, what does sync1 catch, and from which pointer change?
Racing bits settle to a value the source never held
012345clkfreerstfreeincptr000000010010freemeta000000100000sync1000000110010ptr 0001 to 0010, sampled mid-changesync1 = 0011SBY reports FAIL

Measured with smtbmc yices at depth 20, binary pointer. Old 1, new 2, caught 3: one bit from each. Reported at 5.

Remember

  • Structural CDC finds a missing synchroniser; a property checks that the value sent through it is one a synchroniser can carry.
  • A multi-bit value crosses safely only if at most one bit changes per source edge: gray code.
  • Formal flops never go metastable; to find these bugs, model a changing bit as settling either way.
  • RDC is the same hazard with an asynchronous reset as the cause.