Skip to content
VLSI Mentor

SPI · Module 8

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.

Chapter 8.6 showed one way a frame ends unexpectedly. This chapter takes the general case and closes the module.

Chip select rises in the middle of a word. The device has four bits of something in its shift register. What is it required to do?

The answer is whatever its datasheet says, and the interesting part is that this is a genuine specification gap rather than a detail — many datasheets do not say, and the device's behaviour is then discoverable only by experiment.

The first thing to establish is that an abort is not an error condition. Chapter 5.1 §4 showed that deasserting CS is the only way a master can withdraw a transaction, and that this is a deliberate property of the design: the whole reason a write is staged rather than committed byte by byte is so that the master can change its mind.

Masters abort for entirely ordinary reasons:

  • A higher-priority transfer arrives and the driver preempts the current one.
  • A DMA underrun the controller chooses not to stall through (Chapter 7.1 §2 describes stalling as the better option, but not every controller offers it).
  • An error detected mid-transfer — a parity failure in the source buffer, a checksum computed as the data was assembled.
  • A timeout or reset in the software layer above.

So a device must handle an abort correctly, and "correctly" is stronger than "not crash". It must leave no trace of the withdrawn transaction, which is the property that makes the master's abort mechanism usable at all.

2. Three Ways a Frame Can End

They are not equivalent, and conflating them is the source of the disagreement in §4.

Clean close. A whole number of words arrived. The transaction is complete as far as the bus is concerned, and this is the only case in which committing is correct.

Empty frame. CS opened and closed with no clock edges at all. Usually a glitch (Chapter 8.6) or a selection the master immediately reconsidered. There is nothing to commit and nothing to discard.

Mid-word close. Bits arrived, but not a whole number of words. This is the dangerous one: a partial word is sitting in the shift register, and a device that commits it writes a value the master never sent — some bits from the current transaction, the remainder from whatever was there before.

3. Closing Mid-Word

Three bits of an eight-bit word, then the frame ends

10 cycles
An SPI frame in which chip select falls, three clock pulses occur, and chip select rises again. A running bit count reaches three, which is not a whole word.closed at 3 bitsclosed at 3 bitscs_nsclkbits01122333----t0t1t2t3t4t5t6t7t8t9
Figure 1 — a frame carrying three bits of an eight-bit word before chip select rises. The running bit count is shown because it is what decides the classification: not zero, and not a multiple of the word width, so a partial word is in the register and must be discarded rather than committed.

4. What a Device Must Be Specified to Do

Here is the uncomfortable part. There is no universal rule, because SPI does not define one (Chapter 4.1 §2), so each device decides — and many datasheets are silent.

The behaviours actually found in the field:

Discard the partial word. The correct and most common behaviour. The device acts on complete words only and forgets the fragment. This is what makes a master's abort safe.

Commit the partial word, zero-padded or left-shifted. Rare, and dangerous. The written value bears an arbitrary relationship to what the master sent.

Commit previous complete words and discard only the fragment. Common on auto-incrementing writes: bytes 0–3 of a 5-byte burst are written and the partial fifth is dropped. Whether that is desirable depends entirely on the application — for a memory it is usually fine, for a register set it can leave a device half-configured.

Undefined. The datasheet does not say, which means the behaviour is whatever the silicon does and may differ between revisions.

The practical consequence is a rule worth internalising: never rely on an abort being clean unless the datasheet says so. If the abort path matters — and it does whenever a driver can preempt a transfer — either verify the behaviour experimentally or design so that aborts cannot happen at a point where they would matter.

And when writing a slave: specify it, and specify it in the datasheet. The behaviour is free to choose and expensive to leave undocumented, and the integrator has no way to discover it except by trying.

5. Building the Abort Guard — Three HDLs

The circuit

Circuit. Two counters and a classifier that fires on the CS rising edge.

State. The total bit count for the frame, and the position within the current word.

Datapath. None — this classifies, it does not carry data. It gates whatever does.

Control. A CS falling edge resets both counters; each bit strobe advances them; a CS rising edge classifies and emits exactly one of three outcomes.

Clock and reset. System clock; asynchronous active-low reset with both strobes low.

Enables. Tracking the position within a word avoids a modulo on the bit count. bit_cnt % WIDTH would infer a divider or a wide comparator; a counter that wraps at WIDTH-1 gives the same answer for free, and word_bit != 0 is then the mid-word test.

Timing. close_valid and commit_ok are single-cycle strobes coincident with the classification, and frame_bits is held so a consumer can read the count after the strobe.

Synthesis. A CNT_W counter, a small word-position counter, and a comparator. The commit_ok output is the important one structurally: it is asserted on exactly one branch, so there is no path by which a non-clean close can commit.

