A request/acknowledge interface between two blocks passes thousands of simulation runs. In the lab, once every few hours, a word goes missing. The waveform captured around the failure looks almost normal: a request rises and falls, the next request rises, and the receiver never captured the first one. No single test had produced that ordering of events, so no test had seen it.
How can you find the one ordering of events that breaks a protocol, without waiting for the lab to find it?
The obvious answer is more simulation: more seeds, longer runs, more random delays. Simulation explores the orderings your stimulus happens to create. Two independent sides, each with a few states and variable timing, produce many interleavings, and the dangerous ones are often rare, because they need one side to be unusually fast at exactly the moment the other is unusually slow. Random testing finds them eventually; the lab finds them sooner, at a much higher price.
Each side of an interface is a state machine: it is in one state, and the signals it sees move it to the next. The protocol is not either machine alone. It is the pair running side by side, called the product machine, whose state is (requester state, completer state, the signals between them). Every rule in the contract is an invariant on that product state: a condition that must hold in every state the pair can ever reach.
The product has a useful property: it is finite. For a small protocol you can list every reachable state by trying every move each side could make from every state, which is what a model checker does. If no reachable state breaks an invariant, the protocol is correct for every ordering of events, not only the ones a test produced.
The figure shows the two machines of a four-phase request/acknowledge protocol. The requester raises req with the data held stable, waits for ack, drops req, and waits for ack to fall before the next word. The completer captures the data when it sees req, raises ack, and drops ack when req falls.
Ignoring the word counters, each side has a handful of control states, so the product has only six combinations. Walking through them shows which can actually occur and what each means:
Requester | Completer | Reachable? | Meaning |
|---|---|---|---|
IDLE | WAIT | Yes | Idle; both wires low |
REQ | WAIT | Yes | Request raised, not yet seen; data must be stable |
REQ | ACKED | Yes | Data captured; requester has not yet seen ack |
WAIT LOW | ACKED | Yes | Requester dropped req; completer has not yet seen it |
WAIT LOW | WAIT | Yes | Completer dropped ack; requester has not yet seen it |
IDLE | ACKED | No | Would mean the requester started over while ack is still high |
The last row is the point of the exercise. The requester leaves WAIT LOW only after seeing ack low, so it can never be idle while the completer still holds ack high. That one unreachable state is what makes the protocol safe: a new request can never be mistaken for the old one.
The model below is a tiny model checker. From each state it tries every move either side could make, one at a time, in any order, and records every new state it reaches. It checks three promises: the completer captures only while the requester holds the data; no word is ever lost; and the pair never stops before all words are delivered. Then it repeats the search with a buggy requester that drops req without waiting for ack.
"""Explore every interleaving of a requester and a completer joined by a
four-phase request/acknowledge handshake, and check three promises:
1. the completer captures data only while the requester is holding it,
2. no word is ever lost (every word sent is captured exactly once),
3. the pair never deadlocks before all words are delivered.
Then repeat with a requester that drops req without waiting for ack.
"""
from collections import deque
N = 2 # words to send
def requester_moves(st, correct):
r, c, req, ack, sent, cap = st
if r == "IDLE" and sent < N:
yield ("R raises req", ("REQ", c, 1, ack, sent + 1, cap))
if r == "REQ" and (ack == 1 or not correct):
yield ("R drops req", ("WAITLOW" if correct else "IDLE", c, 0, ack, sent, cap))
if r == "WAITLOW" and ack == 0:
yield ("R sees ack low", ("IDLE", c, req, ack, sent, cap))
def completer_moves(st):
r, c, req, ack, sent, cap = st
if c == "WAIT" and req == 1:
yield ("C captures, raises ack", (r, "ACKED", req, 1, sent, cap + 1))
if c == "ACKED" and req == 0:
yield ("C drops ack", (r, "WAIT", req, 0, sent, cap))
def explore(correct):
start = ("IDLE", "WAIT", 0, 0, 0, 0)
parent = {start: None}
queue = deque([start])
violations = []
while queue:
st = queue.popleft()
moves = list(requester_moves(st, correct)) + list(completer_moves(st))
for label, nxt in moves:
if label.startswith("C captures") and st[0] != "REQ":
violations.append(("captured data the requester was not holding", st))
if nxt not in parent:
parent[nxt] = (st, label)
queue.append(nxt)
if not moves: # nobody can move: the run has ended here
r, c, req, ack, sent, cap = st
if cap != sent:
violations.append((f"lost word: sent {sent}, captured {cap}", st))
elif sent != N:
violations.append(("deadlock before all words were sent", st))
return len(parent), violations, parent
def trace(parent, st):
steps = []
while parent[st] is not None:
st, label = parent[st]
steps.append(label)
return list(reversed(steps))
n_ok, bad_ok, _ = explore(correct=True)
print(f"correct requester: {n_ok} reachable states, {len(bad_ok)} violations")
n_bug, bad_bug, parent = explore(correct=False)
print(f"buggy requester: {n_bug} reachable states, {len(bad_bug)} violations")
what, st = bad_bug[0]
print(f"first violation: {what}")
print(" shortest path: " + " -> ".join(trace(parent, st)))
assert not bad_ok and bad_bug
print("PASS: the four-phase rules keep every promise; dropping req early loses a word")It prints:
correct requester: 11 reachable states, 0 violations
buggy requester: 15 reachable states, 2 violations
first violation: lost word: sent 2, captured 0
shortest path: R raises req -> R drops req -> R raises req -> R drops req
PASS: the four-phase rules keep every promise; dropping req early loses a wordThe correct protocol has 11 reachable states (control states plus word counts) and no violations: every ordering of events, however unlikely, keeps every promise. The buggy requester reaches 15 states, two of which break a promise, and the search hands you the shortest path to the failure: the requester raises and drops req twice before the completer moves at all, so both words are sent and none is captured. That is the lab failure from the opening, found in milliseconds.
Method | What it explores | When it wins |
|---|---|---|
Directed tests | The cases you thought of | Bring-up and documenting intended behaviour |
Constrained-random simulation | Many orderings, weighted by your stimulus | Large designs, real data paths |
Exhaustive exploration of a protocol model (as above) | Every ordering of an abstract model | Small protocols and the control part of larger ones; finding races |
Formal property checking on RTL | Every ordering of the real design, within limits | Interfaces and control logic where a missed race is expensive (Part 15) |
Protocol | Where its specification uses state machines |
|---|---|
APB | A transfer moves through idle, setup and access phases; the access phase repeats until PREADY is high. |
PCI Express | Link training and link power states are defined as a state machine (the LTSSM, Part 11); the data link layer's replay mechanism is defined by counters and timers in each direction (lesson 10.11). |
I2C | Each transfer is a sequence of conditions on the wires: START, address, acknowledge, data bytes each with an acknowledge, and STOP. |
The buggy requester in the model is not contrived (illustrative story). A designer optimised the requester to drop req one cycle after raising it, reasoning that the completer always responded within one cycle in the current system. The completer was later moved behind a synchroniser into a slower clock domain, where it sometimes needed several cycles to see req. Short requests then vanished entirely, exactly as in the model's counterexample.
The fix was to restore the rule "hold req until ack is seen". The deeper lesson: the optimisation was correct only for one timing of the other side. A protocol rule exists precisely so that each side stays correct for every timing of the other. Exploring the product machine makes that visible, because it does not assume any timing at all.
The requester returns to IDLE only from WAIT LOW, and only after seeing ack low. While the completer is in ACKED, ack is high, so the requester cannot be idle at the same time.
It is correct only for one timing of the other side. The contract must hold for every timing; when the completer slows down (another clock domain, a busy pipeline), requests vanish. Exhaustive exploration assumes no timing, so it exposes the dependency.
This protocol has a few control states per side and a small bound on words, so the product has only tens of states. A whole SoC has astronomically many states, so you explore abstract models of its protocols or apply formal tools to bounded parts.
Each side of an interface is a state machine; the protocol is the product of the two, including the wires between them.
Every rule of the contract is an invariant on the product; safety comes from dangerous states being unreachable.
Exploring every interleaving finds races that random simulation reaches only by luck; a rule that holds only for one timing of the other side is a bug.