Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • RTL Foundations36
  • Combinational Design25
  • Sequential Design27
  • FSM & Control27
    • Controller and Datapath
    • Moore vs Mealy
    • State Encoding
    • Multi-Cycle Sequencers
    • UART Transmitter
      • Framing and Timing
      • The UART TX FSM
      • Cycle-by-Cycle Walkthrough
      • Verification and Integration
      • Mini Project: an SPI Master
    • Memory Controller
  • Timing & Clocking39
  • Reset & CDC34
  • Memory & Buses57
  • Advanced RTL28
  • Senior & Interview76
  • Glossary and Notation
Loading lesson

‹UART Transmitter
  1. Topics
  2. RTL Design
  3. FSM & Control
  4. UART Transmitter
  5. Mini Project: an SPI Master

Part 4 · FSM & Control · Intermediate

Mini Project: an SPI Master

A hands-on mini project: build a mode-0 SPI master as a controller and a datapath, with a programmable SCLK divider and back-to-back transfers, and prove it against an echoing slave model.

On this page

    Why this matters

    Building an SPI master proves the controller and datapath method on a block with two clocks of its own: SCLK timing, two shift registers and a handshake. A master can shift every bit correctly and still drop the byte offered in its done cycle.

    After this lesson you can: specify an SPI mode-0 master, build it as a four-state FSM with a divider, bit counter and two shift registers, and prove it with an echoing slave model, back-to-back starts and exact timing checks.

    Build an SPI master from one small FSM and five registers

    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.

    The SPI master as built: a controller FSM above a datapath of divider, bit counter, SCLK and chip-select flops, a transmit shift register and a receive shift register, with the four SPI pins on the right

    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.

    The spec

    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

    1. Reset: cs_n = 1, sclk = 0, busy = 0, done = 0, rx_data = 0.

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

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

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

    5. Ignored inputs: a start while busy is 1 is ignored. tx_data and div may change freely once a start is taken.

    Before you start

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

    Done when

    The project is finished when every row below is true, and you have run each command yourself.

    Check

    How

    Done when

    Clean compile

    iverilog -g2012 -Wall -o sim.out spi_master.sv tb_spi.sv

    No output at all

    Clean lint

    verilator --lint-only -Wall spi_master.sv

    No warnings

    The test passes

    vvp -n sim.out

    The PASS line, no FAIL line

    No latch

    Yosys synth, then stat

    Only flops and gates; no $_DLATCH_ cell

    Timing numbers

    The bench's half-period and done checks

    17h clocks per transfer, 17h + 1 back to back, at every divider tried

    Reference solution

    The complete listing is under Testbench. Read it in three parts: the states, the divider, and the edge actions.

    State diagram. IDLE goes to LOW on start. LOW goes to HIGH on tick, raising SCLK and sampling MISO. HIGH goes back to LOW on tick while bits are left, lowering SCLK and shifting. After bit 0, HIGH goes to TRAIL on tick. TRAIL goes to IDLE on tick, raising cs_n and pulsing done.
    Four states: LOW and HIGH are the two halves of every SCLK period, and TRAIL holds chip select for half a period after the last falling edge.

    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.

    The divider

    design.sv
    1
    2
    3
    4
    // 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 clean

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

    What happens on each edge

    design.sv
    1
    2
    3
    4
    5
    6
    7
    // 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; end
    • Rising 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.

    One transfer, slot by slot

    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 test

    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.

    Diagram
    $ 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 checked

    The compile prints nothing. Synthesis shows the five registers and the FSM as flops, with no latch:

    Diagram
    $ 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.

    Common mistakes

    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":

    design.sv
    1
    2
    3
    4
    5
    6
    // 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 clock

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

    Diagram
    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: watchdog

    0x3C 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.

    Common pitfall

    `busy` low must mean "a start is taken now". If any state lowers busy but ignores start, a producer that follows the handshake loses bytes or hangs. Derive busy from the same condition that takes the start (here state != IDLE), and test with a producer that starts in the done cycle, not only after an idle gap.

    Check yourself With div = 2 and a 100 MHz system clock, what is the SCLK frequency, how many clocks does one transfer take, and how many bytes per second does a back-to-back stream carry?

    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.

    Check yourself Move the MISO sample from the rising edge to the falling edge (into the HIGH branch). The bench still prints PASS. Why, and does that make the change safe?

    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.

    Stretch goals

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

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

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

    Project files

    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

    spi_master.sv

    The design: SPI master, mode 0, controller plus datapath

    tb_spi.sv

    The bench: an echoing slave model, timing checks and back-to-back starts

    run.sh

    Diagram
    #!/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"   # optional

    Expected output: the line a correct design makes the simulation print at the end of its run.

    Diagram
    PASS: 182 words, div 0-9, 101 back-to-back starts, 16 starts while busy ignored; slave echo and timing checked

    Testbench

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

    spi_master.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    // ---- 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
    

    Remember

    • Mode 0: SCLK idles low, MOSI changes only when SCLK falls, both sides sample on the rising edge, most significant bit first.

    • The FSM moves only on the divider's tick; the bit position lives in a counter, not in sixteen states.

    • busy low must mean a start is taken now. Test with a producer that starts in the done cycle.

    Rule of thumb: derive busy from the same condition that takes the start, so the two can never disagree.

    How this was checked

    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.

    Keep going

    • Code lab: SPI master FSM

    • Code lab: SPI shift register

    • Interview: run a mock interview on this topic

    Related topics

    • Verification and integration

    • Signal contract between FSM and datapath

    Previous

    111 / 350

    Next: Memory Controller

    Up next

    Up nextMemory Controller

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Framing and Timing
    • The UART TX FSM
    • Cycle-by-Cycle Walkthrough
    sclkcs_nmosimisobusydone
    Simulated transfer at div = 0, one slot per system clock: the master sends 0x96 on MOSI while the slave returns 0xC5 on MISO