SPI Slave Verilog Code (Mode 0): Shift Register + Testbench

The Serial Peripheral Interface (SPI) is the workhorse link between a microcontroller and the flash chips, ADCs, and sensors around it. On the slave side the whole protocol collapses into one idea: a shift register that moves one bit per clock edge, while a chip-select line marks where each byte begins and ends. This tutorial builds working, runnable SPI slave Verilog code for the most common configuration, mode 0, and drives it with a testbench that behaves like a real SPI master. Every listing below compiles with Icarus Verilog using iverilog -g2005 -o sim FILES && vvp sim, and the simulation log under each block is the actual output.

SPI mode 0 in one paragraph

SPI mode 0 means CPOL=0 (the clock idles low) and CPHA=0 (data is sampled on the leading, rising edge and shifted on the falling edge). Bytes travel MSB-first. Chip select (cs_n) is active-low: pulling it low opens a transaction, releasing it high closes one. Because CPHA is 0, the first data bit must already be valid on the line before the first rising edge, so both master and slave present their MSB the moment cs_n goes low. Keep those four facts in mind and the code reads itself.

The SPI slave Verilog code

The design below has two independent paths. A receive shift register samples mosi on every rising sclk edge; a transmit multiplexer drives miso and advances on every falling edge. The counters reset whenever cs_n is high, so framing is automatic.

