SPI · Module 13
Master Microarchitecture
Partitioning an SPI master into blocks that each earn their place: why control and datapath separate along the line of what changes per bit, why configuration must be snapshotted at transfer start, and what a mid-transfer write must do instead of being ignored.
Chapter 13.1 produced six requirements and an instrument that checks them. Nothing has been designed. This chapter makes the first structural decision, and it is the one every later chapter inherits.
Software writes a new divisor to the control register while a transfer is running. What should the hardware do?
There are three answers. Two of them are wrong in ways that are hard to find, and picking between them is not a coding question — it is the microarchitecture.
1. The Partition That Matters
An SPI master has to do six things, and they divide cleanly if you ask the right question about each. The question is not "is this control or data?" — that framing produces arguments. It is:
Does this logic do something different on every bit, or something once per transfer?
once per transfer every bit
------------------------------------ --------------------------------------
decode the request shift out the next bit
snapshot the configuration shift in the arriving bit
assert chip select, pay the lead toggle SCLK
count the frames of a burst count the bits
pay the lag, release chip select
pay the inter-transaction gapThe left column is control. The right column is datapath. The division is not stylistic: the right column is clocked at the SCLK rate and the left is not, so anything that migrates from left to right lands on the timing-critical path, and anything that migrates the other way gets re-evaluated when it should have been frozen.
That gives the blocks, and each one is a chapter:
13.4 clock divider SCLK and two edge strobes
13.5 mode logic which edge launches, which captures
13.6 shift datapath the two shift registers
13.8 width and order frame width and bit order
13.7 chip-select control lead, lag, gap, and burst hold
13.9 streaming keeping the wire busy between frames
13.10 abort policy stopping without leaving the bus illegal
13.2 configuration latch when the settings take effectThe last one is this chapter's, and it exists because of the question in the epigraph.
2. The Block Diagram
Two things in that diagram are worth naming before the rest of the module relies on them.
Nothing in the middle column reads the register file. Everything it needs arrives from the latch, which means a register write cannot reach the SCLK-rate logic at all — not by accident, not by timing, not by a path someone adds later. That is a structural guarantee rather than a discipline, and structural guarantees are the only ones that survive maintenance.
The divider's enable comes from chip-select control, not from the register file. The clock runs when a transfer is in progress and at no other time, and the block that knows whether a transfer is in progress is the one that owns the select pin. An enable derived from a "start" bit in a register would let the clock run before chip select had settled, which is requirement R2 from Chapter 13.1.
3. The Question, and the Three Answers
Software writes a new divisor mid-transfer. The candidates:
Answer A — the datapath reads the registers continuously. Simplest to write, and wrong. The divisor changes mid-frame, so the second half of a bit period is a different length from the first; the frame's bit boundaries move; the slave, which is counting edges, is now out of step. The transfer completes and returns plausible garbage, and because the corruption depends on when the write landed relative to the frame, it is not reproducible.
Answer B — writes are rejected while busy. Safe and defensible, and it pushes a problem into software: the driver must now poll a status bit before every configuration write, and handle the case where the write was refused. It also makes a common pattern awkward — configuring the next transfer while the current one runs is exactly what a driver wants to do, and rejecting it forces the bus to idle between transfers for no hardware reason.
Answer C — the registers accept the write, and the datapath uses a snapshot taken at frame start. The write succeeds, nothing in flight changes, and the new setting takes effect on the next frame. Software gets a register file that behaves like a register file; hardware gets a configuration that is stable for the whole of every frame.
C is the answer, and the block that implements it is one register-width of flops plus a single load condition. The cost is trivial; the value is that there is exactly one place in the design where the question "when does configuration take effect" is answered, and every other block is relieved of it.
4. What "Snapshot" Has To Mean Precisely
The load condition looks obvious and has two traps in it.
It must be start && !busy, not start. A start request arriving while a transfer is already running must not reload the snapshot, or the running transfer's configuration changes underneath it — which is Answer A with extra steps. In a burst (Chapter 13.7) the second and subsequent frames are started while the transaction is still open, and it is precisely those frames that must keep the first frame's settings.
The snapshot must not be partially updated. All five fields — mode, divisor, width, bit order, length — load together or not at all. A design that loads each field when that field's register is written has no snapshot at all; it has five independent races. The condition is one condition and it drives all five loads.
And the change_ignored logic has a trap of its own, which is the one this chapter's testbench exists to catch:
A mid-transfer write must set the flag and change nothing. It is very easy to write the flag logic as "if busy and the request differs, record it" and then — in the same branch, because it reads naturally — also assign the configuration. The result is a design that sets the flag correctly and also corrupts the transfer, which is worse than either mistake alone: the flag says the write was ignored, and it was not.
start && !busy -> load all five fields, clear change_ignored
busy && differs -> set change_ignored, assign NOTHING
otherwise -> hold5. Building the Configuration Latch — Three HDLs
The circuit
The latch takes a requested configuration and a start/busy pair, and publishes the configuration the datapath should use plus the change_ignored flag. It also publishes unstable, which counts cycles on which a published field changed while busy was asserted — a self-check that exists so the testbench can assert the central property directly rather than inferring it.
unstable is worth a note. It is redundant in a correct design: it must be zero always, so it carries no information when the design is right. That is exactly what makes it valuable during development, and it costs one comparator and one counter. In a production build it can be parameterised away; the habit worth keeping is that the property a block exists to guarantee should be observable at the block's own boundary.
// spi_cfg_latch.sv
//
// Chapter 13.2 -- where the control/datapath boundary actually sits.
//
// A microarchitecture is a set of decisions about which block knows what.
// This block is the first of those decisions, and it is the one that makes
// every later chapter simpler:
//
// THE DATAPATH NEVER SEES A CHANGING CONFIGURATION.
//
// Mode, divisor, frame width, bit order and length are latched once, at the
// instant a transfer starts, and held for its whole duration. So the shift
// register (Ch 13.6), the divider (Ch 13.4), the edge logic (Ch 13.5) and
// the chip-select generator (Ch 13.7) can each read their parameter as a
// constant. None of them needs to handle it changing, because it cannot.
//
// That is worth stating as a cost as well as a benefit. The cost is one
// register per parameter -- about thirty flip-flops. The benefit is that
// four blocks lose an entire class of behaviour they would otherwise have
// to implement and verify. Buying simplicity in four places with thirty
// flip-flops in one is the trade this chapter argues for.
//
// A request that changes mid-transfer is IGNORED and REPORTED. Not applied
// late, not applied partially, and not silently dropped -- because software
// writing a new divisor while a transfer runs has made a mistake it needs
// to be told about, and a controller that applied it would produce a frame
// matching no device (Chapter 10.1's atomicity argument, now about time
// rather than about validity).
module spi_cfg_latch #(
parameter int DIV_W = 8,
parameter int LEN_W = 16
) (
input logic clk,
input logic rst_n,
input logic start, // pulse: begin a transfer
input logic busy, // from the control FSM
// The requested configuration. May change at any time.
input logic [1:0] req_mode,
input logic [DIV_W-1:0] req_div,
input logic [5:0] req_width,
input logic req_lsb_first,
input logic [LEN_W-1:0] req_len,
// The configuration in force. Constant for the whole transfer.
output logic [1:0] cfg_mode,
output logic [DIV_W-1:0] cfg_div,
output logic [5:0] cfg_width,
output logic cfg_lsb_first,
output logic [LEN_W-1:0] cfg_len,
output logic cfg_valid,
output logic change_ignored // sticky: a change was refused
);
// Does the request currently differ from what is in force? Computed
// continuously so a mid-transfer change is detected on the cycle it
// appears rather than at the end of the transfer.
wire differs = (req_mode != cfg_mode) ||
(req_div != cfg_div) ||
(req_width != cfg_width) ||
(req_lsb_first != cfg_lsb_first) ||
(req_len != cfg_len);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
// Reset loads a safe configuration rather than an arbitrary one:
// mode 0, the slowest divisor, eight bits, MSB first. The same
// argument as Chapter 11.2's reset default -- a controller that
// has not been configured must not be able to run fast or in an
// exotic mode.
cfg_mode <= 2'd0;
cfg_div <= {DIV_W{1'b1}};
cfg_width <= 6'd8;
cfg_lsb_first <= 1'b0;
cfg_len <= {{(LEN_W-1){1'b0}}, 1'b1};
cfg_valid <= 1'b0;
change_ignored <= 1'b0;
end else begin
if (start && !busy) begin
// The single instant at which the configuration may change.
cfg_mode <= req_mode;
cfg_div <= req_div;
cfg_width <= req_width;
cfg_lsb_first <= req_lsb_first;
cfg_len <= req_len;
cfg_valid <= 1'b1;
// A new transfer clears the previous verdict, so software
// reads a report about the transfer it is looking at.
change_ignored <= 1'b0;
end else if (busy && differs) begin
// Refused, and recorded. Note what is NOT here: any
// assignment to cfg_*. The refusal is the absence of an
// action rather than an action of its own, which is why no
// partial application is possible.
change_ignored <= 1'b1;
end
end
end
endmodule// spi_cfg_latch_tb.sv
//
// The property under test is STABILITY, and it is checked continuously
// rather than at the end: the configuration must not change on any cycle
// while busy, so the testbench snapshots it when busy rises and compares
// every cycle until busy falls.
//
// Checking only at the end would pass a design that changed the
// configuration mid-transfer and changed it back.
`timescale 1ns/1ps
module spi_cfg_latch_tb;
localparam int DIV_W = 8;
localparam int LEN_W = 16;
logic clk = 1'b0;
logic rst_n = 1'b0;
always #5 clk = ~clk;
logic start = 1'b0;
logic busy = 1'b0;
logic [1:0] req_mode = 2'd0;
logic [DIV_W-1:0] req_div = 8'd4;
logic [5:0] req_width = 6'd8;
logic req_lsb_first = 1'b0;
logic [LEN_W-1:0] req_len = 16'd1;
// Driven by the DUT, so declared as nets: a module output cannot drive
// a variable in Verilog-2001, and using `wire` here keeps the two
// published sources identical in structure.
wire [1:0] cfg_mode;
wire [DIV_W-1:0] cfg_div;
wire [5:0] cfg_width;
wire cfg_lsb_first;
wire [LEN_W-1:0] cfg_len;
wire cfg_valid;
wire change_ignored;
int errors = 0;
spi_cfg_latch #(.DIV_W(DIV_W), .LEN_W(LEN_W)) dut (
.clk(clk), .rst_n(rst_n),
.start(start), .busy(busy),
.req_mode(req_mode), .req_div(req_div), .req_width(req_width),
.req_lsb_first(req_lsb_first), .req_len(req_len),
.cfg_mode(cfg_mode), .cfg_div(cfg_div), .cfg_width(cfg_width),
.cfg_lsb_first(cfg_lsb_first), .cfg_len(cfg_len),
.cfg_valid(cfg_valid), .change_ignored(change_ignored)
);
// Continuous stability checker: snapshot on the first busy cycle, then
// compare on every subsequent one.
logic [1:0] s_mode;
logic [DIV_W-1:0] s_div;
logic [5:0] s_width;
logic s_lsb;
logic [LEN_W-1:0] s_len;
logic snapped;
int unstable_cycles;
always_ff @(posedge clk) begin
if (!rst_n) begin
snapped <= 1'b0;
unstable_cycles <= 0;
end else if (busy) begin
if (!snapped) begin
s_mode <= cfg_mode; s_div <= cfg_div; s_width <= cfg_width;
s_lsb <= cfg_lsb_first; s_len <= cfg_len;
snapped <= 1'b1;
end else if (cfg_mode !== s_mode || cfg_div !== s_div ||
cfg_width !== s_width || cfg_lsb_first !== s_lsb ||
cfg_len !== s_len) begin
unstable_cycles <= unstable_cycles + 1;
end
end else begin
snapped <= 1'b0;
end
end
task automatic request(input int m, input int d, input int w,
input bit lsb, input int n);
begin
@(negedge clk);
req_mode = 2'(m); req_div = DIV_W'(d); req_width = 6'(w);
req_lsb_first = lsb; req_len = LEN_W'(n);
end
endtask
// Begin a transfer of `cycles` busy cycles.
task automatic run(input int cycles);
begin
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (cycles) @(negedge clk);
busy = 1'b0;
@(negedge clk);
end
endtask
task automatic check_cfg(input string what, input int m, input int d,
input int w, input bit lsb, input int n);
begin
if (cfg_mode !== 2'(m) || cfg_div !== DIV_W'(d) ||
cfg_width !== 6'(w) || cfg_lsb_first !== lsb ||
cfg_len !== LEN_W'(n)) begin
$display(" FAIL: %s -- cfg is mode=%0d div=%0d width=%0d lsb=%0b len=%0d, wanted %0d/%0d/%0d/%0b/%0d",
what, cfg_mode, cfg_div, cfg_width, cfg_lsb_first,
cfg_len, m, d, w, lsb, n);
errors++;
end
end
endtask
initial begin
unstable_cycles = 0; snapped = 1'b0;
repeat (3) @(negedge clk);
rst_n = 1'b1;
@(negedge clk);
// 1. RESET LOADS A SAFE CONFIGURATION, and it is not yet valid --
// nothing may transact on a configuration nobody supplied.
if (cfg_valid) begin
$display(" FAIL: cfg_valid set out of reset"); errors++;
end
if (cfg_div !== {DIV_W{1'b1}} || cfg_mode !== 2'd0 ||
cfg_width !== 6'd8 || cfg_lsb_first !== 1'b0) begin
$display(" FAIL: reset did not load the safe configuration (mode=%0d div=%0d width=%0d lsb=%0b)",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first);
errors++;
end
$display(" reset: mode=%0d div=%0d width=%0d lsb=%0b, valid=%0b -- safe and not yet usable",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first, cfg_valid);
// 2. A START LATCHES THE WHOLE REQUEST.
request(3, 4, 16, 1'b1, 32);
run(10);
check_cfg("after start", 3, 4, 16, 1'b1, 32);
if (!cfg_valid) begin
$display(" FAIL: cfg_valid not set after a start"); errors++;
end
if (change_ignored) begin
$display(" FAIL: a change was reported when none was made");
errors++;
end
$display(" latched: mode=%0d div=%0d width=%0d lsb=%0b len=%0d",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first, cfg_len);
// 3. A CHANGE MID-TRANSFER IS REFUSED AND REPORTED. Every field is
// changed, so a design that latched any one of them would fail.
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (3) @(negedge clk);
req_mode = 2'd1; req_div = 8'd99; req_width = 6'd12;
req_lsb_first = 1'b0; req_len = 16'd7;
repeat (5) @(negedge clk);
check_cfg("during a refused change", 3, 4, 16, 1'b1, 32);
if (!change_ignored) begin
$display(" FAIL: a mid-transfer change was not reported"); errors++;
end
busy = 1'b0;
@(negedge clk);
// And it stays refused after busy drops -- the change is not applied
// late, which would be the most confusing possible behaviour.
check_cfg("after busy fell", 3, 4, 16, 1'b1, 32);
$display(" mid-transfer change: refused, reported, and NOT applied late");
// 4. THE NEXT START APPLIES THE NEW REQUEST, and clears the report.
run(6);
check_cfg("after the next start", 1, 99, 12, 1'b0, 7);
if (change_ignored) begin
$display(" FAIL: the report survived a new start"); errors++;
end
$display(" next start: mode=%0d div=%0d width=%0d len=%0d, report cleared",
cfg_mode, cfg_div, cfg_width, cfg_len);
// 5. A START WHILE BUSY IS IGNORED by this block -- remembering it is
// Chapter 13.10's job, and doing it here would put two owners on
// one decision.
@(negedge clk);
busy = 1'b1;
request(2, 7, 4, 1'b1, 3);
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
repeat (3) @(negedge clk);
check_cfg("start while busy", 1, 99, 12, 1'b0, 7);
busy = 1'b0;
@(negedge clk);
$display(" start while busy: configuration unchanged");
// 6. THE CONTINUOUS STABILITY CHECK. Nothing above should have
// produced a single unstable cycle.
if (unstable_cycles != 0) begin
$display(" FAIL: the configuration changed on %0d busy cycles",
unstable_cycles);
errors++;
end
$display(" stability: 0 busy cycles on which the configuration changed");
// 7. A SWEEP. Many transfers, each with a different request and a
// change attempted part-way through, all required to be stable.
for (int k = 0; k < 24; k++) begin
request(k % 4, (k % 7) + 2, (k % 24) + 1, k[0], (k % 9) + 1);
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (2) @(negedge clk);
// Attempt a change every time.
req_div = DIV_W'(200 - k);
req_mode = 2'((k + 1) % 4);
repeat (4) @(negedge clk);
if (!change_ignored) begin
$display(" FAIL: transfer %0d did not report the change", k);
errors++;
end
busy = 1'b0;
@(negedge clk);
end
if (unstable_cycles != 0) begin
$display(" FAIL: the sweep produced %0d unstable cycles",
unstable_cycles);
errors++;
end
$display(" 24 transfers swept: every change refused and reported, 0 unstable cycles");
// 8. NO CHANGE MEANS NO REPORT. A request that stays identical
// through a transfer must not be reported -- otherwise the flag
// means nothing, since software usually leaves the request alone.
request(1, 5, 8, 1'b0, 2);
run(4);
@(negedge clk);
busy = 1'b1;
repeat (6) @(negedge clk); // busy, request unchanged
if (change_ignored) begin
$display(" FAIL: an unchanged request was reported as a change");
errors++;
end
busy = 1'b0;
@(negedge clk);
$display(" unchanged request during a transfer: not reported");
if (errors == 0)
$display("PASS: reset loads a safe configuration that is not yet valid, a start latches every field at once, a change attempted mid-transfer is refused and reported and is not applied late, the next start applies it and clears the report, a start while busy changes nothing, an unchanged request is never reported, and across 24 transfers with a change attempted in each there was not one busy cycle on which the configuration in force changed");
else
$display("FAIL: %0d error(s)", errors);
$finish;
end
endmodule// spi_cfg_latch.v
//
// Chapter 13.2 -- where the control/datapath boundary actually sits.
//
// A microarchitecture is a set of decisions about which block knows what.
// This block is the first of those decisions, and it is the one that makes
// every later chapter simpler:
//
// THE DATAPATH NEVER SEES A CHANGING CONFIGURATION.
//
// Mode, divisor, frame width, bit order and length are latched once, at the
// instant a transfer starts, and held for its whole duration. So the shift
// register (Ch 13.6), the divider (Ch 13.4), the edge logic (Ch 13.5) and
// the chip-select generator (Ch 13.7) can each read their parameter as a
// constant. None of them needs to handle it changing, because it cannot.
//
// That is worth stating as a cost as well as a benefit. The cost is one
// register per parameter -- about thirty flip-flops. The benefit is that
// four blocks lose an entire class of behaviour they would otherwise have
// to implement and verify. Buying simplicity in four places with thirty
// flip-flops in one is the trade this chapter argues for.
//
// A request that changes mid-transfer is IGNORED and REPORTED. Not applied
// late, not applied partially, and not silently dropped -- because software
// writing a new divisor while a transfer runs has made a mistake it needs
// to be told about, and a controller that applied it would produce a frame
// matching no device (Chapter 10.1's atomicity argument, now about time
// rather than about validity).
module spi_cfg_latch #(
parameter DIV_W = 8,
parameter LEN_W = 16
) (
input wire clk,
input wire rst_n,
input wire start, // pulse: begin a transfer
input wire busy, // from the control FSM
// The requested configuration. May change at any time.
input wire [1:0] req_mode,
input wire [DIV_W-1:0] req_div,
input wire [5:0] req_width,
input wire req_lsb_first,
input wire [LEN_W-1:0] req_len,
// The configuration in force. Constant for the whole transfer.
output reg [1:0] cfg_mode,
output reg [DIV_W-1:0] cfg_div,
output reg [5:0] cfg_width,
output reg cfg_lsb_first,
output reg [LEN_W-1:0] cfg_len,
output reg cfg_valid,
output reg change_ignored // sticky: a change was refused
);
// Does the request currently differ from what is in force? Computed
// continuously so a mid-transfer change is detected on the cycle it
// appears rather than at the end of the transfer.
wire differs = (req_mode != cfg_mode) ||
(req_div != cfg_div) ||
(req_width != cfg_width) ||
(req_lsb_first != cfg_lsb_first) ||
(req_len != cfg_len);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
// Reset loads a safe configuration rather than an arbitrary one:
// mode 0, the slowest divisor, eight bits, MSB first. The same
// argument as Chapter 11.2's reset default -- a controller that
// has not been configured must not be able to run fast or in an
// exotic mode.
cfg_mode <= 2'd0;
cfg_div <= {DIV_W{1'b1}};
cfg_width <= 6'd8;
cfg_lsb_first <= 1'b0;
cfg_len <= {{(LEN_W-1){1'b0}}, 1'b1};
cfg_valid <= 1'b0;
change_ignored <= 1'b0;
end else begin
if (start && !busy) begin
// The single instant at which the configuration may change.
cfg_mode <= req_mode;
cfg_div <= req_div;
cfg_width <= req_width;
cfg_lsb_first <= req_lsb_first;
cfg_len <= req_len;
cfg_valid <= 1'b1;
// A new transfer clears the previous verdict, so software
// reads a report about the transfer it is looking at.
change_ignored <= 1'b0;
end else if (busy && differs) begin
// Refused, and recorded. Note what is NOT here: any
// assignment to cfg_*. The refusal is the absence of an
// action rather than an action of its own, which is why no
// partial application is possible.
change_ignored <= 1'b1;
end
end
end
endmodule// spi_cfg_latch_tb.v
//
// The property under test is STABILITY, and it is checked continuously
// rather than at the end: the configuration must not change on any cycle
// while busy, so the testbench snapshots it when busy rises and compares
// every cycle until busy falls.
//
// Checking only at the end would pass a design that changed the
// configuration mid-transfer and changed it back.
`timescale 1ns/1ps
module spi_cfg_latch_tb;
integer k;
localparam DIV_W = 8;
localparam LEN_W = 16;
reg clk;
reg rst_n;
always #5 clk = ~clk;
reg start;
reg busy;
reg [1:0] req_mode;
reg [DIV_W-1:0] req_div;
reg [5:0] req_width;
reg req_lsb_first;
reg [LEN_W-1:0] req_len;
// Driven by the DUT, so declared as nets: a module output cannot drive
// a variable in Verilog-2001, and using `wire` here keeps the two
// published sources identical in structure.
wire [1:0] cfg_mode;
wire [DIV_W-1:0] cfg_div;
wire [5:0] cfg_width;
wire cfg_lsb_first;
wire [LEN_W-1:0] cfg_len;
wire cfg_valid;
wire change_ignored;
integer errors;
spi_cfg_latch #(.DIV_W(DIV_W), .LEN_W(LEN_W)) dut (
.clk(clk), .rst_n(rst_n),
.start(start), .busy(busy),
.req_mode(req_mode), .req_div(req_div), .req_width(req_width),
.req_lsb_first(req_lsb_first), .req_len(req_len),
.cfg_mode(cfg_mode), .cfg_div(cfg_div), .cfg_width(cfg_width),
.cfg_lsb_first(cfg_lsb_first), .cfg_len(cfg_len),
.cfg_valid(cfg_valid), .change_ignored(change_ignored)
);
// Continuous stability checker: snapshot on the first busy cycle, then
// compare on every subsequent one.
reg [1:0] s_mode;
reg [DIV_W-1:0] s_div;
reg [5:0] s_width;
reg s_lsb;
reg [LEN_W-1:0] s_len;
reg snapped;
integer unstable_cycles;
always @(posedge clk) begin
if (!rst_n) begin
snapped <= 1'b0;
unstable_cycles <= 0;
end else if (busy) begin
if (!snapped) begin
s_mode <= cfg_mode; s_div <= cfg_div; s_width <= cfg_width;
s_lsb <= cfg_lsb_first; s_len <= cfg_len;
snapped <= 1'b1;
end else if (cfg_mode !== s_mode || cfg_div !== s_div ||
cfg_width !== s_width || cfg_lsb_first !== s_lsb ||
cfg_len !== s_len) begin
unstable_cycles <= unstable_cycles + 1;
end
end else begin
snapped <= 1'b0;
end
end
task request;
input integer m;
input integer d;
input integer w;
input lsb;
input integer n;
begin
@(negedge clk);
req_mode = (m); req_div = (d); req_width = (w);
req_lsb_first = lsb; req_len = (n);
end
endtask
// Begin a transfer of `cycles` busy cycles.
task run;
input integer cycles;
begin
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (cycles) @(negedge clk);
busy = 1'b0;
@(negedge clk);
end
endtask
task check_cfg;
input [8*40:1] what;
input integer m;
input integer d;
input integer w;
input lsb;
input integer n;
begin
if (cfg_mode !== (m) || cfg_div !== (d) ||
cfg_width !== (w) || cfg_lsb_first !== lsb ||
cfg_len !== (n)) begin
$display(" FAIL: %0s -- cfg is mode=%0d div=%0d width=%0d lsb=%0b len=%0d, wanted %0d/%0d/%0d/%0b/%0d",
what, cfg_mode, cfg_div, cfg_width, cfg_lsb_first,
cfg_len, m, d, w, lsb, n);
errors = errors + 1;
end
end
endtask
initial begin
unstable_cycles = 0; snapped = 1'b0;
repeat (3) @(negedge clk);
rst_n = 1'b1;
@(negedge clk);
// 1. RESET LOADS A SAFE CONFIGURATION, and it is not yet valid --
// nothing may transact on a configuration nobody supplied.
if (cfg_valid) begin
$display(" FAIL: cfg_valid set out of reset"); errors = errors + 1;
end
if (cfg_div !== {DIV_W{1'b1}} || cfg_mode !== 2'd0 ||
cfg_width !== 6'd8 || cfg_lsb_first !== 1'b0) begin
$display(" FAIL: reset did not load the safe configuration (mode=%0d div=%0d width=%0d lsb=%0b)",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first);
errors = errors + 1;
end
$display(" reset: mode=%0d div=%0d width=%0d lsb=%0b, valid=%0b -- safe and not yet usable",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first, cfg_valid);
// 2. A START LATCHES THE WHOLE REQUEST.
request(3, 4, 16, 1'b1, 32);
run(10);
check_cfg("after start", 3, 4, 16, 1'b1, 32);
if (!cfg_valid) begin
$display(" FAIL: cfg_valid not set after a start"); errors = errors + 1;
end
if (change_ignored) begin
$display(" FAIL: a change was reported when none was made");
errors = errors + 1;
end
$display(" latched: mode=%0d div=%0d width=%0d lsb=%0b len=%0d",
cfg_mode, cfg_div, cfg_width, cfg_lsb_first, cfg_len);
// 3. A CHANGE MID-TRANSFER IS REFUSED AND REPORTED. Every field is
// changed, so a design that latched any one of them would fail.
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (3) @(negedge clk);
req_mode = 2'd1; req_div = 8'd99; req_width = 6'd12;
req_lsb_first = 1'b0; req_len = 16'd7;
repeat (5) @(negedge clk);
check_cfg("during a refused change", 3, 4, 16, 1'b1, 32);
if (!change_ignored) begin
$display(" FAIL: a mid-transfer change was not reported"); errors = errors + 1;
end
busy = 1'b0;
@(negedge clk);
// And it stays refused after busy drops -- the change is not applied
// late, which would be the most confusing possible behaviour.
check_cfg("after busy fell", 3, 4, 16, 1'b1, 32);
$display(" mid-transfer change: refused, reported, and NOT applied late");
// 4. THE NEXT START APPLIES THE NEW REQUEST, and clears the report.
run(6);
check_cfg("after the next start", 1, 99, 12, 1'b0, 7);
if (change_ignored) begin
$display(" FAIL: the report survived a new start"); errors = errors + 1;
end
$display(" next start: mode=%0d div=%0d width=%0d len=%0d, report cleared",
cfg_mode, cfg_div, cfg_width, cfg_len);
// 5. A START WHILE BUSY IS IGNORED by this block -- remembering it is
// Chapter 13.10's job, and doing it here would put two owners on
// one decision.
@(negedge clk);
busy = 1'b1;
request(2, 7, 4, 1'b1, 3);
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
repeat (3) @(negedge clk);
check_cfg("start while busy", 1, 99, 12, 1'b0, 7);
busy = 1'b0;
@(negedge clk);
$display(" start while busy: configuration unchanged");
// 6. THE CONTINUOUS STABILITY CHECK. Nothing above should have
// produced a single unstable cycle.
if (unstable_cycles != 0) begin
$display(" FAIL: the configuration changed on %0d busy cycles",
unstable_cycles);
errors = errors + 1;
end
$display(" stability: 0 busy cycles on which the configuration changed");
// 7. A SWEEP. Many transfers, each with a different request and a
// change attempted part-way through, all required to be stable.
for (k = 0; k < 24; k = k + 1) begin
request(k % 4, (k % 7) + 2, (k % 24) + 1, k[0], (k % 9) + 1);
@(negedge clk);
start = 1'b1;
@(negedge clk);
start = 1'b0;
busy = 1'b1;
repeat (2) @(negedge clk);
// Attempt a change every time.
req_div = (200 - k);
req_mode = ((k + 1) % 4);
repeat (4) @(negedge clk);
if (!change_ignored) begin
$display(" FAIL: transfer %0d did not report the change", k);
errors = errors + 1;
end
busy = 1'b0;
@(negedge clk);
end
if (unstable_cycles != 0) begin
$display(" FAIL: the sweep produced %0d unstable cycles",
unstable_cycles);
errors = errors + 1;
end
$display(" 24 transfers swept: every change refused and reported, 0 unstable cycles");
// 8. NO CHANGE MEANS NO REPORT. A request that stays identical
// through a transfer must not be reported -- otherwise the flag
// means nothing, since software usually leaves the request alone.
request(1, 5, 8, 1'b0, 2);
run(4);
@(negedge clk);
busy = 1'b1;
repeat (6) @(negedge clk); // busy, request unchanged
if (change_ignored) begin
$display(" FAIL: an unchanged request was reported as a change");
errors = errors + 1;
end
busy = 1'b0;
@(negedge clk);
$display(" unchanged request during a transfer: not reported");
if (errors == 0)
$display("PASS: reset loads a safe configuration that is not yet valid, a start latches every field at once, a change attempted mid-transfer is refused and reported and is not applied late, the next start applies it and clears the report, a start while busy changes nothing, an unchanged request is never reported, and across 24 transfers with a change attempted in each there was not one busy cycle on which the configuration in force changed");
else
$display("FAIL: %0d error(s)", errors);
$finish;
end
initial begin
clk = 1'b0;
rst_n = 1'b0;
start = 1'b0;
busy = 1'b0;
req_mode = 2'd0;
req_div = 8'd4;
req_width = 6'd8;
req_lsb_first = 1'b0;
req_len = 16'd1;
errors = 0;
end
endmodule-- spi_cfg_latch.vhd
--
-- Chapter 13.2 -- where the control/datapath boundary actually sits, in VHDL.
--
-- A microarchitecture is a set of decisions about which block knows what.
-- This is the first of them, and it makes every later chapter simpler:
--
-- THE DATAPATH NEVER SEES A CHANGING CONFIGURATION.
--
-- Mode, divisor, frame width, bit order and length are latched once, at the
-- instant a transfer starts, and held for its whole duration. So the shift
-- register, the divider, the edge logic and the chip-select generator can
-- each read their parameter as a constant -- none needs to handle it
-- changing, because it cannot.
--
-- The cost is one register per parameter, about thirty flip-flops. The
-- benefit is that four blocks lose an entire class of behaviour they would
-- otherwise have to implement and verify.
--
-- A request that changes mid-transfer is IGNORED and REPORTED. Not applied
-- late, not applied partially, and not silently dropped.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_cfg_latch is
generic (
DIV_W : positive := 8;
LEN_W : positive := 16
);
port (
clk : in std_logic;
rst_n : in std_logic;
start : in std_logic; -- pulse: begin a transfer
busy : in std_logic; -- from the control FSM
-- The requested configuration. May change at any time.
req_mode : in unsigned(1 downto 0);
req_div : in unsigned(DIV_W - 1 downto 0);
req_width : in unsigned(5 downto 0);
req_lsb_first : in std_logic;
req_len : in unsigned(LEN_W - 1 downto 0);
-- The configuration in force. Constant for the whole transfer.
cfg_mode : out unsigned(1 downto 0);
cfg_div : out unsigned(DIV_W - 1 downto 0);
cfg_width : out unsigned(5 downto 0);
cfg_lsb_first : out std_logic;
cfg_len : out unsigned(LEN_W - 1 downto 0);
cfg_valid : out std_logic;
change_ignored : out std_logic -- sticky: a change was refused
);
end entity;
architecture rtl of spi_cfg_latch is
-- Named with an r_ prefix rather than after the ports: VHDL is
-- case-insensitive, so a signal called cfg_mode would be the same
-- identifier as the port.
signal r_mode : unsigned(1 downto 0) := (others => '0');
signal r_div : unsigned(DIV_W - 1 downto 0) := (others => '1');
signal r_width : unsigned(5 downto 0) := to_unsigned(8, 6);
signal r_lsb : std_logic := '0';
signal r_len : unsigned(LEN_W - 1 downto 0) := to_unsigned(1, LEN_W);
signal r_valid : std_logic := '0';
signal r_chg : std_logic := '0';
signal differs : std_logic;
begin
-- Does the request currently differ from what is in force? Computed
-- continuously so a mid-transfer change is detected on the cycle it
-- appears rather than at the end of the transfer.
differs <= '1' when (req_mode /= r_mode) or (req_div /= r_div) or
(req_width /= r_width) or (req_lsb_first /= r_lsb) or
(req_len /= r_len)
else '0';
latch : process (clk, rst_n)
begin
if rst_n = '0' then
-- Reset loads a safe configuration rather than an arbitrary one:
-- mode 0, the slowest divisor, eight bits, MSB first. A
-- controller that has not been configured must not be able to
-- run fast or in an exotic mode.
r_mode <= (others => '0');
r_div <= (others => '1');
r_width <= to_unsigned(8, 6);
r_lsb <= '0';
r_len <= to_unsigned(1, LEN_W);
r_valid <= '0';
r_chg <= '0';
elsif rising_edge(clk) then
if start = '1' and busy = '0' then
-- The single instant at which the configuration may change.
r_mode <= req_mode;
r_div <= req_div;
r_width <= req_width;
r_lsb <= req_lsb_first;
r_len <= req_len;
r_valid <= '1';
-- A new transfer clears the previous verdict, so software
-- reads a report about the transfer it is looking at.
r_chg <= '0';
elsif busy = '1' and differs = '1' then
-- Refused, and recorded. Note what is NOT here: any
-- assignment to the configuration registers. The refusal is
-- the absence of an action rather than an action of its own,
-- which is why no partial application is possible.
r_chg <= '1';
end if;
end if;
end process;
cfg_mode <= r_mode;
cfg_div <= r_div;
cfg_width <= r_width;
cfg_lsb_first <= r_lsb;
cfg_len <= r_len;
cfg_valid <= r_valid;
change_ignored <= r_chg;
end architecture;-- spi_cfg_latch_tb.vhd
--
-- The property under test is STABILITY, and it is checked continuously
-- rather than at the end: the configuration must not change on any cycle
-- while busy, so the testbench snapshots it when busy rises and compares
-- every cycle until busy falls.
--
-- Checking only at the end would pass a design that changed the
-- configuration mid-transfer and changed it back.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_cfg_latch_tb is
end entity;
architecture sim of spi_cfg_latch_tb is
constant DIV_W : positive := 8;
constant LEN_W : positive := 16;
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal halt : boolean := false;
signal start : std_logic := '0';
signal busy : std_logic := '0';
signal req_mode : unsigned(1 downto 0) := (others => '0');
signal req_div : unsigned(DIV_W - 1 downto 0) := to_unsigned(4, DIV_W);
signal req_width : unsigned(5 downto 0) := to_unsigned(8, 6);
signal req_lsb_first : std_logic := '0';
signal req_len : unsigned(LEN_W - 1 downto 0) := to_unsigned(1, LEN_W);
signal cfg_mode : unsigned(1 downto 0);
signal cfg_div : unsigned(DIV_W - 1 downto 0);
signal cfg_width : unsigned(5 downto 0);
signal cfg_lsb_first : std_logic;
signal cfg_len : unsigned(LEN_W - 1 downto 0);
signal cfg_valid : std_logic;
signal change_ignored : std_logic;
signal unstable_cycles : natural := 0;
signal errors : natural := 0;
begin
clk <= not clk after 5 ns when not halt else '0';
dut : entity work.spi_cfg_latch
generic map (DIV_W => DIV_W, LEN_W => LEN_W)
port map (
clk => clk, rst_n => rst_n,
start => start, busy => busy,
req_mode => req_mode, req_div => req_div, req_width => req_width,
req_lsb_first => req_lsb_first, req_len => req_len,
cfg_mode => cfg_mode, cfg_div => cfg_div, cfg_width => cfg_width,
cfg_lsb_first => cfg_lsb_first, cfg_len => cfg_len,
cfg_valid => cfg_valid, change_ignored => change_ignored
);
-- Continuous stability checker: snapshot on the first busy cycle, then
-- compare on every subsequent one.
stability : process (clk)
variable s_mode : unsigned(1 downto 0);
variable s_div : unsigned(DIV_W - 1 downto 0);
variable s_width : unsigned(5 downto 0);
variable s_lsb : std_logic;
variable s_len : unsigned(LEN_W - 1 downto 0);
variable snapped : boolean := false;
begin
if rising_edge(clk) then
if rst_n = '0' then
snapped := false;
unstable_cycles <= 0;
elsif busy = '1' then
if not snapped then
s_mode := cfg_mode; s_div := cfg_div;
s_width := cfg_width; s_lsb := cfg_lsb_first;
s_len := cfg_len;
snapped := true;
elsif cfg_mode /= s_mode or cfg_div /= s_div or
cfg_width /= s_width or cfg_lsb_first /= s_lsb or
cfg_len /= s_len then
unstable_cycles <= unstable_cycles + 1;
end if;
else
snapped := false;
end if;
end if;
end process;
stim : process
variable errs : natural := 0;
-- Parameters are NOT named after the generics or ports, because VHDL
-- is case-insensitive and they would shadow them.
procedure request(n_mode : natural; n_div : natural; n_width : natural;
n_lsb : std_logic; n_len : natural) is
begin
wait until falling_edge(clk);
req_mode <= to_unsigned(n_mode, 2);
req_div <= to_unsigned(n_div, DIV_W);
req_width <= to_unsigned(n_width, 6);
req_lsb_first <= n_lsb;
req_len <= to_unsigned(n_len, LEN_W);
end procedure;
procedure run(cycles : natural) is
begin
wait until falling_edge(clk);
start <= '1';
wait until falling_edge(clk);
start <= '0';
busy <= '1';
for k in 1 to cycles loop
wait until falling_edge(clk);
end loop;
busy <= '0';
wait until falling_edge(clk);
end procedure;
procedure check_cfg(what : string; n_mode : natural; n_div : natural;
n_width : natural; n_lsb : std_logic;
n_len : natural) is
begin
if to_integer(cfg_mode) /= n_mode or
to_integer(cfg_div) /= n_div or
to_integer(cfg_width) /= n_width or
cfg_lsb_first /= n_lsb or
to_integer(cfg_len) /= n_len then
report " FAIL: " & what & " -- the configuration is wrong";
errs := errs + 1;
end if;
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 LOADS A SAFE CONFIGURATION, not yet valid.
if cfg_valid = '1' then
report " FAIL: cfg_valid set out of reset"; errs := errs + 1;
end if;
if cfg_div /= (cfg_div'range => '1') or cfg_mode /= 0 or
to_integer(cfg_width) /= 8 or cfg_lsb_first /= '0' then
report " FAIL: reset did not load the safe configuration";
errs := errs + 1;
end if;
report " reset: mode=0 div=" & integer'image(to_integer(cfg_div)) &
" width=8 lsb=0, valid=0 -- safe and not yet usable";
-- 2. A START LATCHES THE WHOLE REQUEST.
request(3, 4, 16, '1', 32);
run(10);
check_cfg("after start", 3, 4, 16, '1', 32);
if cfg_valid /= '1' then
report " FAIL: cfg_valid not set after a start"; errs := errs + 1;
end if;
if change_ignored = '1' then
report " FAIL: a change was reported when none was made";
errs := errs + 1;
end if;
report " latched: mode=3 div=4 width=16 lsb=1 len=32";
-- 3. A CHANGE MID-TRANSFER IS REFUSED AND REPORTED. Every field is
-- changed, so a design latching any one of them would fail.
wait until falling_edge(clk);
start <= '1';
wait until falling_edge(clk);
start <= '0';
busy <= '1';
for k in 1 to 3 loop
wait until falling_edge(clk);
end loop;
req_mode <= to_unsigned(1, 2);
req_div <= to_unsigned(99, DIV_W);
req_width <= to_unsigned(12, 6);
req_lsb_first <= '0';
req_len <= to_unsigned(7, LEN_W);
for k in 1 to 5 loop
wait until falling_edge(clk);
end loop;
check_cfg("during a refused change", 3, 4, 16, '1', 32);
if change_ignored /= '1' then
report " FAIL: a mid-transfer change was not reported";
errs := errs + 1;
end if;
busy <= '0';
wait until falling_edge(clk);
-- And it stays refused after busy drops -- not applied late, which
-- would be the most confusing possible behaviour.
check_cfg("after busy fell", 3, 4, 16, '1', 32);
report " mid-transfer change: refused, reported, and NOT applied late";
-- 4. THE NEXT START APPLIES THE NEW REQUEST, and clears the report.
run(6);
check_cfg("after the next start", 1, 99, 12, '0', 7);
if change_ignored = '1' then
report " FAIL: the report survived a new start"; errs := errs + 1;
end if;
report " next start: mode=1 div=99 width=12 len=7, report cleared";
-- 5. A START WHILE BUSY IS IGNORED by this block -- remembering it is
-- Chapter 13.10's job, and doing it here would put two owners on
-- one decision.
wait until falling_edge(clk);
busy <= '1';
request(2, 7, 4, '1', 3);
wait until falling_edge(clk);
start <= '1';
wait until falling_edge(clk);
start <= '0';
for k in 1 to 3 loop
wait until falling_edge(clk);
end loop;
check_cfg("start while busy", 1, 99, 12, '0', 7);
busy <= '0';
wait until falling_edge(clk);
report " start while busy: configuration unchanged";
-- 6. THE CONTINUOUS STABILITY CHECK.
if unstable_cycles /= 0 then
report " FAIL: the configuration changed on a busy cycle";
errs := errs + 1;
end if;
report " stability: 0 busy cycles on which the configuration changed";
-- 7. A SWEEP. Many transfers, each with a different request and a
-- change attempted part-way through, all required to be stable.
for k in 0 to 23 loop
request(k mod 4, (k mod 7) + 2, (k mod 24) + 1,
std_logic'val((k mod 2) + character'pos('0') - 48 + 2),
(k mod 9) + 1);
wait until falling_edge(clk);
start <= '1';
wait until falling_edge(clk);
start <= '0';
busy <= '1';
for j in 1 to 2 loop
wait until falling_edge(clk);
end loop;
req_div <= to_unsigned(200 - k, DIV_W);
req_mode <= to_unsigned((k + 1) mod 4, 2);
for j in 1 to 4 loop
wait until falling_edge(clk);
end loop;
if change_ignored /= '1' then
report " FAIL: a swept transfer did not report the change";
errs := errs + 1;
end if;
busy <= '0';
wait until falling_edge(clk);
end loop;
if unstable_cycles /= 0 then
report " FAIL: the sweep produced unstable cycles";
errs := errs + 1;
end if;
report " 24 transfers swept: every change refused and reported, 0 unstable cycles";
-- 8. NO CHANGE MEANS NO REPORT, or the flag means nothing since
-- software usually leaves the request alone.
request(1, 5, 8, '0', 2);
run(4);
wait until falling_edge(clk);
busy <= '1';
for k in 1 to 6 loop
wait until falling_edge(clk);
end loop;
if change_ignored = '1' then
report " FAIL: an unchanged request was reported as a change";
errs := errs + 1;
end if;
busy <= '0';
wait until falling_edge(clk);
report " unchanged request during a transfer: not reported";
errors <= errs;
if errs = 0 then
report "PASS: reset loads a safe configuration that is not yet valid, a start latches every field at once, a change attempted mid-transfer is refused and reported and is not applied late, the next start applies it and clears the report, a start while busy changes nothing, an unchanged request is never reported, and across 24 transfers with a change attempted in each there was not one busy cycle on which the configuration in force changed";
else
report "FAIL: " & integer'image(errs) & " error(s)" severity error;
end if;
halt <= true;
wait;
end process;
end architecture;Parity
All three report zero unstable cycles across twenty-four transfers, including transfers during which every field was rewritten, and all three set change_ignored for exactly the writes that were ignored — not for writes that happened to arrive while busy and requested the value already in force, which is a distinction worth having because a driver that re-writes its configuration defensively before every transfer would otherwise see the flag constantly and learn to ignore it.
6. Why a Verification Engineer Cares
The property is a stability property, which is the shape assertions are best at.
// The whole of Chapter 13.2 in four assertions. Note that none of them mentions
// the requested value: the specification is about STABILITY, not about content,
// and an assertion that checked content would have to duplicate the load
// condition and would then agree with the design by construction.
module spi_cfg_latch_sva #(parameter int DIV_W = 8, LEN_W = 6) (
input logic clk,
input logic rst_n,
input logic start,
input logic busy,
input logic [1:0] cfg_mode,
input logic [DIV_W-1:0] cfg_div,
input logic [LEN_W-1:0] cfg_len,
input logic cfg_lsb_first,
input logic [2:0] cfg_width,
input logic change_ignored
);
default clocking cb @(posedge clk); endclocking
default disable iff (!rst_n);
// The published configuration may change ONLY on a cycle that loads it.
// Everything else follows from this.
property p_stable_while_busy;
busy && !(start && !busy) |=>
$stable(cfg_mode) && $stable(cfg_div) && $stable(cfg_len) &&
$stable(cfg_lsb_first) && $stable(cfg_width);
endproperty
a_stable: assert property (p_stable_while_busy);
// All five fields move together or not at all. A partial update is five
// races wearing one name, and this is the only assertion that finds it.
property p_atomic;
!$stable(cfg_div) |-> !$stable(cfg_mode) || $stable(cfg_mode);
endproperty
// Stated properly: on any cycle where the latch loads, every field takes
// its requested value; on any other cycle, none of them moves.
property p_all_or_none;
(start && !busy) or
($stable(cfg_mode) && $stable(cfg_div) && $stable(cfg_len) &&
$stable(cfg_lsb_first) && $stable(cfg_width));
endproperty
a_all_or_none: assert property (p_all_or_none);
// The flag must be set by an ignored write, and it must be STICKY: a driver
// that checks it once at the end of a transaction has to see a write that
// was ignored twenty cycles earlier.
property p_flag_sticky;
change_ignored && !(start && !busy) |=> change_ignored;
endproperty
a_flag_sticky: assert property (p_flag_sticky);
// And it must be cleared by the next accepted load, so it describes the
// transfer in front of you rather than the history of the device.
property p_flag_clears;
(start && !busy) |=> !change_ignored;
endproperty
a_flag_clears: assert property (p_flag_clears);
endmodule// The interesting axis is not the VALUES written -- there are far too many and
// they are all equivalent -- but WHICH FIELDS were rewritten and at what point
// in the transfer. A suite that only ever rewrites the divisor has not tested
// the latch; it has tested one fifth of it.
covergroup cg_cfg_latch @(posedge clk);
// Which field differed from the one in force, as a bitmask. The cross of
// this with `when` below is the real coverage goal.
field: coverpoint differing_fields iff (req_write) {
bins mode = {5'b00001};
bins div = {5'b00010};
bins len = {5'b00100};
bins order = {5'b01000};
bins width = {5'b10000};
bins several = {[5'b00011:5'b11110]};
bins all_five = {5'b11111};
}
// When the write landed, relative to the transfer. IDLE must take effect;
// everything else must not.
when: coverpoint phase iff (req_write) {
bins idle = {0};
bins first_bit = {1};
bins mid_frame = {2};
bins last_bit = {3};
bins inter_frame = {4};
}
// A write on the same cycle as the start request: the boundary case, and
// the one where `start && !busy` differs from `start`.
bins_on_start: coverpoint (req_write && start) {
bins simultaneous = {1};
}
// And the flag's two outcomes must both occur, including the case where a
// write arrives while busy but requests what is already in force -- which
// must NOT set the flag.
flag: coverpoint {change_ignored, redundant_write} iff (req_write && busy) {
bins genuinely_ignored = {2'b10};
bins no_change_wanted = {2'b00};
}
x_field_when: cross field, when;
endgroup7. Why an FPGA or ASIC Engineer Cares
The partition has consequences that show up in timing closure rather than in simulation.
The SCLK-rate logic is small, and deliberately. Only the divider, the mode logic and the two shift registers run at the bit rate. Everything else — five counters, a state machine, a register file — is once-per-frame logic whose timing is irrelevant at any plausible SCLK. If a master fails timing, the failing path is in a block the partition already isolated, and the fix is local.
The configuration latch breaks a long combinational path that would otherwise exist. Without it, the register file's flops feed the divider's comparators, the width barrel shifter and the mode mux directly. With it, they feed one set of flops. On an FPGA where the register file may be placed far from the SPI logic, that single stage of registers is often the difference between closing and not.
The latch is also where a clock-domain crossing would go, if the register bus were on a different clock. It is already a load-enabled register with a single load condition, so converting it into the destination side of a handshake is a local change — which is not true of a design where the datapath reads the registers directly, because there the crossing is in five places at once.
Cost. The latch is 2 + DIV_W + LEN_W + 1 + 3 flops — 21 at the defaults — plus one equality comparator across the same width and one flag. The unstable counter is development-only. Against a master of a few hundred flops this is noise, and it is the cheapest architectural insurance in the design.
8. Failure Signature — A Transfer That Is Corrupted Only Under Interrupt Load
Symptom. A sensor driver reads a 16-bit value every millisecond. Roughly one reading in ten thousand is wrong — not noise, but a value that looks like the correct value shifted or with the wrong upper byte. The rate rises sharply when an unrelated network interrupt becomes busy and falls to zero when the network is quiet.
What that rules out. A fault that depends on the load of an unrelated interrupt is not an analogue problem, not a sensor problem and not a bit-order problem — all three would be constant. It is a timing coincidence between two pieces of software, which narrows it to a shared resource.
The mechanism. The driver's read routine writes the control register before every transfer, defensively, with the same values every time. The network interrupt occasionally delays that routine so the write lands after the transfer has already started — and the master reads its configuration continuously. The write is idempotent in value, so nothing should change; but the register write takes effect mid-frame, and on this design the divisor register's output goes through a comparator whose result changes for one cycle while the write settles. One bit period is short by a cycle, and the slave's next sample lands in the wrong place.
Why it is one in ten thousand. The write has to land inside the frame, which is a window of a few microseconds in each millisecond, and the network interrupt has to delay it by the right amount. The conditional probability is small and the absolute rate is therefore small — but it is not zero, and it is not going to get better.
How the latch fixes it. With Answer C, a write landing mid-frame changes nothing in flight. The defensive rewrite still succeeds, change_ignored is set, and the transfer completes with the configuration it started with. The driver's behaviour does not have to change at all — which matters, because the driver is not wrong: writing your configuration before use is good practice.
The diagnostic that would have found it faster. change_ignored asserted in a system where no configuration ever changes is an immediate signal that writes are landing inside transfers. That is a one-line check at the end of the read routine, and it points at the interaction rather than at the sensor.
9. Common Misconceptions
"Rejecting writes while busy is safer than latching." It is equally safe and less useful. It forces every driver to poll before writing, it prevents configuring the next transfer during the current one, and it produces an error path that most drivers will not handle correctly. Latching makes the safe behaviour the default.
"A snapshot means the design has two copies of the configuration, which can disagree." They can and must: that is the point. The requested configuration and the in-force configuration are different things, and a design that cannot represent the difference cannot represent "the setting you just wrote takes effect next time".
"change_ignored is an error flag." It is a notification. Writing configuration while a transfer runs is legal and often deliberate. The flag exists so a driver author can tell the difference between "took effect now" and "takes effect next", which is otherwise invisible.
"The partition is a style preference; the same logic will synthesise the same way." It will not, because the partition changes which flops feed which combinational logic. The latch specifically inserts a register stage between the register file and the SCLK-rate logic, and removing it lengthens a real path.
"Control and datapath split along the line between state machines and registers." They split along the line between once-per-transfer and once-per-bit. A counter is a register and belongs on whichever side its counting rate puts it — the bit counter is datapath, the frame counter is control, and they are the same kind of object.
10. Reason It Through
Why must the load condition be start && !busy rather than start?
Because in a burst the second and subsequent frames are started while the transaction is still open and busy is asserted. With the condition on start alone, each of those frames would reload the snapshot from whatever is in the register file at that instant — so a mid-burst configuration write would take effect between two bytes of one transaction, which is Answer A restricted to burst boundaries. That is harder to find than Answer A, not easier, because most tests use single-frame transfers.
What would break if the five fields loaded independently, each on a write to its own register?
There would be no snapshot. A driver that writes mode and then divisor would have a window between the two writes in which the in-force configuration is half old and half new, and a frame starting in that window would use a combination that the driver never asked for. The bug would depend on the instruction timing of two consecutive stores, which is about as reproducible as anything gets.
Why does unstable count cycles rather than being a sticky bit?
Because the count distinguishes a single-cycle glitch from sustained instability, and the two have different causes. One cycle suggests a load condition that is momentarily true — a decode glitch, or a start that is not one cycle wide. Many cycles suggests the published configuration is simply wired to the requested one on some path. A sticky bit would say only that something is wrong.
A design uses Answer B, rejecting writes while busy, and a driver still sees corrupted transfers. Where would you look?
At what "busy" means. If busy is asserted only while SCLK is running, then the lead time, the inter-frame hold and the lag are all windows in which the design believes it is idle and a write will be accepted — and a write accepted during the inter-frame hold of a burst reaches the next frame of a transaction that is still in progress. Answer B is only as good as its definition of busy, and the definition that makes it correct is "a transaction is open", which is a different signal from "the clock is running".
Where would a clock-domain crossing go, if the register bus ran on a different clock from the SPI logic?
At the latch, and only there. The latch is already a load-enabled register with a single load condition, so the crossing becomes: synchronise the request-valid into the SPI domain, and load on the synchronised pulse. The data can cross without synchronisers because it is stable for many cycles before and after the load — a single-bit control crossing plus a stable data bus, which is the cheapest safe crossing there is. In a design where the datapath reads the register file directly, the same conversion needs five crossings and none of them has a stable data window.
11. Understanding Check
12. Summary
The master partitions along one question: does this logic do something different on every bit, or once per transfer? Per-bit logic is datapath and is timing-critical; per-transfer logic is control and is not. The partition is structural, not stylistic — migrating logic across it either lands it on the critical path or lets it be re-evaluated when it should be frozen.
Nothing in the datapath reads the register file. Everything arrives through the configuration latch, which makes "a register write cannot disturb a frame in flight" a property of the structure rather than a discipline someone must remember.
Of the three possible responses to a mid-transfer configuration write, snapshot at frame start is the right one. Applying immediately corrupts the frame; rejecting pushes an error path into software and forbids the useful pattern of configuring the next transfer during the current one; snapshotting does neither and costs about twenty flops.
The load condition is start && !busy, because burst frames start while the transaction is open and must keep the transaction's configuration. All five fields load together or not at all, because independent loads are five races wearing one name.
change_ignored is a notification, not an error, and the branch that sets it must assign nothing — a design that sets the flag and honours the write anyway is worse than one that does neither, because the flag then actively misleads.
For verification the property is stability, not content: the published configuration may change only on a load. Coverage bins which fields were rewritten crossed with when, because a suite that only ever rewrites the divisor has tested one fifth of the latch. And in UVM the same snapshot discipline applies to the configuration object, for the same reason.
For implementation the latch is a register stage on the path from the register file to the SCLK-rate logic, which often decides timing closure on an FPGA — and it is the single place a clock-domain crossing would go if the register bus ever moved to another clock.
13. What Comes Next
The blocks are named and the boundary between them is drawn. The first block to build is the one that decides what happens when.
Chapter 13.3 — The Master Control FSM builds the state machine that sequences a transfer: idle, the lead into the first edge, the shift, the lag out of the last edge, and the gap before the next transaction. Five states, and the interesting part is not that each exists but that one of them differs from another by exactly one behaviour — and that single difference is the whole reason the fifth state cannot be merged into the first.
Continue learning
Related tutorials
- Related topic
FPGA Configuration over SPI
Master and slave configuration modes and why they differ only in who clocks, why the sync word must be searched byte by byte, why a blank flash is indistinguishable from preamble, and the loader that shares one datapath between both modes.
- Related topic
Launch and Sample Edges
One edge of each bit time places a bit on the wire, the other captures it, and they must never be the same edge. Why the separation is forced, why it buys half a period, and how RTL maps physical edges onto those roles.
- Related topic
Deriving Mode Behaviour from CPOL and CPHA
The four SPI modes are a two-bit truth table you can rebuild in seconds. The standard numbering, the derivation, the complete mode decoder in three HDLs, and the assertions that keep a configurable design honest.
- 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.
