fpga: Update SPI simulation testbenches

Extend the SPI slave model to propagate simulation failures to the test
execution framework.

Extract the timing monitor from the control-port SPI testbench so it
can be shared by the GPIO SPI simulations while keeping timing checks
centralized and consistent.
This commit is contained in:
Max Becker 2026-09-15 11:19:29 +02:00 committed by Frank Dietze
parent 440ed5ae76
commit 772e682503
2 changed files with 320 additions and 159 deletions

View File

@ -42,6 +42,7 @@ module ctrlport_spi_adrv_tb #(
import ctrlport_pkg::*;
import ctrlport_spi_adrv_pkg::*;
import ctrlport_bfm_pkg::*;
import spi_slave_bfm_pkg::*;
//---------------------------------------------------------------------------
@ -83,11 +84,10 @@ module ctrlport_spi_adrv_tb #(
// Expected SPI half-period in simulation time. All SPI signal edges are
// registered on posedge clk, so every half-period is exactly this long.
realtime half_per_time = (HALF_PER + 1) * CLK_PERIOD_NS * 1.0ns;
time half_per_time;
// Number of CS_N assertions observed by the timing monitor. Checked at the
// end of the test to confirm the monitor actually ran for every transaction.
int cs_n_monitor_count = 0; // Incremented by the timing monitor
// Number of issued transactions. Checked against the timing monitor's
// observed transaction count at the end of the test.
int expected_cs_n_count = 0; // Incremented once per issued transaction
@ -123,7 +123,16 @@ module ctrlport_spi_adrv_tb #(
//
//---------------------------------------------------------------------------
logic spi_sclk, spi_cs_n, spi_mosi, spi_miso;
// SPI interface
logic spi_sclk, spi_cs_n, spi_mosi, spi_miso;
spi_if spi_monitor_if ();
assign spi_monitor_if.cs_n = spi_cs_n;
assign spi_monitor_if.sck = spi_sclk;
assign spi_monitor_if.mosi = spi_mosi;
assign spi_monitor_if.miso = spi_miso;
spi_timing_monitor_bfm spi_timing_mon;
logic [7:0] miso_byte_ctr = '0;
logic [7:0] miso_shift = '0;
@ -1811,6 +1820,12 @@ module ctrlport_spi_adrv_tb #(
$sformatf("REG_HALF_PER after write: got 0x%h, expected 0x%h",
rd_val[HALF_PER_W-1:0], NEW_HALF_PER));
half_per_time = (NEW_HALF_PER + 1) * CLK_PERIOD_NS * 1.0ns;
spi_timing_mon.set_timing(
.half_per_time (half_per_time),
.cs_hold_time (CS_HOLD * half_per_time),
.cs_to_first_rise_time (half_per_time),
.cs_guard_time (CS_GUARD * half_per_time)
);
//-------------------------------------------------------------------------
// Write 3 bytes at the new HALF_PER setting
@ -1827,156 +1842,17 @@ module ctrlport_spi_adrv_tb #(
$sformatf("REG_HALF_PER after restore: got 0x%h, expected 0x%h",
rd_val[HALF_PER_W-1:0], HALF_PER));
half_per_time = (HALF_PER + 1) * CLK_PERIOD_NS * 1.0ns;
spi_timing_mon.set_timing(
.half_per_time (half_per_time),
.cs_hold_time (CS_HOLD * half_per_time),
.cs_to_first_rise_time (half_per_time),
.cs_guard_time (CS_GUARD * half_per_time)
);
test.end_test();
endtask : test_sclk_freq
//---------------------------------------------------------------------------
// SPI Timing Monitor
//---------------------------------------------------------------------------
//
// Runs in parallel with the main test. On every transaction (CS_N
// assertion), checks:
//
// 1. CS_N assert -> first SCLK rise is exactly 1 half-period
// 2. SCLK high period (posedge -> negedge) is exactly 1 half-period
// 3. SCLK low period (negedge -> posedge):
// - intra-byte (fall N where N % 8 != 0): exactly 1 half-period
// - inter-byte (fall N where N % 8 == 0): >= 1 half-period
// 4. Last SCLK fall -> CS_N deassert:
// - CS_HOLD=1 : exactly 1 half-period
// - CS_HOLD=0 : 0 (same registered clock edge)
// 5. CS_N deassert -> next CS_N assert : >= CS_GUARD half-periods
//
// CS_HOLD and CS_GUARD are read hierarchically from dut.axis_spi_i.
//---------------------------------------------------------------------------
initial begin : spi_timing_monitor
realtime t_cs_assert, t_rise, t_fall, t_cs_deassert, t_cs_deassert_prev;
int rise_cnt; // rising edge count within the current transaction
static bit first_txn = 1'b1; // suppress guard check on first transaction
@(negedge rst); // wait for reset to deassert
forever begin
//-----------------------------------------------------------------------
// Wait for CS_N assertion (start of transaction)
//-----------------------------------------------------------------------
@(negedge spi_cs_n);
t_cs_assert = $realtime;
cs_n_monitor_count++;
//-----------------------------------------------------------------------
// Check 5: CS_N guard: previous transaction's CS_N deassert must be >=
// CS_GUARD half-periods before this assertion. Checked here (at the
// start of each transaction) so the guard @(negedge spi_cs_n) doesn't
// consume a transaction count. Skipped on the first transaction.
//-----------------------------------------------------------------------
if (!first_txn) begin
`ASSERT_ERROR(t_cs_assert - t_cs_deassert_prev >=
dut.axis_spi_i.CS_GUARD * half_per_time,
$sformatf("CS_N guard too short: minimum %0t, got %0t",
dut.axis_spi_i.CS_GUARD * half_per_time,
t_cs_assert - t_cs_deassert_prev));
end
first_txn = 1'b0;
//-----------------------------------------------------------------------
// Check 1: CS_N assert -> first SCLK rising edge = exactly 1 half-period
//-----------------------------------------------------------------------
@(posedge spi_sclk);
t_rise = $realtime;
rise_cnt = 1;
`ASSERT_ERROR(t_rise - t_cs_assert == half_per_time,
$sformatf("CS_N assert to first SCLK rise: expected %0t, got %0t",
half_per_time, t_rise - t_cs_assert));
//-----------------------------------------------------------------------
// Checks 2, 3, 4: Walk all SCLK edges until CS_N deasserts.
//
// State at loop top: we are at a posedge sclk; rise_cnt is current.
//
// Step A: Wait for negedge sclk OR posedge cs_n (CS_HOLD=0 causes
// both to arrive on the same clock edge).
// Step B: If cs_n is still low, wait for posedge sclk OR posedge
// cs_n (CS_HOLD=1 deasserts cs_n here after ST_HOLD).
//-----------------------------------------------------------------------
forever begin
// Step A: wait for SCLK fall (or simultaneous CS_N deassert)
fork : wait_fall
begin : fall_th @(negedge spi_sclk); end
begin : cs0_th @(posedge spi_cs_n); end
join_any
disable wait_fall;
t_fall = $realtime;
// Check 2: SCLK high period must be exactly 1 half-period
`ASSERT_ERROR(t_fall - t_rise == half_per_time,
$sformatf("SCLK high period: expected %0t, got %0t",
half_per_time, t_fall - t_rise));
if (spi_cs_n) begin
// CS_HOLD=0: CS_N deasserted on the same clock edge as last SCLK
// fall, so time difference is 0.
t_cs_deassert = t_fall;
`ASSERT_ERROR(dut.axis_spi_i.CS_HOLD == 0,
"CS_N deasserted coincident with last SCLK fall but CS_HOLD != 0");
break;
end
// Step B: wait for SCLK rise or CS_N deassert (CS_HOLD=1)
fork : wait_rise
begin : rise_th @(posedge spi_sclk); end
begin : cs1_th @(posedge spi_cs_n); end
join_any
disable wait_rise;
t_rise = $realtime;
rise_cnt++;
// Check 3: SCLK low period.
//
// rise_cnt just incremented, so the preceding fall was fall number
// (rise_cnt - 1). A fall at a multiple of 8 is an inter-byte boundary
// where the FSM waits in ST_IDLE for the next byte; duration is
// unbounded but must be >= 1 half-period. All other falls are
// intra-byte and must be exactly 1 half-period.
if ((rise_cnt - 1) % 8 == 0) begin
`ASSERT_ERROR(t_rise - t_fall >= half_per_time,
$sformatf("SCLK low period (inter-byte) too short: minimum %0t, got %0t",
half_per_time, t_rise - t_fall));
end else begin
`ASSERT_ERROR(t_rise - t_fall == half_per_time,
$sformatf("SCLK low period (intra-byte, fall %0d): expected %0t, got %0t",
rise_cnt - 1, half_per_time, t_rise - t_fall));
end
if (spi_cs_n) begin
// CS_HOLD=1: CS_N deasserted exactly 1 half-period after last
// SCLK fall (after ST_HOLD).
t_cs_deassert = t_rise;
`ASSERT_ERROR(dut.axis_spi_i.CS_HOLD == 1,
"CS_N deasserted 1 half-period after SCLK fall but CS_HOLD != 1");
`ASSERT_ERROR(t_cs_deassert - t_fall == half_per_time,
$sformatf("CS_HOLD=1 hold time: expected %0t, got %0t",
half_per_time, t_cs_deassert - t_fall));
break;
end
// cs_n still low and we got posedge sclk; continue to next bit
end // inner forever (SCLK edge walk)
// Save deassert time for next iteration's guard check
t_cs_deassert_prev = t_cs_deassert;
end // outer forever (transaction loop)
end : spi_timing_monitor
//---------------------------------------------------------------------------
// Main test
//---------------------------------------------------------------------------
@ -2005,6 +1881,17 @@ module ctrlport_spi_adrv_tb #(
clk_gen.reset();
@(negedge rst);
// Create and start passive SPI timing checks after reset is released.
half_per_time = (HALF_PER + 1) * CLK_PERIOD_NS * 1.0ns;
spi_timing_mon = new(
.iface (spi_monitor_if),
.half_per_time (half_per_time),
.cs_hold_time (CS_HOLD * half_per_time),
.cs_to_first_rise_time (half_per_time),
.cs_guard_time (CS_GUARD * half_per_time)
);
spi_timing_mon.run();
//-------------------------------------------------------------------------
// Tests
//-------------------------------------------------------------------------
@ -2025,9 +1912,15 @@ module ctrlport_spi_adrv_tb #(
// Confirm the timing monitor ran exactly once per SPI transaction.
// expected_cs_n_count is incremented once per issued transaction, so it
// tracks the true number of transactions without manual bookkeeping.
`ASSERT_ERROR(cs_n_monitor_count == expected_cs_n_count,
`ASSERT_ERROR(spi_timing_mon.get_transaction_count() == expected_cs_n_count,
$sformatf("Timing monitor CS_N count mismatch: expected %0d, got %0d",
expected_cs_n_count, cs_n_monitor_count));
expected_cs_n_count, spi_timing_mon.get_transaction_count()));
`ASSERT_ERROR(spi_timing_mon.get_error_count() == 0,
$sformatf("SPI timing monitor reported %0d errors",
spi_timing_mon.get_error_count()));
// Stop the monitor before the next parameterized testbench instance runs.
disable fork;
// Don't call $finish; other instances of this testbench may follow.
test.end_tb(0);

View File

@ -1,5 +1,5 @@
//
// Copyright 2025 Ettus Research, a National Instruments Brand
// Copyright 2026 Ettus Research, a National Instruments Brand
//
// SPDX-License-Identifier: LGPL-3.0-or-later
//
@ -16,6 +16,8 @@
package spi_slave_bfm_pkg;
timeunit 1ns / 1ps;
class spi_slave_bfm #(int TRANSACTION_LENGTH = 32);
local mailbox #(logic [TRANSACTION_LENGTH-1:0]) mosi_mailbox;
@ -24,6 +26,9 @@ package spi_slave_bfm_pkg;
// link to interface
local virtual spi_if iface;
local int unsigned error_count;
// ------------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------------
@ -33,6 +38,12 @@ package spi_slave_bfm_pkg;
this.iface = iface;
mosi_mailbox = new;
miso_mailbox = new;
error_count = 0;
endfunction
// Number of errors reported by the BFM.
function int unsigned get_error_count();
return error_count;
endfunction
// ------------------------------------------------------------------------
@ -81,6 +92,11 @@ package spi_slave_bfm_pkg;
join_none
endtask
local function void report_error(string msg);
error_count++;
$error("%s @ t=%0t", msg, $time);
endfunction
// check constantly the status of the CS and SCK signal
task cs_sck_check_body();
automatic int clock_edge_counter = 0;
@ -99,7 +115,8 @@ package spi_slave_bfm_pkg;
// check if the transaction length is correct
// 2 clock edges per transaction bit
assert(clock_edge_counter <= 2*TRANSACTION_LENGTH) else
$error("Too many SCLK cycles received. Clock cycles: %0d", clock_edge_counter);
report_error($sformatf(
"Too many SCLK cycles received. Clock cycles: %0d", clock_edge_counter));
end
end
// ignore special case when cs turn to high impedance
@ -107,7 +124,8 @@ package spi_slave_bfm_pkg;
// if cs goes high check if the transaction length is correct
// 2 clock edges per transaction bit
assert(clock_edge_counter == 2*TRANSACTION_LENGTH) else
$error("Unexpected SPI CS interrupt. Clock cycles: %0d", clock_edge_counter);
report_error($sformatf(
"Unexpected SPI CS interrupt. Clock cycles: %0d", clock_edge_counter));
end
end
endtask
@ -135,12 +153,13 @@ package spi_slave_bfm_pkg;
end
// compare received data with expected data
assert(received_data == expected_data) else
$error("Unexpected data received on SPI interface. Expected: %h Received: %h",
expected_data, received_data);
report_error($sformatf(
"Unexpected data received on SPI interface. Expected: %h Received: %h",
expected_data, received_data));
end else begin
// ignore special case when cs turn to high impedance
if (iface.cs_n !== 'z) begin
$error("No data available in SPI BFM MOSI mailbox.");
report_error("No data available in SPI BFM MOSI mailbox.");
end
end
end
@ -174,7 +193,7 @@ package spi_slave_bfm_pkg;
end else begin
// ignore special case when cs turn to high impedance
if (iface.cs_n !== 'z) begin
$error("No data available in SPI BFM MISO mailbox.");
report_error("No data available in SPI BFM MISO mailbox.");
end
end
end
@ -182,4 +201,253 @@ package spi_slave_bfm_pkg;
endclass
// ------------------------------------------------------------------------
// SPI timing monitor BFM
// ------------------------------------------------------------------------
// Passive monitor for CPOL=0, CPHA=0 SPI timing checks.
//
// On each transaction (CS_N low pulse), the monitor checks:
// 1) CS_N assert to first SCLK rise (minimum)
// 2) SCLK high period (exact)
// 3) SCLK low period:
// - intra-byte: exact
// - inter-byte: minimum (optional)
// 4) CS hold time after final SCLK fall (minimum)
// 5) CS guard time between transactions (minimum)
//
// The monitor is reusable across tests within a testbench by updating timing with
// set_timing() when expected timings change.
class spi_timing_monitor_bfm;
local virtual spi_if iface;
local time half_per_time;
local time cs_hold_time;
local time cs_to_first_rise_time;
local time cs_guard_time;
local int unsigned bits_per_byte;
local bit check_inter_byte_gaps;
local int unsigned transaction_count;
local int unsigned error_count;
// ----------------------------------------------------------------------
// Constructor
// ----------------------------------------------------------------------
// Arguments:
// iface: SPI interface to monitor
// half_per_time: expected SCLK half period (must be non-zero)
// cs_hold_time: minimum time from last SCLK fall to CS_N deassert
// (0 disables CS hold checks)
// cs_to_first_rise_time: minimum time from CS_N assert to first SCLK rise
// (0 disables first-rise check)
// cs_guard_time: minimum time between transactions
// bits_per_byte: bit grouping for inter-byte low-gap checks
// check_inter_byte_gaps: enable minimum-gap checks at byte boundaries
function new(
virtual spi_if iface,
time half_per_time,
time cs_hold_time = 0,
time cs_to_first_rise_time = 0,
time cs_guard_time = 0,
int unsigned bits_per_byte = 8,
bit check_inter_byte_gaps = 1'b1
);
assert (half_per_time != 0)
else $fatal(1, "half_per_time must be non-zero");
this.iface = iface;
this.half_per_time = half_per_time;
this.cs_hold_time = cs_hold_time;
this.cs_to_first_rise_time = cs_to_first_rise_time;
this.cs_guard_time = cs_guard_time;
this.bits_per_byte = bits_per_byte;
this.check_inter_byte_gaps = check_inter_byte_gaps;
this.transaction_count = 0;
this.error_count = 0;
endfunction
// Update timing expectations at runtime.
// Use this after changing SPI rate configuration in the DUT.
function void set_timing(
time half_per_time,
time cs_hold_time,
time cs_to_first_rise_time,
time cs_guard_time
);
assert (half_per_time != 0)
else $fatal(1, "half_per_time must be non-zero");
this.half_per_time = half_per_time;
this.cs_hold_time = cs_hold_time;
this.cs_to_first_rise_time = cs_to_first_rise_time;
this.cs_guard_time = cs_guard_time;
endfunction
// Number of observed CS_N assertions (transactions).
function int unsigned get_transaction_count();
return transaction_count;
endfunction
// Number of timing violations reported by the monitor.
function int unsigned get_error_count();
return error_count;
endfunction
// Start monitoring. Intended to run in a forked process.
task run();
fork
monitor_body();
join_none;
endtask
local function void report_error(string msg);
error_count++;
$error("%s @ t=%0t", msg, $time);
endfunction
// Main timing monitor loop.
// This runs forever and validates each SPI transaction independently.
local task monitor_body();
time t_cs_assert;
time t_rise;
time t_fall;
time t_cs_deassert;
time t_cs_deassert_prev;
int rise_cnt;
bit first_txn;
first_txn = 1'b1;
forever begin
//-----------------------------------------------------------------------
// Wait for CS_N assertion (start of transaction)
//-----------------------------------------------------------------------
@(negedge iface.cs_n);
t_cs_assert = $time;
transaction_count++;
//-----------------------------------------------------------------------
// Check 5: CS_N guard: previous transaction's CS_N deassert must be >=
// CS_GUARD_TIME before this assertion. Checked here (at the
// start of each transaction) so the guard @(negedge iface.cs_n) doesn't
// consume a transaction count. Skipped on the first transaction.
//-----------------------------------------------------------------------
if (!first_txn) begin
if (t_cs_assert - t_cs_deassert_prev < cs_guard_time) begin
report_error($sformatf(
"CS_N guard too short: minimum %0t, got %0t",
cs_guard_time,
t_cs_assert - t_cs_deassert_prev));
end
end
first_txn = 1'b0;
//-----------------------------------------------------------------------
// Check 1: CS_N assert -> first SCLK rising edge >= cs_to_first_rise_time
//-----------------------------------------------------------------------
@(posedge iface.sck);
t_rise = $time;
rise_cnt = 1;
if ((cs_to_first_rise_time != 0) && (t_rise - t_cs_assert < cs_to_first_rise_time)) begin
report_error($sformatf(
"CS_N assert to first SCLK rise too short: minimum %0t, got %0t",
cs_to_first_rise_time, t_rise - t_cs_assert));
end
//-----------------------------------------------------------------------
// Checks 2, 3, 4: Walk all SCLK edges until CS_N deasserts.
//
// State at loop top: we are at a posedge sclk; rise_cnt is current.
//
// Step A: Wait for negedge sclk OR posedge cs_n.
// Step B: If cs_n is still low, wait for posedge sclk OR posedge
// cs_n.
//-----------------------------------------------------------------------
forever begin
// Step A: wait for SCLK fall (or simultaneous CS_N deassert)
fork : wait_fall
begin : fall_th @(negedge iface.sck); end
begin : cs0_th @(posedge iface.cs_n); end
join_any
disable wait_fall;
t_fall = $time;
// Check 2: SCLK high period must be exactly half_per_time
// This indirectly also ensures that CS is not deasserted too early
if (t_fall - t_rise != half_per_time) begin
report_error($sformatf(
"SCLK high period: expected %0t, got %0t",
half_per_time, t_fall - t_rise));
end
if (iface.cs_n) begin
// CS_N deasserted on the same clock edge as last SCLK
t_cs_deassert = t_fall;
if ((cs_hold_time != 0)) begin
report_error($sformatf(
"CS hold time at SCLK fall too short: minimum %0t, got 0",
cs_hold_time));
end
break;
end
// Step B: wait for SCLK rise or CS_N deassert (CS_HOLD=1)
fork : wait_rise
begin : rise_th @(posedge iface.sck); end
begin : cs1_th @(posedge iface.cs_n); end
join_any
disable wait_rise;
if (iface.cs_n) begin
// CS_N deasserted after the last SCLK fall; enforce minimum hold.
t_cs_deassert = $time;
if ((cs_hold_time != 0) && (t_cs_deassert - t_fall < cs_hold_time)) begin
report_error($sformatf(
"CS hold time after last SCLK fall too short: minimum %0t, got %0t",
cs_hold_time, t_cs_deassert - t_fall));
end
break;
end
t_rise = $time;
rise_cnt++;
// Check 3: SCLK low period.
//
// rise_cnt just incremented, so the preceding fall was fall number
// (rise_cnt - 1). A fall at a multiple of bits_per_byte is an
// inter-byte boundary
// where the FSM waits in ST_IDLE for the next byte; duration is
// unbounded but must be >= 1 half-period. All other falls are
// intra-byte and must be exactly 1 half-period.
if (check_inter_byte_gaps && ((rise_cnt - 1) % bits_per_byte == 0)) begin
if (t_rise - t_fall < half_per_time) begin
report_error($sformatf(
"SCLK low period (inter-byte) too short: minimum %0t, got %0t",
half_per_time, t_rise - t_fall));
end
end else begin
if (t_rise - t_fall != half_per_time) begin
report_error($sformatf(
"SCLK low period: expected %0t, got %0t",
half_per_time, t_rise - t_fall));
end
end
// CS_N is still low and we got posedge sclk; continue to next bit
end // inner forever (SCLK edge walk)
// Save deassert time for next iteration's guard check
t_cs_deassert_prev = t_cs_deassert;
end // outer forever (transaction loop)
endtask
endclass
endpackage