Files
FPGA-Neural/sim/flash_copy_engine_save_tb.v
T
micheleandClaude Sonnet 5 97a21be240 feat: flash boot/persistence subsystem (SPI master, copy engine, CRC32 slot catalog)
Adds FPGA-exclusive access to the onboard W25Q128JV SPI NOR flash for
weights/bias/network persistence, layered as spi_flash_master (raw SPI,
USRMCLK-driven) -> flash_copy_engine (flash<->PSRAM streaming, erase-
before-write, Page Program loop) -> flash_slot_manager (16-slot catalog
with CRC32), exposed via 8 new SPI opcodes (0x40-0x47). Fixes two
pre-existing bugs found during bring-up: a psram_controller.v request
lost during power-up, and a one-cycle-pulse race in the PSRAM arbiter
request handshake. Full simulation + real Yosys/nextpnr-ecp5 synthesis
verification (0 errors, Fmax 66.68MHz) in WORKLOG.md and
docs/FPGA-Neural-Flash-Subsystem-Verification.md.

Also updates docs/pinout to reflect the 56-signal real .lpf (3 new
flash pins) and documents the WRITE_RAM/READ_RAM host backpressure
risk found while testing this subsystem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
2026-09-04 08:37:19 +02:00

430 lines
17 KiB
Verilog

