feat: add spi_slave.v physical layer (Phase 4 SPI RTL, part 1/N)

First real RTL piece of the SPI interface (docs §8.1 protocol
draft): the physical layer only -- Mode 0 (CPOL=0, CPHA=0),
MSB-first, byte-level shift register with a 3-stage CDC synchronizer
for SCLK/MOSI/CS_N (the SPI master clock is asynchronous to the
FPGA system clock). Exposes rx_byte/rx_valid, tx_byte/tx_byte_req,
and cs_active/cs_start/cs_end to the (not yet written) protocol
engine.

Documented an important consumer contract on tx_byte_req: it is a
prefetch hint (fires once extra after the last byte of every
transaction, since the slave cannot know in advance whether the
master will keep clocking), not a "byte consumed" event -- a
consumer must advance any stateful pointer (e.g. a RAM read address)
on rx_valid instead, which fires exactly once per real byte
transferred.

sim/spi_slave_tb.v: bit-banged SPI master BFM (4 tests: single byte,
multi-byte in one CS period, back-to-back transactions, slower
SCLK). Two testbench-only bugs found and fixed during bring-up (RTL
itself needed no functional change beyond the tx_byte_req contract
comment): the BFM was advancing its tx queue on tx_byte_req instead
of rx_valid (see contract above), and inter-test reset pulses raced
against posedge clk (blocking `rst=1` landing on the same simulation
time as a clock edge) -- fixed by asserting/deasserting reset on
negedge clk instead.

