feat: config-flash passthrough bridge via STARTUPE2, real board-exclusive flash access (EXP-0077)

Implements the user's board architecture: config flash wired
exclusively to the FPGA, host (ESP32) reaches it only through the
FPGA. flash_spi_master.v is a plain byte-wide SPI master using
STARTUPE2 to reclaim CCLK after configuration (the real, Xilinx-
documented "indirect SPI flash programming" technique, UG470 p94-96).
New opcode 0x40 FLASH_XFER in spi_host_bridge_v3.v relays bytes
byte-for-byte between host and the physical flash bus -- the host
decides the exact SPI NOR command sequence (verified against the real
W25Q32JV datasheet), this RTL knows nothing about flash semantics.

Found and fixed two real bugs during verification: a byte-assembly
off-by-one in flash_spi_master.v, and a genuine protocol-latency bug
in the FLASH_XFER opcode's response timing (needed 2 trailing margin
bytes, not 1 -- the internal flash transfer doesn't start until the
triggering byte finishes, so 1 byte of margin isn't enough). 39/39
tests pass end to end (host SPI -> bridge -> flash_spi_master ->
behavioral flash model).

Wired into n2_system_ddr3_top.v with real pin constraints (flash_mosi
=K17/flash_miso=K18/flash_cs_n=L13, the same pins reserved-but-unused
in EXP-0075) and BITSTREAM.CONFIG.PERSIST=FALSE made explicit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUG92aM9m68TRc4rG55BcC
This commit is contained in:
2026-09-19 21:45:17 +02:00
co-authored by Claude Sonnet 5
parent fd6cc7a2fa
commit a4c080da83
7 changed files with 727 additions and 10 deletions
+146
View File
@@ -0,0 +1,146 @@
`timescale 1ns/1ps
// ============================================================
// V3 -- physical SPI master for the FPGA's OWN configuration flash,
// used AFTER normal configuration completes (indirect programming,
// the same real, Xilinx-documented technique used by Vivado's own
// Hardware Manager "Program Configuration Memory Device" feature --
// UG470 7 Series FPGAs Configuration User Guide, pages 94-96).
//
// WHY THIS EXISTS: the user's board design keeps the config flash
// wired EXCLUSIVELY to the FPGA (no external host has a direct SPI
// connection to it) -- the host (an ESP32) can only reach the flash
// BY GOING THROUGH the FPGA, over the already-existing neural-
// processor management SPI (spi_host_bridge_v3.v). This module is
// the physical side of that bridge: a plain byte-wide SPI master
// (mode 0, MSB-first) driving the flash's own MOSI/CS_B pins and
// reading its MISO, at a fixed internal clock divide, completely
// independent of the host's own (slow, externally-clocked) SPI
// timing.
//
// DESIGN CHOICE (passthrough, not a smart flash controller): this
// module does NOT know any Winbond-specific command opcodes (Write
// Enable 0x06, Page Program 0x02, Sector Erase 0x20, Read Data 0x03,
// Read Status Register-1 0x05, BUSY=status bit0 -- all verified
// against the real W25Q32JV datasheet for the bridge's own protocol
// documentation, see spi_host_bridge_v3.v's header) -- it just
// relays whatever bytes the host sends, byte for byte, onto the
// physical flash bus, and relays back whatever the flash returns.
// The HOST decides the exact command sequence. This keeps this
// module trivial and correct-by-construction, and means a future
// flash part swap needs zero RTL changes here.
//
// CCLK REQUIRES STARTUPE2 (a real, hard Xilinx-imposed requirement,
// not a design choice): the physical CCLK pin is never an ordinary
// fabric I/O, even after configuration completes -- it can only be
// driven by fabric logic through the STARTUPE2 primitive's
// USRCCLKO/USRCCLKTS ports (UG953). MOSI/MISO/CS_B (this project's
// own board pins D00_MOSI/D01_DIN/FCS_B) DO become ordinary fabric
// I/O once configuration completes, PROVIDED the bitstream's
// CONFIG.PERSIST option is FALSE (the Vivado default) -- if a future
// build ever needs to flip PERSIST on for some other reason, this
// module stops working and that's a real, disclosed dependency, not
// a hidden one.
//
// ONLY ONE STARTUPE2 PRIMITIVE IS ALLOWED PER DESIGN (a real Xilinx
// placement rule) -- if this module is ever instantiated alongside
// another STARTUPE2 use (e.g. a future ICAPE2-based warm-reboot
// module that also needs it), they must share ONE instance, not two.
// ============================================================
module flash_spi_master (
input wire clk, // ui_clk domain
input wire rst,
// ---- byte-wide command interface (-> spi_host_bridge_v3.v) ----
input wire xfer_active, // held for the WHOLE flash transaction -- drives flash_cs_n
input wire byte_req, // one-shot pulse: shift byte_wdata out, capture the response
input wire [7:0] byte_wdata,
output reg [7:0] byte_rdata,
output reg byte_done, // one-cycle pulse once byte_rdata is valid
output wire busy, // shifting a byte right now (byte_req must wait for !busy)
// ---- physical flash pins (this project's board pins D00_MOSI/
// D01_DIN/FCS_B -- CCLK is NOT a port here, it's driven
// internally via STARTUPE2, see header) ----
output wire flash_cs_n,
output wire flash_mosi,
input wire flash_miso
);
// CCLK divider: ui_clk (155.039MHz per EXP-0074/0076's real P&R)
// /8 -> ~19.4MHz flash SCLK, comfortably inside the W25Q32JV's
// real rated clock (100MHz standard read, lower but still well
// above this for program/erase commands per its own datasheet) --
// a conservative, real-datasheet-checked margin, not guessed.
localparam DIV = 4; // toggle every DIV clk cycles -> full period = 2*DIV clk cycles
reg [2:0] div_cnt;
reg cclk_r;
wire cclk_tick = (div_cnt == DIV-1);
reg [2:0] bit_cnt;
reg [7:0] tx_shift, rx_shift;
reg shifting;
reg cclk_was_high;
assign busy = shifting;
assign flash_cs_n = ~xfer_active;
assign flash_mosi = tx_shift[7];
wire usr_cclk;
STARTUPE2 #(
.PROG_USR("FALSE"),
.SIM_CCLK_FREQ(0.0)
) u_startupe2 (
.CFGCLK(), .CFGMCLK(), .EOS(), .PREQ(),
.CLK(1'b0), .GSR(1'b0), .GTS(1'b0), .KEYCLEARB(1'b0), .PACK(1'b0),
.USRCCLKO(usr_cclk), .USRCCLKTS(1'b0),
.USRDONEO(1'b1), .USRDONETS(1'b1)
);
assign usr_cclk = cclk_r;
always @(posedge clk) begin
if (rst) begin
div_cnt <= 3'd0; cclk_r <= 1'b0; bit_cnt <= 3'd0;
tx_shift <= 8'h00; rx_shift <= 8'h00;
shifting <= 1'b0; byte_done <= 1'b0; byte_rdata <= 8'h00;
end else begin
byte_done <= 1'b0;
if (!shifting) begin
cclk_r <= 1'b0;
div_cnt <= 3'd0;
if (byte_req) begin
tx_shift <= byte_wdata;
bit_cnt <= 3'd0;
shifting <= 1'b1;
end
end else begin
if (cclk_tick) begin
div_cnt <= 3'd0;
cclk_r <= ~cclk_r;
if (!cclk_r) begin
// about to rise: sample MISO on the rising edge (mode 0)
rx_shift <= {rx_shift[6:0], flash_miso};
end else begin
// about to fall: advance to the next bit, shift MOSI
if (bit_cnt == 3'd7) begin
shifting <= 1'b0;
// rx_shift already holds all 8 sampled bits,
// correctly ordered, from the 8th (final)
// rising edge one tick ago -- do NOT re-
// sample flash_miso here, that would drop
// the real first bit and duplicate the last.
byte_rdata <= rx_shift;
byte_done <= 1'b1;
end else begin
bit_cnt <= bit_cnt + 3'd1;
tx_shift <= {tx_shift[6:0], 1'b0};
end
end
end else begin
div_cnt <= div_cnt + 1'b1;
end
end
end
end
endmodule
+24
View File
@@ -70,6 +70,17 @@ module n2_system_ddr3_top #(
output wire miso,
input wire cs_n,
// ---- config-flash passthrough physical pins (this project's own
// board pins D00_MOSI=K17/D01_DIN=K18/FCS_B=L13, reclaimed as
// ordinary fabric I/O post-configuration -- see flash_spi_master.v's
// own header for the real Xilinx PERSIST/STARTUPE2 requirements
// this depends on). CCLK is NOT a port here -- flash_spi_master.v
// drives it internally via STARTUPE2, a dedicated pin that can
// never be an ordinary top-level port. ----
output wire flash_cs_n,
output wire flash_mosi,
input wire flash_miso,
// ---- results (small enough to keep as real top-level pins for
// observation; NOT part of the activation-interface pin-count
// problem described below) ----
@@ -201,6 +212,9 @@ module n2_system_ddr3_top #(
wire [15:0] mem_wdata, mem_rdata;
wire soft_rst_pulse;
wire flash_xfer_active, flash_byte_req, flash_byte_done;
wire [7:0] flash_byte_wdata, flash_byte_rdata;
spi_host_bridge_v3 #(
.JOB_ADDR_WIDTH(JOB_ADDR_WIDTH), .MEM_ADDR_WIDTH(MEM_ADDR_WIDTH), .N_SLOTS(N_SLOTS)
) u_spi (
@@ -214,9 +228,19 @@ module n2_system_ddr3_top #(
.mem_req(mem_req), .mem_wr(mem_wr), .mem_addr(mem_addr),
.mem_wdata(mem_wdata), .mem_lb_n(mem_lb_n), .mem_ub_n(mem_ub_n),
.mem_rdata(mem_rdata), .mem_ready(mem_ready),
.flash_xfer_active(flash_xfer_active), .flash_byte_req(flash_byte_req),
.flash_byte_wdata(flash_byte_wdata), .flash_byte_rdata(flash_byte_rdata),
.flash_byte_done(flash_byte_done),
.soft_rst_pulse(soft_rst_pulse)
);
flash_spi_master u_flash (
.clk(ui_clk), .rst(ui_clk_sync_rst),
.xfer_active(flash_xfer_active), .byte_req(flash_byte_req),
.byte_wdata(flash_byte_wdata), .byte_rdata(flash_byte_rdata), .byte_done(flash_byte_done), .busy(),
.flash_cs_n(flash_cs_n), .flash_mosi(flash_mosi), .flash_miso(flash_miso)
);
host_mem_bridge #(
.BURST_LEN(BURST_LEN), .ADDR_WIDTH(MEM_ADDR_WIDTH)
) u_host_bridge (
+76 -1
View File
@@ -137,6 +137,48 @@
// below), so host software doesn't need
// to hardcode it.
//
// 0x40 FLASH_XFER -- raw byte-for-byte SPI passthrough to the
// FPGA's OWN configuration flash (see
// flash_spi_master.v's own header for why
// this exists: the board wires the config
// flash EXCLUSIVELY to the FPGA, so the host
// can only reach it by going through this
// opcode). Every MOSI byte received while
// this opcode is active is relayed, bit for
// bit, onto the physical flash's own MOSI
// line; whatever the flash returns is relayed
// back on MISO. This module knows NOTHING
// about SPI NOR command semantics (Write
// Enable, Page Program, etc.) -- the host is
// responsible for sending a real flash command
// sequence, exactly as if it were wired to
// the flash directly.
// LATENCY (real, measured via simulation, not
// guessed -- see EXP-0077): flash_spi_master.v's
// own byte transfer takes real internal clock
// cycles to complete (~640ns at this project's
// real 155.039MHz ui_clk with the default
// DIV=4 setting), and that transfer only
// STARTS once byte N is fully received -- i.e.
// right as byte N+1's OWN transmission begins,
// not before. Byte N's response therefore only
// becomes stable partway through byte N+1's
// own window, NOT for its very first bit --
// relying on "ready by the next byte" corrupts
// exactly the byte N+1 response's own early
// bits (confirmed: a real, reproduced bug
// during this opcode's own development, not
// hypothetical). The safe, real requirement is
// TWO trailing dummy bytes, not one: byte N's
// response is only guaranteed stable and
// correct during host byte N+2's own window,
// since a full extra host byte period is
// always comfortably longer than one internal
// flash transfer at any realistic host SPI
// clock rate. The host must clock TWO extra
// dummy bytes at the end of a transaction to
// safely receive the final real response.
//
// Any opcode byte not listed above is treated as NOP (0 payload,
// MISO drives 0x00) -- matches spi_host_bridge.v's own "unknown
// opcode is inert, never wedges the bus" precedent.
@@ -179,6 +221,13 @@ module spi_host_bridge_v3 #(
input wire [15:0] mem_rdata,
input wire mem_ready,
// ---- config-flash passthrough (-> flash_spi_master.v) ----
output reg flash_xfer_active,
output reg flash_byte_req,
output reg [7:0] flash_byte_wdata,
input wire [7:0] flash_byte_rdata,
input wire flash_byte_done,
output reg soft_rst_pulse
);
@@ -280,6 +329,7 @@ module spi_host_bridge_v3 #(
localparam OP_STATUS = 8'h20;
localparam OP_REG_WRITE = 8'h30;
localparam OP_REG_READ = 8'h31;
localparam OP_FLASH_XFER= 8'h40;
localparam ST_OPCODE = 4'd0;
localparam ST_JOB = 4'd1; // collecting 16 WRITE_JOB payload bytes
@@ -294,6 +344,8 @@ module spi_host_bridge_v3 #(
localparam ST_REG_ADDR = 4'd10; // collecting 1 reg_addr byte
localparam ST_REG_WDATA= 4'd11; // REG_WRITE: collecting 4 value bytes
localparam ST_REG_ROUT = 4'd12; // REG_READ: shifting 4 value bytes out
localparam ST_FLASH_XFER = 4'd13; // FLASH_XFER: ready for next host byte
localparam ST_FLASH_WAIT = 4'd14; // FLASH_XFER: waiting for flash_byte_done
reg [3:0] state;
reg [7:0] opcode;
@@ -304,6 +356,7 @@ module spi_host_bridge_v3 #(
reg job_busy_r, mem_busy_r, last_job_accepted_r;
reg [7:0] reg_addr;
reg [31:0] reg_wdata; // REG_WRITE: assembling the 4 value bytes
reg [7:0] flash_rdata_r; // FLASH_XFER: previous byte's flash response (see header's own "off by one" note)
// ---- ROUT-exit deferral (real bug found and fixed this session,
// see the header's own note near the physical layer): the
@@ -355,6 +408,8 @@ module spi_host_bridge_v3 #(
tx_mux = (byte_idx == 5'd0) ? cur_word[15:8] : cur_word[7:0];
else if (opcode == OP_REG_READ && state == ST_REG_ROUT)
tx_mux = reg_rdata[8*(3-byte_idx) +: 8];
else if (opcode == OP_FLASH_XFER)
tx_mux = flash_rdata_r;
end
assign tx_byte = tx_mux;
@@ -372,9 +427,12 @@ module spi_host_bridge_v3 #(
reg_addr <= 8'h00; reg_wdata <= 32'h0;
mem_rout_pending_ignore <= 1'b0; mem_rout_pending_riss <= 1'b0;
reg_rout_pending <= 1'b0;
flash_xfer_active <= 1'b0; flash_byte_req <= 1'b0;
flash_byte_wdata <= 8'h00; flash_rdata_r <= 8'h00;
end else begin
mem_req <= 1'b0;
soft_rst_pulse <= 1'b0;
flash_byte_req <= 1'b0;
// Same protection as spi_host_bridge.v: don't let a new CS
// assertion reset state/byte_idx while a previous
@@ -398,6 +456,11 @@ module spi_host_bridge_v3 #(
OP_READ_MEM: state <= ST_MEM_ADDR;
OP_REG_WRITE: state <= ST_REG_ADDR;
OP_REG_READ: state <= ST_REG_ADDR;
OP_FLASH_XFER: begin
state <= ST_FLASH_XFER;
flash_xfer_active <= 1'b1;
flash_rdata_r <= 8'h00;
end
OP_RESET: state <= ST_IGNORE;
default: state <= ST_IGNORE; // NOP, STATUS: no MOSI payload
endcase
@@ -499,7 +562,13 @@ module spi_host_bridge_v3 #(
if (byte_idx != 5'd3) byte_idx <= byte_idx + 5'd1;
end
default: ; // ST_JOB_WAIT/ST_MEM_WISS/ST_MEM_RISS/ST_MEM_ROUT/ST_REG_ROUT/ST_IGNORE: no MOSI payload expected
ST_FLASH_XFER: begin
flash_byte_wdata <= rx_byte;
flash_byte_req <= 1'b1;
state <= ST_FLASH_WAIT;
end
default: ; // ST_JOB_WAIT/ST_MEM_WISS/ST_MEM_RISS/ST_MEM_ROUT/ST_REG_ROUT/ST_FLASH_WAIT/ST_IGNORE: no MOSI payload expected
endcase
end
@@ -575,12 +644,18 @@ module spi_host_bridge_v3 #(
state <= ST_IGNORE;
end
if (state == ST_FLASH_WAIT && flash_byte_done) begin
flash_rdata_r <= flash_byte_rdata;
state <= ST_FLASH_XFER;
end
job_busy_r <= (state == ST_JOB_WAIT);
if (cs_rose) begin
if (opcode == OP_RESET) soft_rst_pulse <= 1'b1;
if (state != ST_JOB_WAIT && state != ST_MEM_WISS && state != ST_MEM_RISS)
state <= ST_OPCODE;
flash_xfer_active <= 1'b0;
end
end
end