The UART transmitter in this topic drove one wire from a counter, a shift register and a four-state FSM. An SPI master uses the same method with a clock wire added. It drives SCLK, chip select and MOSI, and samples MISO, so one byte goes out and another comes back during the same 16 SCLK edges. The hard part is not the shifting. It is one rule about when a new start may be taken: get it wrong and the bench below stops with FAIL: start taken with busy low, but busy=0 cs_n=1 after the edge.
You will build the block so that a producer can start a new byte in the very cycle the last one finishes, and prove it against a slave model that echoes every byte back.
Read it from the controller down. The FSM never touches a data bit; it only decides when to load, sample and shift.
Divider: counts the clocks of each SCLK half period and raises tick in the last one. The FSM moves only on tick, so one counter sets the SCLK rate.
Bit counter: counts finished bits and tells the FSM when bit 0 has gone.
Two shift registers: tx_sh drives MOSI from its bit 7; rx_sh takes MISO into its bit 0. After eight bits, rx_sh holds the received byte and is copied to rx_data.
Freeze the ports and the rules first. The bench checks every rule below.
Port | Dir | Width | Meaning |
|---|---|---|---|
clk, rst_n | in | 1, 1 | System clock; asynchronous active-low reset |
start | in | 1 | Request a transfer; taken on any rising edge where busy is 0 |
tx_data | in | 8 | Byte to send, captured with start |
div | in | 8 | SCLK half period = div + 1 clocks, captured with start |
busy | out | 1 | 1 from the edge that takes start until the edge that raises done |
done | out | 1 | One-clock pulse: rx_data holds the new byte |
rx_data | out | 8 | Last byte received, held until the next done |
sclk, cs_n, mosi | out | 1 each | Serial clock (idles low), chip select (active low), master out |
miso | in | 1 | Slave out, sampled on each rising SCLK edge |
Reset: cs_n = 1, sclk = 0, busy = 0, done = 0, rx_data = 0.
Mode 0 (CPOL = 0, CPHA = 0): SCLK idles low. MOSI changes only while SCLK is low; both sides sample on the rising edge. Bits go most significant first in both directions.
Timing, with h = div + 1: cs_n falls on the edge that takes start, and MOSI shows bit 7 at once. The first rising SCLK edge comes h clocks later, every half period is h clocks, and h clocks after the last falling edge cs_n rises and done pulses. One transfer is 17h clocks from start to done.
Back to back: busy is already 0 in the done cycle, so a start offered in that cycle is taken on the next edge. cs_n is then high for exactly one clock, and a stream of bytes runs at 17h + 1 clocks per byte.
Ignored inputs: a start while busy is 1 is ignored. tx_data and div may change freely once a start is taken.
Split design: intent first: which decisions belong in the FSM and which in the datapath.
Signal contract between FSM and datapath: the load, shift and status signals drawn above.
The UART TX FSM: the baud counter and tick that the SCLK divider copies.
Prescaler feeding a timer tick: a programmable divider that produces a one-clock tick.
The project is finished when every row below is true, and you have run each command yourself.
Check | How | Done when |
|---|---|---|
Clean compile |
| No output at all |
Clean lint |
| No warnings |
The test passes |
| The PASS line, no FAIL line |
No latch | Yosys | Only flops and gates; no |
Timing numbers | The bench's half-period and done checks | 17h clocks per transfer, 17h + 1 back to back, at every divider tried |
The complete listing is under Testbench. Read it in three parts: the states, the divider, and the edge actions.
Each state is half an SCLK period long except IDLE. The bit position is not in the state: eight bits would need sixteen states, and bit_cnt does the same job in three flops.
// snippet: the half-period counter in spi_master
assign tick = (cnt == div_q); // last clock of a half period
cnt <= tick ? '0 : cnt + 8'd1; // default in the clocked block
IDLE: cnt <= '0; // so the first half period starts cleandiv is copied into div_q when start is taken, so a change on the div port during a transfer has no effect. With div = 0, tick is true every clock and SCLK runs at half the system clock, the fastest a single-edge design can go. There is no illegal divider value: the half period is div + 1, never zero.
// snippet: the edge actions in spi_master
LOW: if (tick) begin sclk <= 1'b1; rx_sh <= {rx_sh[6:0], miso}; state <= HIGH; end
HIGH: if (tick) begin
sclk <= 1'b0; tx_sh <= {tx_sh[6:0], 1'b0}; bit_cnt <= bit_cnt + 3'd1;
if (bit_cnt == 3'd7) state <= TRAIL; else state <= LOW;
end
TRAIL: if (tick) begin cs_n <= 1'b1; done <= 1'b1; rx_data <= rx_sh; state <= IDLE; endRising edge (end of LOW): sclk goes high and miso is shifted into rx_sh in the same clock. The slave changed MISO half a period earlier, so it has been stable for h clocks.
Falling edge (end of HIGH): sclk goes low and tx_sh shifts, which puts the next bit on MOSI. MOSI therefore changes only when SCLK falls, as mode 0 requires.
`mosi` is `tx_sh[7]`, a flop output with no logic after it, so the pin cannot glitch.
`busy` is `state != IDLE`. In the done cycle the state is already IDLE, so busy is 0 and a start is taken on the next edge. This one line makes back-to-back transfers work.
Read each slot as the value just after a rising system-clock edge; SCLK is a plain signal here, not the sampling clock.
Slot 1: start is taken. cs_n falls, busy rises, and both data lines already show bit 7 (1 on MOSI, 1 on MISO) before any SCLK edge. A mode-0 slave must drive its first bit as soon as it is selected.
Slots 2, 4, ..., 16: the eight rising SCLK edges. MISO is sampled there; MOSI and MISO never change in an even slot.
Slots 3 to 15, odd: SCLK falls and both lines move to the next bit. MOSI reads 1, 0, 0, 1, 0, 1, 1, 0 across slots 1 to 15, which is 0x96 most significant bit first.
Slot 18: half a period after the last falling edge (slot 17), cs_n rises, done pulses for one clock, busy drops and MISO floats. Start to done is 17 clocks, which is 17h with h = 1.
The bench drives inputs only on the falling clock edge and checks everything at the rising edge. Its parts are:
An echoing slave model. On select it puts the byte it received last on MISO (0xA5 the first time), shifts MISO on each falling SCLK edge and samples MOSI on each rising one. On deselect it checks that it saw exactly 8 rising edges and the byte the master was asked to send.
A clock-domain monitor. It records every start taken (start && !busy before the edge) and checks that busy and cs_n respond at once; that SCLK is low whenever cs_n is high; that MOSI never changes while SCLK is high; that every SCLK edge and the deselect come exactly h clocks apart; and that done arrives 17h clocks after its start with rx_data equal to the byte the slave sent.
Stimulus. Directed bytes 0x96 and 0x3C, then 20 words at each of div = 0, 1, 2 and 7 with idle gaps, starts thrown in while busy, two bursts of 30 words that start in every done cycle (div = 0 and 3), and 40 words at random dividers from 0 to 9.
$ iverilog -g2012 -Wall -o sim.out spi_master.sv tb_spi.sv
$ vvp -n sim.out
PASS: 182 words, div 0-9, 101 back-to-back starts, 16 starts while busy ignored; slave echo and timing checkedThe compile prints nothing. Synthesis shows the five registers and the FSM as flops, with no latch:
$ yosys -p "read_verilog -sv spi_master.sv; synth -top spi_master; stat"
...
121 cells
6 $_ANDNOT_
28 $_AND_
38 $_DFFE_PN0P_
1 $_DFFE_PN1P_
9 $_DFF_PN0_
7 $_MUX_
5 $_NAND_
3 $_NOR_
2 $_NOT_
2 $_ORNOT_
5 $_OR_
9 $_XNOR_
6 $_XOR_There are 48 flops: 2 for the state, 8 each for div_q, cnt, tx_sh, rx_sh and rx_data, 3 for bit_cnt, and one each for sclk, cs_n and done. The single $_DFFE_PN1P_ is cs_n, the only flop that resets to 1.
A natural way to write the end of a transfer is a DONE state that drives done for one clock, with busy meaning "the bus is active":
// bad-example: a DONE state that is not busy but does not take a start
assign busy = (state == LOW) || (state == HIGH) || (state == TRAIL); // the bus is active
assign done = (state == DONE);
...
TRAIL: if (tick) begin cs_n <= 1'b1; rx_data <= rx_sh; state <= DONE; end
DONE: state <= IDLE; // done = 1 for this one clockEvery single transfer still works. The second directed byte breaks it: the bench offers it in the done cycle, where busy is 0, but only IDLE looks at start.
FAIL: start taken with busy low, but busy=0 cs_n=1 after the edge
FAIL slave: got 8d in 8 bits, want 3c
FAIL slave: got 0d in 8 bits, want 8d
...
FATAL: tb_spi.sv:113: FAIL: watchdog0x3C was dropped, so the slave received the next random byte where it expected 0x3C, and every later byte is one place off. Then the burst driver, which starts each byte when it sees done, has its start ignored and waits for a done that never comes: the bench stops on its watchdog.
h = 3 clocks per half period, so SCLK = 100 MHz / 6 = 16.7 MHz. One transfer is 17h = 51 clocks; back to back each byte takes 52 clocks, so about 1.92 million bytes per second.
In this zero-delay simulation the slave changes MISO only after the master's falling-edge clock has already sampled it, so the old bit is still there and all 182 words match. Whether it works on a board is a timing question: the slave's output delay after SCLK falls against the master's sampling instant. A zero-delay bench cannot answer it, so the rising-edge rule has to be checked in review or with an assertion on the RTL.
All four SPI modes. Add cpol and cpha inputs captured with start: CPOL inverts the idle level of SCLK, and CPHA = 1 moves the sample to the second edge of each bit, so MOSI must change on the first edge. Extend the slave model to the same mode and run the bench in all four. See Moore and Mealy: behavioural difference for when an output moves relative to the state.
16- and 32-bit words. Make the word width a parameter: size the shift registers by it, bit_cnt by $clog2 of it, and compare with WIDTH - 1. The transfer becomes (2 × WIDTH + 1)h clocks. See Parameters vs localparams.
A FIFO in front. Put a synchronous FIFO before tx_data and start whenever it is not empty and busy is 0, so software can queue bytes. See Sync FIFO: contract and invariants.
The complete listing below contains every file of this project, each starting at a // ---- file: <name> ---- line. Save each part under its name in one folder, then run the script.
File | What it holds |
|---|---|
| The design: SPI master, mode 0, controller plus datapath |
| The bench: an echoing slave model, timing checks and back-to-back starts |
run.sh
#!/bin/sh
set -e
iverilog -g2012 -Wall -o sim.out spi_master.sv tb_spi.sv
vvp -n sim.out
yosys -q -p "read_verilog -sv spi_master.sv; synth -top spi_master; stat" # optionalExpected output: the line a correct design makes the simulation print at the end of its run.
PASS: 182 words, div 0-9, 101 back-to-back starts, 16 starts while busy ignored; slave echo and timing checkedThe master and the self-checking bench in one listing. Save the first module as spi_master.sv and the second as tb_spi.sv to run the commands above.
// ---- file: spi_master.sv ----
// SPI master, mode 0 (CPOL = 0, CPHA = 0), 8-bit words, MSB first.
// SCLK half period = div + 1 clocks, so div = 0 gives SCLK = clk / 2.
module spi_master (
input logic clk, rst_n,
input logic start, // taken on any edge where busy is 0
input logic [7:0] tx_data, // captured with start
input logic [7:0] div, // captured with start
output logic busy,
output logic done, // one-clock pulse: rx_data is new
output logic [7:0] rx_data,
output logic sclk, cs_n, mosi,
input logic miso
);
typedef enum logic [1:0] {IDLE, LOW, HIGH, TRAIL} state_t;
state_t state;
// Datapath registers
logic [7:0] div_q, cnt; // half-period length and position in it
logic [7:0] tx_sh, rx_sh; // MOSI leaves tx_sh[7]; MISO enters rx_sh[0]
logic [2:0] bit_cnt; // bits finished in this word
logic tick;
assign tick = (cnt == div_q); // last clock of a half period
assign busy = (state != IDLE);
assign mosi = tx_sh[7];
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
state <= IDLE; cs_n <= 1'b1; sclk <= 1'b0; done <= 1'b0;
div_q <= '0; cnt <= '0; tx_sh <= '0; rx_sh <= '0; bit_cnt <= '0; rx_data <= '0;
end else begin
done <= 1'b0;
cnt <= tick ? '0 : cnt + 8'd1;
case (state)
IDLE: begin
cnt <= '0;
if (start) begin // capture the word and the divider
tx_sh <= tx_data; div_q <= div; bit_cnt <= '0;
cs_n <= 1'b0; state <= LOW; // MOSI shows bit 7 from now
end
end
LOW: if (tick) begin // rising edge: sample MISO
sclk <= 1'b1;
rx_sh <= {rx_sh[6:0], miso};
state <= HIGH;
end
HIGH: if (tick) begin // falling edge: next bit onto MOSI
sclk <= 1'b0;
tx_sh <= {tx_sh[6:0], 1'b0};
bit_cnt <= bit_cnt + 3'd1;
if (bit_cnt == 3'd7) state <= TRAIL; // that was bit 0
else state <= LOW;
end
TRAIL: if (tick) begin // half a period of hold, then release
cs_n <= 1'b1;
done <= 1'b1;
rx_data <= rx_sh;
state <= IDLE;
end
default: state <= IDLE;
endcase
end
endmodule
Tools: Icarus Verilog 13.0, Verilator 5.048 and Yosys 0.69+post (git sha1 143eb14f9), run on 2026-10-17. Commands: iverilog -g2012 -Wall -o sim.out spi_master.sv tb_spi.sv; vvp -n sim.out; verilator --lint-only -Wall spi_master.sv; yosys -p "read_verilog -sv spi_master.sv; synth -top spi_master; stat" on the modules of the lesson's Testbench listing.
Results: the compile and the lint print nothing; the bench prints PASS: 182 words, div 0-9, 101 back-to-back starts, 16 starts while busy ignored. Yosys: 121 cells, 48 flops, no latch cell. The waveform is generated by gen-waves.mjs (fsm:uart-tx) from wave-src/uart-tx/spi-transfer.sv, the same spi_master with div = 0, a 0x96 word and a slave replying 0xC5.
Variants run with the same bench: a DONE state with busy low that ignores start fails with the FAIL lines shown and stops on the watchdog; moving the MISO sample to the falling edge still prints PASS (182 words), as the second check-yourself answer says.
Scope and limits: zero-delay simulation against a behavioural slave model; no pad, board or slave output delays, so it says nothing about SCLK rates a real slave can follow. Only mode 0 and 8-bit words are built and tested.