Verified two ways: Icarus Verilog (4/4 tests pass) and the real
ECP5 toolchain used for prior benchmarks (Yosys 0.68 synth: 0
problems, 41 FF / 55 LUT4, no latches; nextpnr-ecp5 --45k --package
CABGA381 --speed 8 --freq 80: PASS, Fmax 403.23 MHz; ecppack:
bitstream generated with no errors).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQV3vS9TXaGDJ5cRfnfidt
This commit is contained in:
2026-09-02 15:24:17 +02:00
co-authored by Claude Sonnet 5
parent 87efce3d9b
commit d716eb04dd
5 changed files with 14941 additions and 0 deletions
+380
View File
@@ -0,0 +1,380 @@
`timescale 1ns/1ps
// ================================================================
// SPI_SLAVE PHYSICAL LAYER TESTBENCH
//
// Bit-bangs a simulated SPI master (Mode 0, MSB-first) against
// rtl/spi_slave.v and checks:
// TEST 1: single-byte transaction (rx_byte/rx_valid, MISO readback)
// TEST 2: multi-byte transaction within one CS-low period
// TEST 3: back-to-back separate transactions (state resets cleanly)
// TEST 4: a slower SPI clock (stresses nothing new, but confirms
// the module isn't implicitly tied to one SCLK/clk ratio)
// ================================================================
module tb;
localparam CLK_PERIOD = 12.5; // 80 MHz system clock
reg clk;
reg rst;
initial begin
clk = 1'b0;
forever #(CLK_PERIOD / 2.0) clk = ~clk;
end
reg sclk;
reg mosi;
wire miso;
reg cs_n;
wire [7:0] rx_byte;
wire rx_valid;
reg [7:0] tx_byte;
wire tx_byte_req;
wire cs_active;
wire cs_start;
wire cs_end;
spi_slave dut (
.clk(clk),
.rst(rst),
.sclk(sclk),
.mosi(mosi),
.miso(miso),
.cs_n(cs_n),
.rx_byte(rx_byte),
.rx_valid(rx_valid),
.tx_byte(tx_byte),
.tx_byte_req(tx_byte_req),
.cs_active(cs_active),
.cs_start(cs_start),
.cs_end(cs_end)
);
// ============================================================
// tx_byte queue: serves tx_queue[tx_queue_idx] combinationally
// at all times (spi_slave.v prefetches it via tx_byte_req).
//
// The index advances on `rx_valid`, NOT on `tx_byte_req`:
// tx_byte_req fires one extra ("phantom") time after the last
// byte of every transaction (see the contract note in
// rtl/spi_slave.v), while rx_valid fires exactly once per REAL
// byte transferred, in both directions (SPI is full-duplex) --
// the correct signal to retire one queue entry.
// ============================================================
reg [7:0] tx_queue [0:7];
integer tx_queue_len;
integer tx_queue_idx;
always @(posedge clk) begin
if (rst) begin
tx_queue_idx <= 0;
end else if (rx_valid) begin
if (tx_queue_idx < tx_queue_len)
tx_queue_idx <= tx_queue_idx + 1;
end
end
always @(*) begin
tx_byte = (tx_queue_idx < tx_queue_len) ? tx_queue[tx_queue_idx] : 8'h00;
end
// ============================================================
// rx capture: record every received byte in order
// ============================================================
reg [7:0] rx_log [0:7];
integer rx_log_len;
always @(posedge clk) begin
if (rst) begin
rx_log_len <= 0;
end else if (rx_valid) begin
rx_log[rx_log_len] <= rx_byte;
rx_log_len <= rx_log_len + 1;
end
end
// ============================================================
// cs_start / cs_end pulse counters
// ============================================================
integer cs_start_count;
integer cs_end_count;
always @(posedge clk) begin
if (rst) begin
cs_start_count <= 0;
cs_end_count <= 0;
end else begin
if (cs_start) cs_start_count <= cs_start_count + 1;
if (cs_end) cs_end_count <= cs_end_count + 1;
end
end
// ============================================================
// SPI MASTER BFM (Mode 0, MSB-first, bit-banged)
//
// half_period is in ns; must stay large enough relative to
// CLK_PERIOD for the 2-flop CDC synchronizer in spi_slave.v to
// reliably catch every edge (>= ~3 system clocks per SCLK
// half-period is a safe margin).
// ============================================================
reg [7:0] miso_capture [0:7];
integer miso_capture_len;
// clk-cycle-counted wait: every SPI edge in this BFM is placed a
// fixed number of `clk` cycles apart, instead of a raw `#ns`
// delay. This keeps the master deterministically phase-aligned
// to the system clock, so the fixed CDC latency of spi_slave.v
// (3-stage synchronizer + 1 cycle for edge detect, ~4 clk
// cycles) always falls comfortably inside the margin instead of
// drifting against it run to run.
task clk_wait;
input integer n;
integer k;
begin
for (k = 0; k < n; k = k + 1)
@(posedge clk);
end
endtask
task spi_begin;
input integer half_bit_cycles;
begin
cs_n = 1'b1;
sclk = 1'b0;
mosi = 1'b0;
clk_wait(half_bit_cycles * 2);
cs_n = 1'b0;
clk_wait(half_bit_cycles * 2);
end
endtask
task spi_end;
input integer half_bit_cycles;
begin
clk_wait(half_bit_cycles * 2);
cs_n = 1'b1;
clk_wait(half_bit_cycles * 2);
end
endtask
task spi_xfer_byte;
input [7:0] tx;
input integer half_bit_cycles;
output [7:0] rx;
integer i;
reg [7:0] rx_acc;
begin
rx_acc = 8'h00;
for (i = 7; i >= 0; i = i - 1) begin
mosi = tx[i];
clk_wait(half_bit_cycles);
sclk = 1'b1; // rising edge: slave samples MOSI
rx_acc[i] = miso; // master samples MISO (stable since the prior falling edge)
clk_wait(half_bit_cycles);
sclk = 1'b0; // falling edge: slave updates MISO
clk_wait(half_bit_cycles);
end
rx = rx_acc;
end
endtask
reg [7:0] rx_tmp;
integer errors;
integer errors_before;
// ============================================================
// MAIN
// ============================================================
initial begin
$dumpfile("sim/spi_slave.vcd");
$dumpvars(0, tb);
rst = 1'b1;
cs_n = 1'b1;
sclk = 1'b0;
mosi = 1'b0;
errors = 0;
tx_queue_len = 0;
rx_log_len = 0;
repeat (5) @(posedge clk);
rst = 1'b0;
repeat (5) @(posedge clk);
$display("");
$display("========================================");
$display("SPI_SLAVE PHYSICAL LAYER TEST");
$display("========================================");
// --------------------------------------------------------
// TEST 1: single-byte transaction
// Master sends 0xA5, slave echoes back queued 0x3C.
// --------------------------------------------------------
errors_before = errors;
tx_queue[0] = 8'h3C;
tx_queue_len = 1;
spi_begin(8);
spi_xfer_byte(8'hA5, 8, rx_tmp);
spi_end(8);
@(posedge clk); @(posedge clk);
$display("");
$display("TEST 1: single byte");
$display(" MOSI sent = 0xA5, slave rx_byte = 0x%02x (expect 0xA5)", rx_log[0]);
$display(" MISO sent = 0x3C, master received = 0x%02x (expect 0x3C)", rx_tmp);
$display(" cs_start pulses = %0d (expect 1), cs_end pulses = %0d (expect 1)",
cs_start_count, cs_end_count);
if (rx_log[0] !== 8'hA5) begin $display(" FAIL: rx_byte mismatch"); errors = errors + 1; end
if (rx_tmp !== 8'h3C) begin $display(" FAIL: MISO readback mismatch"); errors = errors + 1; end
if (cs_start_count !== 1) begin $display(" FAIL: cs_start count"); errors = errors + 1; end
if (cs_end_count !== 1) begin $display(" FAIL: cs_end count"); errors = errors + 1; end
if (errors == errors_before) $display(" PASS");
// --------------------------------------------------------
// TEST 2: multi-byte transaction, single CS-low period
// Master sends 0x11, 0x22, 0x33, 0x44.
// Slave echoes back 0xDE, 0xAD, 0xBE, 0xEF.
// --------------------------------------------------------
@(negedge clk); rst = 1'b1; @(negedge clk); rst = 1'b0; @(posedge clk);
rx_log_len = 0; cs_start_count = 0; cs_end_count = 0;
errors_before = errors;
tx_queue[0] = 8'hDE;
tx_queue[1] = 8'hAD;
tx_queue[2] = 8'hBE;
tx_queue[3] = 8'hEF;
tx_queue_len = 4;
spi_begin(8);
spi_xfer_byte(8'h11, 8, rx_tmp); miso_capture[0] = rx_tmp;
spi_xfer_byte(8'h22, 8, rx_tmp); miso_capture[1] = rx_tmp;
spi_xfer_byte(8'h33, 8, rx_tmp); miso_capture[2] = rx_tmp;
spi_xfer_byte(8'h44, 8, rx_tmp); miso_capture[3] = rx_tmp;
spi_end(8);
@(posedge clk); @(posedge clk);
$display("");
$display("TEST 2: multi-byte, one CS period");
$display(" rx_log = %02x %02x %02x %02x (expect 11 22 33 44)",
rx_log[0], rx_log[1], rx_log[2], rx_log[3]);
$display(" miso = %02x %02x %02x %02x (expect de ad be ef)",
miso_capture[0], miso_capture[1], miso_capture[2], miso_capture[3]);
$display(" cs_start pulses = %0d (expect 1), cs_end pulses = %0d (expect 1)",
cs_start_count, cs_end_count);
if (rx_log[0] !== 8'h11 || rx_log[1] !== 8'h22 ||
rx_log[2] !== 8'h33 || rx_log[3] !== 8'h44) begin
$display(" FAIL: rx sequence mismatch");
errors = errors + 1;
end
if (miso_capture[0] !== 8'hDE || miso_capture[1] !== 8'hAD ||
miso_capture[2] !== 8'hBE || miso_capture[3] !== 8'hEF) begin
$display(" FAIL: MISO sequence mismatch");
errors = errors + 1;
end
if (cs_start_count !== 1) begin $display(" FAIL: cs_start count"); errors = errors + 1; end
if (cs_end_count !== 1) begin $display(" FAIL: cs_end count"); errors = errors + 1; end
if (errors == errors_before) $display(" PASS");
// --------------------------------------------------------
// TEST 3: back-to-back separate transactions
// Two independent single-byte transactions; state must
// reset cleanly between them (no leftover bit_count/shift).
// --------------------------------------------------------
@(negedge clk); rst = 1'b1; @(negedge clk); rst = 1'b0; @(posedge clk);
rx_log_len = 0; cs_start_count = 0; cs_end_count = 0;
errors_before = errors;
tx_queue[0] = 8'h01;
tx_queue_len = 1;
spi_begin(8);
spi_xfer_byte(8'h7E, 8, rx_tmp);
spi_end(8);
repeat (10) @(posedge clk);
tx_queue[0] = 8'h02;
tx_queue_len = 1;
spi_begin(8);
spi_xfer_byte(8'h81, 8, rx_tmp);
spi_end(8);
@(posedge clk); @(posedge clk);
$display("");
$display("TEST 3: back-to-back transactions");
$display(" rx_log = %02x %02x (expect 7e 81)", rx_log[0], rx_log[1]);
$display(" cs_start pulses = %0d (expect 2), cs_end pulses = %0d (expect 2)",
cs_start_count, cs_end_count);
if (rx_log[0] !== 8'h7E || rx_log[1] !== 8'h81) begin
$display(" FAIL: rx sequence mismatch");
errors = errors + 1;
end
if (cs_start_count !== 2) begin $display(" FAIL: cs_start count"); errors = errors + 1; end
if (cs_end_count !== 2) begin $display(" FAIL: cs_end count"); errors = errors + 1; end
if (errors == errors_before) $display(" PASS");
// --------------------------------------------------------
// TEST 4: slower SPI clock (larger half_period), same
// single-byte check, confirms no hidden dependency on a
// specific SCLK/clk ratio (as long as the CDC margin holds).
// --------------------------------------------------------
@(negedge clk); rst = 1'b1; @(negedge clk); rst = 1'b0; @(posedge clk);
rx_log_len = 0; cs_start_count = 0; cs_end_count = 0;
errors_before = errors;
tx_queue[0] = 8'h5A;
tx_queue_len = 1;
spi_begin(20);
spi_xfer_byte(8'h96, 20, rx_tmp);
spi_end(20);
@(posedge clk); @(posedge clk);
$display("");
$display("TEST 4: slower SCLK (200ns half-period)");
$display(" rx_byte = 0x%02x (expect 0x96), MISO = 0x%02x (expect 0x5a)",
rx_log[0], rx_tmp);
if (rx_log[0] !== 8'h96) begin $display(" FAIL: rx_byte mismatch"); errors = errors + 1; end
if (rx_tmp !== 8'h5A) begin $display(" FAIL: MISO readback mismatch"); errors = errors + 1; end
if (errors == errors_before) $display(" PASS");
$display("");
$display("========================================");
if (errors == 0)
$display("SPI_SLAVE TEST PASSED");
else
$display("SPI_SLAVE TEST FAILED: %0d errors", errors);
$display("========================================");
$display("");
$finish;
end
endmodule