SPI · Module 5
Anatomy of a Write Transaction
A complete SPI write from chip-select assertion to the final shifted bit: what each byte does, why the write commits at the CS rising edge, why nothing acknowledges it, and the staged write path in three HDLs.
Module 4 established what the bits on an SPI bus mean: that the device, not the bus, defines width, order, phases and turnaround. This module follows one complete transaction end to end, and the write is the right place to start because it exposes something the read does not.
The master has shifted out the last bit of a write. What has actually changed inside the device — and how does the master find out whether it worked?
The second half of that question has an uncomfortable answer, and it shapes every SPI write path in existence.
1. A Write, End to End
The conventional shape follows directly from Chapter 4.4: a command naming the operation, an address saying where, and data. For a register write:
- CS asserts. The frame opens; the device's sequencer leaves IDLE and expects an opcode.
- Opcode byte. The device decodes it, learns this is a write, and learns the shape of the rest.
- Address byte or bytes. Accumulated most-significant byte first.
- Data byte or bytes. Staged internally.
- CS deasserts. The frame closes — and this is where the write takes effect.
Step 5 is the one that surprises people, and §4 is devoted to it.
Throughout, note what is not in the list: no acknowledge, no status, no completion signal, no error. The master drives the whole exchange and receives no confirmation of any kind (Chapter 4.1 §2).
2. The Transaction
3. What the Device Does With Each Byte
Three bytes in, nothing out, one commit at the end
6 cyclesTwo things in that figure repay attention.
MISO is empty for the entire transaction. The device is shifting something out — Chapter 1.4 guarantees an exchange in both directions on every edge — but none of it is a response. A write is a one-way operation on a bidirectional bus.
The commit strobe lands after the data. The register does not change as 0xC3 arrives. It changes one clock later, when CS rises.
4. The Commit Point: CS Rising
This is the chapter's central fact, and it is a design decision with a clear justification.
Why not commit as the data byte arrives? Because at that instant the device does not know the transaction is over. Chapter 4.4 §2 established that SPI has no length field and no terminator — the only event that says "the transaction is complete" is CS deasserting. A device that committed on the data byte would be committing on a guess, and it would be wrong every time a multi-byte write followed.
What that buys. Up until the CS edge the write is reversible from the bus. A master that detects a problem mid-transaction — a DMA underrun, a higher-priority interrupt, a detected error in the data it was about to send — can simply deassert CS, and a well-designed device discards everything. That is the only abort mechanism SPI offers, and committing early would remove it.
A truncated frame must therefore leave no trace. If CS rises after the address but before any data, the device has an address and nothing to write. The correct behaviour is to discard, and the testbenches in §6 check an abort at every phase for exactly that reason. A device that wrote a partial value would produce corruption the bus cannot report and the master cannot detect.
Not every device works this way, and the exceptions matter. Some commit each byte as it arrives — typically simple devices with no concept of a transaction. Some commit at the end of each byte within a continuing frame. The commit point is a device property and belongs in the datasheet reading of Module 10. What is universal is that something defines the commit point, and assuming it is "when I sent the data" is how partial-write bugs happen.
5. Nothing Acknowledges a Write
The master has released CS. The write has committed — or has been silently discarded, or was never understood, or landed in a device that was busy and ignored it. The master cannot tell these apart.
There is no acknowledge bit, no status line, no error code, and no completion interrupt on the SPI bus itself (Chapter 4.1 §2). This is not an oversight; it is the same minimalism that makes SPI cheap. But it means a write is fire-and-forget at the bus level, and every reliable SPI write path in existence compensates in software.
The three standard compensations:
Read it back. Issue a read of the register just written and compare. This is the only genuinely end-to-end check, and it is what bring-up code should do for every configuration register before declaring a device initialised. It costs a second transaction.
Poll a status bit. Devices that take time to complete a write — flash, EEPROM — expose a busy or write-in-progress bit. The master polls it until it clears. This confirms the device started a write; it does not confirm the write was the one intended.
Use a write-enable interlock. Many non-volatile devices require a separate write-enable command immediately before each write, and clear that enable automatically when the write completes. That is a safety mechanism against spurious writes, and it gives the master a weak confirmation: if the enable latch has cleared, a write was accepted.
6. Building the Staged Write Path — Three HDLs
The circuit
Circuit. A small state machine above Chapter 4.2's byte engine, plus staging registers and a CS edge detector.
State. Which phase the write is in, a staged address, a staged data byte, and a registered copy of CS used to detect its rising edge.
Datapath. Bytes are latched into addr_r and data_r as they arrive. Neither is visible outside the module until commit.
Control. The interesting control is the CS rising edge: cs_n && !cs_n_q. That single condition is the commit point, and it is the only place commit_addr and commit_data are written. Everything else stages.
Clock. The system clock throughout; byte_done is a strobe from the byte engine, not a clock.
Reset. Asynchronous, active-low, to IDLE with cleared staging and both strobes low.
Enables. Nothing changes between byte strobes, so the module is idle-safe.
Timing. commit and discarded are single-cycle strobes, not levels, so downstream logic sees exactly one event per frame. The committed address and data are held stable after the strobe, so a consumer may register them on the strobe or read them afterwards.
Synthesis. Three state bits, ADDR_W + DATA_W staging flip-flops, the same again for the committed outputs, a comparator against the opcode, and an edge detector. No latches; no gated clocks.
Limitations. One register per transaction — a multi-byte auto-incrementing write is Chapter 5.3. There is no write-enable interlock and no busy modelling, both of which are device policy rather than bus mechanics.
// spi_write_commit.sv — the slave-side write path, and its commit point.
//
// The idea this module exists to teach: a write does not take effect as the
// data byte arrives. It is STAGED, and it commits when CS rises. Until that
// edge the master may still abort, and a frame that ends early must leave no
// trace -- SPI has no acknowledge, so a half-written register is a fault the
// bus can neither report nor undo.
module spi_write_commit #(
parameter int ADDR_W = 8,
parameter int DATA_W = 8
) (
input logic clk,
input logic rst_n,
input logic cs_n,
input logic byte_done, // one pulse per received byte
input logic [7:0] rx_byte,
output logic commit, // one pulse: frame closed, write valid
output logic [ADDR_W-1:0] commit_addr,
output logic [DATA_W-1:0] commit_data,
output logic discarded // one pulse: frame closed too early
);
typedef enum logic [2:0] {
ST_IDLE, // CS deasserted
ST_CMD, // expecting the opcode
ST_ADDR, // expecting the address byte
ST_WAIT, // address in hand, no data byte yet
ST_DATA, // at least one data byte staged -- the committable state
ST_IGNORE // an opcode this module does not implement
} state_t;
state_t state;
logic [ADDR_W-1:0] addr_r;
logic [DATA_W-1:0] data_r;
logic cs_n_q;
localparam logic [7:0] CMD_WRITE = 8'h02; // representative, not an SPI definition
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= ST_IDLE;
addr_r <= '0;
data_r <= '0;
commit <= 1'b0;
commit_addr <= '0;
commit_data <= '0;
discarded <= 1'b0;
cs_n_q <= 1'b1;
end else begin
cs_n_q <= cs_n;
commit <= 1'b0; // both outputs are strobes, not levels
discarded <= 1'b0;
if (cs_n && !cs_n_q) begin
// CS RISING EDGE -- the commit point. This is the only place a
// write becomes visible, and the only place an abort is seen.
if (state == ST_DATA) begin
commit <= 1'b1;
commit_addr <= addr_r;
commit_data <= data_r;
end else if (state != ST_IDLE) begin
// A frame that opened but never staged a full write.
// Report it and change nothing.
discarded <= 1'b1;
end
state <= ST_IDLE;
end else if (cs_n) begin
state <= ST_IDLE;
end else begin
case (state)
ST_IDLE: state <= ST_CMD;
ST_CMD: if (byte_done) begin
if (rx_byte == CMD_WRITE) state <= ST_ADDR;
else state <= ST_IGNORE;
end
ST_ADDR: if (byte_done) begin
addr_r <= rx_byte[ADDR_W-1:0];
state <= ST_WAIT;
end
ST_WAIT: if (byte_done) begin
data_r <= rx_byte[DATA_W-1:0];
state <= ST_DATA;
end
// Further data bytes overwrite: a single-register write
// takes the LAST byte before CS rises.
ST_DATA: if (byte_done) data_r <= rx_byte[DATA_W-1:0];
ST_IGNORE: ; // consume the rest of the frame
default: state <= ST_IDLE;
endcase
end
end
end
endmoduleThe ST_DATA state is doing something worth naming: it is the committable state, and the distinction between it and ST_WAIT is the whole abort story. ST_WAIT means "address received, no data yet" — a frame closing there has nothing to write. ST_DATA means at least one data byte is staged. Collapsing the two would make an address-only frame commit whatever was left in data_r from the previous transaction, which is precisely the silent corruption §4 warns about.
// spi_write_commit_tb.sv — commit, abort at every phase, and last-byte-wins.
`timescale 1ns/1ps
module spi_write_commit_tb;
logic clk = 0, rst_n = 0;
always #5 clk = ~clk;
logic cs_n = 1, byte_done = 0;
logic [7:0] rx_byte = 8'h00;
logic commit, discarded;
logic [7:0] commit_addr, commit_data;
spi_write_commit #(.ADDR_W(8), .DATA_W(8)) dut (
.clk, .rst_n, .cs_n, .byte_done, .rx_byte,
.commit, .commit_addr, .commit_data, .discarded);
int errors = 0, commits = 0, discards = 0;
task automatic chk(input string what, input int got, input int exp);
if (got !== exp) begin $display("FAIL %s: got 0x%0h exp 0x%0h", what, got, exp); errors++; end
endtask
always @(posedge clk) if (rst_n) begin
if (commit) commits++;
if (discarded) discards++;
end
task automatic send_byte(input logic [7:0] b);
rx_byte = b; @(negedge clk); byte_done = 1; @(negedge clk); byte_done = 0; @(negedge clk);
endtask
task automatic open_frame(); cs_n = 0; @(negedge clk); @(negedge clk); endtask
task automatic close_frame(); cs_n = 1; @(negedge clk); @(negedge clk); endtask
initial begin
repeat (3) @(negedge clk); rst_n = 1; @(negedge clk);
// --- A complete write: 0x02, addr 0x40, data 0xC3 ---
open_frame(); send_byte(8'h02); send_byte(8'h40); send_byte(8'hC3);
chk("no commit before CS rises", commits, 0); // the whole point
close_frame();
chk("committed", commits, 1);
chk("commit_addr", commit_addr, 8'h40);
chk("commit_data", commit_data, 8'hC3);
chk("not discarded", discards, 0);
// --- Abort after the opcode: nothing staged ---
open_frame(); send_byte(8'h02); close_frame();
chk("abort in ADDR: no commit", commits, 1);
chk("abort in ADDR: discarded", discards, 1);
// --- Abort after the address, before any data ---
open_frame(); send_byte(8'h02); send_byte(8'h55); close_frame();
chk("abort in WAIT: no commit", commits, 1);
chk("abort in WAIT: discarded", discards, 2);
chk("commit_addr unchanged", commit_addr, 8'h40); // no trace left
chk("commit_data unchanged", commit_data, 8'hC3);
// --- Abort with CS never even carrying a byte ---
open_frame(); close_frame();
chk("empty frame: no commit", commits, 1);
chk("empty frame: discarded", discards, 3);
// --- Last byte wins for a single-register write ---
open_frame(); send_byte(8'h02); send_byte(8'h7E);
send_byte(8'h11); send_byte(8'h22); send_byte(8'h33);
close_frame();
chk("multi-byte commit", commits, 2);
chk("addr", commit_addr, 8'h7E);
chk("last byte wins", commit_data, 8'h33);
// --- An unimplemented opcode must never commit ---
open_frame(); send_byte(8'h9F); send_byte(8'hAA); send_byte(8'hBB); close_frame();
chk("unknown opcode: no commit", commits, 2);
chk("unknown opcode: discarded", discards, 4);
if (errors == 0)
$display("PASS: a write commits only at CS rise and only from a complete frame; every abort is reported and leaves no trace");
else
$display("FAILED with %0d error(s)", errors);
$finish;
end
initial begin #200000; $display("FAIL: watchdog timeout"); $finish; end
endmoduleThat testbench aborts after the opcode, after the address, and on an empty frame, then checks that commit_addr and commit_data are unchanged — not merely that no commit fired. Checking the strobe alone would pass a design that corrupted the outputs while suppressing the pulse.
// spi_write_commit.v — the same staged write path in Verilog-2001.
module spi_write_commit #(
parameter ADDR_W = 8,
parameter DATA_W = 8
) (
input wire clk,
input wire rst_n,
input wire cs_n,
input wire byte_done,
input wire [7:0] rx_byte,
output reg commit,
output reg [ADDR_W-1:0] commit_addr,
output reg [DATA_W-1:0] commit_data,
output reg discarded
);
localparam ST_IDLE = 3'd0,
ST_CMD = 3'd1,
ST_ADDR = 3'd2,
ST_WAIT = 3'd3,
ST_DATA = 3'd4,
ST_IGNORE = 3'd5;
localparam CMD_WRITE = 8'h02; // representative, not an SPI definition
reg [2:0] state;
reg [ADDR_W-1:0] addr_r;
reg [DATA_W-1:0] data_r;
reg cs_n_q;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= ST_IDLE;
addr_r <= {ADDR_W{1'b0}};
data_r <= {DATA_W{1'b0}};
commit <= 1'b0;
commit_addr <= {ADDR_W{1'b0}};
commit_data <= {DATA_W{1'b0}};
discarded <= 1'b0;
cs_n_q <= 1'b1;
end else begin
cs_n_q <= cs_n;
commit <= 1'b0;
discarded <= 1'b0;
if (cs_n && !cs_n_q) begin
// CS RISING EDGE -- the commit point.
if (state == ST_DATA) begin
commit <= 1'b1;
commit_addr <= addr_r;
commit_data <= data_r;
end else if (state != ST_IDLE) begin
discarded <= 1'b1;
end
state <= ST_IDLE;
end else if (cs_n) begin
state <= ST_IDLE;
end else begin
case (state)
ST_IDLE: state <= ST_CMD;
ST_CMD: if (byte_done) begin
if (rx_byte == CMD_WRITE) state <= ST_ADDR;
else state <= ST_IGNORE;
end
ST_ADDR: if (byte_done) begin
addr_r <= rx_byte[ADDR_W-1:0];
state <= ST_WAIT;
end
ST_WAIT: if (byte_done) begin
data_r <= rx_byte[DATA_W-1:0];
state <= ST_DATA;
end
ST_DATA: if (byte_done) data_r <= rx_byte[DATA_W-1:0];
ST_IGNORE: ;
default: state <= ST_IDLE;
endcase
end
end
end
endmodule// spi_write_commit_tb.v — the same checks in Verilog-2001.
`timescale 1ns/1ps
module spi_write_commit_tb;
reg clk = 0, rst_n = 0;
always #5 clk = ~clk;
reg cs_n = 1, byte_done = 0;
reg [7:0] rx_byte = 8'h00;
wire commit, discarded;
wire [7:0] commit_addr, commit_data;
spi_write_commit #(.ADDR_W(8), .DATA_W(8)) dut (
.clk(clk), .rst_n(rst_n), .cs_n(cs_n), .byte_done(byte_done), .rx_byte(rx_byte),
.commit(commit), .commit_addr(commit_addr), .commit_data(commit_data),
.discarded(discarded));
integer errors = 0, commits = 0, discards = 0;
task chk;
input [80*8-1:0] what;
input [31:0] got, exp;
begin
if (got !== exp) begin
$display("FAIL %0s: got 0x%0h exp 0x%0h", what, got, exp);
errors = errors + 1;
end
end
endtask
always @(posedge clk) if (rst_n) begin
if (commit) commits = commits + 1;
if (discarded) discards = discards + 1;
end
task send_byte;
input [7:0] b;
begin
rx_byte = b; @(negedge clk); byte_done = 1; @(negedge clk); byte_done = 0; @(negedge clk);
end
endtask
task open_frame; begin cs_n = 0; @(negedge clk); @(negedge clk); end endtask
task close_frame; begin cs_n = 1; @(negedge clk); @(negedge clk); end endtask
initial begin
repeat (3) @(negedge clk); rst_n = 1; @(negedge clk);
open_frame; send_byte(8'h02); send_byte(8'h40); send_byte(8'hC3);
chk("no commit before CS rises", commits, 0);
close_frame;
chk("committed", commits, 1);
chk("commit_addr", commit_addr, 8'h40);
chk("commit_data", commit_data, 8'hC3);
chk("not discarded", discards, 0);
open_frame; send_byte(8'h02); close_frame;
chk("abort in ADDR: no commit", commits, 1);
chk("abort in ADDR: discarded", discards, 1);
open_frame; send_byte(8'h02); send_byte(8'h55); close_frame;
chk("abort in WAIT: no commit", commits, 1);
chk("abort in WAIT: discarded", discards, 2);
chk("commit_addr unchanged", commit_addr, 8'h40);
chk("commit_data unchanged", commit_data, 8'hC3);
open_frame; close_frame;
chk("empty frame: no commit", commits, 1);
chk("empty frame: discarded", discards, 3);
open_frame; send_byte(8'h02); send_byte(8'h7E);
send_byte(8'h11); send_byte(8'h22); send_byte(8'h33);
close_frame;
chk("multi-byte commit", commits, 2);
chk("addr", commit_addr, 8'h7E);
chk("last byte wins", commit_data, 8'h33);
open_frame; send_byte(8'h9F); send_byte(8'hAA); send_byte(8'hBB); close_frame;
chk("unknown opcode: no commit", commits, 2);
chk("unknown opcode: discarded", discards, 4);
if (errors == 0)
$display("PASS: a write commits only at CS rise and only from a complete frame; every abort is reported and leaves no trace");
else
$display("FAILED with %0d error(s)", errors);
$finish;
end
initial begin #200000; $display("FAIL: watchdog timeout"); $finish; end
endmodule-- spi_write_commit.vhd — the same staged write path in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_write_commit is
generic (
ADDR_W : positive := 8;
DATA_W : positive := 8
);
port (
clk : in std_logic;
rst_n : in std_logic;
cs_n : in std_logic;
byte_done : in std_logic;
rx_byte : in std_logic_vector(7 downto 0);
commit : out std_logic;
commit_addr : out std_logic_vector(ADDR_W - 1 downto 0);
commit_data : out std_logic_vector(DATA_W - 1 downto 0);
discarded : out std_logic
);
end entity spi_write_commit;
architecture rtl of spi_write_commit is
type state_t is (ST_IDLE, ST_CMD, ST_ADDR, ST_WAIT, ST_DATA, ST_IGNORE);
signal state : state_t;
constant CMD_WRITE : std_logic_vector(7 downto 0) := x"02";
signal addr_r : std_logic_vector(ADDR_W - 1 downto 0);
signal data_r : std_logic_vector(DATA_W - 1 downto 0);
signal cs_n_q : std_logic;
begin
process (clk, rst_n) is
begin
if rst_n = '0' then
state <= ST_IDLE;
addr_r <= (others => '0');
data_r <= (others => '0');
commit <= '0';
commit_addr <= (others => '0');
commit_data <= (others => '0');
discarded <= '0';
cs_n_q <= '1';
elsif rising_edge(clk) then
cs_n_q <= cs_n;
commit <= '0';
discarded <= '0';
if cs_n = '1' and cs_n_q = '0' then
-- CS RISING EDGE -- the commit point.
if state = ST_DATA then
commit <= '1';
commit_addr <= addr_r;
commit_data <= data_r;
elsif state /= ST_IDLE then
discarded <= '1';
end if;
state <= ST_IDLE;
elsif cs_n = '1' then
state <= ST_IDLE;
else
case state is
when ST_IDLE =>
state <= ST_CMD;
when ST_CMD =>
if byte_done = '1' then
if rx_byte = CMD_WRITE then
state <= ST_ADDR;
else
state <= ST_IGNORE;
end if;
end if;
when ST_ADDR =>
if byte_done = '1' then
addr_r <= rx_byte(ADDR_W - 1 downto 0);
state <= ST_WAIT;
end if;
when ST_WAIT =>
if byte_done = '1' then
data_r <= rx_byte(DATA_W - 1 downto 0);
state <= ST_DATA;
end if;
when ST_DATA =>
-- Further bytes overwrite: last byte before CS wins.
if byte_done = '1' then
data_r <= rx_byte(DATA_W - 1 downto 0);
end if;
when ST_IGNORE =>
null; -- consume the rest of the frame
end case;
end if;
end if;
end process;
end architecture rtl;-- spi_write_commit_tb.vhd — the same checks in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity spi_write_commit_tb is
end entity spi_write_commit_tb;
architecture tb of spi_write_commit_tb is
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal cs_n : std_logic := '1';
signal byte_done : std_logic := '0';
signal rx_byte : std_logic_vector(7 downto 0) := (others => '0');
signal halt : boolean := false;
signal commit : std_logic;
signal commit_addr : std_logic_vector(7 downto 0);
signal commit_data : std_logic_vector(7 downto 0);
signal discarded : std_logic;
signal errors : natural := 0;
signal commits : natural := 0;
signal discards : natural := 0;
begin
clk <= not clk after 5 ns when not halt else '0';
dut : entity work.spi_write_commit
generic map (ADDR_W => 8, DATA_W => 8)
port map (clk => clk, rst_n => rst_n, cs_n => cs_n, byte_done => byte_done,
rx_byte => rx_byte, commit => commit, commit_addr => commit_addr,
commit_data => commit_data, discarded => discarded);
counter : process (clk) is
begin
if rising_edge(clk) and rst_n = '1' then
if commit = '1' then
commits <= commits + 1;
end if;
if discarded = '1' then
discards <= discards + 1;
end if;
end if;
end process;
stim : process is
procedure chk_n (what : string; got, exp : natural) is
begin
if got /= exp then
report "FAIL " & what & ": got " & integer'image(got)
& " exp " & integer'image(exp) severity error;
errors <= errors + 1;
end if;
end procedure;
procedure chk_v (what : string; got, exp : std_logic_vector) is
begin
if got /= exp then
report "FAIL " & what & ": got 0x" & to_hstring(got)
& " exp 0x" & to_hstring(exp) severity error;
errors <= errors + 1;
end if;
end procedure;
procedure send_byte (b : std_logic_vector(7 downto 0)) is
begin
rx_byte <= b;
wait until falling_edge(clk); byte_done <= '1';
wait until falling_edge(clk); byte_done <= '0';
wait until falling_edge(clk);
end procedure;
procedure open_frame is
begin
cs_n <= '0';
wait until falling_edge(clk);
wait until falling_edge(clk);
end procedure;
procedure close_frame is
begin
cs_n <= '1';
wait until falling_edge(clk);
wait until falling_edge(clk);
end procedure;
begin
for i in 0 to 2 loop
wait until falling_edge(clk);
end loop;
rst_n <= '1';
wait until falling_edge(clk);
open_frame; send_byte(x"02"); send_byte(x"40"); send_byte(x"C3");
chk_n("no commit before CS rises", commits, 0);
close_frame;
chk_n("committed", commits, 1);
chk_v("commit_addr", commit_addr, x"40");
chk_v("commit_data", commit_data, x"C3");
chk_n("not discarded", discards, 0);
open_frame; send_byte(x"02"); close_frame;
chk_n("abort in ADDR: no commit", commits, 1);
chk_n("abort in ADDR: discarded", discards, 1);
open_frame; send_byte(x"02"); send_byte(x"55"); close_frame;
chk_n("abort in WAIT: no commit", commits, 1);
chk_n("abort in WAIT: discarded", discards, 2);
chk_v("commit_addr unchanged", commit_addr, x"40");
chk_v("commit_data unchanged", commit_data, x"C3");
open_frame; close_frame;
chk_n("empty frame: no commit", commits, 1);
chk_n("empty frame: discarded", discards, 3);
open_frame; send_byte(x"02"); send_byte(x"7E");
send_byte(x"11"); send_byte(x"22"); send_byte(x"33");
close_frame;
chk_n("multi-byte commit", commits, 2);
chk_v("addr", commit_addr, x"7E");
chk_v("last byte wins", commit_data, x"33");
open_frame; send_byte(x"9F"); send_byte(x"AA"); send_byte(x"BB"); close_frame;
chk_n("unknown opcode: no commit", commits, 2);
chk_n("unknown opcode: discarded", discards, 4);
wait until falling_edge(clk);
if errors = 0 then
report "PASS: a write commits only at CS rise and only from a complete frame; "
& "every abort is reported and leaves no trace" severity note;
else
report "FAILED with " & integer'image(errors) & " error(s)" severity error;
end if;
halt <= true;
wait;
end process;
watchdog : process is
begin
wait for 200 us;
if not halt then
report "FAIL: watchdog timeout" severity failure;
end if;
wait;
end process;
end architecture tb;Parity
All three describe identical hardware: the same ports, asynchronous active-low reset, a registered CS copy giving a rising-edge detect, commit only from the committable state, a discard strobe from any other non-idle state, last-byte-wins staging, and an ignore path for unrecognised opcodes. The SystemVerilog and VHDL use enumerated state types while Verilog-2001 uses localparam constants — an expressive difference with no behavioural consequence. All three testbenches run the same six scenarios and count commit and discard strobes rather than sampling levels.
7. Why a Verification Engineer Cares
The commit point is a temporal property, and it is the one worth asserting:
// 1. Nothing may commit while the frame is still open. This is the property
// that makes abort possible, so it is the one to protect.
property p_no_commit_in_frame;
@(posedge clk) disable iff (!rst_n)
!cs_n |-> !commit;
endproperty
a_no_commit_in_frame : assert property (p_no_commit_in_frame)
else $error("write committed while CS was still asserted");
// 2. A commit implies the frame just closed -- not some later cycle.
property p_commit_at_cs_edge;
@(posedge clk) disable iff (!rst_n)
commit |-> ($rose(cs_n) || $past(cs_n && !$past(cs_n)));
endproperty
// 3. Commit and discard are mutually exclusive: a frame is one or the
// other, never both, and never neither once it has opened.
property p_commit_xor_discard;
@(posedge clk) disable iff (!rst_n)
!(commit && discarded);
endproperty
a_commit_xor_discard : assert property (p_commit_xor_discard)
else $error("frame reported as both committed and discarded");
// 4. The staged outputs must not move except on a commit -- this is what
// "an aborted write leaves no trace" means as a checkable statement.
property p_outputs_stable_without_commit;
@(posedge clk) disable iff (!rst_n)
!commit |=> $stable(commit_addr) && $stable(commit_data);
endproperty
a_outputs_stable : assert property (p_outputs_stable_without_commit)
else $error("committed outputs changed without a commit strobe");Property 4 is the one most often missing and the most valuable. Properties 1–3 check the strobe; property 4 checks that the data did not leak, which is the actual failure mode. A design that suppresses the pulse but updates the registers passes the first three and corrupts the device.
What these prove. That the module's commit behaviour matches the intended transaction semantics. What they do not prove. That those semantics match the device being modelled — some devices genuinely commit per byte (§4), and a checker asserting CS-rise semantics against such a device would be asserting the wrong thing. This is Chapter 4.1 §7's boundary again: the commit point is device policy, not bus mechanics.
Coverage should target where a frame ends, because that is the state space the abort logic implements:
covergroup spi_write_cg @(posedge cs_rose);
// The phase at frame close IS the abort space. A suite that only ever
// sends complete transactions covers exactly one of these bins.
cp_close_phase : coverpoint phase_at_cs_rise {
bins empty_frame = {ST_CMD}; // CS opened and closed, no bytes
bins after_cmd = {ST_ADDR}; // opcode only
bins after_addr = {ST_WAIT}; // address, no data -- the dangerous one
bins complete = {ST_DATA}; // the only committable close
bins unknown_op = {ST_IGNORE};
}
cp_data_bytes : coverpoint data_bytes_in_frame {
bins none = {0};
bins one = {1};
bins many = {[2:16]}; // last-byte-wins path
}
x_close_bytes : cross cp_close_phase, cp_data_bytes;
endgroupafter_addr is the bin to insist on. It is the case where the device holds a valid address and stale data, so it is the only close that could plausibly commit something wrong — and it is the bin a suite of well-formed transactions never reaches.
8. Why an FPGA or ASIC Engineer Cares
cs_n is asynchronous to your clock, and this module samples its edge. The design above registers cs_n once to derive cs_n_q. In a real slave that is not sufficient: CS is driven by an external master with no relationship to the local clock, so it must cross into the domain through a proper synchroniser before any edge is derived from it. Sampling an asynchronous signal with a single flop and then using it in control logic is a metastability hazard, and deriving an edge from it makes the hazard worse because a metastable sample can produce a spurious edge. Chapter 5.2 builds the synchroniser this module assumes.
The commit is a single-cycle pulse and its consumer must be ready. If the committed value feeds a register file in another clock domain, the pulse cannot simply be sampled there — it must be handed across with a proper crossing. A one-cycle strobe is the easiest thing in the world to lose.
Staging costs flip-flops, and doubling them is deliberate. The design holds both staged and committed copies. Merging them — writing the destination directly and "undoing" on abort — is not possible, because there is nothing to undo to. The extra registers are what make abort free.
On an ASIC, think about what the committed output drives. If it is a configuration register controlling analogue bias or an output enable, a spurious commit is not a data error but a potentially destructive one. That is the case where the write-enable interlock of §5 stops being a convenience and becomes a requirement.
9. Failure Signature — The First Register Sticks and the Rest Do Not
Symptom. Device initialisation writes eight configuration registers in sequence. Reading them back afterwards shows the first one correct and the other seven at their reset values. No error is reported anywhere — every transaction completed normally on the bus, and a logic analyzer shows all eight writes with correct bytes and correct framing.
Why this is confusing. Every observable says success. The bus carried exactly the right bits, CS framed each write properly, and there is no acknowledge to be missing (§5) — so the absence of an error is not evidence of anything at all.
Plausible mechanisms.
- A write-enable interlock that auto-clears after each write, with the driver issuing it only once. The first write consumes the enable; the rest are silently dropped. This is the leading candidate and matches the symptom exactly.
- The device is busy after the first write and ignores subsequent transactions until it completes (Module 6's territory).
- A write-protect or lock bit covering the other seven registers but not the first.
- The registers require a specific unlock sequence that was performed once.
The discriminating observation. The pattern "exactly one succeeded" is itself the strongest clue, and it argues for a consumable, single-use permission rather than anything to do with signalling. If it were a timing or framing fault, the first write would be no more likely to succeed than the others.
Confirm it by reading the status register after each write and watching the enable bit: if it is set before write one and clear before write two, the interlock is the mechanism and the fix is to issue write-enable before every write. If it is a busy bit that never clears, the device needs polling between writes instead.
Why the investigation goes wrong. Because "the analyzer shows the right bytes" is trusted, and the search moves to signal integrity or mode configuration — both of which the first successful write has already exonerated. On a bus with no acknowledge, a device silently declining a command looks identical to accepting it, so the evidence has to come from reading device state back, not from watching the wire.
10. Common Misconceptions
11. Reason It Through
Work this before reading the answer.
A driver writes a 16-bit value to a device by sending the opcode, the address, and then two separate one-byte transfers for the high and low halves — deasserting CS between them because the controller's API sends one byte per call.
The device ends up holding a value that is neither the intended one nor obviously related to it. What happened, and what does the final register contain?
Start from the framing. Chapter 4.2 §3 established that CS deassertion ends a transaction. Dropping CS between the two data bytes does not produce one write of two bytes — it produces three separate transactions, and only the first is well formed.
Walk them through the §6 state machine. Transaction one carries opcode, address, and the high byte: that is a complete write, and it commits the high byte into the addressed register. Transactions two and three each open with a fresh frame whose first byte the device reads as an opcode — so the low data byte is decoded as a command, almost certainly an unrecognised one, and the device ignores the rest of that frame.
So what does the register contain? The high byte, written into a register expecting a 16-bit value — so the intended value's upper half sits in whichever position the device's write path places a single byte, and the lower half was never written at all. The result looks unrelated to the intended value because it is a fragment of it, not a corruption of it.
And what else may have happened? The stray byte interpreted as an opcode is the part worth worrying about. If the low byte happens to coincide with a real command — a chip erase, a write-enable, a reset — the device will execute it. That turns a benign-looking framing mistake into an arbitrary command injection, which is why "send the bytes individually and it'll be fine" is a genuinely dangerous habit rather than merely an inefficient one.
How would you confirm it? Look at CS as a timing waveform across the whole write, not at the byte list. Three CS pulses where one was intended is immediately visible and settles the question in seconds. A byte-list view shows the same bytes in the same order in both the working and broken cases, which is exactly why this bug survives review.
The fix. Use the controller's transfer API that keeps CS asserted across a multi-byte buffer — almost every driver stack distinguishes "write these bytes as one transfer" from "write these bytes". If the hardware genuinely cannot hold CS across calls, drive CS as a GPIO, which is a common and entirely legitimate arrangement.
12. Understanding Check
13. Summary
A write is opcode, address, data, and a CS edge. The device stages each byte as it arrives and, on most devices, makes the write visible only when CS rises — because CS deassertion is the only event on an SPI bus that says a transaction is complete.
That choice buys the bus its only abort mechanism: until the CS edge, a master can withdraw a transaction by deasserting, and a correct device discards everything staged. It also imposes an obligation — a frame closing before any data must leave no trace, since the alternative is silent corruption from stale staging.
Nothing acknowledges a write. No acknowledge bit, no status line, no error. A write that was declined, blocked by a write-protect bit, or ignored because the device was busy is indistinguishable on the wire from one that succeeded. Confirmation must come from reading state back, polling a busy bit, or observing a write-enable latch clear — and the absence of an error is never evidence of success.
In RTL the whole idea reduces to one condition: cs_n && !cs_n_q. That is the only place committed state is written, and everything before it is staging. The state machine must distinguish "address received, no data yet" from "data staged", because merging them commits stale data on an address-only frame.
For verification, the property most often missing is not about the strobe but about the data: committed outputs must be stable on every cycle without a commit. And coverage should enumerate the phase a frame closes in, since a suite of well-formed transactions only ever exercises one of them.
14. What Comes Next
This chapter treated CS as a clean framing signal and took its rising edge as an event. Chapter 5.2 — MOSI Data Flow and CS Framing examines that assumption: what the master drives on MOSI during every part of a transaction including the parts that carry no meaning, exactly what the two CS edges bracket, and why an asynchronous CS must cross into the slave's clock domain through a synchroniser before any edge can safely be derived from it — with that synchroniser and edge detector built in all three HDLs.
Continue learning
Related tutorials
- Related topic
Partial and Aborted Transactions
Chip select deasserting mid-frame: why an abort is legitimate, why an empty frame and a partial word need different responses, and the guard that classifies a frame's ending so a partial word never commits.
- 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
Mode Mismatch and Its Failure Signature
What happens when the two ends disagree about the mode. The distinct signature each mismatch produces, how to tell polarity from phase disagreement from the data alone, and the monitor and coverage work that catches it.
