The SPI is a synchronous, full duplex serial bus where a controller generates the clock and exchanges bits with one or more peripherals.
If we want to send pixels to a TFT LCD screen, read audio from a microphone, or write to an SD card, I2C falls short. We need raw speed.
SPI (Serial Peripheral Interface) is simple and can reach tens of megahertz when the device, board, and signal integrity allow it.
Today we are going to design an SPI controller in Verilog that will allow us to exchange data at full speed.
The Physics of SPI: Four Wires and Push-Pull
Unlike I2C, SPI does not skimp on wires. It uses 4 dedicated lines and Push-Pull logic (here we drive 3.3V and 0V with force, no weak pull-up resistors).
- SCK (Serial Clock): The clock that sets the pace. Generated by the Master.
- MOSI (Master Out Slave In): The Master speaks here and the Slave listens.
- MISO (Master In Slave Out): The Slave replies here (while the Master speaks).
- CS / SS (Chip Select): An individual line for each slave. Typically Active Low (0 to activate).
Push-Pull vs Open Drain:
Because SPI uses standard digital outputs (Push-Pull), the rising edges are very steep and clean. This is what allows us to achieve speeds of tens of MHz without signal degradation.
Exchange via Shift Registers
The most beautiful thing about SPI is understanding how it works internally. Imagine two circular shift registers connected in a ring: one in the Master and one in the Slave.
When the Master generates 8 clock pulses:
- The Master’s most significant bit goes out on MOSI and enters the Slave’s least significant bit.
- Simultaneously, the Slave’s most significant bit goes out on MISO and enters the Master.
It is an exchange. To read a byte from the slave, the master is obligated to send it a byte (even if it’s garbage, 0xFF or 0x00). You cannot receive without giving.
SPI Modes: CPOL and CPHA
This is where beginners often get frustrated. Not all SPI chips speak the same “dialect”. There are 4 modes defined by two parameters:
-
CPOL (Clock Polarity):
- 0: Clock idles Low (0).
- 1: Clock idles High (1).
-
CPHA (Clock Phase):
- 0: Data is sampled on the first edge (rising if CPOL=0).
- 1: Data is sampled on the second edge (falling if CPOL=0).
The most common mode (and the one used by Arduino by default) is Mode 0 (CPOL=0, CPHA=0). This is the one we will implement today.
- Idle at 0.
- Sample data on the Rising edge.
- Shift data on the Falling edge.
Verilog Implementation
We are going to design a generic module. Unlike I2C, we don’t need complex states for ACK or addresses here. We just need to generate 8 (or N) clock pulses and shift the bits.
Design Strategy
- Generate an
sckderived from the main clock. - On the falling edge of SCK, update the MOSI pin with the next bit.
- On the rising edge of SCK, sample the MISO pin and store it.
module spi_master #(
parameter CLK_HZ = 12000000,
parameter SPI_HZ = 1000000 // 1 MHz
)(
input wire clk,
input wire rst,
input wire start,
input wire [7:0] data_in, // Data to send
output reg [7:0] data_out, // Received data (MISO)
output reg busy, // 1 while transmitting
output reg new_data, // Pulse when done
// Physical Pins
output reg sck,
output reg mosi,
input wire miso
);
// 1. SPI Clock Generator (SCK)
// SCK must be 2x the SPI_HZ frequency to have both rising and falling edges
localparam DIVIDER = CLK_HZ / (2 * SPI_HZ);
localparam COUNT_WIDTH = (DIVIDER <= 1) ? 1 : $clog2(DIVIDER);
reg [COUNT_WIDTH-1:0] clk_count;
// States
localparam IDLE = 0;
localparam WORK = 1;
reg state;
reg [2:0] bit_cnt; // Bit counter 0-7
reg [7:0] shift_reg_tx; // Transmit shift register
reg [7:0] shift_reg_rx; // Receive shift register
// Clock divider logic
wire sck_rise = (clk_count == DIVIDER-1) && (sck == 0);
wire sck_fall = (clk_count == DIVIDER-1) && (sck == 1);
always @(posedge clk) begin
if (rst) begin
state <= IDLE;
sck <= 0; // CPOL = 0
mosi <= 0;
busy <= 0;
new_data <= 0;
clk_count <= 0;
end else begin
new_data <= 0; // Pulse defaults to off
case (state)
IDLE: begin
sck <= 0;
busy <= 0;
clk_count <= 0;
if (start) begin
state <= WORK;
busy <= 1;
shift_reg_tx <= data_in; // Load data
shift_reg_rx <= 0;
bit_cnt <= 7; // MSB First
mosi <= data_in[7]; // Output the first bit immediately
// Prepare for the first rising edge
end
end
WORK: begin
// Clock divider management
if (clk_count == DIVIDER-1) begin
clk_count <= 0;
sck <= ~sck; // Toggle the clock
end else begin
clk_count <= clk_count + 1;
end
// Events on SCK edges
// A. RISING Edge: SAMPLE MISO
if (sck_rise) begin
shift_reg_rx <= {shift_reg_rx[6:0], miso};
end
// B. FALLING Edge: UPDATE MOSI (Shift)
if (sck_fall) begin
if (bit_cnt == 0) begin
// All 8 bits are complete!
state <= IDLE;
busy <= 0;
new_data <= 1;
data_out <= shift_reg_rx; // The last bit was captured on the previous edge
end else begin
// Prepare next bit
bit_cnt <= bit_cnt - 1;
shift_reg_tx <= {shift_reg_tx[6:0], 1'b0}; // Shift left
mosi <= shift_reg_tx[6]; // Output the next bit (now at index 6 after shift)
end
end
end
endcase
end
end
endmoduleYou may have noticed that the code does not control the CS pin. It is good practice to leave CS control to the top-level module.
Why? Because often you want to lower CS, send 3 bytes in a row, and then raise it. If the SPI module raised CS automatically after each byte, it would break the transaction.
Usage Example: Reading a Sensor
Imagine you want to read an SPI accelerometer. The top-level module would do this:
// Sequence in Top-Level
case (step)
0: begin
cs_pin <= 0; // 1. Activate slave
data_to_send <= 8'h0B; // "READ" command
start_spi <= 1;
step <= 1;
end
1: begin
start_spi <= 0;
if (spi_done) step <= 2; // Wait for finish
end
2: begin
// Now send 0x00 just to drive the clock and read the response
data_to_send <= 8'h00;
start_spi <= 1;
step <= 3;
end
3: begin
start_spi <= 0;
if (spi_done) begin
read_data <= spi_data_out; // Here is the value!
cs_pin <= 1; // 4. Deactivate slave
step <= 0;
end
end
endcase