Skip to content

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.120.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

FPGA DMA engine composition. Host MMIO drives control and doorbell registers, which feed the descriptor engine. The descriptor engine fetches descriptors from host memory and passes them to the job and scatter-gather controller. That controller drives a DMA read engine and a DMA write engine, which both feed a shared PCIe transmit scheduler connected to the PCIe core. Completions return through a completion receive path with a tag context RAM, which places data into local buffers feeding user logic. Job completion produces a status record and an interrupt event handed to the interrupt controller.control / doorbelldescriptor enginejob / SG controllerDMA read engineDMA write engineTX schedulerPCIe corecompletion RX +context RAMlocal buffersuser logicstatus + interruptevent12
Figure 1 — a realistic PCIe DMA engine. Host MMIO rings a doorbell; the descriptor engine fetches descriptors, which is itself DMA. The job controller walks segments and drives read and write engines sharing one transmit scheduler. Returning Completions are matched against a context table in block RAM — a pipelined lookup, not a combinational one — and placed by context offset. Job completion produces a status record and, separately, a normalized interrupt event. Every arrow is an ownership boundary with its own handshake.

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.

BoundaryWhat movesOwner rule
doorbell → descriptor enginework existsa one-cycle MMIO pulse must be latched (§10)
descriptor engine → job controllera complete descriptorpublished only when whole (20.4 §6)
job controller → read/write enginea segmentsnapshotted (20.2 §6)
engine → schedulera requestgrant held under stall (20.5 §15)
core → completion pathCompletion datamatched by Tag, not by engine state (20.3 §6)
completion path → buffersplaced bytesby context offset (§8)
job controller → status/interrupta resultheld 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.

CrossingWhat crossesCorrect mechanism
control → core domaindescriptors, commandsasync FIFO or full handshake
user → core domainbulk dataasync FIFO / dual-clock RAM
core → controlevents, pulsestoggle synchronizer or handshake
core → controlsteady status bitssynchronized level

6. Normalized Interfaces

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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;
 
endpackage

Classification: 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
 
endmodule

Classification: 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
endmodule

Classification: synthesizable.

Two decisions. The doorbell is latchedChapter 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
endmodule

Classification: 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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):

CheckResult
reconstructed buffer image matches expected0 mismatches
every request's byte count reached its expected total0 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

ImplementationMisassociated streams
metadata pipelined with the RAM read (§8)0
RAM output paired with the current input header59,070 — 98.5%

Error cleanup — 60,000 success/error/abort sequences

Invariant: reserved_bytes == owned_tags × bytes_per_tag.

ImplementationSequences violating
release on every terminal path (§12)0
error path frees the Tag but not the reservation59,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_pending stays 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_TAG rather 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

#MutationCaught byFPGA symptom
1descriptor crosses a clock domain as a raw multi-bit busreview + §5simulates perfectly, corrupts in silicon
2one-cycle doorbell wired into a busy engineP1software submits work and nothing happens
3context RAM assumed combinationalP498.5% misassociation (measured)
4data stalls but context advancesP4bsame, one cycle later
5Tag leaked on the error pathP11wedges after 8 errors (measured)
6Tag double-freed on reset + CompletionP11ba live request's Tag reallocated
7read descriptor done before the output buffer has the dataP9software reads a partly-filled buffer
8write packer drops the final partial beat20.5 P10blast bytes of every descriptor missing
9write address advances under stall20.1 P3bduplicated regions in host memory
10scheduler changes source under stall20.5 P13request mutates mid-transaction
11descriptor fetch starved by data traffic20.5 P13throughput collapses under load
12status writeback starvedP12 + reviewcompletions never reach host memory
13interrupt raised before status publicationP12intermittent, worse on fast machines (§13)
14interrupt event lost while the controller is busy19.5 P3job complete, software never told
15buffer reservation leaked on abortP11slow degradation to a hang
16async FIFO reset domains inconsistentreview + §5metastable pointers; rare corruption
17stale descriptor survives reset20.5 P15previous job's segment runs in the next
18unknown Tag indexes context 0P6a stray Completion corrupts a live transfer
19BRAM read latency ignored in the address pathP4same class as 3
20Completion data placed by arrival orderP5corrupt only when Completions interleave
21cookie taken from the next descriptor20.2 P8bdriver frees the wrong buffer
22source FIFO underflow still emits a beatP7garbage bytes in host memory
23destination FIFO overflow acceptedP8silent data loss
24a new doorbell overwrites the active jobP2a job vanishes mid-flight
25error status cleared by a later successP3root cause erased; failures look intermittent
26completion record pulsed, not held20.2 P8job done, software never told
27a performance counter gates a functional path20.5 P12behaviour changes when diagnostics are cleared
28vendor IP interface described as PCIereview + §4RTL non-portable; wrong assumptions inherited
29reset deassert enables traffic before contexts initializeP13first post-reset transaction uses stale state
30shared TX reservation duplicated20.5 P5two producers spend one credit

Same-cycle audit

CaseDeclared resolution
doorbell + job_takenthe doorbell wins; work_pending stays set for the new work (§10)
Completion + downstream stallthe whole stage holds — header, data, context together (P4b)
terminal event + a second terminal event, same contextreleased once; the second is reported (P11b)
reset + a Completion in stage 1reset wins; the Completion is discarded, its context gone (P13)
job done + status stalledno interrupt until the status is accepted (P12)
context retire + a new allocation of the same Tagrelease 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 one20.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.
  • "ready can 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

ChapterThe engineering question
20.1 DMA over PCIeWhy can the Endpoint initiate memory traffic?
20.2 DMA ConceptsWho owns a descriptor, and when?
20.3 Host Memory AccessHow do reads and writes become TLPs?
20.4 Scatter-GatherHow do many segments become one job?
20.5 High-Speed Data MovementHow do we keep enough legal work in flight?
20.6 FPGA ExamplesHow do the blocks compose into a real engine?

And the module's architectural law:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
performance = correct ownership
            + enough concurrency
            + enough buffering
            + resource-aware scheduling

Not 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 boundary19.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 offers18.9, 19.5, 20.1 at 79.5%, 20.5.

And an asynchronous response must carry its own identity20.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.