SPI · Module 13
The Master Control FSM
The five states an SPI master transfer passes through, why the fifth differs from the first by exactly one behaviour, why outputs must be decoded from state alone, and the failure that disappears the moment you add a print statement.
Chapter 13.2 drew the boundary between control and datapath. The control side is a state machine, and this chapter builds it.
A transfer is over. Chip select is high, the clock is stopped, nothing is in flight. Why is that not the idle state?
Because a state machine that answers yes to that question will start the next transaction too soon, and the slave will ignore it. The difference between the two states is one behaviour — and it is the whole reason the machine has five states rather than four.
1. The Five States
A transfer is not one event. It is five intervals, and each has a different obligation:
IDLE nothing in progress; a request is accepted here
LEAD chip select asserted, clock still stopped — paying t_CSS
SHIFT the clock runs; bits move
LAG clock stopped, chip select still asserted — paying t_CSH
GAP chip select released — paying t_CSD before anything elseEvery one of the six requirements from Chapter 13.1 maps onto exactly one of these, which is the test that the state list is the right one:
R0 SCLK rests at CPOL every state except SHIFT
R1 no edge while deselected IDLE and GAP
R2 lead >= LEAD LEAD's duration
R3 lag >= LAG LAG's duration
R4 exactly LEN periods SHIFT's exit condition
R5 gap >= GAP GAP's durationA state that no requirement refers to is a state that should be questioned. A requirement that maps onto no state is a requirement the machine cannot satisfy. Both checks are worth doing before writing any code, and both are cheap.
2. The Diagram
3. Why GAP Cannot Be IDLE
On the pins, IDLE and GAP are indistinguishable: chip select high, SCLK at its idle level, nothing moving. A reviewer will suggest merging them, and the suggestion is reasonable — it removes a state and some logic.
It also removes requirement R5.
GAP's only job is to refuse. A start request arriving in GAP is not acted upon; the machine stays in GAP until the count expires and only then returns to IDLE, where the request is honoured. If the two states are merged, a request arriving immediately after a transaction is accepted immediately, chip select falls with no inter-transaction gap, and the slave — which requires a minimum CS-high time — ignores the transaction entirely.
with GAP: ... LAG GAP GAP GAP GAP IDLE LEAD ... request honoured after the gap
without GAP: ... LAG IDLE LEAD ... request honoured immediatelyThe failure is worth dwelling on because of how it presents. The second transaction is ignored, so the master's read of it returns whatever the slave was driving — usually the previous result, or all ones. A driver that issues two reads back to back gets the first value twice. And the failure disappears the moment anything slows the driver down: adding a print statement, enabling a debug build, or running under an interrupt load all insert enough delay for the gap to be satisfied by accident.
4. Outputs Decoded From State Alone
Every output of this machine is a function of the current state and nothing else:
cs_active = (state != IDLE) && (state != GAP)
shift_en = (state == SHIFT)
busy = (state != IDLE)
done = leaving LAGThat is a deliberate constraint, and it is worth being explicit about what it buys and what it costs.
It buys glitch-free outputs. An output decoded from the state register is a function of flop outputs, so it changes once per clock edge and never transiently. An output that also depends on an input — shift_en = (state == SHIFT) && !pause, say — inherits that input's glitches, and shift_en gates a clock.
It buys a machine that can be checked by a transition recorder. If outputs depend only on state, then recording the state sequence records the output sequence, and a testbench can check "every transition taken was legal" without modelling the outputs at all.
It costs one extra state, sometimes. Where a Mealy output would let two situations share a state, decoding from state alone requires two states. That trade is almost always right in a design that drives pins, and it is the reason GAP exists as a separate state rather than as a counter inside IDLE.
done is the exception that proves the rule: it is a pulse, not a level, so it is decoded from a transition — leaving LAG — rather than from residence in a state. That is still a function of the state register alone, because "leaving LAG" is "the registered state was LAG and the next state is not", both of which are known from flops.
5. What a Transfer Looks Like
Five states, one transfer, and a request refused in GAP
21 cyclesRead the start row against the state row. The request asserted at cycle 14 sits through the rest of GAP and is honoured at cycle 18, when the machine reaches IDLE. Four cycles of the gap were still owed, and the machine paid them.
Note also that done pulses at cycle 13 — the exit from LAG — not at cycle 10, where the last clock edge was. A consumer that treated the final SCLK edge as completion would release the transfer three cycles early and could start the next one during the hold time.
6. Building the Control FSM — Three HDLs
The circuit
The machine takes start, three interval counts and a bit count, and publishes the state, the four outputs above, and — for the testbench — the state as a small integer so that transitions can be recorded.
Three details are worth stating, and each is a bug the testbench found:
The counters are shared. One counter serves LEAD, LAG and GAP, loaded with the appropriate value on entry to each. They are never simultaneously active, so three counters would be three times the flops for no function. The bit counter is separate because it is active during SHIFT while no interval counter is.
done pulses on leaving LAG, and only there. Not on the last bit, not on entering LAG, not on reaching IDLE. Leaving LAG is the moment chip select is released, which is the earliest moment at which the transfer is over from the slave's point of view.
GAP's refusal is the machine's only asymmetry. IDLE and GAP have identical next-state logic except for the start term, and it is worth writing them out separately rather than sharing code, precisely so that the difference is visible to a reader.
// spi_master_fsm.sv
//
// Chapter 13.3 -- the control FSM, and why each state must exist.
//
// Five states, and the discipline of this chapter is that each one must be
// justified by a requirement rather than by symmetry:
//
// IDLE resting. A start is ACCEPTED here and nowhere else.
// LEAD CS asserted, no SCLK yet -- enforces t_CSS (Ch 2.5)
// SHIFT clocking bits (Ch 1.3)
// LAG last edge past, CS still asserted -- t_CSH (Ch 2.5)
// GAP CS released, enforcing the minimum inactive time (Ch 7.3)
//
// The state most often merged away is GAP, and merging it is the bug. IDLE
// and GAP both have CS inactive and both do nothing -- so they look like one
// state. They differ in exactly one respect: a start is accepted in IDLE and
// must be REFUSED in GAP, because accepting it would begin a frame before
// the device's minimum deselect time had elapsed.
//
// That is the whole argument for a five-state machine over a four-state one,
// and it is a requirement rather than a preference: Chapter 7.3 showed a
// device given too little deselect time treats the next frame as a
// continuation of the previous one.
//
// LEAD and LAG are equally hard to argue away, and for the same reason: a
// combinational CS derived from "am I shifting" asserts and releases on the
// same edges as the first and last SCLK transitions, giving zero lead and
// zero lag. The states exist because the intervals must be non-zero.
module spi_master_fsm #(
parameter int CNT_W = 16,
parameter int LEAD = 2,
parameter int LAG = 2,
parameter int MIN_HIGH = 4
) (
input logic clk,
input logic rst_n,
input logic start,
input logic [CNT_W-1:0] n_bits, // bits in this transfer
input logic bit_done, // one bit complete, from the divider
output logic [2:0] state,
output wire cs_assert, // CS active (the pin is inverted)
output wire shift_en, // the divider may run
output wire busy,
output logic done,
output wire accepting, // a start would be taken now
output logic [CNT_W-1:0] bits_left
);
localparam logic [2:0] S_IDLE = 3'd0;
localparam logic [2:0] S_LEAD = 3'd1;
localparam logic [2:0] S_SHIFT = 3'd2;
localparam logic [2:0] S_LAG = 3'd3;
localparam logic [2:0] S_GAP = 3'd4;
logic [CNT_W-1:0] tick; // interval counter, shared by LEAD, LAG and GAP
// Outputs are decoded from the state alone, which is what makes the
// state diagram the specification: there is no output that depends on
// anything the diagram does not show.
assign cs_assert = (state == S_LEAD) || (state == S_SHIFT) ||
(state == S_LAG);
assign shift_en = (state == S_SHIFT);
assign busy = (state != S_IDLE);
assign accepting = (state == S_IDLE);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= S_IDLE;
tick <= {CNT_W{1'b0}};
done <= 1'b0;
bits_left <= {CNT_W{1'b0}};
end else begin
done <= 1'b0;
case (state)
S_IDLE: begin
// The ONLY state that accepts a start.
if (start && (n_bits != {CNT_W{1'b0}})) begin
bits_left <= n_bits;
tick <= {CNT_W{1'b0}};
state <= S_LEAD;
end
end
S_LEAD: begin
// CS is asserted and SCLK is not running. A lead of zero
// would let the first edge coincide with CS assertion,
// which Chapter 2.5 showed a device may not sample.
if (tick >= CNT_W'(LEAD - 1)) begin
tick <= {CNT_W{1'b0}};
state <= S_SHIFT;
end else begin
tick <= tick + 1'b1;
end
end
S_SHIFT: begin
if (bit_done) begin
if (bits_left == CNT_W'(1)) begin
tick <= {CNT_W{1'b0}};
state <= S_LAG;
end
bits_left <= bits_left - 1'b1;
end
end
S_LAG: begin
if (tick >= CNT_W'(LAG - 1)) begin
tick <= {CNT_W{1'b0}};
// done pulses on leaving LAG rather than on entering
// GAP, so a consumer sees completion at the moment the
// last bit is safely framed -- not after the
// inter-frame wait, which is the master's business
// rather than the transfer's.
done <= 1'b1;
state <= S_GAP;
end else begin
tick <= tick + 1'b1;
end
end
default: begin // S_GAP
// CS is inactive and a start is REFUSED. This is the only
// difference between GAP and IDLE, and it is the whole
// reason both exist.
if (tick >= CNT_W'(MIN_HIGH - 1)) begin
tick <= {CNT_W{1'b0}};
state <= S_IDLE;
end else begin
tick <= tick + 1'b1;
end
end
endcase
end
end
endmodule// spi_master_fsm_tb.sv
//
// For a state machine the specification IS the state diagram, so the
// testbench records every transition taken and compares the set against the
// legal set. A per-scenario check confirms the paths it exercises; the
// transition set confirms that no OTHER path exists.
//
// The interval durations are then measured rather than assumed, and the
// refusal of a start during GAP -- the only thing distinguishing it from
// IDLE -- gets a test of its own.
`timescale 1ns/1ps
module spi_master_fsm_tb;
localparam int CNT_W = 16;
localparam int LEAD = 2;
localparam int LAG = 2;
localparam int MIN_HIGH = 4;
localparam logic [2:0] S_IDLE = 3'd0;
localparam logic [2:0] S_LEAD = 3'd1;
localparam logic [2:0] S_SHIFT = 3'd2;
localparam logic [2:0] S_LAG = 3'd3;
localparam logic [2:0] S_GAP = 3'd4;
logic clk = 1'b0;
logic rst_n = 1'b0;
always #5 clk = ~clk;
logic start = 1'b0;
logic [CNT_W-1:0] n_bits = 16'd8;
logic bit_done = 1'b0;
wire [2:0] state;
wire cs_assert, shift_en, busy, done, accepting;
wire [CNT_W-1:0] bits_left;
int errors = 0;
int before_done;
int illegal;
spi_master_fsm #(.CNT_W(CNT_W), .LEAD(LEAD), .LAG(LAG),
.MIN_HIGH(MIN_HIGH)) dut (
.clk(clk), .rst_n(rst_n),
.start(start), .n_bits(n_bits), .bit_done(bit_done),
.state(state), .cs_assert(cs_assert), .shift_en(shift_en),
.busy(busy), .done(done), .accepting(accepting),
.bits_left(bits_left)
);
// ---- transition recorder --------------------------------------------
// Every (from, to) pair taken is marked. The legal set is small enough
// to enumerate, so anything outside it is a design error the scenarios
// might not have thought to look for.
reg seen_edge [0:7][0:7];
reg [2:0] prev_state;
int n_transitions;
// Interval measurement, so durations are observed rather than assumed.
int cyc_in_state;
int len_lead, len_lag, len_gap, len_shift;
int cs_low_in_gap, cs_high_in_shift, shift_outside;
int done_pulses;
always_ff @(posedge clk) begin
if (!rst_n) begin
prev_state <= S_IDLE;
n_transitions <= 0;
cyc_in_state <= 1;
end else begin
if (state !== prev_state) begin
seen_edge[prev_state][state] <= 1'b1;
n_transitions <= n_transitions + 1;
case (prev_state)
S_LEAD: len_lead <= cyc_in_state;
S_LAG: len_lag <= cyc_in_state;
S_GAP: len_gap <= cyc_in_state;
S_SHIFT: len_shift <= cyc_in_state;
default: ;
endcase
cyc_in_state <= 1;
prev_state <= state;
end else begin
cyc_in_state <= cyc_in_state + 1;
end
// Output-consistency observations, accumulated continuously.
if (state == S_GAP && cs_assert) cs_low_in_gap <= cs_low_in_gap + 1;
if (state == S_SHIFT && !cs_assert) cs_high_in_shift <= cs_high_in_shift + 1;
if (state != S_SHIFT && shift_en) shift_outside <= shift_outside + 1;
if (done) done_pulses <= done_pulses + 1;
end
end
// Feed the FSM `n` bit_done pulses, one every three cycles, which is what
// a divider with any divisor above one looks like from here.
task automatic clock_bits(input int n);
begin
for (int i = 0; i < n; i++) begin
repeat (2) @(negedge clk);
bit_done = 1'b1;
@(negedge clk);
bit_done = 1'b0;
end
end
endtask
task automatic begin_transfer(input int nb);
begin
@(negedge clk);
n_bits = CNT_W'(nb);
start = 1'b1;
@(negedge clk);
start = 1'b0;
end
endtask
// Waits for IDLE and then settles two more cycles, because the recorder
// observes a state change one posedge after it occurs -- so its results
// are not final at the instant IDLE is first visible.
task automatic wait_idle(input int limit);
int guard;
begin
guard = 0;
while (state !== S_IDLE && guard < limit) begin
@(negedge clk);
guard++;
end
if (guard >= limit) begin
$display(" FAIL: the FSM never returned to IDLE (state=%0d)",
state);
errors++;
end
repeat (2) @(negedge clk);
end
endtask
initial begin
for (int a = 0; a < 8; a++)
for (int b = 0; b < 8; b++)
seen_edge[a][b] = 1'b0;
n_transitions = 0; cyc_in_state = 1;
len_lead = 0; len_lag = 0; len_gap = 0; len_shift = 0;
cs_low_in_gap = 0; cs_high_in_shift = 0; shift_outside = 0;
done_pulses = 0;
repeat (3) @(negedge clk);
rst_n = 1'b1;
@(negedge clk);
// 1. RESET STATE. IDLE, not busy, accepting, CS inactive.
if (state !== S_IDLE || busy || !accepting || cs_assert) begin
$display(" FAIL: reset state is wrong (state=%0d busy=%0b accepting=%0b cs=%0b)",
state, busy, accepting, cs_assert);
errors++;
end
$display(" reset: state=IDLE, busy=0, accepting=1, CS inactive");
// 2. ONE COMPLETE TRANSFER. The state sequence is the specification.
begin_transfer(8);
clock_bits(8);
wait_idle(200);
if (!seen_edge[S_IDLE][S_LEAD] || !seen_edge[S_LEAD][S_SHIFT] ||
!seen_edge[S_SHIFT][S_LAG] || !seen_edge[S_LAG][S_GAP] ||
!seen_edge[S_GAP][S_IDLE]) begin
$display(" FAIL: the expected state sequence was not taken");
errors++;
end
$display(" one transfer: IDLE -> LEAD -> SHIFT -> LAG -> GAP -> IDLE");
// 3. THE INTERVALS, MEASURED. Each state must last exactly its
// parameter -- not at least it, because a state that overstays
// slows every transfer and no requirement asks for it.
if (len_lead != LEAD) begin
$display(" FAIL: LEAD lasted %0d cycles, expected %0d",
len_lead, LEAD);
errors++;
end
if (len_lag != LAG) begin
$display(" FAIL: LAG lasted %0d cycles, expected %0d", len_lag, LAG);
errors++;
end
if (len_gap != MIN_HIGH) begin
$display(" FAIL: GAP lasted %0d cycles, expected %0d",
len_gap, MIN_HIGH);
errors++;
end
$display(" intervals measured: LEAD=%0d LAG=%0d GAP=%0d -- exactly their parameters",
len_lead, len_lag, len_gap);
// 4. OUTPUT CONSISTENCY, accumulated over everything so far. CS must
// be asserted through LEAD, SHIFT and LAG and released in GAP;
// shift_en must appear only in SHIFT.
if (cs_low_in_gap != 0 || cs_high_in_shift != 0 || shift_outside != 0) begin
$display(" FAIL: output consistency -- cs in gap=%0d, cs low in shift=%0d, shift outside=%0d",
cs_low_in_gap, cs_high_in_shift, shift_outside);
errors++;
end
$display(" outputs: CS asserted only in LEAD/SHIFT/LAG, shift_en only in SHIFT");
// 5. THE BIT COUNT. SHIFT must end after exactly n_bits bit_done
// pulses -- a count off by one is the failure signature of every
// width bug in this track.
begin_transfer(3);
clock_bits(2);
if (state !== S_SHIFT) begin
$display(" FAIL: SHIFT ended after 2 of 3 bits (state=%0d)", state);
errors++;
end
clock_bits(1);
@(negedge clk);
if (state === S_SHIFT) begin
$display(" FAIL: SHIFT did not end after the third bit");
errors++;
end
wait_idle(200);
$display(" bit count: SHIFT ends after exactly n_bits pulses, not before or after");
// 6. THE GAP REFUSAL -- the reason GAP and IDLE are different states.
// A start during GAP must be refused, and the FSM must stay in GAP
// until its interval is complete.
begin_transfer(2);
clock_bits(2);
// Now in LAG or GAP. Advance to GAP.
while (state !== S_GAP) @(negedge clk);
if (accepting) begin
$display(" FAIL: the FSM reported accepting while in GAP"); errors++;
end
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
// The start must NOT have been taken: no LEAD, and no GAP->LEAD edge.
if (state === S_LEAD) begin
$display(" FAIL: a start during GAP was accepted"); errors++;
end
wait_idle(200);
if (seen_edge[S_GAP][S_LEAD]) begin
$display(" FAIL: a GAP -> LEAD transition occurred -- the inter-frame time was not enforced");
errors++;
end
$display(" GAP refusal: a start during GAP is not accepted, and no GAP -> LEAD edge exists");
// 7. A ZERO-LENGTH TRANSFER is refused rather than producing a frame
// with no bits, which would assert CS for LEAD+LAG cycles and
// clock nothing -- a frame no device can interpret.
begin_transfer(0);
repeat (4) @(negedge clk);
if (state !== S_IDLE) begin
$display(" FAIL: a zero-bit transfer was accepted (state=%0d)", state);
errors++;
end
$display(" zero-length transfer: refused, FSM stays in IDLE");
// 8. BACK-TO-BACK TRANSFERS. Several in succession, each complete,
// with the inter-frame time enforced between them.
for (int t = 0; t < 6; t++) begin
begin_transfer(4);
clock_bits(4);
wait_idle(200);
end
$display(" 6 successive transfers: all completed through the full sequence");
// 9. THE TRANSITION SET. Only the five legal edges may ever have been
// taken. This is the check the scenarios cannot make for
// themselves: it rules out paths nobody thought to test.
illegal = 0;
begin
for (int a = 0; a < 5; a++) begin
for (int b = 0; b < 5; b++) begin
if (seen_edge[a][b]) begin
if (!((a == S_IDLE && b == S_LEAD) ||
(a == S_LEAD && b == S_SHIFT) ||
(a == S_SHIFT && b == S_LAG) ||
(a == S_LAG && b == S_GAP) ||
(a == S_GAP && b == S_IDLE))) begin
$display(" FAIL: illegal transition %0d -> %0d was taken",
a, b);
illegal++;
errors++;
end
end
end
end
$display(" transition set: %0d transitions taken, %0d illegal",
n_transitions, illegal);
end
// 10. done PULSES ONCE PER TRANSFER, and for one cycle -- a level
// would be re-read as a second completion. Counted continuously
// over the whole run, so the total must equal the number of
// transfers that actually completed: one, one, one and six above,
// plus this one, and NOT the refused zero-length one.
before_done = done_pulses;
begin_transfer(4);
clock_bits(4);
wait_idle(200);
if ((done_pulses - before_done) != 1) begin
$display(" FAIL: this transfer produced %0d done pulses, expected 1",
done_pulses - before_done);
errors++;
end
// Ten transfers complete above: one each in steps 2, 5 and 6, six in
// step 8, and this one -- and none for the zero-length transfer the
// FSM refused, which is the point of counting the total as well as
// the delta.
if (done_pulses != 10) begin
$display(" FAIL: %0d done pulses in total, expected 10 (1+1+1+6+1, and none for the refused zero-length transfer)",
done_pulses);
errors++;
end
$display(" done: %0d pulses in total, one per completed transfer and none for the refused one",
done_pulses);
if (errors == 0)
$display("PASS: the FSM resets to IDLE accepting a start, takes the full IDLE-LEAD-SHIFT-LAG-GAP-IDLE sequence, holds each interval for exactly its parameter, asserts CS only in LEAD SHIFT and LAG and shift_en only in SHIFT, ends SHIFT after exactly the requested bit count, refuses a start during GAP so that no GAP-to-LEAD transition exists, refuses a zero-length transfer, and across every scenario took no transition outside the five legal edges");
else
$display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule// spi_master_fsm.v
//
// Chapter 13.3 -- the control FSM, and why each state must exist.
//
// Five states, and the discipline of this chapter is that each one must be
// justified by a requirement rather than by symmetry:
//
// IDLE resting. A start is ACCEPTED here and nowhere else.
// LEAD CS asserted, no SCLK yet -- enforces t_CSS (Ch 2.5)
// SHIFT clocking bits (Ch 1.3)
// LAG last edge past, CS still asserted -- t_CSH (Ch 2.5)
// GAP CS released, enforcing the minimum inactive time (Ch 7.3)
//
// The state most often merged away is GAP, and merging it is the bug. IDLE
// and GAP both have CS inactive and both do nothing -- so they look like one
// state. They differ in exactly one respect: a start is accepted in IDLE and
// must be REFUSED in GAP, because accepting it would begin a frame before
// the device's minimum deselect time had elapsed.
//
// That is the whole argument for a five-state machine over a four-state one,
// and it is a requirement rather than a preference: Chapter 7.3 showed a
// device given too little deselect time treats the next frame as a
// continuation of the previous one.
//
// LEAD and LAG are equally hard to argue away, and for the same reason: a
// combinational CS derived from "am I shifting" asserts and releases on the
// same edges as the first and last SCLK transitions, giving zero lead and
// zero lag. The states exist because the intervals must be non-zero.
module spi_master_fsm #(
parameter CNT_W = 16,
parameter LEAD = 2,
parameter LAG = 2,
parameter MIN_HIGH = 4
) (
input wire clk,
input wire rst_n,
input wire start,
input wire [CNT_W-1:0] n_bits, // bits in this transfer
input wire bit_done, // one bit complete, from the divider
output reg [2:0] state,
output wire cs_assert, // CS active (the pin is inverted)
output wire shift_en, // the divider may run
output wire busy,
output reg done,
output wire accepting, // a start would be taken now
output reg [CNT_W-1:0] bits_left
);
localparam [2:0] S_IDLE = 3'd0;
localparam [2:0] S_LEAD = 3'd1;
localparam [2:0] S_SHIFT = 3'd2;
localparam [2:0] S_LAG = 3'd3;
localparam [2:0] S_GAP = 3'd4;
reg [CNT_W-1:0] tick; // interval counter, shared by LEAD, LAG and GAP
// Outputs are decoded from the state alone, which is what makes the
// state diagram the specification: there is no output that depends on
// anything the diagram does not show.
assign cs_assert = (state == S_LEAD) || (state == S_SHIFT) ||
(state == S_LAG);
assign shift_en = (state == S_SHIFT);
assign busy = (state != S_IDLE);
assign accepting = (state == S_IDLE);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= S_IDLE;
tick <= {CNT_W{1'b0}};
done <= 1'b0;
bits_left <= {CNT_W{1'b0}};
end else begin
done <= 1'b0;
case (state)
S_IDLE: begin
// The ONLY state that accepts a start.
if (start && (n_bits != {CNT_W{1'b0}})) begin
bits_left <= n_bits;
tick <= {CNT_W{1'b0}};
state <= S_LEAD;
end
end
S_LEAD: begin
// CS is asserted and SCLK is not running. A lead of zero
// would let the first edge coincide with CS assertion,
// which Chapter 2.5 showed a device may not sample.
if (tick >= (LEAD - 1)) begin
tick <= {CNT_W{1'b0}};
state <= S_SHIFT;
end else begin
tick <= tick + 1'b1;
end
end
S_SHIFT: begin
if (bit_done) begin
if (bits_left == (1)) begin
tick <= {CNT_W{1'b0}};
state <= S_LAG;
end
bits_left <= bits_left - 1'b1;
end
end
S_LAG: begin
if (tick >= (LAG - 1)) begin
tick <= {CNT_W{1'b0}};
// done pulses on leaving LAG rather than on entering
// GAP, so a consumer sees completion at the moment the
// last bit is safely framed -- not after the
// inter-frame wait, which is the master's business
// rather than the transfer's.
done <= 1'b1;
state <= S_GAP;
end else begin
tick <= tick + 1'b1;
end
end
default: begin // S_GAP
// CS is inactive and a start is REFUSED. This is the only
// difference between GAP and IDLE, and it is the whole
// reason both exist.
if (tick >= (MIN_HIGH - 1)) begin
tick <= {CNT_W{1'b0}};
state <= S_IDLE;
end else begin
tick <= tick + 1'b1;
end
end
endcase
end
end
endmodule// spi_master_fsm_tb.v
//
// For a state machine the specification IS the state diagram, so the
// testbench records every transition taken and compares the set against the
// legal set. A per-scenario check confirms the paths it exercises; the
// transition set confirms that no OTHER path exists.
//
// The interval durations are then measured rather than assumed, and the
// refusal of a start during GAP -- the only thing distinguishing it from
// IDLE -- gets a test of its own.
`timescale 1ns/1ps
module spi_master_fsm_tb;
integer a;
integer b;
integer i;
integer t;
localparam CNT_W = 16;
localparam LEAD = 2;
localparam LAG = 2;
localparam MIN_HIGH = 4;
localparam [2:0] S_IDLE = 3'd0;
localparam [2:0] S_LEAD = 3'd1;
localparam [2:0] S_SHIFT = 3'd2;
localparam [2:0] S_LAG = 3'd3;
localparam [2:0] S_GAP = 3'd4;
reg clk;
reg rst_n;
always #5 clk = ~clk;
reg start;
reg [CNT_W-1:0] n_bits;
reg bit_done;
wire [2:0] state;
wire cs_assert, shift_en, busy, done, accepting;
wire [CNT_W-1:0] bits_left;
integer errors;
integer before_done;
integer illegal;
spi_master_fsm #(.CNT_W(CNT_W), .LEAD(LEAD), .LAG(LAG),
.MIN_HIGH(MIN_HIGH)) dut (
.clk(clk), .rst_n(rst_n),
.start(start), .n_bits(n_bits), .bit_done(bit_done),
.state(state), .cs_assert(cs_assert), .shift_en(shift_en),
.busy(busy), .done(done), .accepting(accepting),
.bits_left(bits_left)
);
// ---- transition recorder --------------------------------------------
// Every (from, to) pair taken is marked. The legal set is small enough
// to enumerate, so anything outside it is a design error the scenarios
// might not have thought to look for.
reg seen_edge [0:7][0:7];
reg [2:0] prev_state;
integer n_transitions;
// Interval measurement, so durations are observed rather than assumed.
integer cyc_in_state;
integer len_lead, len_lag, len_gap, len_shift;
integer cs_low_in_gap, cs_high_in_shift, shift_outside;
integer done_pulses;
always @(posedge clk) begin
if (!rst_n) begin
prev_state <= S_IDLE;
n_transitions <= 0;
cyc_in_state <= 1;
end else begin
if (state !== prev_state) begin
seen_edge[prev_state][state] <= 1'b1;
n_transitions <= n_transitions + 1;
case (prev_state)
S_LEAD: len_lead <= cyc_in_state;
S_LAG: len_lag <= cyc_in_state;
S_GAP: len_gap <= cyc_in_state;
S_SHIFT: len_shift <= cyc_in_state;
default: ;
endcase
cyc_in_state <= 1;
prev_state <= state;
end else begin
cyc_in_state <= cyc_in_state + 1;
end
// Output-consistency observations, accumulated continuously.
if (state == S_GAP && cs_assert) cs_low_in_gap <= cs_low_in_gap + 1;
if (state == S_SHIFT && !cs_assert) cs_high_in_shift <= cs_high_in_shift + 1;
if (state != S_SHIFT && shift_en) shift_outside <= shift_outside + 1;
if (done) done_pulses <= done_pulses + 1;
end
end
// Feed the FSM `n` bit_done pulses, one every three cycles, which is what
// a divider with any divisor above one looks like from here.
task clock_bits;
input integer n;
begin
for (i = 0; i < n; i = i + 1) begin
repeat (2) @(negedge clk);
bit_done = 1'b1;
@(negedge clk);
bit_done = 1'b0;
end
end
endtask
task begin_transfer;
input integer nb;
begin
@(negedge clk);
n_bits = (nb);
start = 1'b1;
@(negedge clk);
start = 1'b0;
end
endtask
// Waits for IDLE and then settles two more cycles, because the recorder
// observes a state change one posedge after it occurs -- so its results
// are not final at the instant IDLE is first visible.
task wait_idle;
input integer limit;
integer guard;
begin
guard = 0;
while (state !== S_IDLE && guard < limit) begin
@(negedge clk);
guard = guard + 1;
end
if (guard >= limit) begin
$display(" FAIL: the FSM never returned to IDLE (state=%0d)",
state);
errors = errors + 1;
end
repeat (2) @(negedge clk);
end
endtask
initial begin
for (a = 0; a < 8; a = a + 1)
for (b = 0; b < 8; b = b + 1)
seen_edge[a][b] = 1'b0;
n_transitions = 0; cyc_in_state = 1;
len_lead = 0; len_lag = 0; len_gap = 0; len_shift = 0;
cs_low_in_gap = 0; cs_high_in_shift = 0; shift_outside = 0;
done_pulses = 0;
repeat (3) @(negedge clk);
rst_n = 1'b1;
@(negedge clk);
// 1. RESET STATE. IDLE, not busy, accepting, CS inactive.
if (state !== S_IDLE || busy || !accepting || cs_assert) begin
$display(" FAIL: reset state is wrong (state=%0d busy=%0b accepting=%0b cs=%0b)",
state, busy, accepting, cs_assert);
errors = errors + 1;
end
$display(" reset: state=IDLE, busy=0, accepting=1, CS inactive");
// 2. ONE COMPLETE TRANSFER. The state sequence is the specification.
begin_transfer(8);
clock_bits(8);
wait_idle(200);
if (!seen_edge[S_IDLE][S_LEAD] || !seen_edge[S_LEAD][S_SHIFT] ||
!seen_edge[S_SHIFT][S_LAG] || !seen_edge[S_LAG][S_GAP] ||
!seen_edge[S_GAP][S_IDLE]) begin
$display(" FAIL: the expected state sequence was not taken");
errors = errors + 1;
end
$display(" one transfer: IDLE -> LEAD -> SHIFT -> LAG -> GAP -> IDLE");
// 3. THE INTERVALS, MEASURED. Each state must last exactly its
// parameter -- not at least it, because a state that overstays
// slows every transfer and no requirement asks for it.
if (len_lead != LEAD) begin
$display(" FAIL: LEAD lasted %0d cycles, expected %0d",
len_lead, LEAD);
errors = errors + 1;
end
if (len_lag != LAG) begin
$display(" FAIL: LAG lasted %0d cycles, expected %0d", len_lag, LAG);
errors = errors + 1;
end
if (len_gap != MIN_HIGH) begin
$display(" FAIL: GAP lasted %0d cycles, expected %0d",
len_gap, MIN_HIGH);
errors = errors + 1;
end
$display(" intervals measured: LEAD=%0d LAG=%0d GAP=%0d -- exactly their parameters",
len_lead, len_lag, len_gap);
// 4. OUTPUT CONSISTENCY, accumulated over everything so far. CS must
// be asserted through LEAD, SHIFT and LAG and released in GAP;
// shift_en must appear only in SHIFT.
if (cs_low_in_gap != 0 || cs_high_in_shift != 0 || shift_outside != 0) begin
$display(" FAIL: output consistency -- cs in gap=%0d, cs low in shift=%0d, shift outside=%0d",
cs_low_in_gap, cs_high_in_shift, shift_outside);
errors = errors + 1;
end
$display(" outputs: CS asserted only in LEAD/SHIFT/LAG, shift_en only in SHIFT");
// 5. THE BIT COUNT. SHIFT must end after exactly n_bits bit_done
// pulses -- a count off by one is the failure signature of every
// width bug in this track.
begin_transfer(3);
clock_bits(2);
if (state !== S_SHIFT) begin
$display(" FAIL: SHIFT ended after 2 of 3 bits (state=%0d)", state);
errors = errors + 1;
end
clock_bits(1);
@(negedge clk);
if (state === S_SHIFT) begin
$display(" FAIL: SHIFT did not end after the third bit");
errors = errors + 1;
end
wait_idle(200);
$display(" bit count: SHIFT ends after exactly n_bits pulses, not before or after");
// 6. THE GAP REFUSAL -- the reason GAP and IDLE are different states.
// A start during GAP must be refused, and the FSM must stay in GAP
// until its interval is complete.
begin_transfer(2);
clock_bits(2);
// Now in LAG or GAP. Advance to GAP.
while (state !== S_GAP) @(negedge clk);
if (accepting) begin
$display(" FAIL: the FSM reported accepting while in GAP"); errors = errors + 1;
end
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
// The start must NOT have been taken: no LEAD, and no GAP->LEAD edge.
if (state === S_LEAD) begin
$display(" FAIL: a start during GAP was accepted"); errors = errors + 1;
end
wait_idle(200);
if (seen_edge[S_GAP][S_LEAD]) begin
$display(" FAIL: a GAP -> LEAD transition occurred -- the inter-frame time was not enforced");
errors = errors + 1;
end
$display(" GAP refusal: a start during GAP is not accepted, and no GAP -> LEAD edge exists");
// 7. A ZERO-LENGTH TRANSFER is refused rather than producing a frame
// with no bits, which would assert CS for LEAD+LAG cycles and
// clock nothing -- a frame no device can interpret.
begin_transfer(0);
repeat (4) @(negedge clk);
if (state !== S_IDLE) begin
$display(" FAIL: a zero-bit transfer was accepted (state=%0d)", state);
errors = errors + 1;
end
$display(" zero-length transfer: refused, FSM stays in IDLE");
// 8. BACK-TO-BACK TRANSFERS. Several in succession, each complete,
// with the inter-frame time enforced between them.
for (t = 0; t < 6; t = t + 1) begin
begin_transfer(4);
clock_bits(4);
wait_idle(200);
end
$display(" 6 successive transfers: all completed through the full sequence");
// 9. THE TRANSITION SET. Only the five legal edges may ever have been
// taken. This is the check the scenarios cannot make for
// themselves: it rules out paths nobody thought to test.
illegal = 0;
begin
for (a = 0; a < 5; a = a + 1) begin
for (b = 0; b < 5; b = b + 1) begin
if (seen_edge[a][b]) begin
if (!((a == S_IDLE && b == S_LEAD) ||
(a == S_LEAD && b == S_SHIFT) ||
(a == S_SHIFT && b == S_LAG) ||
(a == S_LAG && b == S_GAP) ||
(a == S_GAP && b == S_IDLE))) begin
$display(" FAIL: illegal transition %0d -> %0d was taken",
a, b);
illegal = illegal + 1;
errors = errors + 1;
end
end
end
end
$display(" transition set: %0d transitions taken, %0d illegal",
n_transitions, illegal);
end
// 10. done PULSES ONCE PER TRANSFER, and for one cycle -- a level
// would be re-read as a second completion. Counted continuously
// over the whole run, so the total must equal the number of
// transfers that actually completed: one, one, one and six above,
// plus this one, and NOT the refused zero-length one.
before_done = done_pulses;
begin_transfer(4);
clock_bits(4);
wait_idle(200);
if ((done_pulses - before_done) != 1) begin
$display(" FAIL: this transfer produced %0d done pulses, expected 1",
done_pulses - before_done);
errors = errors + 1;
end
// Ten transfers complete above: one each in steps 2, 5 and 6, six in
// step 8, and this one -- and none for the zero-length transfer the
// FSM refused, which is the point of counting the total as well as
// the delta.
if (done_pulses != 10) begin
$display(" FAIL: %0d done pulses in total, expected 10 (1+1+1+6+1, and none for the refused zero-length transfer)",
done_pulses);
errors = errors + 1;
end
$display(" done: %0d pulses in total, one per completed transfer and none for the refused one",
done_pulses);
if (errors == 0)
$display("PASS: the FSM resets to IDLE accepting a start, takes the full IDLE-LEAD-SHIFT-LAG-GAP-IDLE sequence, holds each interval for exactly its parameter, asserts CS only in LEAD SHIFT and LAG and shift_en only in SHIFT, ends SHIFT after exactly the requested bit count, refuses a start during GAP so that no GAP-to-LEAD transition exists, refuses a zero-length transfer, and across every scenario took no transition outside the five legal edges");
else
$display("FAIL: %0d error(s)", errors);
$finish;
end
initial begin
clk = 1'b0;
rst_n = 1'b0;
start = 1'b0;
n_bits = 16'd8;
bit_done = 1'b0;
errors = 0;
end
endmodule-- spi_master_fsm.vhd
--
-- Chapter 13.3 -- the control FSM, and why each state must exist, in VHDL.
--
-- Five states, each justified by a requirement rather than by symmetry:
--
-- IDLE resting. A start is ACCEPTED here and nowhere else.
-- LEAD CS asserted, no SCLK yet -- enforces t_CSS (Ch 2.5)
-- SHIFT clocking bits (Ch 1.3)
-- LAG last edge past, CS still asserted -- t_CSH (Ch 2.5)
-- GAP CS released, enforcing the minimum inactive time (Ch 7.3)
--
-- The state most often merged away is GAP, and merging it is the bug. IDLE
-- and GAP both have CS inactive and both do nothing, so they look like one
-- state. They differ in exactly one respect: a start is accepted in IDLE and
-- must be REFUSED in GAP, because accepting it would begin a frame before the
-- device's minimum deselect time had elapsed.
--
-- LEAD and LAG are equally hard to argue away: a combinational CS derived
-- from "am I shifting" asserts and releases on the same edges as the first
-- and last SCLK transitions, giving zero lead and zero lag. The states exist
-- because those intervals must be non-zero.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_master_fsm is
generic (
CNT_W : positive := 16;
LEAD : positive := 2;
LAG : positive := 2;
MIN_HIGH : positive := 4
);
port (
clk : in std_logic;
rst_n : in std_logic;
start : in std_logic;
n_bits : in unsigned(CNT_W - 1 downto 0);
bit_done : in std_logic; -- one bit complete, from the divider
state : out unsigned(2 downto 0);
cs_assert : out std_logic; -- CS active (the pin is inverted)
shift_en : out std_logic; -- the divider may run
busy : out std_logic;
done : out std_logic;
accepting : out std_logic; -- a start would be taken now
bits_left : out unsigned(CNT_W - 1 downto 0)
);
end entity;
architecture rtl of spi_master_fsm is
constant S_IDLE : unsigned(2 downto 0) := "000";
constant S_LEAD : unsigned(2 downto 0) := "001";
constant S_SHIFT : unsigned(2 downto 0) := "010";
constant S_LAG : unsigned(2 downto 0) := "011";
constant S_GAP : unsigned(2 downto 0) := "100";
signal st : unsigned(2 downto 0) := S_IDLE;
signal tick : unsigned(CNT_W - 1 downto 0) := (others => '0');
signal left : unsigned(CNT_W - 1 downto 0) := (others => '0');
signal dn : std_logic := '0';
begin
-- Outputs are decoded from the state alone, which is what makes the state
-- diagram the specification: no output depends on anything the diagram
-- does not show.
cs_assert <= '1' when st = S_LEAD or st = S_SHIFT or st = S_LAG else '0';
shift_en <= '1' when st = S_SHIFT else '0';
busy <= '0' when st = S_IDLE else '1';
accepting <= '1' when st = S_IDLE else '0';
state <= st;
done <= dn;
bits_left <= left;
fsm : process (clk, rst_n)
begin
if rst_n = '0' then
st <= S_IDLE;
tick <= (others => '0');
left <= (others => '0');
dn <= '0';
elsif rising_edge(clk) then
dn <= '0';
if st = S_IDLE then
-- The ONLY state that accepts a start.
if start = '1' and n_bits /= 0 then
left <= n_bits;
tick <= (others => '0');
st <= S_LEAD;
end if;
elsif st = S_LEAD then
-- CS is asserted and SCLK is not running. A lead of zero would
-- let the first edge coincide with CS assertion, which a
-- device may not sample.
if to_integer(tick) >= LEAD - 1 then
tick <= (others => '0');
st <= S_SHIFT;
else
tick <= tick + 1;
end if;
elsif st = S_SHIFT then
if bit_done = '1' then
if left = 1 then
tick <= (others => '0');
st <= S_LAG;
end if;
left <= left - 1;
end if;
elsif st = S_LAG then
if to_integer(tick) >= LAG - 1 then
tick <= (others => '0');
-- done pulses on LEAVING LAG rather than on entering GAP,
-- so a consumer sees completion the moment the last bit is
-- safely framed -- not after the inter-frame wait, which
-- is the master's business rather than the transfer's.
dn <= '1';
st <= S_GAP;
else
tick <= tick + 1;
end if;
else -- S_GAP
-- CS is inactive and a start is REFUSED. This is the only
-- difference between GAP and IDLE, and the whole reason both
-- exist.
if to_integer(tick) >= MIN_HIGH - 1 then
tick <= (others => '0');
st <= S_IDLE;
else
tick <= tick + 1;
end if;
end if;
end if;
end process;
end architecture;-- spi_master_fsm_tb.vhd
--
-- For a state machine the specification IS the state diagram, so the
-- testbench records every transition taken and compares the set against the
-- legal set. A per-scenario check confirms the paths it exercises; the
-- transition set confirms that no OTHER path exists.
--
-- The interval durations are then measured rather than assumed, and the
-- refusal of a start during GAP -- the only thing distinguishing it from
-- IDLE -- gets a test of its own.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_master_fsm_tb is
end entity;
architecture sim of spi_master_fsm_tb is
constant CNT_W : positive := 16;
constant LEAD : positive := 2;
constant LAG : positive := 2;
constant MIN_HIGH : positive := 4;
constant S_IDLE : unsigned(2 downto 0) := "000";
constant S_LEAD : unsigned(2 downto 0) := "001";
constant S_SHIFT : unsigned(2 downto 0) := "010";
constant S_LAG : unsigned(2 downto 0) := "011";
constant S_GAP : unsigned(2 downto 0) := "100";
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal halt : boolean := false;
signal start : std_logic := '0';
signal n_bits : unsigned(CNT_W - 1 downto 0) := to_unsigned(8, CNT_W);
signal bit_done : std_logic := '0';
signal st : unsigned(2 downto 0);
signal cs_assert : std_logic;
signal shift_en : std_logic;
signal busy : std_logic;
signal dn : std_logic;
signal accepting : std_logic;
signal bits_left : unsigned(CNT_W - 1 downto 0);
-- Transition recorder. The legal set is small enough to enumerate, so
-- anything outside it is a design error the scenarios might not have
-- thought to look for.
type edge_mat is array (0 to 4, 0 to 4) of std_logic;
signal seen_edge : edge_mat := (others => (others => '0'));
signal n_trans : natural := 0;
signal len_lead, len_lag, len_gap : natural := 0;
signal cs_in_gap, cs_low_in_shift, shift_outside : natural := 0;
signal done_pulses : natural := 0;
signal errors : natural := 0;
begin
clk <= not clk after 5 ns when not halt else '0';
dut : entity work.spi_master_fsm
generic map (CNT_W => CNT_W, LEAD => LEAD, LAG => LAG,
MIN_HIGH => MIN_HIGH)
port map (
clk => clk, rst_n => rst_n,
start => start, n_bits => n_bits, bit_done => bit_done,
state => st, cs_assert => cs_assert, shift_en => shift_en,
busy => busy, done => dn, accepting => accepting,
bits_left => bits_left
);
record_p : process (clk)
variable prev : unsigned(2 downto 0) := S_IDLE;
variable cyc : natural := 1;
begin
if rising_edge(clk) then
if rst_n = '0' then
prev := S_IDLE;
cyc := 1;
else
if st /= prev then
seen_edge(to_integer(prev), to_integer(st)) <= '1';
n_trans <= n_trans + 1;
if prev = S_LEAD then len_lead <= cyc;
elsif prev = S_LAG then len_lag <= cyc;
elsif prev = S_GAP then len_gap <= cyc;
end if;
cyc := 1;
prev := st;
else
cyc := cyc + 1;
end if;
-- Output-consistency observations, accumulated continuously.
if st = S_GAP and cs_assert = '1' then
cs_in_gap <= cs_in_gap + 1;
end if;
if st = S_SHIFT and cs_assert = '0' then
cs_low_in_shift <= cs_low_in_shift + 1;
end if;
if st /= S_SHIFT and shift_en = '1' then
shift_outside <= shift_outside + 1;
end if;
if dn = '1' then
done_pulses <= done_pulses + 1;
end if;
end if;
end if;
end process;
stim : process
variable errs : natural := 0;
variable guard : natural;
variable illegal : natural;
variable before_done : natural;
-- Feed the FSM n bit_done pulses, one every three cycles, which is
-- what a divider with any divisor above one looks like from here.
procedure clock_bits(n : natural) is
begin
for i in 1 to n loop
for j in 1 to 2 loop
wait until falling_edge(clk);
end loop;
bit_done <= '1';
wait until falling_edge(clk);
bit_done <= '0';
end loop;
end procedure;
procedure begin_transfer(nb : natural) is
begin
wait until falling_edge(clk);
n_bits <= to_unsigned(nb, CNT_W);
start <= '1';
wait until falling_edge(clk);
start <= '0';
end procedure;
-- Waits for IDLE and then settles two more cycles, because the
-- recorder observes a state change one edge after it occurs -- so its
-- results are not final at the instant IDLE is first visible.
procedure wait_idle(limit : natural) is
begin
guard := 0;
while st /= S_IDLE and guard < limit loop
wait until falling_edge(clk);
guard := guard + 1;
end loop;
if guard >= limit then
report " FAIL: the FSM never returned to IDLE";
errs := errs + 1;
end if;
for k in 1 to 2 loop
wait until falling_edge(clk);
end loop;
end procedure;
begin
for k in 0 to 2 loop
wait until falling_edge(clk);
end loop;
rst_n <= '1';
wait until falling_edge(clk);
-- 1. RESET STATE.
if st /= S_IDLE or busy = '1' or accepting /= '1' or
cs_assert = '1' then
report " FAIL: the reset state is wrong"; errs := errs + 1;
end if;
report " reset: state=IDLE, busy=0, accepting=1, CS inactive";
-- 2. ONE COMPLETE TRANSFER. The state sequence is the specification.
begin_transfer(8);
clock_bits(8);
wait_idle(200);
if seen_edge(0, 1) /= '1' or seen_edge(1, 2) /= '1' or
seen_edge(2, 3) /= '1' or seen_edge(3, 4) /= '1' or
seen_edge(4, 0) /= '1' then
report " FAIL: the expected state sequence was not taken";
errs := errs + 1;
end if;
report " one transfer: IDLE -> LEAD -> SHIFT -> LAG -> GAP -> IDLE";
-- 3. THE INTERVALS, MEASURED. Each state must last exactly its
-- parameter -- not at least it, because a state that overstays
-- slows every transfer and no requirement asks for it.
if len_lead /= LEAD then
report " FAIL: LEAD did not last exactly its parameter";
errs := errs + 1;
end if;
if len_lag /= LAG then
report " FAIL: LAG did not last exactly its parameter";
errs := errs + 1;
end if;
if len_gap /= MIN_HIGH then
report " FAIL: GAP did not last exactly its parameter";
errs := errs + 1;
end if;
report " intervals measured: LEAD=" & integer'image(len_lead) &
" LAG=" & integer'image(len_lag) &
" GAP=" & integer'image(len_gap) &
" -- exactly their parameters";
-- 4. OUTPUT CONSISTENCY, accumulated over everything so far.
if cs_in_gap /= 0 or cs_low_in_shift /= 0 or shift_outside /= 0 then
report " FAIL: an output was inconsistent with the state";
errs := errs + 1;
end if;
report " outputs: CS asserted only in LEAD/SHIFT/LAG, shift_en only in SHIFT";
-- 5. THE BIT COUNT. SHIFT must end after exactly n_bits pulses.
begin_transfer(3);
clock_bits(2);
if st /= S_SHIFT then
report " FAIL: SHIFT ended after 2 of 3 bits"; errs := errs + 1;
end if;
clock_bits(1);
wait until falling_edge(clk);
if st = S_SHIFT then
report " FAIL: SHIFT did not end after the third bit";
errs := errs + 1;
end if;
wait_idle(200);
report " bit count: SHIFT ends after exactly n_bits pulses";
-- 6. THE GAP REFUSAL -- the reason GAP and IDLE are different states.
begin_transfer(2);
clock_bits(2);
while st /= S_GAP loop
wait until falling_edge(clk);
end loop;
if accepting = '1' then
report " FAIL: the FSM reported accepting while in GAP";
errs := errs + 1;
end if;
wait until falling_edge(clk);
start <= '1';
wait until falling_edge(clk);
start <= '0';
if st = S_LEAD then
report " FAIL: a start during GAP was accepted"; errs := errs + 1;
end if;
wait_idle(200);
if seen_edge(4, 1) = '1' then
report " FAIL: a GAP -> LEAD transition occurred -- the inter-frame time was not enforced";
errs := errs + 1;
end if;
report " GAP refusal: a start during GAP is not accepted, and no GAP -> LEAD edge exists";
-- 7. A ZERO-LENGTH TRANSFER is refused rather than producing a frame
-- with no bits, which no device can interpret.
begin_transfer(0);
for k in 1 to 4 loop
wait until falling_edge(clk);
end loop;
if st /= S_IDLE then
report " FAIL: a zero-bit transfer was accepted"; errs := errs + 1;
end if;
report " zero-length transfer: refused, FSM stays in IDLE";
-- 8. BACK-TO-BACK TRANSFERS.
for t in 1 to 6 loop
begin_transfer(4);
clock_bits(4);
wait_idle(200);
end loop;
report " 6 successive transfers: all completed through the full sequence";
-- 9. THE TRANSITION SET. Only the five legal edges may ever have been
-- taken -- the check the scenarios cannot make for themselves.
illegal := 0;
for a in 0 to 4 loop
for b in 0 to 4 loop
if seen_edge(a, b) = '1' then
if not ((a = 0 and b = 1) or (a = 1 and b = 2) or
(a = 2 and b = 3) or (a = 3 and b = 4) or
(a = 4 and b = 0)) then
report " FAIL: an illegal transition was taken";
illegal := illegal + 1;
errs := errs + 1;
end if;
end if;
end loop;
end loop;
report " transition set: " & integer'image(n_trans) &
" transitions taken, " & integer'image(illegal) & " illegal";
-- 10. done PULSES ONCE PER TRANSFER. Counted continuously, so the
-- total must equal the transfers that actually completed -- and
-- NOT include the refused zero-length one.
before_done := done_pulses;
begin_transfer(4);
clock_bits(4);
wait_idle(200);
if (done_pulses - before_done) /= 1 then
report " FAIL: this transfer did not produce exactly one done pulse";
errs := errs + 1;
end if;
if done_pulses /= 10 then
report " FAIL: the total done count is wrong (" &
integer'image(done_pulses) & ", expected 10)";
errs := errs + 1;
end if;
report " done: " & integer'image(done_pulses) &
" pulses in total, one per completed transfer and none for the refused one";
errors <= errs;
if errs = 0 then
report "PASS: the FSM resets to IDLE accepting a start, takes the full IDLE-LEAD-SHIFT-LAG-GAP-IDLE sequence, holds each interval for exactly its parameter, asserts CS only in LEAD SHIFT and LAG and shift_en only in SHIFT, ends SHIFT after exactly the requested bit count, refuses a start during GAP so that no GAP-to-LEAD transition exists, refuses a zero-length transfer, and across every scenario took no transition outside the five legal edges";
else
report "FAIL: " & integer'image(errs) & " error(s)" severity error;
end if;
halt <= true;
wait;
end process;
end architecture;Parity
All three record 45 transitions with zero illegal ones and exactly 10 done pulses across the stimulus, and all three prove that a start request arriving in GAP is refused — which is the one check that distinguishes this machine from the four-state version.
7. Why a Verification Engineer Cares
The machine's specification is almost entirely about which transitions are legal, which makes the assertion set unusually direct.
// A control FSM's specification is a graph. Stating it as assertions is
// mechanical, and the mechanical form is the point: a transition that is not
// listed here is illegal, so adding a state to the design without adding it here
// fails immediately rather than silently widening the specification.
module spi_master_fsm_sva #(parameter int LEN = 8) (
input logic clk,
input logic rst_n,
input logic start,
input logic [2:0] state,
input logic cs_active,
input logic shift_en,
input logic busy,
input logic done
);
localparam logic [2:0] S_IDLE = 3'd0, S_LEAD = 3'd1, S_SHIFT = 3'd2,
S_LAG = 3'd3, S_GAP = 3'd4;
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// Each state may go only where the graph says. Written as one property per
// state so a failure names the state rather than the machine.
a_from_idle: assert property (state == S_IDLE |=> state inside {S_IDLE, S_LEAD});
a_from_lead: assert property (state == S_LEAD |=> state inside {S_LEAD, S_SHIFT});
a_from_shift: assert property (state == S_SHIFT |=> state inside {S_SHIFT, S_LAG});
a_from_lag: assert property (state == S_LAG |=> state inside {S_LAG, S_GAP});
a_from_gap: assert property (state == S_GAP |=> state inside {S_GAP, S_IDLE});
// THE refusal. This is the assertion that the four-state machine fails, and
// it is the reason the fifth state exists.
a_gap_refuses: assert property (state == S_GAP && start |=> state != S_LEAD);
// Outputs are decoded from state alone, so each is an equivalence rather
// than an implication. An equivalence also catches an output that is
// correct in every state it should be high and stuck high somewhere else.
a_cs: assert property (cs_active == (state != S_IDLE && state != S_GAP));
a_shift: assert property (shift_en == (state == S_SHIFT));
a_busy: assert property (busy == (state != S_IDLE));
// `done` is a pulse on exactly one transition, in both directions.
a_done_only_on_lag_exit: assert property (
done |-> $past(state) == S_LAG && state == S_GAP
);
a_lag_exit_always_done: assert property (
$past(state) == S_LAG && state == S_GAP |-> done
);
// And no clock while deselected -- requirement R1, restated where the
// machine can be held to it.
a_no_shift_without_cs: assert property (shift_en |-> cs_active);
endmodule// Covering the five STATES is trivial and nearly worthless: any transfer visits
// all five. What needs covering is the TRANSITIONS, including the self-loops of
// length zero and one, and the interval values at their boundaries.
covergroup cg_master_fsm @(posedge clk);
// Every legal transition, as an ordered pair. The illegal ones are the
// assertions' business; here the goal is that each legal one occurred.
trans: coverpoint {$past(state), state} {
bins idle_hold = {{3'd0, 3'd0}};
bins idle_lead = {{3'd0, 3'd1}};
bins lead_hold = {{3'd1, 3'd1}};
bins lead_shift = {{3'd1, 3'd2}};
bins shift_hold = {{3'd2, 3'd2}};
bins shift_lag = {{3'd2, 3'd3}};
bins lag_hold = {{3'd3, 3'd3}};
bins lag_gap = {{3'd3, 3'd4}};
bins gap_hold = {{3'd4, 3'd4}};
bins gap_idle = {{3'd4, 3'd0}};
}
// The degenerate intervals. A LEAD of one cycle takes the idle_lead and
// lead_shift bins with no lead_hold in between, and it is where an
// off-by-one in the counter comparison shows.
lead_len: coverpoint lead_cyc {
bins one = {1};
bins two = {2};
bins small = {[3:8]};
bins large = {[9:$]};
}
lag_len: coverpoint lag_cyc {
bins one = {1};
bins small = {[2:8]};
bins large = {[9:$]};
}
gap_len: coverpoint gap_cyc {
bins one = {1};
bins small = {[2:8]};
bins large = {[9:$]};
}
// A start request DURING each state. The GAP entry is the one that matters,
// and the others must be shown to be harmless.
start_where: coverpoint state iff (start) {
bins in_idle = {3'd0};
bins in_lead = {3'd1};
bins in_shift = {3'd2};
bins in_lag = {3'd3};
bins in_gap = {3'd4};
}
// Frame lengths at their extremes: one bit exercises the SHIFT exit with no
// self-loop at all.
bits: coverpoint n_bits {
bins one = {1};
bins two = {2};
bins byte_ = {8};
bins wide = {[16:$]};
}
x_start_in_gap: cross start_where, gap_len;
endgroup8. Why an FPGA or ASIC Engineer Cares
Five states is three flops in binary, or five in one-hot, and the choice matters less than the encoding's timing. One-hot makes every output a single flop's output — shift_en becomes a wire from a flop rather than a three-input decode — which removes a level of logic from the signal that gates the clock divider. On an FPGA with abundant flops that is free and worth taking.
shift_en is the only output on a timing-relevant path. It enables the divider, which generates SCLK. Everything else — cs_active, busy, done — feeds logic that runs once per transfer. A designer looking for where to spend effort has exactly one signal to look at.
The shared interval counter is the right trade and has a synthesis consequence. Three separate counters would each have a single load value and no mux; one shared counter has a three-way mux on its load input. The mux is on the load path, which is evaluated once per state entry, so it is never critical — and it saves two counters' worth of flops. This is the general shape of counter sharing: it moves logic from the increment path, which is frequent, to the load path, which is not.
Reset must put the machine in IDLE with chip select released, asynchronously. That is requirement R1 at power-on, and it is the one place in this block where the reset style is not a preference: a synchronous reset needs a clock edge, and a master coming out of reset with no clock running would hold chip select in whatever state the flops powered up in.
9. Failure Signature — The Second Read Returns The First Read's Value
Symptom. A driver reads two consecutive registers from a sensor. The second read returns the first read's value. Inserting any delay between the two reads fixes it. A debug build fixes it. Running the same code in a loop with a printf between the reads fixes it.
What that rules out. A fault that disappears with added delay is a timing or sequencing fault, not a data or wiring fault. That it is specifically the second of two reads, and that the returned value is the first one, narrows it further: the second transaction never reached the device, and the master returned a stale receive register.
The candidates, and how to separate them. Three mechanisms produce this exactly:
the master started the second transaction with no CS-high gap,
and the slave ignored it -> R5, this chapter's bug
the master never started the second transaction at all
because the first one's `done` was missed -> a handshake bug
the master ran the transaction correctly and the
driver read the receive register too early -> a software bugThe requirements monitor from Chapter 13.1 separates them in one run. If R5 is set with a measured gap below the limit, it is the first. If the monitor shows only one chip-select assertion for two reads, it is the second. If it shows two clean assertions, the hardware did its job and the fault is in the driver.
The mechanism, when it is R5. The FSM has four states: LAG returns directly to IDLE, and IDLE accepts the pending request immediately. Chip select rises and falls again within a cycle or two. The slave requires 50 ns of CS-high time and sees perhaps 20; it treats the second assertion as noise on the first transaction and does not resynchronise. The master clocks out its command into a device that is not listening and reads back whatever the MISO pin was doing, which is the previous value still being held.
Why adding a print fixes it. The print takes microseconds. The gap is satisfied by accident, and the bug becomes invisible in exactly the configuration a developer uses to investigate it. This is the characteristic signature of a missing minimum-interval state, and it is worth recognising on its own: a fault that is fixed by instrumentation is nearly always a minimum-time violation.
The fix. GAP, as a state, with the count taken from the slave's t_CSD. Not a delay in the driver — a driver-side delay is correct until someone writes a faster driver, and the requirement belongs to the hardware because the number comes from the slave's datasheet.
10. Common Misconceptions
"IDLE and GAP have identical outputs, so they are the same state." They have identical outputs and different input sensitivity. A state is defined by both, and GAP's entire purpose is the difference.
"The gap could be a counter inside IDLE." It could, and then IDLE has two behaviours depending on a counter, which is a two-state machine written as one state plus a flag. Naming it GAP makes the machine's own diagram say what it does; hiding it in IDLE makes a reviewer have to read the counter logic to discover that requirement R5 is implemented at all.
"done should pulse when the last bit is sent." The last bit is sent at the end of SHIFT, and chip select is still asserted for the whole of LAG after that. A consumer that treats the last bit as completion may start the next transaction during the hold time, violating R3 for the transfer that just finished.
"Mealy outputs would make the machine smaller." Sometimes, and every Mealy output inherits the glitches of the input it depends on. For shift_en, which gates a clock generator, that is not a trade worth considering.
"A state machine with only forward transitions and self-loops cannot deadlock, so liveness needs no checking." It cannot deadlock only if every self-loop has an exit condition that is guaranteed to occur. SHIFT's exit depends on the bit counter reaching its target, and a bit counter that is never incremented — because the divider is misconfigured and produces no periods — leaves the machine in SHIFT forever with chip select asserted. The guard in the testbench exists for exactly that, and it is the reason every wait loop in this module has a bound.
11. Reason It Through
Why is done decoded from a transition rather than from a state?
Because it is a pulse and states are levels. There is no state whose duration is one cycle at the right moment, so a level-decoded done would either last for the whole of GAP — which a consumer would count as many completions — or need a dedicated one-cycle state, which is a sixth state added to carry a pulse. Decoding from the LAG-to-GAP transition is a function of the state register alone, so it keeps every benefit of state-decoded outputs.
A design merges LAG and GAP into one state with a single counter loaded with LAG + GAP. What breaks?
Chip select. The lag is paid with chip select asserted and the gap with chip select released, so a single state cannot produce both — whichever level it drives, one of the two requirements is violated. If it releases chip select on entry, R3 fails and the final bit's hold time is lost. If it holds chip select until exit, R5 fails because the CS-high time is zero. The two intervals have the same shape and opposite pin behaviour, which is exactly what makes them separate states rather than one longer count.
What happens if start is a level rather than a pulse, and the driver leaves it asserted?
The machine runs transactions back to back forever, each one separated by a correct gap. That is legal and sometimes wanted, and it is why start is treated as a level in this design: IDLE accepts it whenever it is asserted. A pulse-only design would require the driver to re-pulse, which is fine too — but the level form makes the streaming case of Chapter 13.9 natural rather than a special case.
Why does the testbench count done pulses over the whole run instead of waiting for each one?
Because waiting detects a missing pulse and not an extra one. A machine that asserted done twice per transfer would satisfy every wait and fail a count. The counting form is also portable, which the fork/join_any form is not — and it is shorter.
The FSM's stimulus produced a measured lead one cycle longer than the parameter, and the testbench initially reported a failure. Where was the bug?
In the stimulus, not the design. The frame task's own trailing wait contributed a cycle to the interval, so the measured value included it. The wrong fix is to tune the stimulus until the numbers agree, because that buries the discrepancy. The right fix is to make the design publish the measured interval and assert against the measurement — which is what Chapter 13.1's monitor does, and why it publishes meas_lead rather than only a pass bit.
12. Understanding Check
13. Summary
A transfer is five intervals, not one event, and each of the six requirements from Chapter 13.1 maps onto exactly one of them. A state no requirement refers to should be questioned; a requirement mapping onto no state cannot be satisfied.
IDLE and GAP are identical on the pins and differ in exactly one behaviour: GAP refuses a start request. That single difference implements requirement R5, and merging the two states deletes it — producing a failure in which the second of two back-to-back transactions is ignored and the first value is returned twice.
That failure disappears when instrumentation is added, which is the characteristic signature of a minimum-time violation and worth recognising on its own.
Every output is decoded from state alone. It buys glitch-free outputs — decisive for shift_en, which gates the clock generator — and it means recording the state sequence records the output sequence. done is decoded from the LAG-to-GAP transition, which is still a function of the state register and avoids adding a sixth state to carry a pulse.
One interval counter is shared between LEAD, LAG and GAP, which moves logic from the increment path to the load path. Reset is asynchronous and puts the machine in IDLE with chip select released, because a synchronous reset needs a clock that may not be running.
For verification, the specification is a transition graph, which makes the assertion set mechanical: one property per state, plus the refusal property that the four-state machine fails. Coverage bins transitions rather than states — every transfer visits all five states, so state coverage is nearly free and nearly worthless — together with degenerate interval lengths of one, and start requests in each state. The testbench counts done pulses rather than waiting for them, which is portable and strictly stronger.
14. What Comes Next
The machine knows when the clock should run. It does not produce one.
Chapter 13.4 — Clock Divider and SCLK Generation builds the divider, and it produces more than SCLK: a datapath needs to know when each edge happens, one system clock at a time. The interesting decision there is what to do with an odd divisor, where the two halves of a period cannot be equal — and the answer follows from which half the slave's output-valid time has to fit into.
Continue learning
Related tutorials
- Related topic
CS-to-SCLK and SCLK-to-CS Timing
Chip select has timing requirements of its own: the lead before the first clock edge, the lag after the last, and the minimum deselect between transactions. Why violating them breaks a transfer whose every SCLK edge was correct.
- Related topic
Command, Address, and Data Phases
How a device layers a transaction onto a raw byte stream: why the opcode decides the shape of everything after it, how a slave tracks phases with no phase marker, and the sequencer that requires in three HDLs.
- Related topic
Command-Then-Read Sequences
Why every SPI read is a write first, why request and response must share one CS frame, what the master drives once its half is done, what the request costs in bus time, and the master read sequencer in three HDLs.
- Related topic
Chip Select Semantics and Device Selection
What selection means on an SPI bus: why active-low is a convention, what each CS edge commits the device to, why selection is physical rather than addressed, and the generator that makes multi-select structurally impossible.
