PCIe · Module 20
FPGA Examples — Composing the Blocks Into a Real DMA Engine
A PCIe DMA engine is not one state machine. It is a pipeline of independently-owned transactions, and the failures that only appear once the blocks are joined are clock domains, memory latency, error cleanup and publication order.
Chapters 20.1–20.5 built the pieces: descriptor ownership, request generation, Tag contexts, Completion matching, segment walking, admission control, arbitration.
Each was verified in isolation. None of them is where the hard bugs live.
The hard bugs appear when the blocks become one design — when the context table becomes a block RAM with read latency, when the descriptor queue crosses a clock boundary, when an error path forgets to release a reservation, and when a completion interrupt races the status write it is announcing.
How do the blocks compose into a realistic FPGA DMA engine, and what fails only at the seams?
1. Scope and Sources
2. The Composition
Three things to read out of the figure.
The descriptor engine reaches the transmit path too — its fetches are DMA (Chapter 20.4 §5), so it competes for the same scheduler.
The Completion path is drawn separately from the request path, because they are different clock-adjacent pipelines with different problems (§7).
And status and interrupt leave together but are ordered (§13) — publish, then notify.
3. Ownership Boundaries Are the Architecture
Every arrow in Figure 1 is a handshake, and each transfers ownership exactly once.
| Boundary | What moves | Owner rule |
|---|---|---|
| doorbell → descriptor engine | work exists | a one-cycle MMIO pulse must be latched (§10) |
| descriptor engine → job controller | a complete descriptor | published only when whole (20.4 §6) |
| job controller → read/write engine | a segment | snapshotted (20.2 §6) |
| engine → scheduler | a request | grant held under stall (20.5 §15) |
| core → completion path | Completion data | matched by Tag, not by engine state (20.3 §6) |
| completion path → buffers | placed bytes | by context offset (§8) |
| job controller → status/interrupt | a result | held until accepted, published before notifying (§13) |
Seven boundaries, seven handshakes. A design that treats any of them as a wire rather than a transfer has a bug that only shows up under backpressure — which is §16's last debugging scenario.
4. The PCIe Core Is a Boundary, Not the Protocol
A hard or soft PCIe core presents its interface to your RTL — request and completion descriptors, data beats, ready semantics, alignment rules.
None of that is PCIe. It is one implementation's presentation of PCIe, and it differs between vendors, families and generations.
What is PCIe is everything Modules 10–18 established: transaction types, routing, ordering, flow control, the LTSSM.
Why the distinction matters practically: RTL written against a particular core's conventions is portable only as far as those conventions extend. The engine in this chapter is written against a normalized internal interface (§6), with a thin adaptation layer to whatever core is used — which is also the structure that makes it testable without the core.
5. Clock Domains
A realistic design has several, and they carry different things.
| Crossing | What crosses | Correct mechanism |
|---|---|---|
| control → core domain | descriptors, commands | async FIFO or full handshake |
| user → core domain | bulk data | async FIFO / dual-clock RAM |
| core → control | events, pulses | toggle synchronizer or handshake |
| core → control | steady status bits | synchronized level |
6. Normalized Interfaces
// SYNTHESIZABLE. Normalized internal interfaces for the whole engine.
// THESE ARE NOT PCIe WIRE FORMATS AND NOT ANY VENDOR'S INTERFACE (§1, §4).
// A thin adapter maps them to whatever core is used, which is also what
// makes the engine testable without one.
package dma_top_pkg;
parameter int ADDR_W = 64;
parameter int LEN_W = 24;
parameter int TAG_W = 5; // 20.3 §1: 5-bit Tag field
parameter int N_TAGS = 32;
parameter int DATA_W = 256; // internal datapath width
parameter int KEEP_W = DATA_W/8;
// ---- Request toward the PCIe core -------------------------------------
typedef struct packed {
logic is_write; // 20.3 §2: direction picks MWr / MRd
logic [ADDR_W-1:0] addr;
logic [LEN_W-1:0] length_bytes;
logic [TAG_W-1:0] tag; // meaningful for reads
logic [15:0] owner_id; // which descriptor / job
} dma_req_t;
// ---- Completion from the PCIe core ------------------------------------
typedef struct packed {
logic [TAG_W-1:0] tag;
logic [LEN_W-1:0] bytes;
logic error;
logic last;
} dma_cpl_hdr_t;
// ---- Local stream, AXI-Stream-like TEACHING convention ----------------
typedef struct packed {
logic [DATA_W-1:0] data;
logic [KEEP_W-1:0] keep;
logic last;
} dma_beat_t;
// ---- Normalized error taxonomy (section 11) ---------------------------
// NOT PCIe-defined. Every one has an owner, a sticky status, and a
// defined effect on the job.
typedef enum logic [3:0] {
ERR_NONE = 4'd0,
ERR_BAD_DESC = 4'd1,
ERR_CPL_STATUS = 4'd2,
ERR_CPL_OVERRUN = 4'd3,
ERR_UNKNOWN_TAG = 4'd4,
ERR_BUFFER_OVF = 4'd5,
ERR_CHAIN_LIMIT = 4'd6,
ERR_LINK_ABORT = 4'd7,
ERR_RESET_ABORT = 4'd8
} dma_err_e;
endpackageClassification: synthesizable (compile-time definitions).
owner_id travels with every request so that a Completion, an error or a debug capture can be attributed to a descriptor without consulting engine state — the rule from Chapter 20.3 §6.
7. The Context RAM Has Latency
8. RTL — Pipelined Completion Path
import dma_top_pkg::*;
// SYNTHESIZABLE. Completion handling with a SYNCHRONOUS context RAM.
// SECTION 7: pairing the RAM output with the current cycle's header
// misassociates 98.5% of back-to-back Completion streams (section 14).
//
// The metadata travels WITH the RAM read, in a stage register, and the
// whole stage stalls together.
module cpl_path #(
parameter int TAGS = N_TAGS
) (
input logic clk,
input logic rst_n,
// From the PCIe core (normalized, §6)
input logic cpl_valid,
output logic cpl_ready,
input dma_cpl_hdr_t cpl_hdr,
input dma_beat_t cpl_beat,
// Context RAM: address out, data back ONE CYCLE LATER.
output logic [TAG_W-1:0] ctx_raddr,
input logic [15:0] ctx_owner_q, // registered RAM outputs
input logic [LEN_W-1:0] ctx_offset_q,
input logic [LEN_W-1:0] ctx_expected_q,
input logic [LEN_W-1:0] ctx_received_q,
input logic ctx_valid_q,
// To local buffers -- placement by context offset (§9)
output logic place_valid,
input logic place_ready,
output logic [LEN_W-1:0] place_offset,
output dma_beat_t place_beat,
output logic [15:0] place_owner,
// Context update / retirement
output logic ctx_upd,
output logic [TAG_W-1:0] ctx_upd_tag,
output logic [LEN_W-1:0] ctx_upd_received,
output logic ctx_retire,
output dma_err_e err_out
);
// ---- Stage 0: present the address -------------------------------------
assign ctx_raddr = cpl_hdr.tag;
// ---- Stage 1: the RAM output arrives; METADATA MUST ARRIVE WITH IT ----
logic s1_valid_q;
dma_cpl_hdr_t s1_hdr_q;
dma_beat_t s1_beat_q;
dma_err_e err_q;
assign err_out = err_q;
// The stage advances only when it is empty or being drained -- so the
// header, the data and the RAM output stay together under backpressure.
wire s1_will_drain = s1_valid_q && place_ready;
assign cpl_ready = !s1_valid_q || s1_will_drain;
// ==================================================================
// EVERYTHING BELOW USES s1_hdr_q -- THE HEADER THAT REQUESTED THIS
// RAM READ -- never cpl_hdr, which is already the NEXT Completion.
// ==================================================================
wire matched = s1_valid_q && ctx_valid_q;
wire [LEN_W-1:0] next_rcv = ctx_received_q + s1_hdr_q.bytes;
wire overrun = matched && (next_rcv > ctx_expected_q);
wire failed = matched && s1_hdr_q.error;
assign place_valid = matched && !overrun && !failed;
assign place_offset = ctx_offset_q + ctx_received_q; // PLACEMENT BY OFFSET
assign place_beat = s1_beat_q;
assign place_owner = ctx_owner_q;
assign ctx_upd = place_valid && place_ready;
assign ctx_upd_tag = s1_hdr_q.tag;
assign ctx_upd_received = next_rcv;
// Retire on the exact byte count -- 20.3 §19 measured the alternative at
// 100% failure to retire.
assign ctx_retire = ctx_upd && (next_rcv == ctx_expected_q);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
s1_valid_q <= 1'b0; s1_hdr_q <= '0; s1_beat_q <= '0; err_q <= ERR_NONE;
end else begin
if (cpl_valid && cpl_ready) begin
// Capture header AND data together with the RAM read they issued.
s1_valid_q <= 1'b1;
s1_hdr_q <= cpl_hdr;
s1_beat_q <= cpl_beat;
end else if (s1_will_drain || (s1_valid_q && (overrun || failed || !ctx_valid_q))) begin
s1_valid_q <= 1'b0;
end
// Errors are REPORTED and terminate the context; they never
// accumulate bytes (20.3 §16).
if (s1_valid_q) begin
if (!ctx_valid_q) err_q <= ERR_UNKNOWN_TAG; // 20.3 §1's requirement
else if (overrun) err_q <= ERR_CPL_OVERRUN;
else if (failed) err_q <= ERR_CPL_STATUS;
end
end
end
endmoduleClassification: synthesizable.
The whole design is s1_hdr_q. Every downstream decision uses the header that issued the RAM read, never the header currently on the input — which is the difference between 0 and 98.5% misassociation (§15).
And cpl_ready gates the entire stage. Header, data and RAM output advance or stall together; a design that stalls only the data reproduces the bug one cycle later.
Failure — four. Using cpl_hdr in stage 1 — §15's measured 98.5%. Stalling the data but not the metadata. Placing by arrival order rather than by context offset (§9). And accumulating an errored Completion's bytes.
9. Placement by Offset Removes the Reorder Buffer
Each context carries local_offset (Chapter 20.3 §5). When a Completion arrives, its bytes are written at offset + received, wherever they land in arrival order.
§15 verified it: across 40,000 random read jobs with Completions fragmented and shuffled, the reconstructed buffer image matched the expected one every time — 0 mismatches.
So out-of-order Completions need no reorder FIFO — they need a context. The ordering information was never in the arrival sequence; it was in the request, and the context is where the request's intent is kept.
This is why Chapter 20.3 §5 called local_offset the field people forget. Without it an engine can only append in arrival order, which is correct only if Completions never interleave — i.e. only at one outstanding request, which is Chapter 20.5 §6's 64× penalty.
10. RTL — Doorbell Capture and Register Interface
import dma_top_pkg::*;
// SYNTHESIZABLE. Software/hardware meeting point.
// A NORMALIZED register model -- NOT a universal map (§1). Its purpose is
// to show where the ownership boundaries sit, not to specify a product.
module dma_csr #(parameter int EW = 4) (
input logic clk,
input logic rst_n,
// Host MMIO write strobes (already in this clock domain, or safely
// crossed -- see section 5; this block does NOT perform the crossing).
input logic wr_control,
input logic [31:0] wr_data,
input logic wr_doorbell, // ONE-CYCLE strobe
input logic [31:0] doorbell_value,
input logic job_taken, // the engine consumed the work
input logic job_done,
input dma_err_e job_err,
input logic [31:0] bytes_completed,
output logic go,
output logic [31:0] producer_index,
output logic work_pending,
output logic [31:0] status,
output dma_err_e sticky_err
);
logic pend_q, go_q;
logic [31:0] prod_q;
dma_err_e err_q;
assign work_pending = pend_q;
assign go = go_q;
assign producer_index = prod_q;
assign sticky_err = err_q;
assign status = {24'd0, 3'd0, err_q, go_q};
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
pend_q <= 1'b0; go_q <= 1'b0; prod_q <= '0; err_q <= ERR_NONE;
end else begin
if (wr_control) go_q <= wr_data[0];
// ==============================================================
// THE DOORBELL IS A ONE-CYCLE MMIO STROBE AND MUST BE LATCHED.
//
// The descriptor engine may be busy -- fetching, walking, or
// draining a previous job. A pulse wired straight into its start
// condition is lost, and software waits forever for work it
// believes it submitted (section 14, mutation 2).
//
// This is Chapter 20.2 section 7's publication boundary: the
// doorbell IS the handoff.
// ==============================================================
if (wr_doorbell) begin
prod_q <= doorbell_value;
pend_q <= 1'b1;
end else if (job_taken) begin
pend_q <= 1'b0; // consumed exactly once
end
// ==============================================================
// FIRST-FAILURE STICKY. A later success must NOT clear it
// (mutation 25) -- the first error is the one that explains the
// run, and a self-clearing status is worse than none.
// ==============================================================
if (job_done && (job_err != ERR_NONE) && (err_q == ERR_NONE))
err_q <= job_err;
end
end
endmoduleClassification: synthesizable.
Two decisions. The doorbell is latched — Chapter 20.2 §7's publication is the ownership handoff, and losing it means software waits forever. And the error status is first-failure sticky, because the first error explains a run and a later success must not erase it.
Failure — three. A pulsed doorbell wired into a busy engine. A last-error register overwritten by subsequent failures, hiding the root cause. And an error status cleared by success, which makes intermittent failures invisible.
11. Error Cleanup Is a Resource Problem
12. RTL — Resource Cleanup and Reset
import dma_top_pkg::*;
// SYNTHESIZABLE. THE FLAGSHIP BLOCK OF THIS CHAPTER.
// One cleanup path for every terminal outcome (section 11). Section 14
// measured the alternative: an error path that frees the Tag but not the
// reservation violates the resource invariant in 99.9% of sequences and
// wedges the engine after 8 errors.
module ctx_cleanup #(
parameter int TAGS = N_TAGS,
parameter int CW = 16
) (
input logic clk,
input logic rst_n,
// Per-context ownership, set when the request was admitted.
input logic [TAGS-1:0] tag_owned,
input logic [TAGS-1:0] buffer_reserved,
input logic [TAGS-1:0] desc_owned,
// Terminal outcomes -- ALL of them arrive here.
input logic done_success,
input logic done_error,
input logic abort, // link-down, reset request, job abort
input logic [TAG_W-1:0] done_tag,
input logic [CW-1:0] done_bytes,
// Release strobes -- consumed by the pool, the reservation and the walker
output logic rel_tag,
output logic [TAG_W-1:0] rel_tag_idx,
output logic rel_buffer,
output logic [CW-1:0] rel_bytes,
output logic rel_desc,
output logic err_double_release
);
logic [TAGS-1:0] released_q;
logic dbl_q;
assign err_double_release = dbl_q;
// ==================================================================
// ONE PATH, ALL OUTCOMES.
//
// success, error and abort are DIFFERENT REASONS and the SAME cleanup.
// Writing three separate release paths is how one of them ends up
// missing a resource -- which is exactly section 11's failure.
// ==================================================================
wire terminal = done_success || done_error || abort;
assign rel_tag = terminal && tag_owned[done_tag] && !released_q[done_tag];
assign rel_tag_idx = done_tag;
assign rel_buffer = terminal && buffer_reserved[done_tag] && !released_q[done_tag];
assign rel_bytes = done_bytes;
assign rel_desc = terminal && desc_owned[done_tag] && !released_q[done_tag];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
released_q <= '0; dbl_q <= 1'b0;
end else begin
if (terminal) begin
// EXACTLY ONCE. A second terminal event for the same context --
// an error arriving after an abort, say -- is reported rather
// than releasing twice (mutation 6).
if (released_q[done_tag]) dbl_q <= 1'b1;
else released_q[done_tag] <= 1'b1;
end
// The marker clears when the context is re-allocated, not here.
if (!tag_owned[done_tag]) released_q[done_tag] <= 1'b0;
end
end
endmoduleClassification: synthesizable.
Success, error and abort are different reasons and the same cleanup. Three separate release paths is how one of them ends up missing a resource — §11's measured 99.9%.
And released_q makes it exactly once, so a second terminal event for the same context is reported rather than double-releasing (which underflows the reservation and over-admits).
13. Publish, Then Notify
14. Assertions
// SVA over the composed engine. LOCAL contract only. Nothing asserts that
// Completions arrive, that software consumes status, that the Link is
// available, or that a job ever finishes.
//
// NOTE (section 16): these run in simulation and formal. They are NOT in
// the bitstream -- section 10's sticky status registers are what exist in
// silicon.
// ---- CONTROL BOUNDARY -------------------------------------------------
// P1: A ONE-CYCLE DOORBELL IS NEVER LOST. The engine may be busy; the
// publication is the ownership handoff (20.2 §7).
property p_doorbell_latched;
@(posedge clk) disable iff (!rst_n)
wr_doorbell |=> (work_pending || $past(job_taken));
endproperty
a_db : assert property (p_doorbell_latched);
// P1b: and it is consumed exactly once.
property p_doorbell_once;
@(posedge clk) disable iff (!rst_n)
(work_pending && job_taken && !wr_doorbell) |=> !work_pending;
endproperty
a_db1 : assert property (p_doorbell_once);
// P2: A NEW DOORBELL CANNOT OVERWRITE AN ACTIVE JOB'S OWNERSHIP.
property p_no_job_overwrite;
@(posedge clk) disable iff (!rst_n)
(job_active && wr_doorbell) |-> $stable(active_job_id);
endproperty
a_ovw : assert property (p_no_job_overwrite);
// P3: FIRST-FAILURE STICKY -- a later success must not clear it (§10).
property p_error_sticky;
@(posedge clk) disable iff (!rst_n)
(sticky_err != ERR_NONE) |=> ((sticky_err == $past(sticky_err)) || !rst_n);
endproperty
a_sticky : assert property (p_error_sticky);
// ---- COMPLETION PIPELINE -- THE CENTRAL PROPERTIES --------------------
// P4: THE CONTEXT USED IS THE ONE THE HEADER REQUESTED. Section 14
// measured the alternative at 98.5% misassociation.
property p_context_paired;
@(posedge clk) disable iff (!rst_n)
(place_valid) |-> (s1_hdr_q.tag == $past(ctx_raddr));
endproperty
a_pair : assert property (p_context_paired);
// P4b: THE WHOLE STAGE STALLS TOGETHER -- header, data and context.
// Holding the data while the context advances reproduces the bug one
// cycle later (mutation 4).
property p_stage_atomic;
@(posedge clk) disable iff (!rst_n)
(s1_valid_q && !place_ready) |=> (s1_valid_q && $stable(s1_hdr_q)
&& $stable(s1_beat_q));
endproperty
a_stage : assert property (p_stage_atomic);
// P5: DATA IS PLACED BY CONTEXT OFFSET, never by arrival order (§9).
// Section 14: 40,000 shuffled, fragmented read jobs -- 0 image mismatches.
property p_placement_by_offset;
@(posedge clk) disable iff (!rst_n)
place_valid |-> (place_offset == (ctx_offset_q + ctx_received_q));
endproperty
a_place : assert property (p_placement_by_offset);
// P6: AN UNMATCHED, ERRORED OR OVERRUNNING COMPLETION IS REPORTED and
// never placed. 20.3 §1 sources the unmatched-Tag requirement.
property p_bad_cpl_reported;
@(posedge clk) disable iff (!rst_n)
(s1_valid_q && (!ctx_valid_q || s1_hdr_q.error || overrun))
|-> (!place_valid);
endproperty
a_badcpl : assert property (p_bad_cpl_reported);
// ---- DATA PATH --------------------------------------------------------
// P7: NO SOURCE UNDERFLOW -- a beat is never emitted from an empty FIFO.
property p_no_underflow;
@(posedge clk) disable iff (!rst_n)
(src_valid && src_ready) |-> (src_occupancy != '0);
endproperty
a_uf : assert property (p_no_underflow);
// P8: NO DESTINATION OVERFLOW -- capacity is checked before acceptance.
property p_no_overflow;
@(posedge clk) disable iff (!rst_n)
dst_occupancy <= DST_CAPACITY;
endproperty
a_of : assert property (p_no_overflow);
// P9: A READ DESCRIPTOR RETIRES ONLY WHEN ITS DATA HAS LANDED, not when
// its requests were issued (20.1 §19: 99.5% early otherwise).
property p_read_done_on_data;
@(posedge clk) disable iff (!rst_n)
read_desc_done |-> (bytes_placed >= bytes_expected);
endproperty
a_rdone : assert property (p_read_done_on_data);
// P10: request metadata is stable under transmit stall.
property p_req_stable;
@(posedge clk) disable iff (!rst_n)
(rq_valid && !rq_ready) |=> (rq_valid && $stable(rq_desc));
endproperty
a_req : assert property (p_req_stable);
// ---- RESOURCE CLEANUP -------------------------------------------------
// P11: EVERY TERMINAL OUTCOME RELEASES EVERY OWNED RESOURCE. Section 14
// measured the incomplete error path: 99.9% invariant violation, wedged
// after 8 errors.
property p_cleanup_complete;
@(posedge clk) disable iff (!rst_n)
(done_success || done_error || abort)
|-> (rel_tag == tag_owned[done_tag])
&& (rel_buffer == buffer_reserved[done_tag])
&& (rel_desc == desc_owned[done_tag]);
endproperty
a_clean : assert property (p_cleanup_complete);
// P11b: and EXACTLY ONCE -- a second terminal event is reported, not
// released again (mutation 6).
property p_release_once;
@(posedge clk) disable iff (!rst_n)
(terminal && released_q[done_tag]) |=> err_double_release;
endproperty
a_once : assert property (p_release_once);
// P11c: CONSERVATION -- reserved capacity always matches owned contexts.
property p_resource_conservation;
@(posedge clk) disable iff (!rst_n)
reserved_bytes == ($countones(buffer_reserved) * BYTES_PER_CTX);
endproperty
a_cons : assert property (p_resource_conservation);
// ---- PUBLICATION ORDER ------------------------------------------------
// P12: THE INTERRUPT NEVER PRECEDES THE STATUS IT ANNOUNCES (§13).
property p_publish_then_notify;
@(posedge clk) disable iff (!rst_n)
irq_event_valid |-> status_write_accepted;
endproperty
a_order : assert property (p_publish_then_notify);
// P12b: and the interrupt event is HELD if the controller is busy
// (19.5 §16's rule).
property p_irq_held;
@(posedge clk) disable iff (!rst_n)
(irq_event_valid && !irq_event_ready) |=> irq_event_valid;
endproperty
a_irqheld : assert property (p_irq_held);
// ---- RESET ------------------------------------------------------------
// P13: RESET DEASSERT DOES NOT ENABLE TRAFFIC. Release is not readiness
// (18.10 §11) -- contexts must be initialized first (mutation 29).
property p_no_traffic_before_ready;
@(posedge clk) disable iff (!rst_n)
rq_valid |-> (contexts_initialized && !reset_abort_active);
endproperty
a_rst : assert property (p_no_traffic_before_ready);
// P14: reset clears every ownership; no stale context, Tag, reservation
// or prefetched descriptor survives.
property p_reset_clears;
@(posedge clk)
!rst_n |=> ((ctx_valid_map == '0) && (reserved_bytes == '0)
&& !irq_event_valid && !work_pending);
endproperty
a_rstclr : assert property (p_reset_clears);P4 with P4b is this chapter's signature pair. P4 requires the context to belong to the header that requested it — §14's 98.5% case. P4b requires the whole stage to stall together, because holding only the data reproduces the same misassociation one cycle later.
P11, P11b and P11c are the cleanup triple: complete (every resource), once (no double release), and conserved (the invariant that catches a leak before it wedges anything).
And P12 is the software-visible one. It is the only property here whose violation is observed by a driver rather than by hardware.
No liveness. "Completions arrive", "software consumes status" and "the Link is available" are environment properties. P12b is the bounded form for the interrupt hand-off — held, however long the controller takes.
15. Verification, Fault Injection, and Model Verification
Executed before publication.
End-to-end write — 50,000 random jobs
Random lengths, chunk sizes and source beat patterns, checking that bytes reaching host memory equal bytes accepted from the source: 0 mismatches, including final short chunks.
End-to-end read — 40,000 random jobs, fragmented and shuffled
Completions split into random fragments and delivered in random order, placed by context offset (§9):
| Check | Result |
|---|---|
| reconstructed buffer image matches expected | 0 mismatches |
| every request's byte count reached its expected total | 0 failures |
Arrival order is irrelevant when placement is by context offset — which is the architectural claim, verified.
Context RAM pairing — 60,000 back-to-back streams
| Implementation | Misassociated streams |
|---|---|
| metadata pipelined with the RAM read (§8) | 0 |
| RAM output paired with the current input header | 59,070 — 98.5% |
Error cleanup — 60,000 success/error/abort sequences
Invariant: reserved_bytes == owned_tags × bytes_per_tag.
| Implementation | Sequences violating |
|---|---|
| release on every terminal path (§12) | 0 |
| error path frees the Tag but not the reservation | 59,934 — 99.9% |
And the wedge: with a 64-byte reservation pool at 8 bytes per request, that leak blocks the engine after 8 errors.
Directed tests
- Single-beat write; multi-chunk write; final short chunk — verify byte conservation.
- Source stalls and PCIe stalls independently and together (P7, P8).
- Read with Completions in order, then shuffled, then fragmented and shuffled (P5). Required.
- Back-to-back Completions for different Tags — verify pairing (P4). Required, and §15's 98.5% case.
- Completion path stalled mid-stage — verify header, data and context hold together (P4b).
- Unknown Tag, Completion error, overrun — verify each is reported and terminates its context (P6).
- Error injection followed by a long clean run — verify all resources return (P11). Required.
- Repeated errors — verify the free counts return to their initial values every time, with no drift.
- Doorbell while the engine is busy — verify it is latched (P1). Required.
- Two doorbells before either is taken — verify the producer index is the later one and
work_pendingstays set once. - Reset mid-job — verify cleanup, epoch increment, and that traffic does not resume before contexts are re-initialized (P13, P14).
- A Completion arriving after reset — verify
ERR_UNKNOWN_TAGrather than aliasing (P6). - Status stalled, job done — verify the interrupt does not precede publication (P12). Required.
The scoreboard maintains an independent memory image and resource ledger, built from observed requests and Completions alone, and never reads the context RAM, the reservation counter or the cleanup markers.
Mutations
| # | Mutation | Caught by | FPGA symptom |
|---|---|---|---|
| 1 | descriptor crosses a clock domain as a raw multi-bit bus | review + §5 | simulates perfectly, corrupts in silicon |
| 2 | one-cycle doorbell wired into a busy engine | P1 | software submits work and nothing happens |
| 3 | context RAM assumed combinational | P4 | 98.5% misassociation (measured) |
| 4 | data stalls but context advances | P4b | same, one cycle later |
| 5 | Tag leaked on the error path | P11 | wedges after 8 errors (measured) |
| 6 | Tag double-freed on reset + Completion | P11b | a live request's Tag reallocated |
| 7 | read descriptor done before the output buffer has the data | P9 | software reads a partly-filled buffer |
| 8 | write packer drops the final partial beat | 20.5 P10b | last bytes of every descriptor missing |
| 9 | write address advances under stall | 20.1 P3b | duplicated regions in host memory |
| 10 | scheduler changes source under stall | 20.5 P13 | request mutates mid-transaction |
| 11 | descriptor fetch starved by data traffic | 20.5 P13 | throughput collapses under load |
| 12 | status writeback starved | P12 + review | completions never reach host memory |
| 13 | interrupt raised before status publication | P12 | intermittent, worse on fast machines (§13) |
| 14 | interrupt event lost while the controller is busy | 19.5 P3 | job complete, software never told |
| 15 | buffer reservation leaked on abort | P11 | slow degradation to a hang |
| 16 | async FIFO reset domains inconsistent | review + §5 | metastable pointers; rare corruption |
| 17 | stale descriptor survives reset | 20.5 P15 | previous job's segment runs in the next |
| 18 | unknown Tag indexes context 0 | P6 | a stray Completion corrupts a live transfer |
| 19 | BRAM read latency ignored in the address path | P4 | same class as 3 |
| 20 | Completion data placed by arrival order | P5 | corrupt only when Completions interleave |
| 21 | cookie taken from the next descriptor | 20.2 P8b | driver frees the wrong buffer |
| 22 | source FIFO underflow still emits a beat | P7 | garbage bytes in host memory |
| 23 | destination FIFO overflow accepted | P8 | silent data loss |
| 24 | a new doorbell overwrites the active job | P2 | a job vanishes mid-flight |
| 25 | error status cleared by a later success | P3 | root cause erased; failures look intermittent |
| 26 | completion record pulsed, not held | 20.2 P8 | job done, software never told |
| 27 | a performance counter gates a functional path | 20.5 P12 | behaviour changes when diagnostics are cleared |
| 28 | vendor IP interface described as PCIe | review + §4 | RTL non-portable; wrong assumptions inherited |
| 29 | reset deassert enables traffic before contexts initialize | P13 | first post-reset transaction uses stale state |
| 30 | shared TX reservation duplicated | 20.5 P5 | two producers spend one credit |
Same-cycle audit
| Case | Declared resolution |
|---|---|
doorbell + job_taken | the doorbell wins; work_pending stays set for the new work (§10) |
| Completion + downstream stall | the whole stage holds — header, data, context together (P4b) |
| terminal event + a second terminal event, same context | released once; the second is reported (P11b) |
| reset + a Completion in stage 1 | reset wins; the Completion is discarded, its context gone (P13) |
| job done + status stalled | no interrupt until the status is accepted (P12) |
| context retire + a new allocation of the same Tag | release applies first, so the Tag is immediately reusable (20.5 §13) |
16. Debugging
Symptom → which seam → signal → experiment.
Simulation passes; the FPGA corrupts a word occasionally
Four seams, in the order worth checking, because all four are invisible to a single-clock testbench.
CDC (§5). Any multi-bit value crossing on flops rather than through a FIFO or handshake. Check descriptors and any control word first — a data path is usually already a FIFO; a "just one register" control field usually is not.
Memory latency (§7). A synchronous RAM treated as combinational — mutation 3, 98.5% under back-to-back traffic.
Ready/valid hold. Anything that changes while valid && !ready.
Width conversion. Byte-enable and last-beat handling at a gearbox.
The distinguishing experiment: reduce concurrency to one outstanding request. If the corruption vanishes, it is a pipelining or association bug (§7, §9); if it persists, it is CDC or width conversion.
Works with one outstanding read, corrupts with four
Almost certainly the Completion path, and §15's numbers say which candidate is likeliest.
Context RAM pairing (mutation 3) is the first check — it fails only with back-to-back Completions and is silent at one outstanding.
Then placement (mutation 20): data written in arrival order rather than at the context offset corrupts only when Completions interleave.
Then Tag association (Chapter 20.3 §19) — a Tag freed early or a Completion matched against engine state.
All three share the same signature and the same experiment: they are invisible at one outstanding and appear immediately at two.
After one Completion error, performance slowly degrades until it hangs
A resource leak (§11), and the shape of the curve is the diagnosis: it degrades rather than failing, because each error takes a little capacity permanently.
Watch the free counts over a long run. free_tag_count and free_bytes should return to their initial values whenever the engine is idle. A monotonic drift that never recovers is a leak, and §15 measured the error-path variant at 99.9% with a wedge after 8 errors.
The distinguishing experiment: inject errors deliberately and check the free counts between them. They must return to baseline every time.
The interrupt fires but the driver cannot find a completed descriptor
Publication ordering (§13) — mutation 13.
Check whether the interrupt event is raised at job_done or after the status writeback is accepted. If the former, the interrupt races its own status write.
The tell is that it is worse on faster machines, because a fast ISR is more likely to arrive before the status lands. A slow test machine hides it entirely, which is why it reaches the field.
Failures only when the PCIe path backpressures
Anything that changes under valid && !ready is suspect (§3).
Enumerate the boundaries in Figure 1 and check each for stability: the request descriptor, the scheduler grant, the completion stage, the status record. Module 20 has a measured failure for almost every one — 20.1 §20's 79.5%, 20.5 §17's grant, §15's stage here.
17. Common Misconceptions
- "An FPGA DMA engine is one state machine." It is a pipeline of independently-owned transactions (§3).
- "Two flops is enough for a descriptor bus." Independent bits resolve separately (§5).
- "Block RAM reads are combinational." 98.5% misassociation under back-to-back Completions (§7).
- "The PCIe core's user interface is PCIe." It is one implementation's presentation (§4).
- "The DMA engine should know the MSI-X destination." It emits a normalized event (20.1 §12).
- "Reset cancels in-flight PCIe requests." It clears local state; Completions may still arrive (§12).
- "Error handling is one status bit." It is resource cleanup — 99.9% violation without it (§11).
- "Simulation success proves CDC correctness." A single-clock testbench cannot exercise it (§5).
- "Deeper outstanding reads only need more Tags." Chapter 20.5 §9's four resources.
- "
readycan be ignored if timing is tight." Every boundary is a handshake (§3). - "An interrupt may precede the status it announces." §13, and it fails worst on fast hosts.
- "SVA gives you silicon diagnostics." Assertions are simulation and formal; §10's sticky status is what exists in the bitstream.
18. Understanding Check
19. Module 20 Complete
| Chapter | The engineering question |
|---|---|
| 20.1 DMA over PCIe | Why can the Endpoint initiate memory traffic? |
| 20.2 DMA Concepts | Who owns a descriptor, and when? |
| 20.3 Host Memory Access | How do reads and writes become TLPs? |
| 20.4 Scatter-Gather | How do many segments become one job? |
| 20.5 High-Speed Data Movement | How do we keep enough legal work in flight? |
| 20.6 FPGA Examples | How do the blocks compose into a real engine? |
And the module's architectural law:
performance = correct ownership
+ enough concurrency
+ enough buffering
+ resource-aware schedulingNot performance = bigger packets — which Chapter 20.5 §8 measured as worth +0.6 efficiency points at the top of the curve.
Three laws recurred across the whole module, each with measured failure rates behind it:
A structure two agents share must be sampled once, whole, at a defined boundary — 19.2, 19.3, 19.4 at 57.8%, 20.2, 20.4.
A count of what has been delivered must advance on transfers, never on offers — 18.9, 19.5, 20.1 at 79.5%, 20.5.
And an asynchronous response must carry its own identity — 20.3 at 100%, and §7 here at 98.5%.
20. What's Next
A DMA engine is a pipeline of independently-owned transactions, and the seams are where it breaks.
Memory latency turns a lookup into a pipeline (§7) — 98.5% misassociation without carrying the key forward. Clock domains need mechanisms, not flops (§5). Errors are a resource problem (§11) — 99.9% invariant violation, wedged after 8 errors. And publication must precede notification (§13).
Module 20 is complete. An engine built from Modules 19 and 20 knows who owns each descriptor, what transaction moves data in each direction, why a read needs Tags, when a Tag may be reused, what bounds a descriptor walk, what actually limits throughput, and what has to be released when something fails.
Module 21 — PCIe Switches turns from one Link to the fabric. Everything so far assumed a single point-to-point connection. A switch has several ports and must decide, for every packet, which one it goes out of — by address, by identifier, or implicitly — and that decision is the beginning of everything about multi-endpoint systems.
The idea to carry forward: a lookup whose result arrives later than its key must carry the key forward.