// spi_slave.v  -  SPI slave, mode 0 (CPOL=0, CPHA=0), 8-bit, MSB first
// Receive shift register sampled on rising SCLK; transmit MUX changes on
// falling SCLK; cs_n frames one 8-bit transaction.
module spi_slave (
    input  wire        sclk,     // SPI clock from the master (mode 0)
    input  wire        cs_n,     // active-low chip select / frame
    input  wire        mosi,     // master-out slave-in
    output wire        miso,     // master-in slave-out (tri-stated when idle)
    input  wire [7:0]  tx_data,  // byte this slave shifts back, MSB first
    output reg  [7:0]  rx_data,  // last fully received byte
    output reg         rx_valid  // high after a complete byte
);
    reg [2:0] rxcnt;             // counts the 8 sampled bits
    reg [2:0] txbit;             // index of the bit currently on MISO
    reg [7:0] rxsh;              // receive shift register

    // Transmit: present MSB first, advance on each falling SCLK edge.
    assign miso = cs_n ? 1'bz : tx_data[3'd7 - txbit];

    always @(negedge sclk or posedge cs_n) begin
        if (cs_n)
            txbit <= 3'd0;             // idle: point back at the MSB
        else
            txbit <= txbit + 3'd1;     // shift out on the falling edge
    end

    // Receive: sample MOSI on the rising edge, shift in MSB first.
    always @(posedge sclk or posedge cs_n) begin
        if (cs_n) begin
            rxcnt <= 3'd0;             // deasserting CS ends the frame
        end else begin
            rxsh <= {rxsh[6:0], mosi};
            if (rxcnt == 3'd7) begin
                rx_data  <= {rxsh[6:0], mosi};
                rx_valid <= 1'b1;
                rxcnt    <= 3'd0;
            end else begin
                if (rxcnt == 3'd0) rx_valid <= 1'b0;
                rxcnt <= rxcnt + 3'd1;
            end
        end
    end
endmodule

// tb_spi_slave.v  -  a mode-0 SPI master drives one byte per frame
module tb_spi_slave;
    reg        sclk, cs_n, mosi;
    wire       miso;
    reg  [7:0] tx_data;
    wire [7:0] rx_data;
    wire       rx_valid;

    reg  [7:0] mosi_byte;   // byte the master sends to the slave
    reg  [7:0] miso_byte;   // byte the master reads back from the slave
    integer    k;

    spi_slave dut (
        .sclk(sclk), .cs_n(cs_n), .mosi(mosi), .miso(miso),
        .tx_data(tx_data), .rx_data(rx_data), .rx_valid(rx_valid)
    );

    // Clock one byte: CS low, 8 SCLK pulses, MSB first, CS high.
    task spi_byte(input [7:0] data);
        begin
            mosi_byte = data;
            cs_n = 1'b0;             // start the frame
            mosi = mosi_byte[7];     // MSB valid before the first edge
            #10;
            for (k = 0; k < 8; k = k + 1) begin
                sclk = 1'b1;  #10;                  // rising: both sample
                miso_byte = {miso_byte[6:0], miso};
                sclk = 1'b0;                        // falling: change data
                if (k < 7) mosi = mosi_byte[6-k];
                #10;
            end
            cs_n = 1'b1;             // end the frame
            #10;
        end
    endtask

    initial begin
        sclk = 1'b0; mosi = 1'b0; miso_byte = 8'h00;
        cs_n = 1'b0; tx_data = 8'hA5;   // settle, then raise CS to arm slave
        #10 cs_n = 1'b1;
        #10;

        spi_byte(8'h3C);                // master sends 0x3C, slave returns 0xA5
        $display("Frame 1: slave rx=0x%02h valid=%b | master rx=0x%02h",
                 rx_data, rx_valid, miso_byte);

        tx_data = 8'h5A;                // queue the next response while idle
        spi_byte(8'hF0);                // master sends 0xF0, slave returns 0x5A
        $display("Frame 2: slave rx=0x%02h valid=%b | master rx=0x%02h",
                 rx_data, rx_valid, miso_byte);

        #20 $finish;
    end
endmodule
Frame 1: slave rx=0x3c valid=1 | master rx=0xa5
Frame 2: slave rx=0xf0 valid=1 | master rx=0x5a
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpio0zgfu8/spi_slave.v:96: $finish called at 400 (1s)

How the shift register and CS framing work

The testbench plays the master. For each byte it pulls cs_n low, presents the MSB, then issues eight clock pulses. On every rising edge it reads miso; on every falling edge it changes mosi to the next bit. Because the slave uses the same edge convention, master and slave stay in lockstep and exchange a full byte in both directions at once.

The receive path

On each rising edge the slave does rxsh <= {rxsh[6:0], mosi}, pushing the new bit into the low end of the register. After the eighth bit, the fully assembled byte is copied to rx_data and rx_valid goes high. The count then wraps to zero so the next frame starts clean.

The transmit path

Rather than a second shift register, the output is a simple multiplexer: miso = tx_data[7 - txbit]. The index txbit starts at 0 (so the MSB appears first) and increments on each falling edge, exactly when mode 0 expects the slave to change its output. Reading tx_data directly means you can prepare the next response byte any time the bus is idle.

Chip-select framing

Both counters sit in always blocks sensitive to posedge cs_n. When the master releases chip select, the counters snap back to zero, so a short or glitched frame can never leave the slave half-way through a byte. The signals are summarised below.

SignalDirectionRole
sclkinputSerial clock; mode-0 sample on rising, shift on falling
cs_ninputActive-low frame; resets the bit counters when high
mosiinputSerial data from master, MSB first
misooutputSerial data to master; high-Z when not selected
tx_datainputByte the slave shifts back this frame
rx_data / rx_validoutputCaptured byte and its one-frame valid flag

A minimal receive-only slave

If you only need to capture bytes, the design shrinks to one shift register and one counter. The testbench clocks 0xB7 in bit by bit, and $monitor prints the line as it fills so you can watch the shift register advance one bit per rising edge.

// spi_rx.v  -  minimal mode-0 SPI receive slave (shift register + bit counter)
module spi_rx (
    input  wire       sclk,   // mode 0: sample on the rising edge
    input  wire       cs_n,   // active-low frame
    input  wire       mosi,   // serial data in, MSB first
    output reg  [7:0] data,   // last byte captured
    output reg        done    // high once 8 bits are in
);
    reg [2:0] cnt;
    reg [7:0] sh;

    always @(posedge sclk or posedge cs_n) begin
        if (cs_n) begin
            cnt <= 3'd0;                 // CS high: reset the bit counter
        end else begin
            sh <= {sh[6:0], mosi};       // shift MOSI in from the LSB side
            if (cnt == 3'd7) begin
                data <= {sh[6:0], mosi}; // 8th bit completes the byte
                done <= 1'b1;
                cnt  <= 3'd0;
            end else begin
                if (cnt == 3'd0) done <= 1'b0;
                cnt <= cnt + 3'd1;
            end
        end
    end
endmodule

// tb_spi_rx.v  -  drive 0xB7 into the slave, one bit per SCLK pulse
module tb_spi_rx;
    reg        sclk, cs_n, mosi;
    wire [7:0] data;
    wire       done;
    reg  [7:0] b;
    integer    k;

    spi_rx dut (.sclk(sclk), .cs_n(cs_n), .mosi(mosi), .data(data), .done(done));

    initial begin
        sclk = 1'b0; cs_n = 1'b1; mosi = 1'b0;
        $monitor("t=%0t cs_n=%b sclk=%b mosi=%b done=%b data=0x%02h",
                 $time, cs_n, sclk, mosi, done, data);
        #10;
        b = 8'hB7;              // 1011_0111
        cs_n = 1'b0;            // open the frame
        mosi = b[7];            // MSB first
        #10;
        for (k = 0; k < 8; k = k + 1) begin
            sclk = 1'b1; #10;                 // rising edge: slave samples
            sclk = 1'b0; if (k < 7) mosi = b[6-k];
            #10;
        end
        cs_n = 1'b1;            // close the frame
        #10;
        $display("Captured byte = 0x%02h, done = %b", data, done);
        #10 $finish;
    end
endmodule
t=0 cs_n=1 sclk=0 mosi=0 done=x data=0xxx
t=10 cs_n=0 sclk=0 mosi=1 done=x data=0xxx
t=20 cs_n=0 sclk=1 mosi=1 done=0 data=0xxx
t=30 cs_n=0 sclk=0 mosi=0 done=0 data=0xxx
t=40 cs_n=0 sclk=1 mosi=0 done=0 data=0xxx
t=50 cs_n=0 sclk=0 mosi=1 done=0 data=0xxx
t=60 cs_n=0 sclk=1 mosi=1 done=0 data=0xxx
t=70 cs_n=0 sclk=0 mosi=1 done=0 data=0xxx
t=80 cs_n=0 sclk=1 mosi=1 done=0 data=0xxx
t=90 cs_n=0 sclk=0 mosi=0 done=0 data=0xxx
t=100 cs_n=0 sclk=1 mosi=0 done=0 data=0xxx
t=110 cs_n=0 sclk=0 mosi=1 done=0 data=0xxx
t=120 cs_n=0 sclk=1 mosi=1 done=0 data=0xxx
t=130 cs_n=0 sclk=0 mosi=1 done=0 data=0xxx
t=140 cs_n=0 sclk=1 mosi=1 done=0 data=0xxx
t=150 cs_n=0 sclk=0 mosi=1 done=0 data=0xxx
t=160 cs_n=0 sclk=1 mosi=1 done=1 data=0xb7
t=170 cs_n=0 sclk=0 mosi=1 done=1 data=0xb7
t=180 cs_n=1 sclk=0 mosi=1 done=1 data=0xb7
Captured byte = 0xb7, done = 1
/var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmprqmb2lqp/spi_rx.v:56: $finish called at 200 (1s)

Common mistakes

  • Sampling on the wrong edge. Mode 0 samples on the rising edge and changes data on the falling edge. Copying a mode-3 example (which idles clock high) into a mode-0 system shifts every bit by half a clock and you read garbage.
  • Forgetting MSB-first ordering. SPI is almost always MSB-first. If your shift direction or bit index is reversed, the byte comes out bit-reversed even though every edge looks correct on a waveform.
  • Presenting MISO too late. With CPHA=0 the first bit is sampled on the very first rising edge, so the MSB must be valid at the moment cs_n falls, not after the first edge. Driving it from tx_data[7] at reset avoids the off-by-one.
  • Not tri-stating MISO. Driving miso with a hard 0 or 1 while deselected fights other slaves on a shared bus. Return 1'bz when cs_n is high so only the selected device drives the line.
  • Skipping the counter reset on CS. Without resetting the bit counter when cs_n is high, one noisy or short frame desynchronises every byte that follows. Tie the reset to cs_n as shown.
  • Ignoring clock-domain crossing in real silicon. Clocking logic directly on a slow external sclk is fine for learning and simulation, but on an FPGA you should synchronise cs_n and mosi into the system clock, or oversample sclk, to avoid metastability.

Quick check

In SPI mode 0, on which SCLK edge does the slave sample MOSI?

The rising (leading) edge. Because CPOL=0 and CPHA=0, data is sampled on the rising edge and the slave changes its output on the falling edge.

What marks the boundary of one byte transaction?

The chip-select line cs_n. Pulling it low opens the frame; releasing it high closes it and resets the bit counters, so framing cannot drift.

Why must the MISO MSB be valid before the first rising edge?

With CPHA=0 the first bit is captured on the very first rising edge, so it has to be on the line the instant cs_n goes low — that is why the transmit index starts at 0 (the MSB).

Why return 1’bz on MISO when the slave is not selected?

So several slaves can share one MISO wire. Only the device whose cs_n is low drives the line; the rest stay high-impedance and keep off the bus.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.

Run it in the Lab

Learn Verilog with mentors, labs and placement support

This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.

Explore VLSI Courses
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD