When a formal check fails, the tool gives you a counterexample: a short waveform that starts at reset and ends with the broken rule. Reading it well is the most important practical skill in formal verification. This article takes a real counterexample from the lab engine, for a FIFO occupancy counter with a bug, and reads it step by step, from the failure back to the cause.

9 min read | Beginner to Intermediate

What You'll Learn

  • Find the failing step and the failing assertion in the formal log
  • Read a counterexample waveform backwards from the failure to the cause
  • Tell the inputs that cause a failure from the inputs that are only there by chance
  • Explain why a counterexample is as short as possible

The Design: A FIFO Occupancy Counter

A FIFO (first in, first out buffer) keeps track of how many entries it holds. This is the counter part of a FIFO with 4 entries. A push adds an entry, a pop removes one, full blocks pushes when there is no room, and empty blocks pops when there is nothing to read:


module fifo_count (input clk, input rst, input push, input pop,
                   output reg [2:0] count, output full, output empty);
  assign full  = (count == 3'd5);     // bug: a 4-entry FIFO is full at 4
  assign empty = (count == 3'd0);
  always @(posedge clk)
    if (rst)                count <= 3'd0;
    else if (push && !full) count <= count + 3'd1;
    else if (pop && !empty) count <= count - 3'd1;
endmodule

If a push and a pop arrive in the same cycle, the push wins. That keeps the example short. A real FIFO would let both happen and keep the count unchanged.

The designer typed 5 instead of 4 in the full condition. The rule that catches it is simple: a FIFO with 4 entries never holds more than 4.


a_count: assert property (@(posedge clk) disable iff (rst) count <= 3'd4);

In the form the lab reads:


`ifdef FORMAL
  reg init = 1'b1;
  always @(posedge clk) init <= 1'b0;
  always @(*) if (init) assume (rst);
  always @(posedge clk) if (!init) assert (count <= 3'd4);
`endif

Step 1: Read the Log

Run the check at depth 10. The log ends like this (shortened):

Output
SBY [run] engine_0: ##   0:00:00  Checking assertions in step 7..
SBY [run] engine_0: ##   0:00:00  BMC failed!
SBY [run] engine_0: ##   0:00:00  Assert failed in fifo_count: design.sv:14.36-14.58
SBY [run] engine_0: ##   0:00:00  Writing trace to VCD file: engine_0/trace.vcd
SBY [run] summary: engine_0 (smtbmc bitwuzla) returned FAIL
SBY [run] summary: counterexample trace: run/engine_0/trace.vcd
SBY [run] summary:   failed assertion fifo_count._witness_.check_assert_design_sv_14_15 at design.sv:14.36-14.58 step 7
SBY [run] DONE (FAIL, rc=2)

Three facts come from the log before you open any waveform:

  • Which property failed: the one at design.sv, line 14, columns 36 to 58. That is the assert (count <= 3'd4). When a design has several assertions, the line number tells you which one.
  • When it failed: step 7. Steps start at 0, so the failure happens in the eighth clock cycle.
  • Where the trace is: trace.vcd, a standard waveform file. The lab opens it for you in the waveform viewer.

The tool stopped at step 7 although the depth was 10. A bounded check looks at step 0, then step 1, and so on, and stops at the first step in which an assertion can fail. So the counterexample is always as short as possible: there is no way to break this rule in fewer than 8 cycles.

Quick Check

Q: A design has five assertions and the check fails. How do you know which assertion failed?

Show Answer

From the log. The "Assert failed" and "failed assertion" lines give the file, line and column of the assertion that failed, and the step in which it failed.

Step 2: Read the Waveform Backwards

A counterexample is read from the end. Start at the failure and ask "why" until you reach a line of RTL. Here is the trace from the lab engine:

Working backwards:

  1. Step 7: the assertion fails. The value it checked is the one from step 6, where count is 5. An assert inside a clocked block samples its signals at the clock edge, so the tool reports the failure one step after the bad value appears.
  2. Why is count 5 in step 6? In step 5, count was 4 and push was 1. The counter only adds 1 when full is 0, and full was 0.
  3. Why was full 0 with 4 entries? Because full is defined as count == 3'd5. With 4 entries the FIFO is already full, but the design does not say so. That is the bug.

Three questions led from the symptom to the line of RTL. This is the normal way to debug a counterexample, and with practice it often takes less than a minute.

Step 3: Ignore What Does Not Matter

The trace also shows pop going high in steps 3 and 5. A beginner might think the pops are part of the bug. They are not. The solver picks a value for every input in every step, and any value that does not stop the failure is allowed. In steps 3 and 5 push is also high, and push has priority, so the pops have no effect at all.

To tell the important inputs from the accidental ones, ask: "would the failure still happen if this input had the other value?" For the pops the answer is yes, so they are noise. For the push in step 5 the answer is no: without it, count stays at 4 and the rule holds.

Quick Check

Q: In the counterexample, pop is 1 in step 3 but count still goes up. Is that a second bug?

Show Answer

No. push is also 1 in step 3, and the design gives a push priority over a pop. The pop is a value the solver chose freely; it does not influence the failure.

Step 4: Fix and Rerun

Change the full condition to count == 3'd4 and run again. The run passes: within 10 cycles no input sequence can push the count above 4. Always rerun after a fix. A fix can be incomplete, or it can break a different rule, and the next counterexample shows that at once.

A good habit is to add a second assertion that states the meaning of full directly, so that a similar mistake is caught by a clearer property next time:


a_nofull: assert property (@(posedge clk) disable iff (rst) full |-> count == 3'd4);

The operator |-> means "if the left side is true, the right side must be true in the same cycle". Chapter 3 covers it. In the lab form it is if (!init && full) assert (count == 3'd4);.

Running It in ChipVerify Lab

The example below is the buggy FIFO counter with the assertion. When the check fails, the lab loads the counterexample into the waveform viewer, where you can add count, push, pop and full and step through the cycles yourself.

Common Mistakes

Mistake #1: Reading the counterexample from the start

The early cycles of a trace are usually reset and setup. Start at the failing step and work backwards, asking why each value is what it is. That leads to the cause much faster.

Mistake #2: Treating every input change as part of the bug

The solver chooses all inputs, including ones that play no role. Check each input change by asking whether the failure still happens without it, and ignore the ones that do not matter.

Mistake #3: Fixing the assertion instead of the design

If the rule is right, the counterexample points to a design bug. Relaxing the assertion to count <= 3'd5 makes the check pass and hides the bug. Change a property only when the counterexample shows that the property itself is wrong.

Practice Exercise

Challenge: Debug and Harden the FIFO Counter

Open the example and run it. Find the bug from the counterexample, fix it, and rerun. Then add the assertion "when empty is high, count is 0" and an assertion that full and empty are never high together. Run again.

Hint

In the lab form, a "when A, then B" rule in the same cycle is written as if (!init && A) assert (B);. "Never both" is assert (!(full && empty));.

Solution

The fix is assign full = (count == 3'd4);. The two new assertions are:


always @(posedge clk) if (!init && empty) assert (count == 3'd0);
always @(posedge clk) if (!init) assert (!(full && empty));

All three assertions pass at depth 10.

Open the ready-made example in the lab and run the formal check from the formal panel. The free tier allows a depth of up to 10 cycles, and PRO allows up to 100.

Summary

  • The log tells you which assertion failed, by file and line, and in which step.
  • A bounded check stops at the first failing step, so a counterexample is the shortest possible way to break the rule.
  • A clocked assertion reports a failure one step after the bad value appears, because it samples at the clock edge.
  • Read the waveform backwards: from the failure, ask why each value is what it is until you reach a line of RTL.
  • Inputs that do not change the outcome are chosen freely by the solver. Ignore them.
  • After a fix, always rerun, and fix the design, not the assertion, unless the assertion is wrong.
Did you find what you were looking for?

Quiz

No quiz questions available for this article.