Skip to content
VLSI Mentor

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:

  1. CS asserts. The frame opens; the device's sequencer leaves IDLE and expects an opcode.
  2. Opcode byte. The device decodes it, learns this is a write, and learns the shape of the rest.
  3. Address byte or bytes. Accumulated most-significant byte first.
  4. Data byte or bytes. Staged internally.
  5. 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

A sequence diagram of an SPI register write. The master asserts chip select, sends a write opcode, an address byte and a data byte, then releases chip select, at which point the device commits the write. No acknowledgement travels from device to master.A register write: opcode, address, data, then commitmasterdeviceCS asserts — frameopens0x02 — write opcode0x40 — registeraddress0xC3 — data, stagedonlyCS releases — writecommits
Figure 1 — a single-register write. Every arrow is the master clocking eight bits out; the device returns bits on MISO throughout, but none of them mean anything and the master discards them. Nothing travels back to confirm the write, and the final arrow — CS releasing — is what makes it take effect.

3. What the Device Does With Each Byte

Three bytes in, nothing out, one commit at the end

6 cycles
Byte-time lanes for an SPI register write. MOSI carries 0x02, 0x40 and 0xC3 across three byte times while chip select is low. MISO carries nothing throughout. A commit lane pulses once, at the byte time where chip select returns high.frame opensframe openswrite commitswrite commitscs_nmosi--0x020x400xC3----miso------------committ0t1t2t3t4t5
Figure 2 — the same write as byte-time lanes. MISO carries nothing meaningful for the whole transaction, which is the visible form of 'a write is unacknowledged'. The commit strobe is internal device state, drawn to show when the register actually changes: one cycle, at the CS rising edge, after the last bit has already gone.

Two 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.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit.sv — staging, and the one edge where a write becomes real
// 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
endmodule

The 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.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit_tb.sv — a complete write, and an abort at every phase
// 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
endmodule

That 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.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit.v — the same staged write path in Verilog-2001
// 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
Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit_tb.v — the same checks in Verilog-2001
// 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
Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit.vhd — the same staged write path in VHDL
-- 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;
Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit_tb.vhd — the same checks in VHDL
-- 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:

Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_commit.sva — a write becomes visible exactly once, at the frame boundary
   // 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:

Azvya Education Pvt. Ltd.VLSI Mentor
spi_write_cg.sv — every phase a frame can close in
   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;
   endgroup

after_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