Limitations. It classifies the frame; what to do about a mid-word close is device policy, which §4 says must be specified rather than assumed.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard.sv — three ways to end, one way to commit
// spi_abort_guard.sv — classifying how a frame ended.
//
// Chapter 5.1 established that CS rising is the commit point and that a
// frame closing early must leave no trace. This module makes "early" precise,
// because there are three ways a frame can end and they are not equivalent:
//
//   EMPTY     CS opened and closed with no bits at all. Usually a glitch
//             (Chapter 8.6) or an aborted selection. Nothing to commit.
//   CLEAN     A whole number of words arrived. The only committable close.
//   MID_WORD  Bits arrived but not a whole word. The dangerous one: a
//             partial word is sitting in the shift register, and a device
//             that commits it writes a value the master never sent.
//
// Distinguishing MID_WORD from EMPTY matters because they need different
// responses: an empty frame is noise, a mid-word abort means the master
// changed its mind and the transaction must be discarded AND reported.
module spi_abort_guard #(
    parameter int WIDTH = 8,
    parameter int CNT_W = 16
) (
    input  logic             clk,
    input  logic             rst_n,
    input  logic             cs_n,         // synchronised, active low
    input  logic             bit_stb,      // one pulse per received bit
    output logic [CNT_W-1:0] frame_bits,   // bits in the frame just closed
    output logic [1:0]       close_kind,   // see the localparams
    output logic             close_valid,  // pulse: a frame just closed
    output logic             commit_ok     // pulse: CLEAN close, safe to commit
);
    localparam logic [1:0] CK_EMPTY    = 2'd0,
                           CK_CLEAN    = 2'd1,
                           CK_MID_WORD = 2'd2;

    localparam int BW = (WIDTH > 1) ? $clog2(WIDTH) : 1;

    logic [CNT_W-1:0] bit_cnt;
    logic [BW-1:0]    word_bit;    // position within the current word
    logic             cs_n_q;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            bit_cnt     <= '0;
            word_bit    <= '0;
            cs_n_q      <= 1'b1;
            frame_bits  <= '0;
            close_kind  <= CK_EMPTY;
            close_valid <= 1'b0;
            commit_ok   <= 1'b0;
        end else begin
            cs_n_q      <= cs_n;
            close_valid <= 1'b0;
            commit_ok   <= 1'b0;

            if (!cs_n && cs_n_q) begin
                // CS just fell: a new frame. Reset the accounting.
                bit_cnt  <= '0;
                word_bit <= '0;
            end else if (cs_n && !cs_n_q) begin
                // CS just rose: classify what arrived.
                frame_bits  <= bit_cnt;
                close_valid <= 1'b1;
                if (bit_cnt == '0) begin
                    close_kind <= CK_EMPTY;
                end else if (word_bit != '0) begin
                    // Bits arrived but the last word is incomplete.
                    close_kind <= CK_MID_WORD;
                end else begin
                    close_kind <= CK_CLEAN;
                    commit_ok  <= 1'b1;      // the ONLY path that commits
                end
            end else if (!cs_n && bit_stb) begin
                bit_cnt <= bit_cnt + 1'b1;
                // Tracking the position avoids a modulo on the bit count.
                if (word_bit == BW'(WIDTH - 1)) word_bit <= '0;
                else                            word_bit <= word_bit + 1'b1;
            end
        end
    end
endmodule

The structure is the guarantee. commit_ok is assigned inside a single else branch reached only when the bit count is non-zero and the word position is zero — so a partial word cannot commit by any route, including a future edit that adds a case. Compare that with computing a commit signal from several conditions ORed together, where a new condition can enable a path nobody considered.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard_tb.sv — every closure kind, and the invariant that ties them together
// spi_abort_guard_tb.sv — all three closure kinds, and the guarantee that
// only a clean close commits.
`timescale 1ns/1ps
module spi_abort_guard_tb;
    logic clk = 0, rst_n = 0;
    always #5 clk = ~clk;

    localparam int W = 8, CW = 16;
    localparam logic [1:0] CK_EMPTY = 2'd0, CK_CLEAN = 2'd1, CK_MID_WORD = 2'd2;

    logic cs_n = 1, bit_stb = 0;
    logic [CW-1:0] frame_bits;
    logic [1:0] close_kind;
    logic close_valid, commit_ok;

    spi_abort_guard #(.WIDTH(W), .CNT_W(CW)) dut (
        .clk, .rst_n, .cs_n, .bit_stb, .frame_bits, .close_kind,
        .close_valid, .commit_ok);

    int errors = 0, commits = 0, closes = 0;
    task automatic chk(input string what, input int g, input int e);
        if (g !== e) begin $display("FAIL %s: got %0d exp %0d", what, g, e); errors++; end
    endtask

    always @(posedge clk) if (rst_n) begin
        if (close_valid) closes++;
        if (commit_ok)   commits++;
        // INVARIANT: a commit may only accompany a CLEAN close.
        if (commit_ok && close_kind != CK_CLEAN) begin
            $display("FAIL: commit_ok on a non-clean close (kind=%0d)", close_kind);
            errors++;
        end
    end

    task automatic send_bits(input int n);
        for (int i = 0; i < n; i++) begin
            @(negedge clk); bit_stb = 1;
            @(negedge clk); bit_stb = 0;
        end
    endtask

    task automatic frame(input int n_bits);
        cs_n = 0; @(negedge clk); @(negedge clk);
        send_bits(n_bits);
        cs_n = 1; @(negedge clk); @(negedge clk);
    endtask

    initial begin
        repeat (3) @(negedge clk); rst_n = 1; @(negedge clk);

        // --- two whole words: CLEAN, and the only committable case ---
        frame(16);
        chk("clean: reported",   closes,     1);
        chk("clean: kind",       close_kind, CK_CLEAN);
        chk("clean: bit count",  frame_bits, 16);
        chk("clean: committed",  commits,    1);

        // --- 13 bits: MID_WORD. A partial word is in the register. ---
        frame(13);
        chk("mid-word: reported",     closes,     2);
        chk("mid-word: kind",         close_kind, CK_MID_WORD);
        chk("mid-word: bit count",    frame_bits, 13);
        chk("mid-word: NOT committed", commits,   1);   // unchanged

        // --- no bits at all: EMPTY (a glitch-length selection) ---
        frame(0);
        chk("empty: reported",      closes,     3);
        chk("empty: kind",          close_kind, CK_EMPTY);
        chk("empty: bit count",     frame_bits, 0);
        chk("empty: NOT committed", commits,    1);

        // --- exactly one word ---
        frame(8);
        chk("one word: kind",      close_kind, CK_CLEAN);
        chk("one word: committed", commits,    2);

        // --- one bit short of a word: the classic off-by-one abort ---
        frame(7);
        chk("seven bits: kind",          close_kind, CK_MID_WORD);
        chk("seven bits: NOT committed", commits,    2);

        // --- one bit past a word ---
        frame(9);
        chk("nine bits: kind",          close_kind, CK_MID_WORD);
        chk("nine bits: NOT committed", commits,    2);

        // --- a long clean burst, to show the accounting scales ---
        frame(8 * 20);
        chk("long burst: kind",      close_kind, CK_CLEAN);
        chk("long burst: bits",      frame_bits, 160);
        chk("long burst: committed", commits,    3);

        // --- and the accounting resets per frame, not cumulatively ---
        frame(4);
        chk("after long burst: mid-word", close_kind, CK_MID_WORD);
        chk("after long burst: bits",     frame_bits, 4);

        if (errors == 0)
            $display("PASS: only a whole number of words commits; empty and mid-word closes are distinguished, reported with their bit count, and never commit");
        else
            $display("FAILED with %0d error(s)", errors);
        $finish;
    end

    initial begin #900000; $display("FAIL: watchdog timeout"); $finish; end
endmodule

The continuous invariant — commit_ok may never accompany a non-clean close — is checked on every clock rather than at the classification points. That makes it a proof about the design rather than a check of the cases the testbench happened to think of, and it is the same discipline as Chapter 8.1's one-hot check.

The chosen bit counts are deliberate: 16 and 8 for clean closes, 7 and 9 to straddle a word boundary from both sides, 13 for a general mid-word case, 0 for an empty frame, and 160 to confirm the accounting scales. Seven and nine are the off-by-one pair — a comparison written with >= instead of ==, or a counter that wraps one late, fails on exactly one of them.

Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard.v — the same classifier in Verilog-2001
// spi_abort_guard.v — the same closure classifier in Verilog-2001.
module spi_abort_guard #(
    parameter WIDTH = 8,
    parameter CNT_W = 16
) (
    input  wire             clk,
    input  wire             rst_n,
    input  wire             cs_n,
    input  wire             bit_stb,
    output reg  [CNT_W-1:0] frame_bits,
    output reg  [1:0]       close_kind,
    output reg              close_valid,
    output reg              commit_ok
);
    localparam CK_EMPTY    = 2'd0,
               CK_CLEAN    = 2'd1,
               CK_MID_WORD = 2'd2;

    function integer clogb2;
        input integer value;
        integer v;
        begin
            v = value - 1;
            for (clogb2 = 0; v > 0; clogb2 = clogb2 + 1) v = v >> 1;
        end
    endfunction

    localparam BW = (WIDTH > 1) ? clogb2(WIDTH) : 1;

    reg [CNT_W-1:0] bit_cnt;
    reg [BW-1:0]    word_bit;
    reg             cs_n_q;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            bit_cnt     <= {CNT_W{1'b0}};
            word_bit    <= {BW{1'b0}};
            cs_n_q      <= 1'b1;
            frame_bits  <= {CNT_W{1'b0}};
            close_kind  <= CK_EMPTY;
            close_valid <= 1'b0;
            commit_ok   <= 1'b0;
        end else begin
            cs_n_q      <= cs_n;
            close_valid <= 1'b0;
            commit_ok   <= 1'b0;

            if (!cs_n && cs_n_q) begin
                bit_cnt  <= {CNT_W{1'b0}};
                word_bit <= {BW{1'b0}};
            end else if (cs_n && !cs_n_q) begin
                frame_bits  <= bit_cnt;
                close_valid <= 1'b1;
                if (bit_cnt == {CNT_W{1'b0}}) begin
                    close_kind <= CK_EMPTY;
                end else if (word_bit != {BW{1'b0}}) begin
                    close_kind <= CK_MID_WORD;
                end else begin
                    close_kind <= CK_CLEAN;
                    commit_ok  <= 1'b1;        // the ONLY path that commits
                end
            end else if (!cs_n && bit_stb) begin
                bit_cnt <= bit_cnt + 1'b1;
                if (word_bit == (WIDTH - 1)) word_bit <= {BW{1'b0}};
                else                         word_bit <= word_bit + 1'b1;
            end
        end
    end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard_tb.v — the same checks in Verilog-2001
// spi_abort_guard_tb.v — the same checks in Verilog-2001.
`timescale 1ns/1ps
module spi_abort_guard_tb;
    reg clk = 0, rst_n = 0;
    always #5 clk = ~clk;

    parameter W = 8, CW = 16;
    localparam CK_EMPTY = 2'd0, CK_CLEAN = 2'd1, CK_MID_WORD = 2'd2;

    reg cs_n = 1, bit_stb = 0;
    wire [CW-1:0] frame_bits;
    wire [1:0] close_kind;
    wire close_valid, commit_ok;

    spi_abort_guard #(.WIDTH(W), .CNT_W(CW)) dut (
        .clk(clk), .rst_n(rst_n), .cs_n(cs_n), .bit_stb(bit_stb),
        .frame_bits(frame_bits), .close_kind(close_kind),
        .close_valid(close_valid), .commit_ok(commit_ok));

    integer errors = 0, commits = 0, closes = 0, i;

    task chk;
        input [80*8-1:0] what;
        input [31:0] g, e;
        begin
            if (g !== e) begin
                $display("FAIL %0s: got %0d exp %0d", what, g, e);
                errors = errors + 1;
            end
        end
    endtask

    always @(posedge clk) if (rst_n) begin
        if (close_valid) closes  = closes  + 1;
        if (commit_ok)   commits = commits + 1;
        if (commit_ok && close_kind !== CK_CLEAN) begin
            $display("FAIL: commit_ok on a non-clean close (kind=%0d)", close_kind);
            errors = errors + 1;
        end
    end

    task send_bits;
        input integer n;
        integer k;
        begin
            for (k = 0; k < n; k = k + 1) begin
                @(negedge clk); bit_stb = 1;
                @(negedge clk); bit_stb = 0;
            end
        end
    endtask

    task frame;
        input integer n_bits;
        begin
            cs_n = 0; @(negedge clk); @(negedge clk);
            send_bits(n_bits);
            cs_n = 1; @(negedge clk); @(negedge clk);
        end
    endtask

    initial begin
        repeat (3) @(negedge clk); rst_n = 1; @(negedge clk);

        frame(16);
        chk("clean: reported",  closes,     1);
        chk("clean: kind",      close_kind, CK_CLEAN);
        chk("clean: bit count", frame_bits, 16);
        chk("clean: committed", commits,    1);

        frame(13);
        chk("mid-word: reported",      closes,     2);
        chk("mid-word: kind",          close_kind, CK_MID_WORD);
        chk("mid-word: bit count",     frame_bits, 13);
        chk("mid-word: NOT committed", commits,    1);

        frame(0);
        chk("empty: reported",      closes,     3);
        chk("empty: kind",          close_kind, CK_EMPTY);
        chk("empty: bit count",     frame_bits, 0);
        chk("empty: NOT committed", commits,    1);

        frame(8);
        chk("one word: kind",      close_kind, CK_CLEAN);
        chk("one word: committed", commits,    2);

        frame(7);
        chk("seven bits: kind",          close_kind, CK_MID_WORD);
        chk("seven bits: NOT committed", commits,    2);

        frame(9);
        chk("nine bits: kind",          close_kind, CK_MID_WORD);
        chk("nine bits: NOT committed", commits,    2);

        frame(8 * 20);
        chk("long burst: kind",      close_kind, CK_CLEAN);
        chk("long burst: bits",      frame_bits, 160);
        chk("long burst: committed", commits,    3);

        frame(4);
        chk("after long burst: mid-word", close_kind, CK_MID_WORD);
        chk("after long burst: bits",     frame_bits, 4);

        if (errors == 0)
            $display("PASS: only a whole number of words commits; empty and mid-word closes are distinguished, reported with their bit count, and never commit");
        else
            $display("FAILED with %0d error(s)", errors);
        $finish;
    end

    initial begin #900000; $display("FAIL: watchdog timeout"); $finish; end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard.vhd — the same classifier in VHDL
-- spi_abort_guard.vhd — the same closure classifier in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity spi_abort_guard is
    generic (
        WIDTH : positive := 8;
        CNT_W : positive := 16
    );
    port (
        clk         : in  std_logic;
        rst_n       : in  std_logic;
        cs_n        : in  std_logic;                        -- synchronised
        bit_stb     : in  std_logic;                        -- one pulse per bit
        frame_bits  : out unsigned(CNT_W - 1 downto 0);
        close_kind  : out std_logic_vector(1 downto 0);
        close_valid : out std_logic;                        -- pulse
        commit_ok   : out std_logic                         -- pulse
    );
end entity spi_abort_guard;

architecture rtl of spi_abort_guard is
    constant CK_EMPTY    : std_logic_vector(1 downto 0) := "00";
    constant CK_CLEAN    : std_logic_vector(1 downto 0) := "01";
    constant CK_MID_WORD : std_logic_vector(1 downto 0) := "10";

    signal bit_cnt  : unsigned(CNT_W - 1 downto 0);
    signal word_bit : integer range 0 to WIDTH - 1;   -- position within a word
    signal cs_n_q   : std_logic;
begin

    process (clk, rst_n) is
    begin
        if rst_n = '0' then
            bit_cnt     <= (others => '0');
            word_bit    <= 0;
            cs_n_q      <= '1';
            frame_bits  <= (others => '0');
            close_kind  <= CK_EMPTY;
            close_valid <= '0';
            commit_ok   <= '0';
        elsif rising_edge(clk) then
            cs_n_q      <= cs_n;
            close_valid <= '0';
            commit_ok   <= '0';

            if cs_n = '0' and cs_n_q = '1' then
                -- CS just fell: a new frame. Reset the accounting.
                bit_cnt  <= (others => '0');
                word_bit <= 0;

            elsif cs_n = '1' and cs_n_q = '0' then
                -- CS just rose: classify what arrived.
                frame_bits  <= bit_cnt;
                close_valid <= '1';
                if bit_cnt = 0 then
                    close_kind <= CK_EMPTY;
                elsif word_bit /= 0 then
                    close_kind <= CK_MID_WORD;
                else
                    close_kind <= CK_CLEAN;
                    commit_ok  <= '1';        -- the ONLY path that commits
                end if;

            elsif cs_n = '0' and bit_stb = '1' then
                bit_cnt <= bit_cnt + 1;
                -- Tracking the position avoids a modulo on the bit count.
                if word_bit = WIDTH - 1 then
                    word_bit <= 0;
                else
                    word_bit <= word_bit + 1;
                end if;
            end if;
        end if;
    end process;

end architecture rtl;
Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard_tb.vhd — the same checks in VHDL
-- spi_abort_guard_tb.vhd — the same checks in VHDL.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity spi_abort_guard_tb is
end entity spi_abort_guard_tb;

architecture tb of spi_abort_guard_tb is
    constant W  : positive := 8;
    constant CW : positive := 16;

    constant CK_EMPTY    : std_logic_vector(1 downto 0) := "00";
    constant CK_CLEAN    : std_logic_vector(1 downto 0) := "01";
    constant CK_MID_WORD : std_logic_vector(1 downto 0) := "10";

    signal clk     : std_logic := '0';
    signal rst_n   : std_logic := '0';
    signal cs_n    : std_logic := '1';
    signal bit_stb : std_logic := '0';
    signal halt    : boolean := false;

    signal frame_bits  : unsigned(CW - 1 downto 0);
    signal close_kind  : std_logic_vector(1 downto 0);
    signal close_valid : std_logic;
    signal commit_ok   : std_logic;

    signal errors  : natural := 0;
    signal inv_err : natural := 0;
    signal commits : natural := 0;
    signal closes  : natural := 0;
begin

    clk <= not clk after 5 ns when not halt else '0';

    dut : entity work.spi_abort_guard
        generic map (WIDTH => W, CNT_W => CW)
        port map (clk => clk, rst_n => rst_n, cs_n => cs_n, bit_stb => bit_stb,
                  frame_bits => frame_bits, close_kind => close_kind,
                  close_valid => close_valid, commit_ok => commit_ok);

    observe : process (clk) is
    begin
        if rising_edge(clk) and rst_n = '1' then
            if close_valid = '1' then
                closes <= closes + 1;
            end if;
            if commit_ok = '1' then
                commits <= commits + 1;
                -- INVARIANT: a commit may only accompany a CLEAN close.
                if close_kind /= CK_CLEAN then
                    report "FAIL: commit_ok on a non-clean close" severity error;
                    inv_err <= inv_err + 1;
                end if;
            end if;
        end if;
    end process;

    stim : process is
        procedure chk_n (what : string; g, e : natural) is
        begin
            if g /= e then
                report "FAIL " & what & ": got " & integer'image(g)
                    & " exp " & integer'image(e) severity error;
                errors <= errors + 1;
            end if;
        end procedure;

        procedure chk_v (what : string; g, e : std_logic_vector) is
        begin
            if g /= e then
                report "FAIL " & what severity error;
                errors <= errors + 1;
            end if;
        end procedure;

        procedure send_bits (n : natural) is
        begin
            for k in 1 to n loop
                wait until falling_edge(clk); bit_stb <= '1';
                wait until falling_edge(clk); bit_stb <= '0';
            end loop;
        end procedure;

        procedure frame (n_bits : natural) is
        begin
            cs_n <= '0';
            wait until falling_edge(clk);
            wait until falling_edge(clk);
            send_bits(n_bits);
            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);

        frame(16);
        chk_n("clean: reported",  closes, 1);
        chk_v("clean: kind",      close_kind, CK_CLEAN);
        chk_n("clean: bit count", to_integer(frame_bits), 16);
        chk_n("clean: committed", commits, 1);

        frame(13);
        chk_n("mid-word: reported",      closes, 2);
        chk_v("mid-word: kind",          close_kind, CK_MID_WORD);
        chk_n("mid-word: bit count",     to_integer(frame_bits), 13);
        chk_n("mid-word: NOT committed", commits, 1);

        frame(0);
        chk_n("empty: reported",      closes, 3);
        chk_v("empty: kind",          close_kind, CK_EMPTY);
        chk_n("empty: bit count",     to_integer(frame_bits), 0);
        chk_n("empty: NOT committed", commits, 1);

        frame(8);
        chk_v("one word: kind",      close_kind, CK_CLEAN);
        chk_n("one word: committed", commits, 2);

        frame(7);
        chk_v("seven bits: kind",          close_kind, CK_MID_WORD);
        chk_n("seven bits: NOT committed", commits, 2);

        frame(9);
        chk_v("nine bits: kind",          close_kind, CK_MID_WORD);
        chk_n("nine bits: NOT committed", commits, 2);

        frame(8 * 20);
        chk_v("long burst: kind",      close_kind, CK_CLEAN);
        chk_n("long burst: bits",      to_integer(frame_bits), 160);
        chk_n("long burst: committed", commits, 3);

        frame(4);
        chk_v("after long burst: mid-word", close_kind, CK_MID_WORD);
        chk_n("after long burst: bits",     to_integer(frame_bits), 4);

        chk_n("invariant never violated", inv_err, 0);

        if errors = 0 then
            report "PASS: only a whole number of words commits; empty and mid-word "
                 & "closes are distinguished, reported with their bit count, and "
                 & "never commit" 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 900 us;
        if not halt then
            report "FAIL: watchdog timeout" severity failure;
        end if;
        wait;
    end process;

end architecture tb;

Parity

All three implement the same classifier: identical ports and generics, asynchronous active-low reset, counters cleared on a CS falling edge, a word-position counter avoiding a modulo, classification into empty, clean or mid-word on the rising edge, and commit_ok asserted on the clean branch alone. All three testbenches run the same eight scenarios and check the same continuous invariant.

6. Why a Verification Engineer Cares

Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_guard.sva — the commit path has exactly one entrance
   // 1. THE property. A commit implies a clean close. Everything else in
   //    this module exists to make this true.
   a_commit_implies_clean : assert property (
       @(posedge clk) disable iff (!rst_n)
           commit_ok |-> (close_kind == CK_CLEAN))
       else $error("committed on a non-clean close");

   // 2. Exactly one classification per frame. Two would double-commit;
   //    none would lose a transaction silently.
   a_one_close_per_frame : assert property (
       @(posedge clk) disable iff (!rst_n)
           $rose(cs_n) |-> close_valid);
   a_no_spurious_close : assert property (
       @(posedge clk) disable iff (!rst_n)
           close_valid |-> $rose(cs_n))
       else $error("classification without a frame ending");

   // 3. The classification matches the count. Catches a classifier that
   //    reports CLEAN for a count that is not a multiple of the width --
   //    the failure that would let a partial word through.
   a_clean_means_whole_words : assert property (
       @(posedge clk) disable iff (!rst_n)
           (close_valid && close_kind == CK_CLEAN)
           |-> (frame_bits != 0) && ((frame_bits % WIDTH) == 0))
       else $error("CLEAN reported for a partial word");

   // 4. An aborted frame leaves the DESTINATION untouched -- the property
   //    that actually matters to a user, and the one Chapter 5.1 §7 noted
   //    is usually missing.
   a_abort_leaves_no_trace : assert property (
       @(posedge clk) disable iff (!rst_n)
           (close_valid && close_kind != CK_CLEAN) |=> $stable(dest_register))
       else $error("an aborted frame modified the destination");

Property 4 is the one to insist on, and it checks something the other three do not. Properties 1–3 verify the classifier; property 4 verifies the consequence — that nothing downstream acted on a frame the classifier rejected. A design can classify perfectly and still have a data path that commits on the wrong signal, and only a property on the destination catches it.

What these prove. That closures are classified correctly and that only clean ones propagate. What they cannot prove is that the device's chosen policy matches what the system needs — whether previous complete words in an aborted burst should be kept or discarded is an application decision, not a bus one.

Coverage must reach all three closures, and the boundary cases:

Azvya Education Pvt. Ltd.VLSI Mentor
spi_abort_cg.sv — where a frame ended, relative to a word boundary
   covergroup spi_abort_cg @(posedge close_valid);
       cp_kind : coverpoint close_kind {
           bins empty    = {CK_EMPTY};
           bins clean    = {CK_CLEAN};
           bins mid_word = {CK_MID_WORD};
       }

       // Position within the word at the moment of closing. One short and
       // one long of a boundary are where off-by-ones hide; a random bit
       // count reaches them rarely.
       cp_offset : coverpoint (frame_bits % WIDTH) {
           bins exact_boundary = {0};
           bins one_short      = {WIDTH-1};
           bins one_over       = {1};
           bins middle         = default;
       }

       // How many whole words preceded the abort -- the case where a device
       // must decide whether to keep them (§4).
       cp_prior_words : coverpoint (frame_bits / WIDTH) iff (close_kind == CK_MID_WORD) {
           bins none  = {0};          // abort within the first word
           bins some  = {[1:15]};     // whole words already received
           bins many  = {[16:$]};
       }
   endgroup

cp_prior_words.some is the bin that exposes §4's ambiguity. An abort in the middle of the first word has nothing to keep; an abort in the middle of the fifth word raises the question of what happened to words one through four — and a test suite that only ever aborts early never asks it.

7. Why an FPGA or ASIC Engineer Cares

Discarding must be the default path, not an action. The safest structure is one where a partial word is discarded because nothing ever moves it — the commit is gated on a clean close, so a fragment stays in the shift register and is overwritten by the next frame. A design that actively "clears" a fragment on abort has an action that can be missed; a design that never propagates it cannot.

The classification must survive an abort at any instant, including between the last bit strobe and the CS edge. The guard classifies on the registered CS edge using counters that stopped advancing, so there is no race — but a design that classified combinationally from a raw CS could sample a count mid-update.

Report it upward. close_kind and frame_bits are cheap to expose and expensive to lack. A slave that silently discards aborted frames gives its integrator no way to distinguish "the master aborted" from "my command was ignored" — which is exactly the ambiguity that makes SPI bring-up slow (Chapter 5.1 §9).

On a slave with a memory behind it, decide about prior words explicitly. If a 5-byte burst aborts in byte 5, are bytes 1–4 written? Both answers are defensible and the decision must be made deliberately, implemented visibly, and written in the datasheet. Leaving it emergent means it can change with a synthesis run.

8. Failure Signature — A Register Holding a Value Nobody Wrote

Symptom. A device is found holding a configuration value that no code path writes. The value is not random — it looks like a plausible fragment of a real value, sometimes with its low bits zeroed or its high bits belonging to a different field. It appears rarely, is never reproducible on demand, and the system is otherwise healthy.

What the value's shape establishes. This is the clue and it is available from a register dump alone. A value that is a recognisable fragment — part of a real value, shifted or truncated — is not corruption in any usual sense. Noise produces random bits; a stuck driver produces all-ones or all-zeros; a bit-order fault produces an exact reversal. A fragment means a partial word was committed.

Plausible mechanisms.

  • The device commits partial words on an aborted frame, and the master aborted mid-word — §4's dangerous behaviour, met in the field.
  • The master aborts transfers under some condition, and nobody knew it did — a preemption path, a timeout, or an error handler that drops CS.
  • A CS glitch (Chapter 8.6) truncated a frame the master thought it had completed.
  • The device's abort behaviour is undefined in its datasheet and differs from the assumption the driver was written against.

The discriminating observation. Compare the bad value against the value the driver intended to write. If the bad one is the intended one shifted by some number of bit positions, or the intended one with its remaining bits taken from the previous value, the partial-commit mechanism is confirmed — and the shift amount tells you how many bits arrived before the abort.

That is arithmetic on two values you already have, and it identifies both the mechanism and the point at which the frame ended.

Then instrument the master to log every CS deassertion that was not at a word boundary. If the driver has an abort path it did not know about, that finds it.

Why the investigation goes wrong. Because a rare, unreproducible wrong value in a configuration register reads as a cosmic-ray event or a software bug, and the register is simply rewritten. The shape of the value is the evidence, and it is discarded along with the value — which is why capturing the actual bytes before correcting them is worth doing on any unexplained register fault.

9. Common Misconceptions

10. Reason It Through

Work this before reading the answer.

A driver writes a 4-byte configuration block to a device in one frame. An RTOS preempts the SPI task partway through, and the driver's cleanup path deasserts CS to release the bus. The device's datasheet says nothing about partial transfers.

The device afterwards behaves as though it is partly configured. What are the possibilities, and how should the driver be changed?

Enumerate what the device might have done, because the datasheet's silence means all four are live.

Discarded everything. The device is entirely unconfigured — the safest outcome, and the easiest to detect because nothing took effect.

Committed the whole bytes and dropped the fragment. Bytes 1–3 took effect and byte 4 did not, so the device is configured with three of four fields. This matches "partly configured" best, and on an auto-incrementing write it is common behaviour.

Committed the partial byte too. Three fields correct and a fourth holding a fragment — §8's failure.

Something undefined. Including a device left in a state no command clears, which a CS toggle §9 would resolve.

Which is it? Read the registers back. The three outcomes are distinguishable from a dump: nothing written, three written, or three written plus a garbage fourth. That is the first diagnostic step and it settles the mechanism.

Now the driver. There are three fixes and they are not equally good.

Make the write atomic with respect to preemption. Disable preemption for the duration, or take a lock the SPI task holds across the whole frame. Correct, and it means a high-priority task can be delayed by a full 4-byte transfer — at 1 MHz that is 32 µs, which may or may not be acceptable.

Do not deassert CS in the cleanup path. The instinct that releasing the bus is "tidy" is exactly what caused this. If the task is preempted but will resume, leaving CS asserted and the clock stopped is legal (Chapter 7.1 §2) and preserves the transaction. This is usually the right fix, and it is a deletion rather than an addition.

Split the block into four single-byte transactions. Each is atomic, so a preemption between them leaves a consistent partial state rather than a fragment. Costs four times the framing overhead (Chapter 7.3 §4) and changes "partly configured" from a fault into a defined intermediate state the driver can resume from.

Which to choose? The second, if the task genuinely resumes — it is free and it preserves the abort semantics for the cases where an abort is actually wanted. The third if the configuration must be robust against the task not resuming, since it makes every intermediate state a valid one. The first only if the device requires a single atomic block.

And regardless: verify the device's abort behaviour experimentally and write it down. The datasheet does not say, so the project must — one afternoon of deliberately aborting frames at known bit positions and reading registers back produces a fact that is otherwise rediscovered by every engineer who touches the driver.

The general lesson. When a specification is silent, the behaviour still exists — it is simply undocumented, which makes it your documentation task rather than an absence. And a cleanup path that "releases resources" can destroy exactly the state that made recovery possible; on SPI, holding the bus is often safer than freeing it.

11. Understanding Check

12. Summary

An abort is legal and ordinary. Deasserting chip select is the only way a master can withdraw a transaction, and preemption, underruns, error paths and glitches all produce one. A device must leave no trace of a withdrawn transfer, which is what makes the mechanism usable.

A frame ends in one of three ways, and they are not equivalent: a clean close with whole words, the only committable case; an empty frame, which is noise with nothing to discard; and a mid-word close, which leaves a fragment in the shift register.

What a device does with that fragment is not defined by SPI. Most discard it, some commit it, many commit preceding whole words and drop only the fragment, and a good number of datasheets are silent — so the behaviour must be verified rather than assumed, and specified rather than left emergent when writing a slave.

In RTL the classifier tracks position within a word rather than computing a modulo, and asserts commit_ok on exactly one branch — so a partial word cannot commit by any route, including one added by a later edit. Discarding is the default, not an action, because an action can be missed.

For verification, the property that matters beyond classification is that an aborted frame leaves the destination untouched — a design can classify perfectly and still commit on the wrong signal. Coverage must reach one bit short and one bit over a boundary, and the case where whole words preceded the abort, which is where §4's ambiguity lives.

And when a register holds a value nobody wrote, its shape is the diagnosis: a recognisable fragment means a partial commit, and the shift amount says where the frame ended.

13. What Comes Next

That closes Module 8, and with it the bus-ownership material. Selection has semantics and is made structurally exclusive; the wiring makes three signals shared and one private; output enables release faster than they assert; turnaround leaves a gap when the bus changes hands; contention is what happens when it does not; glitches are how selection goes wrong unintentionally; and an abort is a legitimate ending that must leave nothing behind.

Module 9 changes subject entirely. Having established what SPI does, it asks what it costs: the throughput a bus can actually deliver against its clock rate, where the overhead goes, what limits the maximum frequency in practice rather than on paper, and how to decide whether a design is limited by its bus, its devices, or its software. Every efficiency ratio that appeared through Modules 4 to 7 becomes the central subject there.

Browse the path on the SPI curriculum index, or revisit Chip Select Semantics for the selection rules this module was built on.

Continue learning