
Formal Verification with SymbiYosys and Yices2: Proving Your RTL Correct
Simulation tells you what happened in the cases you thought of. Formal verification tells you what happens in every case that can ever occur.
That difference matters. A handshake protocol looks correct in simulation when your testbench never hits the corner case where both sides stall simultaneously. Formal will find it on the first run.
This post walks through a complete formal verification flow using SymbiYosys and Yices2. You will write a real design with real bugs, write SVAs to catch them, run the tools, read the counterexamples, and fix the issues one by one.
What SymbiYosys Actually Is
SymbiYosys is a front end for formal hardware verification. It takes your RTL, your property files, and a solver backend, and either proves your assertions hold for all reachable states or gives you a concrete trace showing a violation.
It is not a simulator. It does not run test vectors. It uses formal methods, specifically bounded model checking and k-induction, to reason about all possible behaviors of your design up to some bound depth.
Yices2 is one of the SMT solvers that SymbiYosys can use as a backend. It is fast, mature, and works well for most RTL problems. Other backends include Boolector, Z3, and ABC.
The flow looks like this:
RTL (Verilog/SV)
+
Property file (.sby)
|
v
SymbiYosys
|
v
Yosys (synthesis to internal IR)
|
v
Yices2 (SMT solving)
|
v
PASS or FAIL + counterexample trace
Installing the Tools
On Ubuntu or Debian
sudo apt-get update
sudo apt-get install -y symbiyosys yices2
If those packages are not in your distribution’s repository, install from source.
Installing SymbiYosys from Source
# Install Yosys first
sudo apt-get install -y build-essential clang bison flex \
libreadline-dev gawk tcl-dev libffi-dev git graphviz \
xdot pkg-config python3 libboost-system-dev \
libboost-python-dev libboost-filesystem-dev zlib1g-dev
git clone https://github.com/YosysHQ/yosys.git
cd yosys
make -j$(nproc)
sudo make install
cd ..
# Now install SymbiYosys
git clone https://github.com/YosysHQ/sby.git
cd sby
sudo make install
cd ..
Installing Yices2 from Source
git clone https://github.com/SRI-CSL/yices2.git
cd yices2
autoconf
./configure
make -j$(nproc)
sudo make install
cd ..
Verify the Installation
yosys --version
sby --version
yices-smt2 --version
All three should print version strings without errors.
The Design Under Verification
The design is a ready/valid handshake controller. It manages a two-sided handshake: a producer side and a consumer side. The controller asserts that data only transfers when both sides are ready, and it should never deadlock.
Here is the design with intentional bugs included. We will find and fix them using formal.
// handshake_ctrl.v
module handshake_ctrl (
input wire clk,
input wire rst_n,
// Producer side
input wire prod_valid,
output reg prod_ready,
// Consumer side
output reg cons_valid,
input wire cons_ready,
// Internal data path
output reg transfer_done
);
// State encoding
localparam IDLE = 2'b00;
localparam WAITING = 2'b01;
localparam ACTIVE = 2'b10;
localparam DONE = 2'b11;
reg [1:0] state;
reg [1:0] next_state;
// BUG 1: State register has no reset. On power-up,
// state is X, and the FSM never reaches a known state.
always @(posedge clk) begin
state <= next_state;
end
// Next state logic
always @(*) begin
next_state = state;
prod_ready = 1'b0;
cons_valid = 1'b0;
transfer_done = 1'b0;
case (state)
IDLE: begin
prod_ready = 1'b1;
if (prod_valid)
next_state = WAITING;
end
WAITING: begin
cons_valid = 1'b1;
// BUG 2: If cons_ready never asserts, we stay
// in WAITING forever. No timeout, no escape.
if (cons_ready)
next_state = ACTIVE;
end
ACTIVE: begin
cons_valid = 1'b1;
prod_ready = 1'b1;
transfer_done = 1'b1;
next_state = DONE;
end
DONE: begin
// BUG 3: DONE transitions back to WAITING, not IDLE.
// A new transaction starts before prod_valid is checked.
next_state = WAITING;
end
default: next_state = IDLE;
endcase
end
endmodule
Three bugs are planted here deliberately:
- No reset on the state register
- A deadlock path when the consumer stalls permanently
- A wrong state transition that skips the idle handshake
Writing the Formal Verification Wrapper
The formal wrapper instantiates the DUT and houses all SVA properties. It uses assume for input constraints, assert for properties we want to prove, and cover for reachability goals.
// handshake_ctrl_fv.sv
module handshake_ctrl_fv (
input wire clk,
input wire rst_n
);
// Free variables (inputs to DUT)
wire prod_valid;
wire cons_ready;
// DUT outputs
wire prod_ready;
wire cons_valid;
wire transfer_done;
// Instantiate DUT
handshake_ctrl dut (
.clk (clk),
.rst_n (rst_n),
.prod_valid (prod_valid),
.prod_ready (prod_ready),
.cons_valid (cons_valid),
.cons_ready (cons_ready),
.transfer_done(transfer_done)
);
// -------------------------------------------------------
// Reset behavior
// -------------------------------------------------------
// After reset deasserts, the state must be known (IDLE).
// We check this by asserting prod_ready should be high
// and cons_valid should be low in the first cycle after reset.
property reset_to_idle;
@(posedge clk)
$rose(rst_n) |=> (prod_ready == 1'b1 && cons_valid == 1'b0);
endproperty
assert property (reset_to_idle)
else $error("FV FAIL: Reset did not land in IDLE");
// -------------------------------------------------------
// Handshake protocol: valid before ready is not allowed
// on the producer side. prod_ready must come from the DUT
// and should not assert when cons_valid is active.
// -------------------------------------------------------
property no_double_accept;
@(posedge clk) disable iff (!rst_n)
(cons_valid && !cons_ready) |-> !prod_ready;
endproperty
assert property (no_double_accept)
else $error("FV FAIL: prod_ready asserted while consumer is stalled");
// -------------------------------------------------------
// Transfer done should only pulse for one cycle
// -------------------------------------------------------
property transfer_done_one_cycle;
@(posedge clk) disable iff (!rst_n)
transfer_done |=> !transfer_done;
endproperty
assert property (transfer_done_one_cycle)
else $error("FV FAIL: transfer_done held for more than one cycle");
// -------------------------------------------------------
// Deadlock check: if cons_valid is asserted, the FSM
// must eventually leave the WAITING state. We bound this
// using a liveness-style assertion over a finite window.
// -------------------------------------------------------
// Track consecutive cycles where we are stuck waiting
reg [4:0] wait_count;
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
wait_count <= 5'd0;
else if (cons_valid && !cons_ready && !prod_ready)
wait_count <= wait_count + 1;
else
wait_count <= 5'd0;
end
// If we have been stuck for 16 cycles, something is wrong
property no_deadlock;
@(posedge clk) disable iff (!rst_n)
wait_count < 5'd16;
endproperty
assert property (no_deadlock)
else $error("FV FAIL: Deadlock detected, FSM stuck for 16+ cycles");
// -------------------------------------------------------
// Output stability: cons_valid should not glitch low
// once asserted during a transfer without a reset
// -------------------------------------------------------
property cons_valid_stable;
@(posedge clk) disable iff (!rst_n)
(cons_valid && !cons_ready) |=> cons_valid;
endproperty
assert property (cons_valid_stable)
else $error("FV FAIL: cons_valid dropped without handshake completion");
// -------------------------------------------------------
// Reachability covers: prove transfer can complete
// -------------------------------------------------------
cover property (
@(posedge clk) disable iff (!rst_n)
$rose(transfer_done)
);
cover property (
@(posedge clk) disable iff (!rst_n)
$rose(rst_n) ##[1:20] $rose(transfer_done)
);
endmodule
The SymbiYosys Configuration File
The .sby file tells SymbiYosys which files to use, which top module to verify, which solver to use, and what mode to run in.
# handshake.sby
[options]
mode bmc
depth 30
multiclock off
[engines]
smtbmc yices
[script]
read -formal handshake_ctrl.v
read -formal handshake_ctrl_fv.sv
prep -top handshake_ctrl_fv
[files]
handshake_ctrl.v
handshake_ctrl_fv.sv
A few things worth understanding here:
mode bmc runs bounded model checking. It checks all assertions up to depth clock cycles from any reachable initial state.
depth 30 means the solver explores traces up to 30 cycles long. For detecting deadlock in this design, 30 is more than enough. For deep protocol bugs, you may need to increase this.
smtbmc yices selects the Yices2 backend via the SMT-BMC bridge that SymbiYosys provides.
Running SymbiYosys
sby -f handshake.sby
The -f flag forces a clean run by removing any previous output directory.
Output will look something like this:
SBY 0:00:00 [handshake] Removing directory 'handshake'.
SBY 0:00:00 [handshake] Copy 'handshake_ctrl.v' to 'handshake/src/handshake_ctrl.v'.
SBY 0:00:00 [handshake] Copy 'handshake_ctrl_fv.sv' to 'handshake/src/handshake_ctrl_fv.sv'.
SBY 0:00:00 [handshake] engine_0: smtbmc yices
SBY 0:00:01 [handshake] Generating SMTLIB2 model...
SBY 0:00:02 [handshake] engine_0: ## 0:00:00 Checking assumptions...
SBY 0:00:02 [handshake] engine_0: ## 0:00:00 BMC: step 1
...
SBY 0:00:03 [handshake] engine_0: ## 0:00:01 BMC: FAILED (assert)
SBY 0:00:03 [handshake] engine_0: Status: FAILED
SBY 0:00:03 [handshake] engine_0: Writing trace to 'handshake/engine_0/trace.vcd'
SBY 0:00:03 [handshake] DONE (FAIL, rc=2)
It fails immediately. The first thing that fails will be the reset property.
Reading the Counterexample
SymbiYosys writes a VCD file to the engine output directory.
gtkwave handshake/engine_0/trace.vcd
You will see that after rst_n rises, the state register does not land in IDLE. It holds the value X because it was never reset. prod_ready is therefore undefined and the assertion fires on cycle 1.
This is Bug 1.
Fix 1: Add a Synchronous Reset to the State Register
always @(posedge clk) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
Run SymbiYosys again.
sby -f handshake.sby
The reset assertion now passes. The tool moves on and finds the next failure. This time it is the deadlock assertion.
SBY engine_0: BMC: FAILED (assert)
SBY engine_0: Writing trace to 'handshake/engine_0/trace.vcd'
Reading the Deadlock Trace
Open the new VCD. You will see:
- Reset deasserts
prod_validasserts for one cycle- FSM moves from
IDLEtoWAITING cons_validassertscons_readynever assertswait_countincrements every cycle- At cycle 17,
wait_countreaches 16 and the assertion fires
This is Bug 2. The FSM has no way to escape WAITING if the consumer never responds.
Fix 2: Add a Timeout and an Abort Path
reg [4:0] timeout_cnt;
always @(posedge clk) begin
if (!rst_n)
timeout_cnt <= 5'd0;
else if (state == WAITING)
timeout_cnt <= timeout_cnt + 1;
else
timeout_cnt <= 5'd0;
end
// In the next state logic, add a timeout exit from WAITING
WAITING: begin
cons_valid = 1'b1;
if (cons_ready)
next_state = ACTIVE;
else if (timeout_cnt == 5'd15)
next_state = IDLE; // Abort and return to idle
end
Run again.
sby -f handshake.sby
The deadlock assertion now passes. The tool finds the next failure: the wrong transition in DONE.
SBY engine_0: BMC: FAILED (assert) -- no_double_accept
Reading the State Transition Trace
The VCD now shows:
- A transfer completes
- FSM enters
DONE DONEimmediately transitions toWAITINGwithout passing throughIDLE- In
WAITING,cons_validasserts - Simultaneously,
prod_readyis still high from the brief passage throughACTIVE - The
no_double_acceptassertion fires
This is Bug 3. After a transfer, the FSM skips IDLE entirely and goes back to hunting for a consumer without checking whether the producer has a new transaction ready.
Fix 3: Return to IDLE from DONE
DONE: begin
transfer_done = 1'b1;
next_state = IDLE; // Return to IDLE, not WAITING
end
Also remove transfer_done from the ACTIVE state since we moved it to DONE:
ACTIVE: begin
cons_valid = 1'b1;
prod_ready = 1'b1;
next_state = DONE;
end
Run again.
sby -f handshake.sby
The Full Fixed Design
// handshake_ctrl_fixed.v
module handshake_ctrl (
input wire clk,
input wire rst_n,
input wire prod_valid,
output reg prod_ready,
output reg cons_valid,
input wire cons_ready,
output reg transfer_done
);
localparam IDLE = 2'b00;
localparam WAITING = 2'b01;
localparam ACTIVE = 2'b10;
localparam DONE = 2'b11;
reg [1:0] state;
reg [1:0] next_state;
reg [4:0] timeout_cnt;
// FIX 1: Synchronous reset on state register
always @(posedge clk) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// FIX 2: Timeout counter
always @(posedge clk) begin
if (!rst_n)
timeout_cnt <= 5'd0;
else if (state == WAITING)
timeout_cnt <= timeout_cnt + 1;
else
timeout_cnt <= 5'd0;
end
always @(*) begin
next_state = state;
prod_ready = 1'b0;
cons_valid = 1'b0;
transfer_done = 1'b0;
case (state)
IDLE: begin
prod_ready = 1'b1;
if (prod_valid)
next_state = WAITING;
end
WAITING: begin
cons_valid = 1'b1;
if (cons_ready)
next_state = ACTIVE;
else if (timeout_cnt == 5'd15)
next_state = IDLE;
end
ACTIVE: begin
cons_valid = 1'b1;
prod_ready = 1'b1;
next_state = DONE;
end
// FIX 3: Return to IDLE, assert transfer_done here
DONE: begin
transfer_done = 1'b1;
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
endmodule
Running the Full Clean Pass
sby -f handshake.sby
Expected output:
SBY 0:00:02 [handshake] engine_0: ## 0:00:01 BMC: step 30
SBY 0:00:02 [handshake] engine_0: ## 0:00:01 BMC: PASSED
SBY 0:00:02 [handshake] engine_0: Status: passed
SBY 0:00:02 [handshake] DONE (PASS, rc=0)
All assertions pass for all traces up to 30 cycles deep.
Running Cover Mode to Check Reachability
A passing BMC result does not tell you whether transfer_done is ever reachable. The cover properties handle that.
Add a second sby configuration for cover mode:
# handshake_cover.sby
[options]
mode cover
depth 30
[engines]
smtbmc yices
[script]
read -formal handshake_ctrl.v
read -formal handshake_ctrl_fv.sv
prep -top handshake_ctrl_fv
[files]
handshake_ctrl.v
handshake_ctrl_fv.sv
sby -f handshake_cover.sby
Expected output:
SBY engine_0: Cover: PASSED (2 reached)
SBY DONE (PASS, rc=0)
Both cover properties are reachable. The solver found a valid input sequence that causes transfer_done to assert within 30 cycles of reset.
Running k-Induction for Unbounded Proof
BMC proves properties up to a depth bound. k-Induction can prove them for all depths.
# handshake_prove.sby
[options]
mode prove
depth 20
[engines]
smtbmc yices
[script]
read -formal handshake_ctrl.v
read -formal handshake_ctrl_fv.sv
prep -top handshake_ctrl_fv
[files]
handshake_ctrl.v
handshake_ctrl_fv.sv
sby -f handshake_prove.sby
If all assertions hold under induction, SymbiYosys prints:
SBY engine_0: Inductive step: PASSED
SBY DONE (PASS, rc=0)
This is a stronger result. It means the properties hold not just up to 20 cycles but for any reachable trace of any length.
SVA Reference: Patterns Used in This Post
Here is a quick reference for the SVA constructs used above.
Implication:
// If A is true at the current clock, then B must be true at the same clock
A |-> B
// If A is true now, then B must be true one cycle later
A |=> B
Sequence operators:
// B must occur exactly 2 cycles after A
A ##2 B
// B must occur between 1 and 5 cycles after A
A ##[1:5] B
// B must occur eventually after A (no bound)
A ##[1:$] B
Disable iff:
// Property is only checked when rst_n is high
property p;
@(posedge clk) disable iff (!rst_n)
A |-> B;
endproperty
$rose and $fell:
// True on the first clock where sig goes 0 to 1
$rose(sig)
// True on the first clock where sig goes 1 to 0
$fell(sig)
$past:
// B must be true if A was true one cycle ago
$past(A) |-> B
What Formal Verification Does Not Do
It is worth being clear about the limits.
Formal BMC proves properties up to the depth bound. If a bug only appears at cycle 100 and your depth is 30, it will not be found unless you use induction or increase the bound.
Formal does not check timing in the analog sense. It reasons about logical behavior, not setup/hold violations or clock skew.
Formal does not help if your properties are wrong. If you write an incorrect assertion that passes trivially, the tool will report a pass for the wrong reason. Writing good properties is the hard part.
Formal also scales poorly with design size. A single module like this handshake controller runs in seconds. A full CPU pipeline with hundreds of state bits can take hours or become intractable. For large designs, bounded model checking is usually applied module by module, with interface assumptions replacing upstream logic.
Summary
At this point you have:
- Installed SymbiYosys and Yices2 from source
- Written a buggy handshake controller
- Created a formal verification wrapper with SVA properties covering reset, deadlock, output stability, and reachability
- Configured and run SymbiYosys in BMC, cover, and prove modes
- Found three bugs using counterexample traces
- Fixed each bug and verified the fix with a clean pass
The bugs found here, missing reset, unbounded wait, and wrong state transition, are exactly the kind that survive simulation for months and surface only in silicon or late in integration. Formal finds them in seconds.
Write the properties before you write the design. Let the solver do the exhaustive work.
