PCIe · Module 26
FPGA Cards — The Half of PCIe You Actually Write
A PCIe core hands you an application interface and everything past it is yours. Doubling the link gave 0% because local DDR was the limit, and removing one epoch check misdelivered 70,976 bytes across a reset boundary.
Every previous chapter in this module described a device somebody else built. This one is about the device you build, where the PCIe core stops and your RTL starts — and where every ownership boundary in Module 25 becomes something you are personally responsible for.
1. Sources, Scope, and What This Chapter Will Not Name
2. What You Actually Build
The most common misconception about FPGA PCIe design is that you implement PCIe. You do not — not the electrical layer, not the LTSSM, not the Data Link Layer, and usually not most of the Transaction Layer.
──────────── the IP core typically owns ────────────
PHY / SerDes, electrical
LTSSM, link training, equalization (Module 18)
Data Link Layer: sequence numbers, ACK/NAK, replay (Module 14)
Transaction Layer framing, credit accounting (Modules 15-16)
────────────── the application interface ──────────────
──────────────── you own everything here ──────────────
BAR decode and register file (23.2, 25.5)
DMA engine, descriptor management (23.3, 25.6)
Tag allocation, Completion matching (23.5, 25.7)
buffering, clock domain crossing (§11, §12)
reset integration and epochs (§13)
interrupt request generation (19.x)
local memory arbitration (§14)
observability (§15, §16)Every item in the lower block is a Module 25 chapter. That is the sense in which this chapter is the module's synthesis: an FPGA card is where the debugging chapters stop being about other people's silicon.
And the boundary is not fixed. Different cores, and different configurations of the same core, place the line differently — some handle Completion reassembly, some do not; some manage Tags, some hand you the field. The first question in any FPGA PCIe design is where the line is, and it is answered by reading your core's documentation, not by assuming.
3. Hard IP, Soft IP, and the Application Interface
A PCIe core exposes a transaction-level interface with roughly five conceptual channels, whatever a given vendor calls them:
| conceptual channel | carries |
|---|---|
| TX request | requests this device originates — MemRd, MemWr, messages |
| TX completion | Completions this device returns for requests it received |
| RX request | requests arriving from the host — BAR-targeted MMIO |
| RX completion | Completions returning for requests this device issued |
| configuration / status | link state, negotiated width and speed, error status, credits |
Normalizing to these five is the single most valuable structural decision in an FPGA PCIe project, and §8 Block 2 is the adapter that does it. Three reasons:
It makes the design portable. A core change or a vendor change becomes an adapter rewrite rather than a redesign.
It makes the design testable. The application logic can be verified against a normalized interface without instantiating vendor IP at all.
And it makes the design reviewable. A reviewer who does not know that vendor's interface can still check ownership, backpressure and conservation — which is where the bugs are.
What the core reports through the configuration channel matters for debugging and is often ignored: negotiated link width and speed, current LTSSM state, correctable and uncorrectable error status, and available credit. §16's first-failure capture records these, because 25.9 §3 established that they are otherwise invisible from the link side.
4. The Vendor-Interface Trap
Signal names from one vendor's core are not PCIe. This sounds obvious and it produces real bugs, in three forms:
Design bugs. A field the core supplies in one configuration may not exist in another — Tag management being the common case. Code that assumes the core allocates Tags breaks silently when the core is reconfigured to pass them through.
Communication bugs. An engineer describing a problem in one vendor's signal names to someone using a different core is not communicating. The normalized vocabulary of §3 is what makes the conversation possible.
And documentation bugs, which are the most durable. A design document that describes behaviour in vendor terms cannot be checked against the PCIe specification, so errors in it are never caught. This is why §8's RTL uses core_* and app_* prefixes throughout and why this chapter names no vendor signal (§1).
5. The Block Diagram
The CDC column is the one that distinguishes this chapter from every other in the module. A GPU, an SSD controller and a NIC all cross clock domains internally, and in each case somebody else solved it. Here it is yours, and §12 is about picking the right mechanism per path rather than one mechanism for everything.
6. Control Path and Data Path, Again
The distinction 26.2 §3 drew for GPUs applies with equal force here, and FPGA designs violate it more often because the BAR path is so easy to build.
A BAR register read is a full host round trip. It is convenient, it is correct, and moving bulk data through it stalls the host for the entire transfer.
The DMA path exists because it is device-initiated and pipelined. The engine issues many outstanding requests, and its throughput is bounded by §17's slowest stage rather than by round-trip latency.
Two rules, both of which §21 sees violated regularly:
Do not benchmark bulk transfer using BAR reads. The result measures round-trip latency, which is a different quantity from streaming throughput (22.2).
And do not optimise BAR register access as if it were bandwidth. A control register read that takes a microsecond is normal; if a design is doing thousands of them per transfer, the architecture is wrong, not the latency.
7. The Instruments, Named
| Instrument | Answers |
|---|---|
pcie_core_adapter | normalize vendor fields to project types (§3, §4) |
bar_control_path | decode MMIO, hold responses under backpressure |
dma_descriptor_engine | own a descriptor, chunk it, track Tags |
dma_tag_alloc | Tag lifetime, with epochs (§13) |
app_stream_bridge | ready/valid between PCIe and application domains |
cdc_control_bit | a stable level crossing domains (§12) |
cdc_command_handshake | a multi-bit command crossing domains (§12) |
cdc_async_fifo_if | the bulk-data contract (§12) |
reset_epoch | reject stale pre-reset responses (§13) |
local_mem_arbiter | DMA and accelerator sharing local memory (§14) |
perf_counters | which stage is the limit? (§15, §17) |
first_failure_capture | one snapshot worth more than a trace (§16) |
8. RTL — The Application Side
Block 1 — the package. COMPILE-TIME.
package fpga_pcie_pkg;
// The five conceptual channels of §3, named locally. A vendor core's
// actual field names never appear above the adapter (Block 2).
typedef enum logic [2:0] {
REQ_MEM_RD = 3'd0,
REQ_MEM_WR = 3'd1,
REQ_CPL = 3'd2,
REQ_CPLD = 3'd3,
REQ_MSG = 3'd4
} req_kind_e;
// §13: an epoch distinguishes work issued before a reset/retrain from work
// issued after. 25.7 §7's law — an asynchronous response must carry its own
// identity, including WHICH USE of that identity.
localparam int unsigned EPOCH_W = 4;
typedef logic [EPOCH_W-1:0] epoch_t;
function automatic int unsigned gw(input int unsigned n);
return (n <= 1) ? 1 : $clog2(n); // §19: $clog2(1) is 0 in some tools
endfunction
// A DMA job as the engine owns it: a SNAPSHOT. §18 measured a live-read
// variant losing 143,872 bytes when the host mutated the descriptor.
typedef struct packed {
logic [31:0] job_id;
logic to_host; // 1 = device→host (MemWr), 0 = host→device (MemRd)
logic [63:0] host_addr;
logic [31:0] local_addr;
logic [31:0] bytes;
epoch_t epoch;
} dma_job_t;
typedef struct packed {
req_kind_e kind;
logic [63:0] addr;
logic [11:0] len_bytes;
logic [7:0] tag;
epoch_t epoch;
} app_req_t;
endpackageBlock 2 — the PCIe core adapter. SYNTHESIZABLE. §3 and §4's central pattern.
Input owner: the vendor core, until a transfer completes on its own terms. Output owner: the application, in project-local types. Handshake: ready/valid on both sides; the adapter never drops a transfer to resolve a mismatch. Beyond this example: real adapters handle header/data alignment, straddled packets and vendor-specific sideband. This one shows the boundary discipline, not a complete conversion.
module pcie_core_adapter (
input logic clk,
input logic rst_n,
// ---- vendor side: DELIBERATELY generic names (§4). A real design binds
// these to the core's actual ports in ONE file, and nothing above this
// module ever sees a vendor-specific identifier.
input logic core_rx_valid,
output logic core_rx_ready,
input logic [2:0] core_rx_kind,
input logic [63:0] core_rx_addr,
input logic [11:0] core_rx_len,
input logic [7:0] core_rx_tag,
output logic core_tx_valid,
input logic core_tx_ready,
output logic [2:0] core_tx_kind,
output logic [63:0] core_tx_addr,
output logic [11:0] core_tx_len,
output logic [7:0] core_tx_tag,
// status the core reports, and which 25.9 §3 showed is invisible from
// the link side — captured by Block 12
input logic core_link_up,
input logic [5:0] core_neg_width,
input logic [3:0] core_neg_speed,
input logic [4:0] core_ltssm,
// ---- application side: project-local types only
output logic app_rx_valid,
input logic app_rx_ready,
output fpga_pcie_pkg::app_req_t app_rx_req,
input logic app_tx_valid,
output logic app_tx_ready,
input fpga_pcie_pkg::app_req_t app_tx_req,
output logic app_link_up,
output logic [5:0] app_neg_width,
output logic [4:0] app_ltssm
);
import fpga_pcie_pkg::*;
// A pure translation with NO buffering and NO field invention. The ready
// signals pass straight through, so the adapter cannot become a hidden
// storage element whose contents diverge from either side (mutation 4).
always_comb begin
app_rx_valid = core_rx_valid;
core_rx_ready = app_rx_ready;
app_rx_req.kind = req_kind_e'(core_rx_kind);
app_rx_req.addr = core_rx_addr;
app_rx_req.len_bytes = core_rx_len;
app_rx_req.tag = core_rx_tag;
app_rx_req.epoch = '0; // epochs are an APPLICATION concept (§13)
core_tx_valid = app_tx_valid;
app_tx_ready = core_tx_ready;
core_tx_kind = app_tx_req.kind;
core_tx_addr = app_tx_req.addr;
core_tx_len = app_tx_req.len_bytes;
core_tx_tag = app_tx_req.tag;
app_link_up = core_link_up;
app_neg_width = core_neg_width; // NEGOTIATED, not capability (26.2 §4)
app_ltssm = core_ltssm;
end
endmoduleBlock 3 — the BAR control path. SYNTHESIZABLE. §6's control side.
Stall: a read response is held stable until accepted; the register file is not re-read.
module bar_control_path #(
parameter int unsigned NREG = 64
)(
input logic clk,
input logic rst_n,
input logic req_valid,
output logic req_ready,
input logic req_is_write,
input logic [31:0] req_offset,
input logic [31:0] req_wdata,
output logic rsp_valid,
input logic rsp_ready,
output logic [31:0] rsp_rdata,
output logic offset_error,
output logic [31:0] reg_file [NREG]
);
import fpga_pcie_pkg::*;
logic [31:0] captured_rdata;
logic rsp_pending;
logic [gw(NREG)-1:0] idx;
// Range-checked, not wrapped. 25.5 §3's law: an unclaimed offset is an
// error, never a default. Wrapping turns a host bug into a silent write
// to an unrelated register (mutation 6).
assign idx = gw(NREG)'(req_offset >> 2);
assign offset_error = req_valid && ((req_offset[1:0] != 2'b00) ||
((req_offset >> 2) >= NREG));
assign req_ready = !rsp_pending;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rsp_pending <= 1'b0; captured_rdata <= '0;
for (int i = 0; i < NREG; i++) reg_file[i] <= '0;
end else begin
if (req_valid && req_ready && !offset_error) begin
if (req_is_write) reg_file[idx] <= req_wdata;
else begin
// The read value is CAPTURED here and held. Re-reading the register
// file while the response is stalled returns a value the host never
// requested — a live-configuration read, which is mutation 7 and
// the same fault 25.5 §10 guards against.
captured_rdata <= reg_file[idx];
rsp_pending <= 1'b1;
end
end
if (rsp_valid && rsp_ready) rsp_pending <= 1'b0;
end
end
assign rsp_valid = rsp_pending;
assign rsp_rdata = captured_rdata;
endmoduleBlock 4 — Tag allocation with epochs. SYNTHESIZABLE. 23.5's allocator plus §13's epoch.
module dma_tag_alloc #(
parameter int unsigned NTAG = 32
)(
input logic clk,
input logic rst_n,
input fpga_pcie_pkg::epoch_t cur_epoch,
input logic alloc_req,
output logic alloc_gnt,
output logic [fpga_pcie_pkg::gw(NTAG)-1:0] alloc_tag,
output fpga_pcie_pkg::epoch_t alloc_epoch,
input logic cpl_valid,
input logic [fpga_pcie_pkg::gw(NTAG)-1:0] cpl_tag,
input fpga_pcie_pkg::epoch_t cpl_epoch,
input logic cpl_last, // final Cpl of the request
output logic cpl_accept,
output logic cpl_stale,
output logic [31:0] stale_count,
output logic [fpga_pcie_pkg::gw(NTAG+1)-1:0] free_count
);
import fpga_pcie_pkg::*;
logic [NTAG-1:0] busy;
epoch_t tag_epoch [NTAG];
always_comb begin
alloc_gnt = alloc_req && (busy != '1);
alloc_tag = '0;
for (int i = NTAG-1; i >= 0; i--) if (!busy[i]) alloc_tag = gw(NTAG)'(i);
alloc_epoch = cur_epoch;
// A Completion is accepted only if its Tag is outstanding AND its epoch
// matches. §18 measured removing this comparison: 62 misdeliveries
// carrying 70,976 bytes into the wrong job's buffer, across 133 resets.
cpl_stale = cpl_valid && (!busy[cpl_tag] || (tag_epoch[cpl_tag] != cpl_epoch));
cpl_accept = cpl_valid && !cpl_stale;
free_count = '0;
for (int i = 0; i < NTAG; i++) free_count += gw(NTAG+1)'(!busy[i]);
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
busy <= '0; stale_count <= '0;
for (int i = 0; i < NTAG; i++) tag_epoch[i] <= '0;
end else begin
if (alloc_req && alloc_gnt) begin
busy[alloc_tag] <= 1'b1;
tag_epoch[alloc_tag] <= cur_epoch;
end
// The Tag is freed on the LAST Completion, not the first. A split
// series frees early otherwise, and the remaining Completions arrive
// for a Tag that has been reissued (mutation 12; 25.7 §7).
if (cpl_accept && cpl_last) busy[cpl_tag] <= 1'b0;
if (cpl_stale && stale_count != 32'hFFFF_FFFF)
stale_count <= stale_count + 32'd1;
end
end
endmoduleBlock 5 — the DMA descriptor engine. SYNTHESIZABLE. Snapshot, chunk, conserve.
module dma_descriptor_engine #(
parameter int unsigned MAX_CHUNK = 256 // IMPLEMENTATION POLICY (≤ MPS)
)(
input logic clk,
input logic rst_n,
input logic desc_valid,
input fpga_pcie_pkg::dma_job_t desc_in,
output logic desc_ready,
input fpga_pcie_pkg::epoch_t cur_epoch,
output logic req_valid,
output fpga_pcie_pkg::app_req_t req_out,
input logic req_ready,
input logic tag_gnt,
input logic [7:0] tag_in,
output logic job_done,
output logic [31:0] job_id_done,
output logic [31:0] bytes_issued,
output logic [31:0] bytes_described
);
import fpga_pcie_pkg::*;
dma_job_t job;
logic active;
logic [31:0] remaining, offset;
logic [31:0] this_chunk;
always_comb begin
// The final chunk is PARTIAL whenever bytes is not a multiple of
// MAX_CHUNK. §18 measured a truncating chunk count dropping it:
// 4,119 lost tails and a 263,616-byte deficit.
this_chunk = (remaining > MAX_CHUNK) ? MAX_CHUNK : remaining;
desc_ready = !active;
req_valid = active && (remaining != 0) && tag_gnt;
req_out = '{kind: job.to_host ? REQ_MEM_WR : REQ_MEM_RD,
addr: job.host_addr + 64'(offset),
len_bytes: 12'(this_chunk),
tag: tag_in,
epoch: job.epoch};
job_done = active && (remaining == 0);
job_id_done = job.job_id;
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
active <= 1'b0; remaining <= '0; offset <= '0;
bytes_issued <= '0; bytes_described <= '0; job <= '0;
end else begin
if (desc_valid && desc_ready) begin
// SNAPSHOT. The engine never re-reads the descriptor afterwards.
// §18's live-read variant lost 143,872 bytes when the host mutated
// a descriptor it had already handed over.
job <= desc_in;
job.epoch <= cur_epoch;
active <= 1'b1;
remaining <= desc_in.bytes;
offset <= '0;
bytes_described <= bytes_described + desc_in.bytes;
end else if (req_valid && req_ready) begin
remaining <= remaining - this_chunk;
offset <= offset + this_chunk;
bytes_issued <= bytes_issued + this_chunk;
end else if (job_done) begin
active <= 1'b0;
end
end
end
endmoduleBlock 6 — a single-bit control crossing. SYNTHESIZABLE. CDC. §12.
module cdc_control_bit #(
parameter int unsigned STAGES = 2
)(
input logic dst_clk,
input logic dst_rst_n,
input logic src_level, // MUST be a stable level, not a pulse
output logic dst_level
);
// Correct ONLY for a single bit that is a stable LEVEL. A pulse shorter
// than the destination clock period can be missed entirely (mutation 20),
// and a MULTI-BIT bus synchronized this way is incoherent even though
// every individual bit is metastability-safe — counterexample A, and the
// reason Block 7 exists.
(* ASYNC_REG = "TRUE" *) logic [STAGES-1:0] sync;
always_ff @(posedge dst_clk or negedge dst_rst_n)
if (!dst_rst_n) sync <= '0;
else sync <= {sync[STAGES-2:0], src_level};
assign dst_level = sync[STAGES-1];
endmoduleBlock 7 — a multi-bit command crossing. SYNTHESIZABLE. CDC. §12's correct mechanism for a word.
module cdc_command_handshake #(
parameter int unsigned WIDTH = 64
)(
input logic src_clk, src_rst_n,
input logic src_valid,
input logic [WIDTH-1:0] src_data,
output logic src_ready,
input logic dst_clk, dst_rst_n,
output logic dst_valid,
output logic [WIDTH-1:0] dst_data,
input logic dst_ready
);
// The DATA never crosses through a synchronizer. It is held stable in the
// source domain while a single-bit REQUEST toggle crosses; the destination
// samples the data only after observing the request, by which time it has
// been stable for at least two destination clocks.
//
// This is the mechanism counterexample A's design should have used.
logic src_req, dst_req_sync, dst_ack_sync, dst_req;
logic [WIDTH-1:0] hold;
cdc_control_bit #(.STAGES(2)) u_req (
.dst_clk(dst_clk), .dst_rst_n(dst_rst_n), .src_level(src_req), .dst_level(dst_req));
logic src_ack;
cdc_control_bit #(.STAGES(2)) u_ack (
.dst_clk(src_clk), .dst_rst_n(src_rst_n), .src_level(dst_ack_sync), .dst_level(src_ack));
always_ff @(posedge src_clk or negedge src_rst_n) begin
if (!src_rst_n) begin src_req <= 1'b0; hold <= '0; end
else if (src_valid && src_ready) begin
hold <= src_data; // stable for the whole crossing
src_req <= ~src_req;
end
end
assign src_ready = (src_req == src_ack);
always_ff @(posedge dst_clk or negedge dst_rst_n) begin
if (!dst_rst_n) begin dst_req_sync <= 1'b0; dst_ack_sync <= 1'b0; end
else begin
dst_req_sync <= dst_req;
if ((dst_req != dst_req_sync) && dst_ready) dst_ack_sync <= dst_req;
end
end
assign dst_valid = (dst_req != dst_ack_sync);
assign dst_data = hold; // sampled only when dst_valid is high
endmoduleBlock 8 — the bulk-data CDC contract. CONCEPTUAL interface. §12's rule about not writing your own.
// An async FIFO is a solved problem with subtle correctness requirements
// (Gray-coded pointers, correctly synchronized in both directions, depth a
// power of two). This chapter does NOT reimplement one — a homemade async
// FIFO in a PCIe datapath is a liability, and vendor libraries and the
// repository's existing CDC primitives should be used instead.
//
// What IS this chapter's business is the CONTRACT the FIFO must satisfy,
// stated here so §16's assertions can check it and §18 can measure it.
module cdc_async_fifo_if #(
parameter int unsigned WIDTH = 512,
parameter int unsigned DEPTH = 32 // MUST be a power of two
)(
input logic wr_clk, wr_rst_n,
input logic wr_en,
input logic [WIDTH-1:0] wr_data,
output logic wr_full,
output logic wr_overflow, // an attempted write while full
input logic rd_clk, rd_rst_n,
input logic rd_en,
output logic [WIDTH-1:0] rd_data,
output logic rd_empty,
output logic rd_underflow // an attempted read while empty
);
// The contract, and the three things §18 measured breaking:
// 1. wr_en must never be asserted while wr_full. (105,801 overflows)
// 2. rd_en must never be asserted while rd_empty. (1,653 underflows)
// 3. DEPTH must be a power of two, or Gray pointers are invalid.
// Overflow and underflow are REPORTED here rather than being silent,
// because 25.1 §6's rule applies: a condition detected and discarded is
// indistinguishable from one never detected.
initial begin
if (DEPTH & (DEPTH-1))
$fatal(1, "cdc_async_fifo_if: DEPTH must be a power of two");
end
endmoduleBlock 9 — the reset epoch. SYNTHESIZABLE. §13, and the module §18's key result measures.
module reset_epoch (
input logic clk,
input logic rst_n,
input logic link_down, // the core reports the link left L0
input logic app_reset_req, // a software-initiated application reset
output fpga_pcie_pkg::epoch_t cur_epoch,
output logic epoch_changed,
output logic [31:0] epoch_count
);
import fpga_pcie_pkg::*;
logic link_down_d;
// The epoch advances on any event that can invalidate in-flight work.
// PCIe's own reset semantics are SPEC-DEFINED and are not restated here;
// this local epoch is IMPLEMENTATION POLICY, and its job is narrow:
// make a response issued before the event distinguishable from one issued
// after. §18 measured 133 such events producing 165 stale responses, all
// correctly discarded — and 62 misdeliveries when the check was removed.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cur_epoch <= '0; link_down_d <= 1'b0; epoch_count <= '0;
end else begin
link_down_d <= link_down;
if ((link_down && !link_down_d) || app_reset_req) begin
cur_epoch <= cur_epoch + 1'b1;
epoch_count <= epoch_count + 32'd1;
end
end
end
assign epoch_changed = (link_down && !link_down_d) || app_reset_req;
endmoduleBlock 10 — the application stream bridge. SYNTHESIZABLE. §10's generic interface.
module app_stream_bridge #(
parameter int unsigned DATA_BYTES = 64
)(
input logic clk,
input logic rst_n,
input logic in_valid,
input logic [DATA_BYTES*8-1:0] in_data,
input logic [DATA_BYTES-1:0] in_keep, // partial final beat
input logic in_last,
output logic in_ready,
output logic out_valid,
output logic [DATA_BYTES*8-1:0] out_data,
output logic [DATA_BYTES-1:0] out_keep,
output logic out_last,
input logic out_ready,
output logic [63:0] bytes_passed
);
// A GENERIC ready/valid stream. AXI is a vendor-ecosystem convention and
// is NOT required by PCIe (§10); this interface is deliberately protocol-
// neutral so the ownership properties can be stated without importing a
// bus specification.
//
// `keep` exists because the final beat of a transfer is usually partial.
// A bridge that ignores it silently rounds every transfer up to a beat
// boundary — 25.6 §4's dropped tail, in a new place (mutation 17).
assign out_valid = in_valid;
assign in_ready = out_ready;
assign out_data = in_data;
assign out_keep = in_keep;
assign out_last = in_last;
function automatic int unsigned popcount(input logic [DATA_BYTES-1:0] k);
popcount = 0;
for (int i = 0; i < DATA_BYTES; i++) popcount += k[i];
endfunction
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) bytes_passed <= '0;
else if (out_valid && out_ready)
bytes_passed <= bytes_passed + 64'(popcount(out_keep));
end
endmoduleBlock 11 — the local memory arbiter. SYNTHESIZABLE. §14.
module local_mem_arbiter #(
parameter int unsigned NREQ = 2 // DMA and accelerator
)(
input logic clk,
input logic rst_n,
input logic [NREQ-1:0] req_valid,
input logic [NREQ-1:0] req_is_write,
input logic [31:0] req_addr [NREQ],
output logic [NREQ-1:0] req_grant,
output logic mem_valid,
output logic [31:0] mem_addr,
output logic mem_is_write,
input logic mem_ready,
// responses must return to the RECORDED owner, not the current requester
input logic rsp_valid,
input logic [fpga_pcie_pkg::gw(NREQ)-1:0] rsp_id,
output logic [NREQ-1:0] rsp_route,
output logic rsp_misroute
);
import fpga_pcie_pkg::*;
logic [gw(NREQ)-1:0] rr, sel;
logic found;
logic [NREQ-1:0] outstanding;
always_comb begin
sel = rr; found = 1'b0;
for (int k = 0; k < NREQ; k++) begin
automatic logic [gw(NREQ)-1:0] i = gw(NREQ)'((rr + k) % NREQ);
if (!found && req_valid[i]) begin sel = i; found = 1'b1; end
end
mem_valid = found;
mem_addr = req_addr[sel];
mem_is_write = req_is_write[sel];
req_grant = '0;
if (found && mem_ready) req_grant[sel] = 1'b1;
// The response is routed by the RECORDED requester id carried with the
// transaction — never by whoever is being served now. Counterexample E
// is the alternative, and it is the same fault 26.4 §16's identity
// oracle exists to catch.
rsp_route = '0;
if (rsp_valid) rsp_route[rsp_id] = 1'b1;
rsp_misroute = rsp_valid && !outstanding[rsp_id];
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rr <= '0; outstanding <= '0;
end else begin
if (found && mem_ready) begin
rr <= (sel == gw(NREQ)'(NREQ-1)) ? '0 : sel + 1'b1;
outstanding[sel] <= 1'b1;
end
if (rsp_valid) outstanding[rsp_id] <= 1'b0;
end
end
endmoduleBlock 12 — performance counters and first-failure capture. VERIFICATION-ONLY / diagnostic. §15 and §16.
module perf_and_capture #(
parameter int unsigned NTAG = 32
)(
input logic clk,
input logic rst_n,
// per-stage activity, so §17's limiting stage can be identified in silicon
input logic pcie_beat, input logic [15:0] pcie_bytes,
input logic app_beat, input logic [15:0] app_bytes,
input logic stall_credit, stall_tag, stall_fifo_full,
input logic stall_fifo_empty, stall_localmem,
// first-failure inputs
input logic fifo_overflow, fifo_underflow, cpl_stale,
input logic byte_mismatch, rsp_misroute,
input logic [4:0] ltssm,
input logic [7:0] tag_snapshot,
input logic [31:0] job_snapshot,
input logic [15:0] fifo_level,
input logic clear,
output logic [63:0] c_pcie_bytes, c_app_bytes,
output logic [31:0] c_credit, c_tag, c_full, c_empty, c_localmem,
output logic captured,
output logic [3:0] first_cause,
output logic [4:0] cap_ltssm,
output logic [7:0] cap_tag,
output logic [31:0] cap_job,
output logic [15:0] cap_fifo_level,
output logic [31:0] cap_cycle
);
logic [31:0] cycle;
logic [3:0] this_cause;
// §16: in hardware, ONE well-chosen snapshot at the first failure is worth
// more than hours of trace capture — 25.9 §3 measured that the link-side
// view cannot see any of these signals at all.
always_comb begin
if (fifo_overflow) this_cause = 4'd1;
else if (fifo_underflow) this_cause = 4'd2;
else if (byte_mismatch) this_cause = 4'd3;
else if (rsp_misroute) this_cause = 4'd4;
else if (cpl_stale) this_cause = 4'd5;
else this_cause = 4'd0;
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n || clear) begin
c_pcie_bytes <= '0; c_app_bytes <= '0;
c_credit <= '0; c_tag <= '0; c_full <= '0; c_empty <= '0; c_localmem <= '0;
captured <= 1'b0; first_cause <= '0; cycle <= '0;
cap_ltssm <= '0; cap_tag <= '0; cap_job <= '0; cap_fifo_level <= '0; cap_cycle <= '0;
end else begin
cycle <= cycle + 32'd1;
if (pcie_beat && c_pcie_bytes != '1) c_pcie_bytes <= c_pcie_bytes + 64'(pcie_bytes);
if (app_beat && c_app_bytes != '1) c_app_bytes <= c_app_bytes + 64'(app_bytes);
// Counting BEATS ACCEPTED, never `valid` cycles. A counter incremented
// on valid alone overstates throughput whenever the sink stalls, which
// is mutation 33 and makes every §17 measurement wrong.
if (stall_credit && c_credit != '1) c_credit <= c_credit + 1'b1;
if (stall_tag && c_tag != '1) c_tag <= c_tag + 1'b1;
if (stall_fifo_full && c_full != '1) c_full <= c_full + 1'b1;
if (stall_fifo_empty && c_empty != '1) c_empty <= c_empty + 1'b1;
if (stall_localmem && c_localmem!= '1) c_localmem<= c_localmem + 1'b1;
if (!captured && (this_cause != 4'd0)) begin
captured <= 1'b1;
first_cause <= this_cause;
cap_ltssm <= ltssm;
cap_tag <= tag_snapshot;
cap_job <= job_snapshot;
cap_fifo_level <= fifo_level;
cap_cycle <= cycle;
end
end
end
endmodule9. Same-Cycle Audit
10. Interfaces: What PCIe Requires and What It Does Not
PCIe requires nothing about your internal buses. It defines what crosses the link.
AXI is a vendor-ecosystem convention, extremely common in FPGA flows and entirely absent from the PCIe specification. Using it is a reasonable choice; describing it as a PCIe requirement is the §4 trap, and it misleads anyone reading the design who has not used that ecosystem.
§8 Block 10 therefore uses a generic ready/valid stream with data, keep and last. Those three concepts are what the ownership properties actually need:
valid/ready— the transfer contract (§20's P-series);keep— the partial final beat, without which every transfer silently rounds up to a beat boundary;last— the transfer boundary, so byte conservation can be checked per job.
If your design uses AXI, map to it in one place, exactly as Block 2 maps the vendor PCIe interface in one place. The reasoning above the mapping stays portable.
11. Clock Domains: Pick the Mechanism Per Path
The multi-bit case is the one that produces the chapter's signature bug, and it is worth stating precisely why it fails.
Two-flop synchronizing every bit of a bus makes each bit individually metastability-safe. That is true and it is not sufficient. The bits do not all resolve on the same clock edge: when the source word changes, some bits may propagate in cycle N and others in cycle N+1. The destination then samples a word that is a mixture of the old and new values — a value that never existed in the source domain.
A 64-bit descriptor address crossing this way can land anywhere. Every bit is safe; the address is garbage. This is counterexample A, and §22 case 4 is what it looks like in the lab.
Do not write your own async FIFO for the bulk path. Gray-coded pointers, correctly synchronized in both directions, and a power-of-two depth are all required and all easy to get subtly wrong. Use the vendor library or the repository's existing CDC primitive — Block 8 deliberately specifies the contract rather than reimplementing the mechanism.
12. Reset and the Epoch
An FPGA card has several resets that are not the same thing: the PCIe core's reset, the application clock's reset, a software-initiated function reset, and any local block reset. Assuming they are identical is a design bug, and the exact PCIe reset semantics are spec-defined and not restated here (§1).
What this chapter owns is the consequence for in-flight work. After a link event or an application reset, Completions for requests issued before the event may still arrive. They are not errors — they are the normal result of an asynchronous system being interrupted.
An epoch makes them distinguishable (§8 Block 9). Each Tag records the epoch it was allocated under; a Completion whose epoch does not match the Tag's current epoch is discarded and counted.
§18 measured this exactly. Across 133 reset events, the correct design saw 165 stale responses and misdelivered none. Removing the epoch comparison produced 62 misdeliveries carrying 70,976 bytes into the wrong job's buffer — measured by a ground-truth job serial that no modelled mechanism reads, which is 25.7 §13's oracle discipline.
The epoch is IMPLEMENTATION POLICY. Its width bounds how many resets can occur before a very old response could alias onto a matching epoch again, and P30 is the property that bounds it.
13. Local Memory Arbitration
The DMA engine and the accelerator both want local memory, and §17 measured that this path is very often the actual throughput limit.
Three requirements, all in §8 Block 11:
Ownership. A request carries the identity of its requester, and the response routes by that recorded identity — never by whoever the arbiter is serving when the response returns (counterexample E).
Fairness, stated explicitly. Round robin here; a weighted scheme is equally valid. What matters is that the policy is written down and asserted (P24), because an emergent policy under saturation starves someone.
And stall stability. A request that has been presented and not accepted must not change (P8's family).
This chapter does not build a memory controller. DDR timing, refresh, bank management and scheduling are a separate discipline; Block 11 shows the arbitration and ownership boundary only.
14. Observability
25.9 §13 measured that three of five fault classes produced a byte-identical link trace. Every one of those faults lives on the application side of this chapter's boundary.
So the instrumentation is not optional, and §8 Block 12 provides two kinds:
Per-stage counters, so §17's limiting stage can be identified in silicon rather than guessed. Count beats accepted, never valid cycles — mutation 33 is that error, and it overstates throughput exactly when the sink is stalling, which is precisely when you are looking.
And first-failure capture. One snapshot — LTSSM state, Tag, job id, FIFO level, cycle, and the first cause — taken at the first anomaly and held. In hardware debugging this is frequently worth more than hours of trace capture, because a trace tells you what happened around the moment you triggered, and the first failure usually happened long before.
15. Throughput Is the Minimum Stage
This is §17's measurement and the most practically useful result in the module.
internal throughput = bytes_per_beat × accepted_beats_per_cycle × clock_frequencyEvery stage in the chain has such a rate, and the end-to-end result is the minimum. A faster PCIe generation does not fix a narrow local interface, and §17 measured that the improvement from doubling the PCIe interface was exactly 0.0% when local DDR was the limit.
Latency and throughput are different questions. A control MMIO read's latency is a round trip; a streaming transfer's throughput is the minimum stage. Optimising BAR register reads to improve bulk throughput is optimising the wrong quantity (§6), and 22.2 owns the distinction.
16. Measured Behaviour — The Throughput Pipeline
| stage | bytes/beat | accept rate | MHz | GB/s |
|---|---|---|---|---|
| PCIe application interface | 64 | 1.00 | 250 | 16.00 |
| DMA engine | 64 | 0.90 | 250 | 14.40 |
| CDC async FIFO | 64 | 1.00 | 250 | 16.00 |
| local DDR path | 32 | 0.85 | 200 | 5.44 |
| accelerator input | 64 | 0.75 | 300 | 14.40 |
End-to-end limit: 5.44 GB/s, set by the local DDR path.
| change | new stage rate | end-to-end | improvement |
|---|---|---|---|
| double the PCIe interface (64→128 B/beat) | 32.00 GB/s | 5.44 GB/s | 0.0% |
| widen the local DDR path (32→64 B/beat) | 10.88 GB/s | 10.88 GB/s | +100% |
Three readings.
Doubling PCIe produced no improvement whatsoever, and produced no error message either. The design simply performs identically — the least informative possible outcome, and the reason §14's per-stage counters exist.
The limiting stage is 34% of the PCIe interface's rate. A design specified from the link's capability would be sized nearly three times too optimistically.
And after widening DDR, DDR is still the limit at 10.88 GB/s. The bottleneck moved within the same stage rather than to another one, which is a useful reminder that one fix does not necessarily relocate the constraint.
17. Measured Behaviour — The Ownership Boundaries
| configuration | described | moved | deficit | FIFO ovf | underflow | stale ep | tail loss | early int | misdelivered |
|---|---|---|---|---|---|---|---|---|---|
| correct | 7,034,304 | 7,034,304 | 0 | 0 | 0 | 165 | 0 | 0 | 0 |
| descriptor read live | 7,171,904 | 7,028,032 | 143,872 | 0 | 0 | 189 | 0 | 0 | 0 |
| stale-epoch accepted | 7,036,352 | 7,036,352 | 0 | 0 | 0 | 160 | 0 | 0 | 62 |
| FIFO full check removed | 7,027,904 | 7,027,904 | 0 | 105,801 | 0 | 179 | 0 | 0 | 0 |
| FIFO empty check removed | 7,067,136 | 7,067,136 | 0 | 0 | 1,653 | 157 | 0 | 0 | 0 |
| final partial beat dropped | 7,296,896 | 7,033,280 | 263,616 | 0 | 0 | 184 | 4,119 | 0 | 0 |
| interrupt before status | 7,034,304 | 7,034,304 | 0 | 0 | 0 | 165 | 0 | 5,178 | 0 |
Baseline verified clean: deficit 0, overflow 0, underflow 0, tail loss 0, early interrupt 0, misdelivery 0 — asserted before any other row was computed. 133 reset events during the run produced 165 stale responses, all correctly discarded.
Four readings.
Removing the epoch check misdelivered 62 responses carrying 70,976 bytes into the wrong job's buffer. Every one is a Completion that was legitimately issued before a reset and legitimately arrived after it — the design's error is accepting it, not the link's error in delivering it.
A live descriptor read lost 143,872 bytes when the host mutated a descriptor it had already handed over.
The dropped partial beat cost 263,616 bytes across 4,119 transfers — every transfer whose length was not a beat multiple.
And one mutation produced a measured non-result. Freeing a Tag before job retirement generated 5,169 events and 0 misdeliveries in this topology, because the response had already arrived by the time the Tag was freed. It is a real hazard that this configuration does not expose — reported as measured rather than adjusted until it produced a number, which is the same discipline 25.8 §13 applied to its individually-safe dependencies.
18. Assertions
Adapter and interface properties — §3, §10.
// P1 — the adapter buffers nothing: ready passes straight through.
property p1_adapter_transparent;
@(posedge clk) disable iff (!rst_n)
(core_rx_ready == app_rx_ready) && (app_tx_ready == core_tx_ready);
endproperty
a_p1: assert property (p1_adapter_transparent);
// P2 — a transfer offered and not accepted stays stable and stays offered.
property p2_stream_stable;
@(posedge clk) disable iff (!rst_n)
(out_valid && !out_ready) |=> (out_valid && $stable(out_data) &&
$stable(out_keep) && $stable(out_last));
endproperty
a_p2: assert property (p2_stream_stable);
// P3 — valid does not depend on ready.
property p3_valid_independent;
@(posedge clk) disable iff (!rst_n)
(out_valid && !out_ready) |=> out_valid;
endproperty
a_p3: assert property (p3_valid_independent);
// P4 — a non-final beat is full; only the last beat may be partial.
property p4_keep_only_last;
@(posedge clk) disable iff (!rst_n)
(out_valid && out_ready && !out_last) |-> (out_keep == '1);
endproperty
a_p4: assert property (p4_keep_only_last);
// P5 — negotiated width is reported, never capability (26.2 §4).
property p5_negotiated_reported;
@(posedge clk) disable iff (!rst_n)
app_link_up |-> (app_neg_width == core_neg_width);
endproperty
a_p5: assert property (p5_negotiated_reported);BAR control-path properties — §8 Block 3.
// P6 — an out-of-range or misaligned offset is reported, never wrapped.
property p6_offset_checked;
@(posedge clk) disable iff (!rst_n)
(req_valid && (((req_offset >> 2) >= NREG) || (req_offset[1:0] != 2'b00)))
|-> offset_error;
endproperty
a_p6: assert property (p6_offset_checked);
// P7 — a captured read response is held stable under backpressure and is
// NOT re-read from the live register file.
property p7_read_response_stable;
@(posedge clk) disable iff (!rst_n)
(rsp_valid && !rsp_ready) |=> (rsp_valid && $stable(rsp_rdata));
endproperty
a_p7: assert property (p7_read_response_stable);
// P8 — no new request is accepted while a response is pending.
property p8_one_outstanding;
@(posedge clk) disable iff (!rst_n)
rsp_pending |-> !req_ready;
endproperty
a_p8: assert property (p8_one_outstanding);Tag and epoch properties — §12.
// P9 — a Tag is allocated only when free.
property p9_tag_free_on_alloc;
@(posedge clk) disable iff (!rst_n)
(alloc_req && alloc_gnt) |-> !busy[alloc_tag];
endproperty
a_p9: assert property (p9_tag_free_on_alloc);
// P10 — a Tag is freed on the LAST Completion, not the first.
property p10_free_on_last;
@(posedge clk) disable iff (!rst_n)
(cpl_accept && !cpl_last) |=> busy[$past(cpl_tag)];
endproperty
a_p10: assert property (p10_free_on_last);
// P11 — no Tag leaks: an allocated Tag is eventually freed.
property p11_no_tag_leak;
@(posedge clk) disable iff (!rst_n)
(alloc_req && alloc_gnt) |-> s_eventually !busy[$past(alloc_tag)];
endproperty
a_p11: assert property (p11_no_tag_leak);
// P12 — a Completion whose epoch does not match its Tag's epoch is rejected.
// §17 measured removing this: 62 misdeliveries, 70,976 bytes.
property p12_stale_epoch_rejected;
@(posedge clk) disable iff (!rst_n)
(cpl_valid && busy[cpl_tag] && (tag_epoch[cpl_tag] != cpl_epoch)) |-> !cpl_accept;
endproperty
a_p12: assert property (p12_stale_epoch_rejected);
// P13 — a Completion for a Tag that is not outstanding is rejected.
property p13_orphan_cpl_rejected;
@(posedge clk) disable iff (!rst_n)
(cpl_valid && !busy[cpl_tag]) |-> !cpl_accept;
endproperty
a_p13: assert property (p13_orphan_cpl_rejected);
// P14 — a stale Completion is COUNTED, never silently dropped.
property p14_stale_counted;
@(posedge clk) disable iff (!rst_n)
cpl_stale |=> (stale_count > $past(stale_count));
endproperty
a_p14: assert property (p14_stale_counted);
// P15 — the epoch advances on a reset or link-down event.
property p15_epoch_advances;
@(posedge clk) disable iff (!rst_n)
epoch_changed |=> (cur_epoch != $past(cur_epoch));
endproperty
a_p15: assert property (p15_epoch_advances);
// P16 — the epoch does not change otherwise.
property p16_epoch_stable;
@(posedge clk) disable iff (!rst_n)
!epoch_changed |=> $stable(cur_epoch);
endproperty
a_p16: assert property (p16_epoch_stable);DMA conservation properties — 25.6 §4's laws, applied here.
// P17 — the descriptor is snapshotted; the job record never changes.
// §17 measured a live read losing 143,872 bytes.
property p17_descriptor_immutable;
@(posedge clk) disable iff (!rst_n)
(active && !job_done) |=> (active && $stable(job));
endproperty
a_p17: assert property (p17_descriptor_immutable);
// P18 — bytes issued equal bytes described, at job completion.
property p18_bytes_conserved;
@(posedge clk) disable iff (!rst_n)
job_done |-> (bytes_issued == bytes_described);
endproperty
a_p18: assert property (p18_bytes_conserved);
// P19 — the engine never issues MORE than described. Two-sided, per
// 25.6 §4 law 1: a one-sided check reports an overrun as success.
property p19_no_overrun;
@(posedge clk) disable iff (!rst_n)
(bytes_issued <= bytes_described);
endproperty
a_p19: assert property (p19_no_overrun);
// P20 — the final chunk may be partial and must not be dropped.
// §17 measured 4,119 lost tails and a 263,616-byte deficit.
property p20_final_chunk;
@(posedge clk) disable iff (!rst_n)
(active && (remaining > 0) && (remaining < MAX_CHUNK))
|-> (req_out.len_bytes == 12'(remaining));
endproperty
a_p20: assert property (p20_final_chunk);
// P21 — a zero-byte job issues no request.
property p21_zero_bytes;
@(posedge clk) disable iff (!rst_n)
(desc_valid && desc_ready && (desc_in.bytes == 0)) |=> !req_valid;
endproperty
a_p21: assert property (p21_zero_bytes);
// P22 — nothing is issued without a Tag.
property p22_tag_before_issue;
@(posedge clk) disable iff (!rst_n)
req_valid |-> tag_gnt;
endproperty
a_p22: assert property (p22_tag_before_issue);FIFO and CDC properties — §11.
// P23 — never write a full FIFO. §17 measured 105,801 overflows.
property p23_no_overflow;
@(posedge clk) disable iff (!rst_n)
wr_en |-> !wr_full;
endproperty
a_p23: assert property (p23_no_overflow);
// P24 — never read an empty FIFO. §17 measured 1,653 underflows.
property p24_no_underflow;
@(posedge clk) disable iff (!rst_n)
rd_en |-> !rd_empty;
endproperty
a_p24: assert property (p24_no_underflow);
// P25 — full and empty are never simultaneously true.
property p25_full_empty_exclusive;
@(posedge clk) disable iff (!rst_n)
!(wr_full && rd_empty && (DEPTH > 0));
endproperty
a_p25: assert property (p25_full_empty_exclusive);
// P26 — the handshake holds its data stable for the whole crossing.
// Counterexample A is what happens without this.
property p26_cdc_data_stable;
@(posedge src_clk) disable iff (!src_rst_n)
(src_req != src_ack) |=> $stable(hold);
endproperty
a_p26: assert property (p26_cdc_data_stable);
// P27 — the destination samples data only when dst_valid is asserted.
property p27_cdc_sample_qualified;
@(posedge dst_clk) disable iff (!dst_rst_n)
dst_valid |-> (dst_req != dst_ack_sync);
endproperty
a_p27: assert property (p27_cdc_sample_qualified);Local memory and notification properties — §13.
// P28 — a memory response routes to the RECORDED owner.
property p28_response_to_owner;
@(posedge clk) disable iff (!rst_n)
rsp_valid |-> (rsp_route == (1 << rsp_id));
endproperty
a_p28: assert property (p28_response_to_owner);
// P29 — a response for a requester with nothing outstanding is reported.
property p29_misroute_reported;
@(posedge clk) disable iff (!rst_n)
(rsp_valid && !outstanding[rsp_id]) |-> rsp_misroute;
endproperty
a_p29: assert property (p29_misroute_reported);
// P30 — at most one grant per cycle, and only to a requester.
property p30_grant_onehot0;
@(posedge clk) disable iff (!rst_n)
$onehot0(req_grant) and ((req_grant & ~req_valid) == '0);
endproperty
a_p30: assert property (p30_grant_onehot0);
// P31 — the interrupt follows the status record, never precedes it.
// §17 measured 5,178 events raised ahead of their record.
property p31_int_after_status;
@(posedge clk) disable iff (!rst_n)
evt_valid |-> $past(status_accepted);
endproperty
a_p31: assert property (p31_int_after_status);Cover — the anti-vacuity set.
// P32 — every DMA job reaches a terminal state: it completes, or it is
// invalidated by an epoch change. A job that reaches neither holds its Tag
// forever, and §21 case 6's leak is exactly this failure accumulating.
property p32_job_terminal;
@(posedge clk) disable iff (!rst_n)
(desc_valid && desc_ready) |-> s_eventually (job_done || epoch_changed);
endproperty
a_p32: assert property (p32_job_terminal);
// P32's covers — the rare states must occur. §17's model needed randomly injected
// resets to produce any stale Completions at all; without them P12, P14,
// P15 and P16 are all vacuously satisfied.
c1_epoch_change: cover property (@(posedge clk) disable iff (!rst_n) epoch_changed);
c2_stale_cpl: cover property (@(posedge clk) disable iff (!rst_n) cpl_stale);
c3_fifo_full: cover property (@(posedge clk) disable iff (!rst_n) wr_full);
c4_fifo_empty: cover property (@(posedge clk) disable iff (!rst_n) rd_empty);
c5_partial_beat: cover property (@(posedge clk) disable iff (!rst_n)
out_valid && out_last && (out_keep != '1));
c6_tag_exhaust: cover property (@(posedge clk) disable iff (!rst_n) free_count == 0);
c7_mem_conflict: cover property (@(posedge clk) disable iff (!rst_n) req_valid == '1);
c8_offset_err: cover property (@(posedge clk) disable iff (!rst_n) offset_error);
c9_status_stall: cover property (@(posedge clk) disable iff (!rst_n)
status_valid && !status_ready);19. Executable Counterexamples
Counterexample A — two-flop synchronizing a multi-bit command (violates P26).
// Every bit of a 64-bit descriptor address crosses through its own
// two-flop synchronizer.
module ce_a_multibit_two_flop (
input logic dst_clk, dst_rst_n,
input logic [63:0] src_addr,
output logic [63:0] dst_addr
);
(* ASYNC_REG = "TRUE" *) logic [63:0] s1, s2;
always_ff @(posedge dst_clk or negedge dst_rst_n)
if (!dst_rst_n) begin s1 <= '0; s2 <= '0; end
else begin s1 <= src_addr; s2 <= s1; end
assign dst_addr = s2; // <-- 64 independent crossings
endmodule
// Failing stimulus: src_addr changes from 64'h0000_0000_FFFF_FFFF to
// 64'h0000_0001_0000_0000 — 33 bits change at once.
// Golden: the destination sees one value or the other.
// This: bits resolve on different edges, so the destination can sample
// 64'h0000_0001_FFFF_FFFF — a value that NEVER EXISTED in the
// source domain.
// P26 fails.
// Observable consequence: a DMA write to an address neither the host nor
// the FPGA ever computed. Every bit is metastability-safe and the word is
// incoherent. §22 case 4 is what this looks like in hardware, and it is
// invisible in simulation without CDC-aware modelling.Counterexample B — the stale pre-reset response (violates P12).
// Completions are matched on Tag alone, with no epoch comparison.
module ce_b_no_epoch (
input logic cpl_valid,
input logic [4:0] cpl_tag,
input logic [31:0] busy,
output logic cpl_accept
);
assign cpl_accept = cpl_valid && busy[cpl_tag]; // <-- no epoch
endmodule
// Failing stimulus: job A issues a read on Tag 9; the link drops and
// recovers, reclaiming outstanding Tags; job B is issued on Tag 9; job A's
// Completion arrives.
// Golden: rejected as stale, counted, discarded.
// This: accepted and credited to job B.
// P12 fails.
// §17 measured 62 misdeliveries carrying 70,976 bytes into wrong buffers
// across 133 reset events. The correct design saw 165 stale responses and
// misdelivered none — late responses are NORMAL, and discarding them is
// the design's job.Counterexample C — the FIFO full check one cycle late (violates P23).
// The full flag is registered, so the write enable is qualified by a
// value that is one cycle old.
module ce_c_late_full_check (
input logic clk, rst_n,
input logic [15:0] level, input logic DEPTH_i,
input logic wr_req,
output logic wr_en
);
logic full_r;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) full_r <= 1'b0;
else full_r <= (level >= 16'd32); // <-- registered
assign wr_en = wr_req && !full_r;
endmodule
// Failing stimulus: the FIFO reaches depth in cycle N with a write also
// requested in cycle N+1.
// Golden: wr_en is low in N+1.
// This: full_r still reflects cycle N-1, so wr_en is high and a write
// lands in a full FIFO.
// P23 fails.
// §17 measured 105,801 overflows. Observable consequence: silently
// corrupted payload, with byte conservation still balancing because the
// bytes were counted as written.Counterexample D — the Tag freed on the first Completion (violates P10).
// A read answered by several split Completions frees its Tag on the first.
module ce_d_free_on_first (
input logic clk, rst_n,
input logic cpl_accept, input logic [4:0] cpl_tag,
output logic [31:0] busy
);
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) busy <= '0;
else if (cpl_accept) busy[cpl_tag] <= 1'b0; // <-- ignores cpl_last
endmodule
// Failing stimulus: a 256-byte read split into four Completions at RCB.
// Golden: the Tag is freed on the fourth.
// This: freed on the first. The Tag is reissued while three Completions
// for the old request are still in flight, and they are credited
// to the new one.
// P10 fails, and P12 cannot save it — the epoch is unchanged, so the stale
// Completions match. This is 25.7 §7's misdelivery window reproduced by a
// design error rather than by a timeout.Counterexample E — the memory response routed by current selection (violates P28).
// The local memory response is delivered to whichever requester the
// arbiter is serving at the moment the response returns.
module ce_e_route_by_current (
input logic rsp_valid,
input logic [0:0] arbiter_sel_now,
output logic [1:0] rsp_route
);
always_comb begin
rsp_route = '0;
if (rsp_valid) rsp_route[arbiter_sel_now] = 1'b1; // <-- not the owner
end
endmodule
// Failing stimulus: the accelerator issues a read; the arbiter then grants
// the DMA engine; the accelerator's read data returns.
// Golden: routed to the accelerator, which recorded the request.
// This: routed to the DMA engine.
// P28 fails, P29 fails.
// Observable consequence: the DMA engine writes the accelerator's data to
// host memory and the accelerator waits forever. This is 26.4 §16's
// identity fault in a new place, and a conservation check on outstanding
// counts does not see it.20. Verification — Mutations
Thirty-eight mutations. Every "Caught by" entry names a property from §18.
| # | Mutation | Symptom | Caught by |
|---|---|---|---|
| 1 | User RTL assumed to own the LTSSM | duplicated state machine fights the core | P5 |
| 2 | Vendor signal names used above the adapter | a core change becomes a redesign (§4) | P1 |
| 3 | AXI presented as a PCIe requirement | design reasoning imports a bus spec (§10) | P2 |
| 4 | Adapter buffers a transfer | its contents diverge from both sides | P1 |
| 5 | Capability width reported instead of negotiated | bandwidth budgets built on a wrong number | P5 |
| 6 | BAR offset wrapped instead of range-checked | host bug becomes a silent write | P6 |
| 7 | Read response re-read from the live register file | host receives a value it never requested | P7 |
| 8 | Second request accepted while a response is pending | responses interleave and mismatch | P8 |
| 9 | Bulk data moved through the BAR path | host stalled for the whole transfer (§6) | P8 |
| 10 | Tag allocated while busy | two requests share a Tag | P9 |
| 11 | Tag freed on the first split Completion | misdelivery (counterexample D) | P10 |
| 12 | Tag never freed on an error Completion | the pool drains; the engine stalls | P11 |
| 13 | Epoch check removed | 62 misdeliveries, 70,976 bytes (§17) | P12 |
| 14 | Completion accepted for a non-outstanding Tag | orphan data written into a live buffer | P13 |
| 15 | Stale Completion discarded silently | indistinguishable from never arriving | P14 |
| 16 | Epoch not advanced on link-down | pre-reset responses match post-reset Tags | P15 |
| 17 | Epoch advanced on unrelated events | valid Completions rejected; transfers lost | P16 |
| 18 | Epoch advanced combinationally | a legitimate same-cycle Completion rejected (§9 audit C) | P15 |
| 19 | Descriptor read live from host memory | 143,872-byte deficit (§17) | P17 |
| 20 | Byte conservation checked one-sided | an overrun reported as success | P19 |
| 21 | Chunk count by truncating division | 4,119 lost tails, 263,616 bytes (§17) | P20 |
| 22 | Zero-byte job issues one request | a transfer for a job describing none | P21 |
| 23 | Request issued before a Tag is granted | Tag field is stale or duplicated | P22 |
| 24 | Multi-bit command two-flop synchronized | incoherent word (counterexample A) | P26 |
| 25 | Pulse crossed by a level synchronizer | the event is missed entirely | P26, P27 |
| 26 | CDC data not held stable across the crossing | destination samples a changing word | P26 |
| 27 | Destination samples data without qualifying on valid | reads a stale or partial word | P27 |
| 28 | FIFO full flag registered | 105,801 overflows (counterexample C, §17) | P23 |
| 29 | FIFO read without checking empty | 1,653 underflows (§17) | P24 |
| 30 | FIFO pointer width one bit too small | wrap aliases full with empty | P25 |
| 31 | FIFO depth not a power of two | Gray-coded pointers become invalid | P25 |
| 32 | Non-final beat has a partial keep | bytes silently dropped mid-transfer | P4 |
| 33 | Counters increment on valid, not on accepted beats | throughput overstated exactly when stalling (§14) | P2 |
| 34 | Local memory response routed by current selection | wrong requester receives the data (counterexample E) | P28 |
| 35 | Misroute detected but not reported | a silent data swap | P29 |
| 36 | Arbiter grants a non-requesting port | a port owns work it never asked for | P30 |
| 37 | Interrupt raised with the status write | 5,178 early events (§17) | P31 |
| 38 | Testbench never injects a reset or link-down | P12, P14, P15, P16 all vacuous (§17) | P32 (c1, c2) |
Mutations 13 and 24 are the two that define this chapter. One is invisible without reset injection; the other is invisible without CDC-aware simulation. Both pass ordinary functional verification completely.
21. Debugging
22. Misconceptions
"An FPGA PCIe design implements PCIe." The core owns the PHY, LTSSM, Data Link Layer and much of the Transaction Layer (§2). You own everything above the application interface.
"The core's interface is the PCIe standard." It is one vendor's transaction-level interface (§4). Normalizing it in one adapter is what keeps the design portable and reviewable.
"AXI is required by PCIe." It is a vendor-ecosystem convention and appears nowhere in the PCIe specification (§10).
"The card is x8, so we get x8 bandwidth." Capability is not the negotiated width (26.2 §4, P5), and §16 measured the negotiated link being irrelevant when a later stage is slower.
"A faster PCIe generation will fix the throughput." §16 measured doubling the PCIe interface giving 0.0% because local DDR was the limit.
"The bottleneck is where the design is most complex." It is wherever bytes_per_beat × accept_rate × frequency is smallest (§15). In §16 that was a 32-byte DDR path at 200 MHz — the simplest stage in the chain.
"Two flip-flops make any signal safe to cross." They make each bit metastability-safe. A multi-bit bus becomes incoherent (§11, counterexample A) — a word that never existed in the source domain.
"A pulse can cross through a synchronizer." Only if it is at least as long as the destination clock period. Otherwise it is missed entirely (mutation 25).
"We'll write our own async FIFO." Gray pointers, dual-direction synchronization and power-of-two depth are all required and all easy to get subtly wrong (§8 Block 8). Use the vendor or repository primitive.
"It works in simulation, so the CDC is fine." Ordinary RTL simulation shows synchronizers as clean flops (§21 case 3). Counterexample A's failure cannot appear without CDC-aware modelling.
"All the resets are the same reset." The core reset, the application reset, a function-level reset and local resets are distinct (§12). Assuming they coincide is a design bug.
"Stale Completions after a reset mean something is broken." The correct design saw 165 of them across 133 resets and misdelivered none (§17). They are the normal result of interrupting an asynchronous system.
"Tag plus Requester ID is enough to match a Completion." Not across a reset boundary (§12). Without an epoch, a pre-reset Completion matches a post-reset Tag — §17 measured 62 misdeliveries and 70,976 bytes.
"Freeing the Tag when data arrives is fine." Only on the last Completion of a split series (P10, counterexample D). Freeing on the first reissues the Tag while three replies are still in flight.
"Byte conservation balanced, so the transfer was correct." Not if the FIFO overflowed — the bytes were counted as written and then destroyed (counterexample C). Conservation and overflow are different oracles, which is 26.4 §16's result again.
"The counters show we are at full rate." Check whether they count accepted beats or valid cycles (mutation 33). Counting valid overstates throughput exactly when the sink is stalling, which is when you are looking.
"An interrupt means the result is ready." Only if it was sequenced after the status write was accepted (P31). §17 measured 5,178 that were not.
"A memory response obviously goes back to whoever asked." Only if it is routed by a recorded owner id (P28, counterexample E). Routing by current arbiter selection silently swaps two requesters' data.
"An ILA trace will find it." 25.9 §13 measured three of five fault classes leaving a byte-identical link trace, and a trace begins where you triggered it. One first-failure snapshot is usually worth more (§14, §8 Block 12).
23. Understanding Check
Q1. A command bus crosses from the PCIe user clock to the accelerator clock through two flip-flops on every bit. Why can every individual bit be metastability-safe while the command word is still logically incoherent?
Because the bits do not all resolve on the same clock edge (§11, counterexample A). Each two-flop chain independently guarantees that its bit settles to a stable 0 or 1 before being used — that is what metastability protection means, and it is genuinely provided per bit. But when the source word changes, some bits may propagate in destination cycle N and others in cycle N+1, so the destination samples a bitwise mixture of the old and new values — a word that never existed in the source domain. A 64-bit address changing from 0x0000_0000_FFFF_FFFF to 0x0000_0001_0000_0000 can be sampled as 0x0000_0001_FFFF_FFFF. The correct mechanism is a handshake with the data held stable in the source domain while a single-bit request toggle crosses (§8 Block 7, P26).
Q2. Doubling your PCIe interface width produced no measurable improvement. What went wrong, and what should have been measured first?
Nothing went wrong — the PCIe interface was not the limit (§15, §16). End-to-end throughput is the minimum of bytes_per_beat × accept_rate × clock_frequency across every stage, and §16 measured local DDR at 5.44 GB/s against a PCIe interface at 16.00. Doubling the interface to 32.00 GB/s changed the end-to-end result by 0.0%; widening the DDR path instead gave +100%. The per-stage counters should have been read first (§8 Block 12) — and note that improving the wrong stage produces no error and no signal, just identical performance, which is the least informative outcome available.
Q3. After a link retrain your DMA writes land in the wrong buffer. The Completions are well-formed and carry valid Tags. Explain.
They were issued before the retrain and arrived after it, and the Tag has been reissued (§12, counterexample B). (Requester ID, Tag) is the entire identity of a Completion, so a pre-reset reply is indistinguishable from the current job's. An epoch — a generation number recorded per Tag at allocation and compared on every Completion — is what separates them. §17 measured the correct design seeing 165 stale responses across 133 resets and misdelivering none, against 62 misdeliveries carrying 70,976 bytes with the comparison removed. The stale responses are not the fault; accepting them is.
Q4. Your byte conservation check balances and the data is corrupt. What else must be checked?
FIFO overflow (counterexample C, P23). A write into a full FIFO is counted as written — the bytes appear on both sides of the conservation equation — and then destroyed. §17 measured 105,801 overflows with byte conservation reporting zero deficit. Conservation and overflow are independent oracles, which is 26.4 §16's result appearing again: an invariant that holds under a fault is not evidence the fault is absent, and two fault classes need two oracles.
Q5. Why must a Tag be freed on the last Completion of a split series rather than the first?
Because the remaining Completions are still in flight and still carry that Tag (P10, counterexample D). Freeing on the first reply makes the Tag available for reissue while three replies to the old request are outstanding; those replies then match the new request and are credited to it. The epoch does not save you here — no reset occurred, so the epoch is unchanged and the stale Completions compare equal. This is 25.7 §7's misdelivery window produced by a design error rather than by a timeout.
Q6. A design works in simulation and fails in hardware. Name the two fault classes most likely responsible and why simulation missed them.
CDC and reset behaviour (§21 case 3). Ordinary RTL simulation models a two-flop synchronizer as two clean flops, so counterexample A's incoherent word cannot occur — every bit propagates on the same edge in the simulator and on different edges in silicon. And a directed testbench typically injects no link-down or reset events, so the entire stale-Completion path is unreachable: §17's model required randomly injected resets to generate any stale responses at all, and mutation 38 makes P12, P14, P15 and P16 vacuous without them.
Q7. Your performance counters report full rate while throughput is visibly low. What is the likely instrumentation error?
Counting valid cycles instead of accepted beats (§14, mutation 33). valid asserts whenever data is offered; a transfer only occurs when valid && ready. A counter incremented on valid alone therefore overstates throughput exactly in proportion to how much the sink is stalling — which is precisely the condition you are investigating. The instrument reports its best numbers when the design is at its worst. P2's stable-under-stall contract is the related property, and §8 Block 12 counts accepted beats for this reason.
Q8. Local memory responses are being delivered to the wrong requester, and your outstanding-count check reports clean. Why?
Because the response is routed by current arbiter selection rather than by a recorded owner (counterexample E, P28). The count of outstanding transactions is unchanged — one request was issued and one response returned — so a conservation-style check on counts sees nothing. The information that was lost is the identity pairing, and detecting it requires carrying the requester id with the transaction and routing by that recorded value (P29 reports the mismatch). This is 26.4 §16's conservation-vs-identity result in a third setting.
Q9. Why does this chapter tell you to normalize the vendor PCIe interface in a single adapter module?
Because it separates three things that otherwise fuse together (§3, §4, §8 Block 2). It makes the design portable — a core or vendor change becomes an adapter rewrite rather than a redesign. It makes it testable — application logic can be verified against normalized types with no vendor IP instantiated. And it makes it reviewable — a reviewer who does not know that vendor's interface can still check ownership, backpressure and conservation, which is where the bugs actually are. The failure mode without it is documentation that cannot be checked against the PCIe specification (§4), so errors in it are never caught.
24. The Five Systems Compared
Not a feature matrix — a comparison of engineering ownership.
| 26.1 CPU | 26.2 GPU | 26.3 SSD | 26.4 NIC | 26.5 FPGA | |
|---|---|---|---|---|---|
| PCIe role | Root Complex | Endpoint | Endpoint | Endpoint | Endpoint |
| who initiates DMA | devices do | GPU copy engines | the controller | the adapter | your engine |
| who decides when work happens | software | software submits | the controller fetches | the network | software submits |
| control path | MMIO from cores | BAR + doorbells | doorbell registers | BAR + doorbells | BAR register file |
| bulk data path | device DMA | copy engines | controller DMA | RX/TX DMA | your DMA engine |
| local memory | host DRAM | GPU VRAM/HBM | controller buffer + NAND | packet buffers | BRAM + DDR/HBM |
| what software queues | nothing directly | command buffers | NVMe SQ entries | descriptor rings | descriptors |
| where buffering is | fabric + memory | GPU memory | controller buffer | packet buffers | your FIFOs |
| "completion" means | a translation response | a copy status record | an NVMe CQ entry | a descriptor status | your status record |
| likely non-PCIe limit | memory path, NUMA | GPU local memory | media, or the host | host drain rate | local DDR |
| what you own | nothing — you are the host | nothing | nothing | nothing | all of it |
Read the last two rows together. In every system the likely bottleneck is not PCIe, and in exactly one of them you are responsible for finding it yourself.
And read the "who decides when work happens" row. It orders the chapters by how much control the device has: software submits and the device consumes (GPU, FPGA); the device fetches its own work (SSD); or the work simply arrives (NIC). That ordering predicts the failure modes — the last case is the only one where being slow costs you data rather than time.
25. The Reusable System Pattern
Most PCIe endpoints instantiate the same skeleton, and recognising it is what makes an unfamiliar device tractable:
software writes control / builds work in memory
→ doorbell or configuration write (a small BAR access)
→ device takes OWNERSHIP of the work (by snapshot, not reference)
→ device generates PCIe requests (Tags, credits, chunking)
→ data is placed (host memory or device memory)
→ local execution (media, compute, network)
→ durable STATUS record (a Posted write)
→ NOTIFICATION (MSI/MSI-X, strictly after)This is DERIVED, not specified. No standard requires it, and real devices vary substantially: an SSD's queues are standard-defined while a GPU's are vendor-defined; a NIC's work arrives rather than being submitted; an FPGA card's structure is whatever you built.
But the ownership obligations at each arrow are the same everywhere, and every one was measured in this module:
| arrow | obligation | measured |
|---|---|---|
| doorbell → ownership | the work must be visible before it is announced | 26.2: 126 empty fetches |
| ownership | snapshot, never reference | 26.5: 143,872-byte deficit |
| requests | reserve before issue; identity on every reply | 26.1: 11,857 misroutes |
| data placement | conserve bytes, two-sided | 26.5: 263,616-byte deficit |
| status | publish durably before notifying | 26.5: 5,178 early events |
| notification | carries no information; the record does | 26.3: 99,604 early interrupts |
A device that violates any one of these will fail in a way that looks like a PCIe problem and is not.
26. Module 26 Complete
Five chapters, five systems, one boundary seen from both sides.
| Chapter | Asks |
|---|---|
| 26.1 CPUs | who owns this address, and what translated it? |
| 26.2 GPUs | is this the control path or the data path? |
| 26.3 SSD Controllers | which layer's "completion" are we discussing? |
| 26.4 Network Adapters | why did a PCIe limit become a packet drop? |
| 26.5 (this) | which stage is the limit, and which boundary is mine? |
Three results run through all five, and each was measured rather than asserted.
The bottleneck is almost never PCIe. §16 measured doubling the link giving 0.0%; 26.2 §12 measured doubling GPU-local bandwidth giving nothing when PCIe was the limit and everything when it was not; 26.3 §16 measured a controller stalled on the host's completion consumption with the media idle. In every system, "PCIe is slow" was a hypothesis that per-stage instrumentation refuted.
Every boundary is an ownership transfer that needs an identity. An address to a window, a request to a translation context, a descriptor to an engine, a completion to a queue, a response to a Tag. 26.1 §14 measured 11,857 misroutes, 26.4 §16 measured 3,284, and §17 here measured 62 — three different systems, one missing identity check.
And one oracle is never enough. 26.4 §16 measured two ownership faults where each oracle was structurally blind to the fault the other caught; §23 Q4 is the same result for conservation versus overflow. This is the fifth time across Modules 25 and 26 that the fix has been an oracle sharing no machinery with the mechanism under test — and it is the most transferable thing in either module.
Module 26 has one chapter left to be written — 26.6, AI Accelerators — which takes up accelerator-oriented PCIe architecture, host-to-accelerator movement patterns, scale-out topologies, and the PCIe/CXL relationship at accelerator-system level. This chapter deliberately left all of that alone (§1), and 26.2 did the same.