`timescale 1ns/1ps
// ================================================================
// FLASH_COPY_ENGINE TESTBENCH -- Phase F3 (SAVE direction, PSRAM -> flash)
//
// Same real stack as sim/flash_copy_engine_load_tb.v (mem_arbiter ->
// int8_memory_access -> memory_interface -> psram_controller ->
// psram_model on the PSRAM side, flash_model.v on the flash side),
// exercising DIR_SAVE this time.
//
// TEST 1 (round-trip byte-exact, self-consistency): a pattern is
// written into PSRAM via arbiter Port A (host-style,
// WRITE_RAM-equivalent), SAVEd to flash, then loaded BACK from
// flash into a DIFFERENT PSRAM region via DIR_LOAD (already
// independently verified in F2) and compared byte-exact against
// the original. This is a round-trip check (not fully
// independent of the RTL under test, since LOAD is used as part
// of the oracle) -- flagged as such, same honesty standard as
// F1/F2. TEST 1b below supplies the genuinely independent half.
//
// TEST 1b (independent oracle): after TEST 1's SAVE, the actual
// flash_model.mem[] bytes at the target sector are read directly
// via hierarchical reference (bypassing the RTL under test
// entirely) and compared against the exact bytes written to
// PSRAM -- the true §A.1 independent-oracle check for F3.
//
// TEST 2 (adversarial §A.3, page-boundary crossing): a SAVE whose
// length spans MULTIPLE 256B pages within one sector (e.g. 300
// bytes) must still produce byte-exact flash content across the
// page boundary -- proving the internal Page Program loop
// correctly issues a SEPARATE WREN+PP+poll per page rather than
// one PP call that would silently truncate/corrupt at the 256B
// boundary (Page Program cannot cross a page, §8 intro p.24).
//
// TEST 3 (adversarial §A.3, sector-unaligned SAVE): flash_addr not
// a multiple of 4096 must be rejected with `err`, no flash or
// PSRAM transaction attempted (checked via a flash-side
// sentinel: a byte just before the requested address, and the
// model's own $fatal-on-out-of-alignment-SE guard in
// flash_model.v, which would fire if the engine incorrectly
// proceeded anyway).
//
// TEST 4 (adversarial §A.3, erase-then-verify across sector):
// confirms the erase phase actually reaches all-0xFF everywhere
// in the target sector(s) BEFORE programming (checked by
// pre-poisoning the target sector with non-0xFF flash content,
// then SAVEing a pattern shorter than the sector and verifying
// the untouched TAIL of the sector reads back 0xFF -- proving a
// real erase happened, not just an in-place program over old
// data that would leave the tail with its original poison).
//
// TEST 5 (adversarial §A.3, prolonged WIP / stuck busy): flash_
// model's RDSR-1 response is hijacked (via a small wrapper wire)
// to report BUSY indefinitely for a bounded window, confirming
// the engine's RDSR poll loop keeps re-polling (not looping a
// fixed number of times and giving up silently) and completes
// correctly once BUSY finally clears -- flash_copy_engine itself
// has NO poll-count timeout by design (documented in
// WORKLOG.md/module header: an unbounded RDSR poll is correct
// per the datasheet, which gives no hard upper bound other than
// tSE/tPP MAX -- a host-side watchdog, not this engine, is where
// any hard timeout policy belongs, per the phase-plan's own
// §5 "STATUS" error-reporting design, deferred to F5).
// ================================================================
module tb;
localparam CLK_PERIOD = 12.5; // 80 MHz
localparam ADDR_WIDTH = 23;
reg clk;
reg rst;
initial begin
clk = 1'b0;
forever #(CLK_PERIOD / 2.0) clk = ~clk;
end
// ------------------------------------------------------------
// Flash side (with an RDSR-hijack wrapper for TEST 5)
// ------------------------------------------------------------
wire mosi, miso_flash, cs_n, sclk_w;
reg force_busy;
wire miso = force_busy ? 1'b1 : miso_flash; // see TEST 5
// ------------------------------------------------------------
// flash_copy_engine command interface
// ------------------------------------------------------------
reg op_start;
reg [1:0] op_dir;
reg [23:0] flash_addr;
reg [ADDR_WIDTH-1:0] psram_addr;
reg [23:0] len;
wire busy;
wire done;
wire err;
localparam DIR_LOAD = 2'd0;
localparam DIR_SAVE = 2'd1;
wire d_req, d_wr;
wire [ADDR_WIDTH-1:0] d_addr;
wire signed [7:0] d_wdata;
wire signed [7:0] d_rdata;
wire d_ready;
reg a_req, a_wr;
reg [ADDR_WIDTH-1:0] a_addr;
reg signed [7:0] a_wdata;
wire signed [7:0] a_rdata;
wire a_ready;
flash_copy_engine #(
.PSRAM_ADDR_WIDTH(ADDR_WIDTH),
.CLK_FREQ_MHZ(80),
.SCLK_DIV(2)
) dut (
.clk(clk), .rst(rst),
.mosi(mosi), .miso(miso), .cs_n(cs_n),
.sclk_sim(sclk_w),
.op_start(op_start), .op_dir(op_dir),
.flash_addr(flash_addr), .psram_addr(psram_addr), .len(len),
.busy(busy), .done(done), .err(err),
.d_req(d_req), .d_wr(d_wr), .d_addr(d_addr), .d_wdata(d_wdata),
.d_rdata(d_rdata), .d_ready(d_ready)
);
flash_model #(
.DEPTH(32'h0002_0000),
.TIME_SCALE(100000)
) dut_flash (
.sclk(sclk_w), .mosi(mosi), .miso(miso_flash), .cs_n(cs_n)
);
// ------------------------------------------------------------
// Real PSRAM stack (same as flash_copy_engine_load_tb.v)
// ------------------------------------------------------------
wire arb_req, arb_wr;
wire [ADDR_WIDTH-1:0] arb_addr;
wire signed [7:0] arb_wdata;
wire signed [7:0] arb_rdata;
wire arb_ready;
mem_arbiter #(.ADDR_WIDTH(ADDR_WIDTH)) u_arbiter (
.clk(clk), .rst(rst),
.a_req(a_req), .a_wr(a_wr), .a_addr(a_addr), .a_wdata(a_wdata),
.a_rdata(a_rdata), .a_ready(a_ready),
.b_req(1'b0), .b_wr(1'b0), .b_addr({ADDR_WIDTH{1'b0}}), .b_wdata(8'sd0),
.b_rdata(), .b_ready(),
.c_req(1'b0), .c_wr(1'b0), .c_addr({ADDR_WIDTH{1'b0}}), .c_wdata(8'sd0),
.c_rdata(), .c_ready(),
.d_req(d_req), .d_wr(d_wr), .d_addr(d_addr), .d_wdata(d_wdata),
.d_rdata(d_rdata), .d_ready(d_ready),
.m_req(arb_req), .m_wr(arb_wr), .m_addr(arb_addr), .m_wdata(arb_wdata),
.m_rdata(arb_rdata), .m_ready(arb_ready)
);
wire i8_req, i8_wr;
wire [ADDR_WIDTH-1:0] i8_addr;
wire [15:0] i8_wdata;
wire i8_lb_n, i8_ub_n;
wire [15:0] i8_rdata;
wire i8_ready;
int8_memory_access #(.ADDR_WIDTH(ADDR_WIDTH)) u_i8 (
.clk(clk), .rst(rst),
.req(arb_req), .wr(arb_wr), .addr(arb_addr), .wdata(arb_wdata),
.rdata(arb_rdata), .ready(arb_ready),
.mem_req(i8_req), .mem_wr(i8_wr), .mem_addr(i8_addr), .mem_wdata(i8_wdata),
.mem_lb_n(i8_lb_n), .mem_ub_n(i8_ub_n),
.mem_rdata(i8_rdata), .mem_ready(i8_ready)
);
wire mi_req, mi_wr;
wire [ADDR_WIDTH-1:0] mi_addr;
wire [15:0] mi_wdata;
wire mi_lb_n, mi_ub_n;
wire [15:0] mi_rdata;
wire mi_ready;
memory_interface #(.ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(16)) u_mi (
.clk(clk), .rst(rst),
.req(i8_req), .wr(i8_wr), .addr(i8_addr), .wdata(i8_wdata),
.lb_n(i8_lb_n), .ub_n(i8_ub_n),
.rdata(i8_rdata), .ready(i8_ready),
.mem_req(mi_req), .mem_wr(mi_wr), .mem_addr(mi_addr), .mem_wdata(mi_wdata),
.mem_lb_n(mi_lb_n), .mem_ub_n(mi_ub_n),
.mem_rdata(mi_rdata), .mem_ready(mi_ready)
);
wire [ADDR_WIDTH-1:0] psram_a;
wire [15:0] psram_dq;
wire psram_ce_n, psram_oe_n, psram_we_n, psram_lb_n, psram_ub_n, psram_zz_n;
psram_controller #(.ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(16), .CLK_FREQ_MHZ(80)) u_psram_ctrl (
.clk(clk), .rst(rst),
.mem_req(mi_req), .mem_wr(mi_wr), .mem_addr(mi_addr), .mem_wdata(mi_wdata),
.mem_lb_n(mi_lb_n), .mem_ub_n(mi_ub_n),
.mem_rdata(mi_rdata), .mem_ready(mi_ready),
.psram_a(psram_a), .psram_dq(psram_dq),
.psram_ce_n(psram_ce_n), .psram_oe_n(psram_oe_n), .psram_we_n(psram_we_n),
.psram_lb_n(psram_lb_n), .psram_ub_n(psram_ub_n), .psram_zz_n(psram_zz_n)
);
psram_model #(.ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(16), .DEPTH(16384)) u_psram (
.clk(clk), .a(psram_a), .dq(psram_dq),
.ce_n(psram_ce_n), .oe_n(psram_oe_n), .we_n(psram_we_n),
.lb_n(psram_lb_n), .ub_n(psram_ub_n), .zz_n(psram_zz_n)
);
// ============================================================
// Helper tasks
// ============================================================
integer errors;
task automatic do_op(
input [1:0] p_dir,
input [23:0] p_flash_addr,
input [ADDR_WIDTH-1:0] p_psram_addr,
input [23:0] p_len
);
integer wd;
begin
@(posedge clk);
op_start <= 1'b1;
op_dir <= p_dir;
flash_addr <= p_flash_addr;
psram_addr <= p_psram_addr;
len <= p_len;
@(posedge clk);
op_start <= 1'b0;
wd = 0;
while (!done) begin
@(posedge clk);
wd = wd + 1;
if (wd > 5_000_000) begin
$display("FATAL: do_op watchdog timeout");
$finish;
end
end
end
endtask
task automatic psram_read_byte(input [ADDR_WIDTH-1:0] a, output [7:0] v);
begin
@(posedge clk);
a_req <= 1'b1;
a_wr <= 1'b0;
a_addr <= a;
@(posedge clk);
a_req <= 1'b0;
while (!a_ready) @(posedge clk);
v = a_rdata;
@(posedge clk);
end
endtask
task automatic psram_write_byte(input [ADDR_WIDTH-1:0] a, input [7:0] v);
begin
@(posedge clk);
a_req <= 1'b1;
a_wr <= 1'b1;
a_addr <= a;
a_wdata <= $signed(v);
@(posedge clk);
a_req <= 1'b0;
while (!a_ready) @(posedge clk);
@(posedge clk);
end
endtask
task automatic check_byte(input [7:0] got, input [7:0] exp, input [255:0] label);
begin
if (got !== exp) begin
$display("FAIL: %0s got=%02h exp=%02h", label, got, exp);
errors = errors + 1;
end
end
endtask
integer i;
reg [7:0] rb;
initial begin
errors = 0;
rst = 1'b1;
op_start = 1'b0;
op_dir = DIR_SAVE;
flash_addr = 24'h0;
psram_addr = {ADDR_WIDTH{1'b0}};
len = 24'h0;
a_req = 1'b0;
a_wr = 1'b0;
a_addr = {ADDR_WIDTH{1'b0}};
a_wdata = 8'sd0;
force_busy = 1'b0;
repeat (5) @(posedge clk);
rst = 1'b0;
repeat (5) @(posedge clk);
// ========================================================
// TEST 1 / 1b: SAVE round-trip + independent oracle
// ========================================================
$display("--- TEST 1/1b starting ---");
for (i = 0; i < 40; i = i + 1)
psram_write_byte(23'h001000 + i, 8'h30 + i[7:0]);
do_op(DIR_SAVE, 24'h004000, 23'h001000, 24'd40); // sector-aligned flash_addr
if (err) begin $display("FAIL: TEST1 unexpected err"); errors = errors + 1; end
// TEST 1b: independent oracle, direct flash_model peek
for (i = 0; i < 40; i = i + 1)
check_byte(dut_flash.mem[24'h004000 + i], 8'h30 + i[7:0], "TEST1b independent flash_model peek");
// TEST 1: round-trip via the already-verified LOAD path
do_op(DIR_LOAD, 24'h004000, 23'h002000, 24'd40);
for (i = 0; i < 40; i = i + 1) begin
psram_read_byte(23'h002000 + i, rb);
check_byte(rb, 8'h30 + i[7:0], "TEST1 SAVE->LOAD round-trip");
end
// ========================================================
// TEST 2 (adversarial, page-boundary crossing): 300 bytes,
// spanning pages [0..255] and [256..299] within one sector.
// ========================================================
$display("--- TEST 2 starting ---");
for (i = 0; i < 300; i = i + 1)
psram_write_byte(23'h003000 + i, 8'h80 + i[7:0]);
do_op(DIR_SAVE, 24'h005000, 23'h003000, 24'd300);
if (err) begin $display("FAIL: TEST2 unexpected err"); errors = errors + 1; end
for (i = 0; i < 300; i = i + 1)
check_byte(dut_flash.mem[24'h005000 + i], 8'h80 + i[7:0], "TEST2 page-boundary-crossing SAVE");
// ========================================================
// TEST 3 (adversarial §A.3): non-sector-aligned flash_addr.
// ========================================================
$display("--- TEST 3 starting ---");
do_op(DIR_SAVE, 24'h005100, 23'h001000, 24'd16); // 0x5100 is NOT a multiple of 0x1000
if (!err) begin $display("FAIL: TEST3 expected err for unaligned SAVE, got none"); errors = errors + 1; end
// No $fatal from flash_model.v (its own SE-alignment guard)
// means the engine correctly never attempted the erase --
// reaching this line at all is part of the pass condition.
// ========================================================
// TEST 4 (adversarial §A.3): erase reaches the WHOLE sector,
// not just the programmed prefix. Poison the sector, SAVE a
// short pattern, confirm the untouched tail is 0xFF.
// ========================================================
$display("--- TEST 4 starting ---");
for (i = 0; i < 4096; i = i + 1)
dut_flash.mem[24'h006000 + i] = 8'h55; // poison, not 0xFF
for (i = 0; i < 20; i = i + 1)
psram_write_byte(23'h001100 + i, 8'hA0 + i[7:0]);
do_op(DIR_SAVE, 24'h006000, 23'h001100, 24'd20);
if (err) begin $display("FAIL: TEST4 unexpected err"); errors = errors + 1; end
for (i = 0; i < 20; i = i + 1)
check_byte(dut_flash.mem[24'h006000 + i], 8'hA0 + i[7:0], "TEST4 programmed prefix");
for (i = 20; i < 4096; i = i + 1) begin
if (dut_flash.mem[24'h006000 + i] !== 8'hFF) begin
$display("FAIL: TEST4 sector tail not erased at offset %0d, got=%02h", i, dut_flash.mem[24'h006000 + i]);
errors = errors + 1;
i = 4096; // stop spamming after the first mismatch
end
end
// ========================================================
// TEST 5 (adversarial §A.3): prolonged WIP. Force RDSR's
// MISO to report BUSY for a bounded window during the first
// poll after this SAVE's erase, then release it -- the
// engine must keep polling (not give up) and finish
// correctly once BUSY clears.
// ========================================================
$display("--- TEST 5 starting ---");
for (i = 0; i < 16; i = i + 1)
psram_write_byte(23'h001200 + i, 8'hC0 + i[7:0]);
fork
do_op(DIR_SAVE, 24'h007000, 23'h001200, 24'd16);
begin
// Hold MISO high (BUSY=1, since RDSR-1 bit0 is the
// first bit shifted out MSB-first... actually the
// model shifts BUSY at a specific bit position, see
// flash_model.v -- forcing the whole byte to 0xFF
// via MISO=1 throughout guarantees bit0=1 (BUSY)
// regardless of exact bit timing) for a fixed
// window, comfortably longer than one real poll
// transaction takes, then release control back to
// the real flash model.
#(50_000);
force_busy = 1'b1;
#(300_000);
force_busy = 1'b0;
end
join
if (err) begin $display("FAIL: TEST5 unexpected err"); errors = errors + 1; end
for (i = 0; i < 16; i = i + 1)
check_byte(dut_flash.mem[24'h007000 + i], 8'hC0 + i[7:0], "TEST5 SAVE completes correctly despite prolonged WIP");
// ========================================================
if (errors == 0)
$display("ALL TESTS PASSED");
else
$display("FAILED: %0d error(s)", errors);
$finish;
end
initial begin
#100_000_000;
$display("FATAL: global simulation timeout");
$finish;
end
endmodule