FSMs and Handshake Controllers
Catch a deadlock as a safety property, find dead states with covers, write a transition check per state, and tell deadlock, livelock and starvation apart.
- Lesson
- F8.4
- Time
- 20 min
- Runs on
- SymbiYosys
- Check
- 4 questions
A producer raised req and waited for ack. The consumer, written by someone else from the same one-page spec, waited for req to fall before raising ack. The two stopped on the first transfer and stayed stopped, and the simulation timeout was filed as a bench hang.
Read the diagram as text
- P_IDLE: start: raise req
- P_WAIT_ACK: req = 1, waits for ack
- P_WAIT_REL: req = 0, waits for ack to fall
- C_IDLE: sees req
- C_WAIT (bug): waits for req to fall before ack
- C_ACK: ack = 1 until req falls
- P_IDLE → P_WAIT_ACK: start
- P_WAIT_ACK → P_WAIT_REL: ack
- C_IDLE → C_ACK: req (fixed)
- C_IDLE → C_WAIT (bug): req (bug)
- C_WAIT (bug) → C_ACK: !req: never comes
P_DONE and C_REL close the four phases and are left out of the drawing.
Deadlock Is a State Pair
Measured with smtbmc yices: FAIL at reported step 4, 0.5 s. p_st 1 is P_WAIT_ACK; c_st 1 is C_WAIT.
A deadlock is a pair of states neither machine can leave. "Never both waiting for each other" is safety: it fails on a finite trace, here at reported step 4. The trace names the two guards: the producer waits for ack, the consumer waits for !req.
One Cover per State, One Check per State
| State | Fixed design | BUG_DEADLOCK | BUG_LIVELOCK |
|---|---|---|---|
P_IDLE | step 2 | step 2 | step 2 |
P_WAIT_ACK | step 3 | step 3 | step 3 |
P_WAIT_REL | step 5 | not reached within 20 | not reached within 20 |
P_DONE | step 7 | not reached within 20 | not reached within 20 |
P_BACKOFF | not reached within 20 | not reached within 20 | step 4 |
C_IDLE | step 2 | step 2 | step 2 |
C_WAIT | not reached within 20 | step 4 | not reached within 20 |
C_ACK | step 4 | not reached within 20 | step 4 |
C_REL | step 6 | not reached within 20 | step 5 |
done | step 7 | not reached within 20 | not reached within 20 |
second done | step 13 | not reached within 20 | not reached within 20 |
Cover mode, depth 20, smtbmc yices. Each cell is the reported step of the shortest trace to that cover.
On the fixed design two states are never reached: C_WAIT and P_BACKOFF exist only for the buggy guards. An unreached state is dead code or an over-constraint (F3.5); here it is dead code to delete. On each buggy design the cover for done stops reaching.
Illegal transitions are checks over last cycle's state: from each state, the next state is in its row of the FSM table. Generated from the table, they fire on the deadlock at step 4. Automatic FSM checks (F5.2) produce exactly these covers and checks from the encoding.
Three Shapes of "stuck"
Measured: FAIL at reported step 11, 0.5 s. done never rises.
| Shape | What the trace shows | Written here as | Measured |
|---|---|---|---|
| Deadlock | both machines frozen | safety on the state pair | FAIL at reported step 4 |
| Livelock | both move, nothing completes | bounded: retry_cnt < 4 | FAIL at reported step 11 |
| Starvation | one requester never wins | bounded wait per requester (F8.2) | see F8.2 |
Livelock and starvation are liveness properties; their bounded forms are safety and run in bmc. F3.7 shows the liveness view.
In the livelock trace the producer withdraws req one cycle after raising it. The ack arrives just as req falls, so the consumer releases and the producer retries. The bench counts the withdrawals. The cover for done tells the same story with no bound to choose: it is unreached on both buggy designs.
The Proof
| Fixed design: dead pair, transitions, retry bound | Measured result |
|---|---|
| bmc depth 20 | BMC PASS to depth 20, 0.6 s |
| prove k = 2 | UNKNOWN at k = 2 (base case passed, induction step failed), 0.5 s |
| prove k = 4 | Prove PASS (k = 4), 0.5 s |
hs_pc has 5 flops: the two state registers.
The retry counter is bench state, and an induction start may hold any value of it. Four steps of history pin it to what the machines can do.
Asked in Interviews
| Question | Answer |
|---|---|
| How does formal find a deadlock? | As a safety property over the state pair. The trace shows the two guards that wait on each other. |
| How do you check an FSM has no unreachable states and no illegal transitions? | A cover per state, and a check per state that the next state is in its allowed set. |
| Deadlock, livelock, starvation? | Frozen, moving without progress, one party never served. The first is a state pair; the other two are liveness, often checked in bounded form. |
Try It
[options]
mode bmc
depth 20
[engines]
smtbmc yices
[script]
read_verilog -formal -sv design.sv
prep -top hs_pc
[files]
design.svassert property (@(posedge clk) disable iff (rst) !(p_st == P_WAIT_ACK && c_st == C_WAIT));
assert property (@(posedge clk) disable iff (rst) $past(p_st) == P_WAIT_ACK |-> p_st inside {P_WAIT_ACK, P_WAIT_REL});
assert property (@(posedge clk) disable iff (rst) retry_cnt < 4);
cover property (@(posedge clk) disable iff (rst) c_st == C_WAIT);f_past_valid keeps the transition checks off the first cycle. always @(posedge clk) begin
if ($initstate) begin assume(rst); end else begin assume(!rst); end
end
reg f_past_valid = 0; // a previous cycle exists
reg [2:0] p_q = 0; // last cycle's states: the registered form of $past
reg [1:0] c_q = 0;
reg [2:0] retry_cnt = 0; // back-offs since the last completed transfer
reg [1:0] n_done = 0; // completed transfers, saturating at 3
always @(posedge clk) begin
f_past_valid <= !rst; p_q <= p_st; c_q <= c_st;
retry_cnt <= rst || done ? 3'd0 : p_st == P_BACKOFF && retry_cnt != 3'd7 ? retry_cnt + 3'd1 : retry_cnt;
n_done <= rst ? 2'd0 : done && n_done != 2'd3 ? n_done + 2'd1 : n_done;
end
always @(posedge clk) if (!rst) begin
assert(!(p_st == P_WAIT_ACK && c_st == C_WAIT)); // never both waiting for each other
assert(retry_cnt < 3'd4); // a transfer completes within four tries
end
always @(posedge clk) if (!rst && f_past_valid) begin // each state goes only where the table allows
if (p_q == P_IDLE) assert(p_st == P_IDLE || p_st == P_WAIT_ACK);
if (p_q == P_WAIT_ACK) assert(p_st == P_WAIT_ACK || p_st == P_WAIT_REL);
if (p_q == P_WAIT_REL) assert(p_st == P_WAIT_REL || p_st == P_DONE);
if (p_q == P_DONE) assert(p_st == P_IDLE);
if (c_q == C_IDLE) assert(c_st == C_IDLE || c_st == C_ACK);
if (c_q == C_ACK) assert(c_st == C_ACK || c_st == C_REL);
if (c_q == C_REL) assert(c_st == C_IDLE);
endCovers
Run the fixed-design cover twin and fill the state table. Which states never appear?
Deadlock
Run as loaded: FAIL at reported step 4. Name the guard each machine is stuck on.
Fix and prove
Send the consumer from
C_IDLEstraight toC_ACK, then prove the fixed design at k = 2 and k = 4.
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
Remember
- Deadlock is a state pair: assert the pair never happens and bmc finds it in a few steps.
- One cover per state: unreached states are dead code or over-constraint.
- One transition check per state, generated from the FSM table, catches a wrong guard at once.
- Livelock and starvation are liveness; bounded counters make them safety you can run.