Skip to content

PCIe · Module 20

Host Memory Access — Writes That Vanish and Reads That Come Back in Pieces

A DMA write is fire-and-forget. A DMA read creates outstanding state that must survive until data returns — possibly split, possibly out of order — and the accounting stops being addition and becomes matching.

Chapter 20.2 established how software hands work to hardware. This chapter is about what hardware does with it.

An owned descriptor names a host address, a length and a direction. Turning that into PCIe traffic is where the two directions stop being symmetric.

Device→host is almost easy. Build a Memory Write, hand it to the transmit path, move on. Nothing comes back.

Host→device is a different kind of problem. A Memory Read Request leaves, and the data returns later — possibly split across several Completions, possibly interleaved with Completions for other requests. Every outstanding request needs state that survives until its last byte arrives, and that state must be found again from what the Completion carries.

How does a DMA engine turn an address and a length into legal PCIe requests, and what exactly must it remember while reads are outstanding?

1. The Verified Sources

2. Two Directions, Two Transaction Types

§1's sourced sentence is the anchor: "the direction of transfer would determine what kind of TLP is generated (MWr or MRd)."

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
device → host memory                    host memory → device
────────────────────                    ────────────────────
Memory WRITE                            Memory READ Request
posted                                  non-posted
payload travels WITH the request        request carries NO data
no Completion, ever                     Completion(s) return the data
fire and forget                         outstanding state until data lands

The naming trap (Chapter 20.1 §4): the transaction is named for the operation on host memory, not for where bytes end up. The device reads host memory to bring data in; it writes host memory to send data out.

And §1's example makes it concrete: a transfer "from PCIe to AHB-Lite" — data flowing into the device — generates MRd.

3. The Write Path

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
descriptor snapshot (20.2 §6)

chunk: address, length          bounded by MPS and the address boundary (§7)

Memory Write TLP: addr + payload

transmit path                   credits, arbitration, LTSSM (20.1 §7)

host memory

What the engine must hold: the destination address, the payload for the current chunk, and progress. What it must not do: wait for anything.

4. The Read Path

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
descriptor snapshot

chunk: address, length                bounded by max read request and boundary (§7)

allocate a TAG                        §9 -- BEFORE the request goes out

create a read CONTEXT                 §11 -- where does this data go?

Memory Read Request TLP

        ... time passes, other requests are issued ...

Completion with Data arrives          carries a Tag (§12)

match to context, accumulate bytes    §13

when expected bytes received:  retire context, FREE the Tag

Every step between issuing and retiring is state the engine must carry, and §11 is about exactly what.

The critical asymmetry with §3: a write engine's obligation ends when it hands the TLP over. A read engine's obligation begins there.

5. Outstanding State Is the Real Subject

For each in-flight read, the engine must be able to answer one question when data arrives: where does this go?

Normalized context (§11's RTL):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
valid            is this Tag in use?
descriptor_id    which transfer does it belong to?
local_offset     where in the device buffer does this data land?
expected_bytes   how much did we ask for?
received_bytes   how much has arrived?
status           did anything fail?

local_offset is the field people forget, and it is the one that makes out-of-order Completions survivable. The returning data carries no information about where in the device's buffer it belongs — only the context does. Without it, an engine can only append in arrival order, which is correct only if Completions never interleave.

6. Accounting Versus Matching

7. Chunking: Two Independent Bounds

A transfer is split because a single request cannot carry it, and there are two separate reasons.

A maximum request size. §1 sources it for reads: "the number of read request TLPs… would depend on the maximum read request size specified in the PCIe Device Control register", with the worked case 4096 bytes at 512 → 8 requests.

An address boundary. A request must not span a 4 KiB address boundary, so the size available from a given address is:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bytes_to_boundary = 4096 - (addr & 0xFFF)

Both bounds apply, plus what remains:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
chunk = min(remaining, max_request, bytes_to_boundary)

§17 walked 5,680 complete transfers across every combination of maximum request size, address offset and length: 0 boundary crossings, 0 oversize chunks, 0 zero-size chunks while bytes remained.

Worked cases, computed:

AddressLengthMax requestChunks
0x1000_0FF064512[16, 48] — the boundary splits it, not the size
0x1000_00004096512[512 × 8]matches §1's sourced example
0x1000_000440964096[4092, 4] — a maximum-size request split by 4 bytes of misalignment
0x1000_0FFF2128[1, 1] — one byte from the boundary

The third row is the one worth staring at. A perfectly-sized 4096-byte request, misaligned by 4 bytes, becomes two requests — and the second carries 4 bytes. Alignment, not size, determined the request count, and §19's debugging ladder uses exactly this.

8. MPS and Max Read Request Are Not the Same Field

§1 sources both, in different registers, and they bound different things.

Max Payload SizeMax Read Request Size
Boundshow much payload a TLP may carryhow much a read may ask for
Applies towrites (and Completions)read requests
§1's values"128, 256, 512, 1024, 2048, and 4096-byte"the example uses 512
RegisterDevice Control MPS fieldDevice Control max read request field

A read request carries no payload, so MPS does not bound it — the request is a header asking for n bytes. What MPS bounds on the read path is the Completions that return.

Which is one reason a single read can produce several Completions (§10): the requested amount may exceed what one Completion may carry.

Mutation 12 is the design that uses one value for both, and it fails in whichever direction the two are configured differently — over-large writes, or needlessly fragmented reads.

9. A Tag Is a Lease

§1 sources the field width: 5 bits (32 Tags) or, with Extended Tag enabled, 8 bits (256).

A Tag identifies an outstanding request. It is placed in the request and returned in its Completions, and it is how §6's matching works.

10. One Request, Several Completions

A Memory Read Request may be answered by more than one Completion with Data.

Two reasons this happens, and §8 gives the first: the requested amount may exceed what a single Completion may carry. The second is completer-side — a completer may return data in pieces as it becomes available.

Two consequences follow, and both are counted wrong routinely:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
one Completion  ≠  one read request completed
all requests issued  ≠  all bytes returned

So the retirement condition is a byte count, not a Completion count — §1's sourced text says it: "once all the requested data of a descriptor entry have been received."

And the accumulator must handle arbitrary fragmentation (§13), which is where §17 found the classic error.

11. What a Completion Must Be Matched Against

§1 sources the failure case: "User should signal if a completion is received but the tag does not match any outstanding requests."

So matching is by Tag against the outstanding set, and an unmatched Tag is a reportable error — not something to absorb into context 0 (mutation 11).

A production matcher may need more than the Tag — the completer's identity and the expected byte range among them — and §12's RTL keeps the check explicit and extensible rather than reducing it to a bare index.

What it must never use is engine state (§6): "the descriptor I am currently on" is not a match.

12. The Two Flows

Two DMA flows. In the write flow the DMA engine issues a Memory Write with payload to host memory and no completion returns. In the read flow the engine allocates a tag, creates a read context, and issues a Memory Read Request. Host memory returns a first Completion with Data carrying that tag, which is matched and accumulated, then a second Completion with the remaining bytes. When received bytes equal expected bytes the context retires and the tag is freed.DMA engineTag / context tableHost memoryWRITE: MemWr addr +payloadno Completion -retire at TXhandshakeREAD: allocate Tag,create contextMemRd Request (tag)- no dataCplD (tag) - firstfragmentCplD (tag) -remaining bytesreceived ==expected: retire,free Tag
Figure 1 — the two directions. A device-to-host transfer issues a Memory Write carrying its payload and receives nothing back; the engine's obligation ends at the transmit handshake. A host-to-device transfer allocates a Tag, creates a context, and issues a Memory Read Request that carries no data; Completions return later, possibly split into several, each matched by Tag against the outstanding context. Only when the expected byte count has been received is the context retired and the Tag released.

Three things to read out of the figure.

The write flow has no return arrow at all — the self-message marks where its obligation ends (§3).

The Tag is allocated before the request goes out, because it must be in the request.

And two Completions carry the same Tag (§10) — the retirement is on the accumulated byte count, not on either Completion individually.

13. A Trace

The dma-access view. A 4-byte-misaligned 512-byte read at max request 512, so the boundary splits nothing here, but the Completion returns in two fragments.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
cycle              1     2     3     4     5     6     7     8     9    10
desc_active        0     1     1     1     1     1     1     1     1     0
 
memrd_valid        0     1     1     0     0     0     0     0     0     0
memrd_ready        0     0     1     0     0     0     0     0     0     0
read_tag          --     3     3    --    --    --    --    --    --    --
tag_busy[3]        0     0     1     1     1     1     1     1     0     0
 
cpl_valid          0     0     0     0     0     1     0     1     0     0
cpl_tag           --    --    --    --    --     3    --     3    --    --
cpl_bytes         --    --    --    --    --   256    --   256    --    --
 
expected           0     0   512   512   512   512   512   512     0     0
received           0     0     0     0     0   256   256   512     0     0
read_done          0     0     0     0     0     0     0     1     0     0

Read cycles 2–3. memrd_valid rises with memrd_ready low — the transmit path is busy. The request is held, and the Tag was allocated before it was offered. It transfers at cycle 3.

Read tag_busy[3] from cycle 3. It is set at the request handshake and stays set through everything that follows. Not freed at transmission (§9).

Read cycles 6 and 8. Two Completions, same Tag, 256 bytes each (§10). received accumulates 0 → 256 → 512.

Read cycle 8. received reaches expected, read_done asserts, and tag_busy[3] clears at cycle 9 — the lease ends with the data, not with the request.

And note there is no cpl_valid for the write path anywhere in this trace, because there never is.

14. RTL — Request Chunker and Write Request Owner

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE. Normalized host-access types.
// TAG WIDTH is the sourced part (section 1): a 5-bit Tag field gives 32
// outstanding identifiers, 8 bits gives 256 with Extended Tag enabled.
// Everything else is internal normalization.
package host_access_pkg;
 
  parameter int ADDR_W = 64;
  parameter int LEN_W  = 24;
  parameter int TAG_W  = 5;                 // section 1: 5-bit Tag field
  parameter int N_TAGS = (1 << TAG_W);
 
  // The address boundary a single request must not span.
  parameter int BOUNDARY = 4096;
  parameter int BND_W    = $clog2(BOUNDARY);   // 12
 
  typedef enum logic [1:0] {
    CPL_OK      = 2'd0,
    CPL_ERR     = 2'd1,      // normalized failure -- Chapter 13.2 owns encodings
    CPL_UNKNOWN = 2'd2       // no matching outstanding request (section 11)
  } cpl_status_e;
 
  function automatic logic [BND_W:0] bytes_to_boundary(input logic [ADDR_W-1:0] a);
    return (BND_W+1)'(BOUNDARY) - {1'b0, a[BND_W-1:0]};
  endfunction
 
endpackage
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import host_access_pkg::*;
 
// SYNTHESIZABLE. Choose one legal request size.
// TWO INDEPENDENT BOUNDS (section 7): a configured maximum, and the
// address boundary. Section 17 verified 0 boundary crossings and 0
// oversize chunks across 5,680 fully-walked transfers.
//
// SOURCED WORKED CASE: 4096 bytes at max_request 512 -> 8 requests, which
// matches section 1's vendor text exactly.
module host_req_chunker (
  input  logic [ADDR_W-1:0] addr,
  input  logic [LEN_W-1:0]  remaining,
  input  logic [LEN_W-1:0]  max_request,     // MRRS for reads, MPS for writes (§8)
 
  output logic [LEN_W-1:0]  chunk_bytes,
  output logic [ADDR_W-1:0] next_addr,
  output logic              chunk_valid
);
 
  wire [BND_W:0] to_bnd = bytes_to_boundary(addr);
 
  always_comb begin
    logic [LEN_W-1:0] c;
    c = remaining;
    if (max_request != '0 && c > max_request) c = max_request;
    // ==============================================================
    // THE BOUNDARY BOUND. Without it, a maximum-size request from a
    // misaligned address spans a boundary -- section 7's worked case
    // 0x1000_0004 with 4096 bytes at max 4096, which must become
    // [4092, 4] rather than one illegal request.
    // ==============================================================
    if (LEN_W'(to_bnd) < c) c = LEN_W'(to_bnd);
    chunk_bytes = c;
  end
 
  // Never zero while bytes remain -- a zero chunk is an infinite loop
  // (section 18, mutation 13). to_bnd is at least 1 by construction.
  assign chunk_valid = (remaining != '0) && (chunk_bytes != '0);
  assign next_addr   = addr + ADDR_W'(chunk_bytes);
 
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import host_access_pkg::*;
 
// SYNTHESIZABLE. Device -> host: posted Memory Write (section 3).
// NO COMPLETION EVER RETURNS. The retirement boundary used here is
// acceptance by the transmit path, and it is DECLARED (section 3).
module host_write_owner (
  input  logic clk,
  input  logic rst_n,
 
  input  logic              active,
  input  logic [ADDR_W-1:0] cur_addr,
  input  logic [LEN_W-1:0]  chunk_bytes,
  input  logic              chunk_valid,
  input  logic              payload_ready,     // the data for this chunk exists
 
  output logic              tx_valid,
  output logic [ADDR_W-1:0] tx_addr,
  output logic [LEN_W-1:0]  tx_len,
  input  logic              tx_ready,
  input  logic              link_ok,
 
  output logic              issue_fire,
  output logic [LEN_W-1:0]  issue_bytes
);
  // Offered only when the payload exists -- a stall must never strand a
  // half-built request (mutation 15).
  assign tx_valid = active && chunk_valid && payload_ready && link_ok;
  assign tx_addr  = cur_addr;
  assign tx_len   = chunk_bytes;
 
  // ==================================================================
  // PROGRESS ON THE HANDSHAKE ONLY. Chapter 20.1 section 19 measured
  // advancing on `valid`: bytes_issued exceeded the total in 79.5% of
  // randomized cases, so the descriptor "completed" with data never sent.
  // ==================================================================
  assign issue_fire  = tx_valid && tx_ready;
  assign issue_bytes = chunk_bytes;
 
  // NOTE what is absent: any completion input. A posted write has none
  // (section 3), and a design that waited for one hangs (mutation 3).
endmodule

Classification: all three synthesizable.

The chunker's third case is the instructive one (§7): address 0x1000_0004, 4096 bytes, maximum request 4096 → [4092, 4]. Alignment, not size, determined the request count.

Failure — four. Omitting the boundary bound produces an illegal request from any misaligned address. A zero chunk loops forever. Advancing on valid. And asserting tx_valid without payload_ready, stranding a request on a stall.

15. RTL — Tag Allocator and Read Context Table

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import host_access_pkg::*;
 
// SYNTHESIZABLE. Tag lease management (section 9).
// A TAG IS ALLOCATED BEFORE THE REQUEST IS OFFERED and freed only at
// CONTEXT RETIREMENT. Section 18's counterexample is the version that
// frees at transmission.
//
// DELIBERATELY SMALL. Section 1 sources 32 or 256 Tags; scaling the pool
// and the outstanding-request depth belongs to Chapter 20.5.
module tag_allocator #(
  parameter int TAGS  = 8,
  parameter int TAG_IDX_W = (TAGS <= 1) ? 1 : $clog2(TAGS)
) (
  input  logic clk,
  input  logic rst_n,
 
  input  logic                 alloc_req,
  output logic                 alloc_valid,
  output logic [TAG_IDX_W-1:0] alloc_tag,
 
  input  logic                 free_req,
  input  logic [TAG_IDX_W-1:0] free_tag,
 
  output logic                 no_tags_available,
  output logic                 err_double_free,
  output logic [TAGS-1:0]      busy_map        // lab observability (§19)
);
  generate if (TAGS < 1) $error("TAGS must be at least 1"); endgenerate
 
  logic [TAGS-1:0] busy_q;
  logic            dfree_q;
  assign busy_map          = busy_q;
  assign err_double_free   = dfree_q;
  assign no_tags_available = (&busy_q);
 
  // Lowest free index. Deterministic, so a failing test is reproducible.
  logic [TAG_IDX_W-1:0] first_free;
  logic                 any_free;
  always_comb begin
    first_free = '0; any_free = 1'b0;
    for (int i = TAGS-1; i >= 0; i--)
      if (!busy_q[i]) begin first_free = TAG_IDX_W'(i); any_free = 1'b1; end
  end
  assign alloc_valid = alloc_req && any_free;
  assign alloc_tag   = first_free;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin busy_q <= '0; dfree_q <= 1'b0; end
    else begin
      // ==============================================================
      // ALLOCATE AND FREE IN THE SAME CYCLE is legal and must be handled:
      // a context retiring while a new request is issued is the steady
      // state of a busy engine (section 16). Free is applied first so the
      // freed Tag is immediately re-allocatable.
      // ==============================================================
      if (free_req) begin
        if (free_tag < TAG_IDX_W'(TAGS) && busy_q[free_tag]) busy_q[free_tag] <= 1'b0;
        else dfree_q <= 1'b1;      // double free or out of range -- REPORTED
      end
      if (alloc_valid && !(free_req && (free_tag == alloc_tag)))
        busy_q[alloc_tag] <= 1'b1;
      else if (alloc_valid)
        busy_q[alloc_tag] <= 1'b1; // re-allocated the just-freed Tag
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import host_access_pkg::*;
 
// SYNTHESIZABLE. One context per outstanding read (section 5).
// THIS TABLE IS WHY OUT-OF-ORDER AND SPLIT COMPLETIONS ARE SURVIVABLE:
// the returning data carries no information about where it belongs
// (section 5) -- only the context does.
module read_context_table #(
  parameter int TAGS = 8,
  parameter int TAG_IDX_W = (TAGS <= 1) ? 1 : $clog2(TAGS)
) (
  input  logic clk,
  input  logic rst_n,
 
  // ---- Create, at the request handshake ----------------------------------
  input  logic                 create,
  input  logic [TAG_IDX_W-1:0] create_tag,
  input  logic [15:0]          create_desc_id,
  input  logic [LEN_W-1:0]     create_offset,
  input  logic [LEN_W-1:0]     create_bytes,
 
  // ---- Completion arrival -------------------------------------------------
  input  logic                 cpl_valid,
  input  logic [TAG_IDX_W-1:0] cpl_tag,
  input  logic [LEN_W-1:0]     cpl_bytes,
  input  cpl_status_e          cpl_status,
 
  // ---- Retirement ---------------------------------------------------------
  output logic                 retire_valid,
  output logic [TAG_IDX_W-1:0] retire_tag,
  output logic [15:0]          retire_desc_id,
  output logic [LEN_W-1:0]     retire_bytes,
  output cpl_status_e          retire_status,
 
  output logic                 err_unknown_tag,
  output logic                 err_overrun
);
  logic [TAGS-1:0]      v_q;
  logic [15:0]          did_q  [TAGS];
  logic [LEN_W-1:0]     off_q  [TAGS], exp_q [TAGS], rcv_q [TAGS];
  cpl_status_e          st_q   [TAGS];
  logic                 unk_q, ovr_q;
 
  assign err_unknown_tag = unk_q;
  assign err_overrun     = ovr_q;
 
  // ==================================================================
  // THE MATCH IS BY TAG AGAINST THE OUTSTANDING SET, never against
  // "the descriptor we are currently on" (section 6). Section 18's third
  // counterexample is that design, and it writes B's data into A's buffer.
  // ==================================================================
  wire in_range   = (cpl_tag < TAG_IDX_W'(TAGS));
  wire matched    = cpl_valid && in_range && v_q[cpl_tag];
  // NEXT value, not current -- section 18's second counterexample measured
  // the alternative at 100.0% failure to retire.
  wire [LEN_W-1:0] next_rcv = matched ? (rcv_q[cpl_tag] + cpl_bytes) : '0;
  wire overrun    = matched && (next_rcv > exp_q[cpl_tag]);
  wire complete   = matched && !overrun && (next_rcv == exp_q[cpl_tag]);
  wire failed     = matched && (cpl_status != CPL_OK);
 
  assign retire_valid   = matched && (complete || failed || overrun);
  assign retire_tag     = cpl_tag;
  assign retire_desc_id = did_q[cpl_tag];
  assign retire_bytes   = overrun ? rcv_q[cpl_tag] : next_rcv;
  assign retire_status  = (failed || overrun) ? CPL_ERR : CPL_OK;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      v_q <= '0; unk_q <= 1'b0; ovr_q <= 1'b0;
    end else begin
      if (create) begin
        v_q[create_tag]   <= 1'b1;
        did_q[create_tag] <= create_desc_id;
        off_q[create_tag] <= create_offset;   // WHERE the data goes (§5)
        exp_q[create_tag] <= create_bytes;
        rcv_q[create_tag] <= '0;
        st_q[create_tag]  <= CPL_OK;
      end
 
      if (cpl_valid) begin
        if (!matched) begin
          // Section 1: "User should signal if a completion is received but
          // the tag does not match any outstanding requests." REPORTED --
          // never aliased onto context 0 (mutation 11).
          unk_q <= 1'b1;
        end else if (overrun) begin
          ovr_q <= 1'b1;
          v_q[cpl_tag] <= 1'b0;
        end else begin
          rcv_q[cpl_tag] <= next_rcv;
          // Retire on the byte count, never on a Completion count (§10).
          if (complete || failed) v_q[cpl_tag] <= 1'b0;
        end
      end
    end
  end
endmodule

Classification: both synthesizable.

The allocator was verified (§17): 300,000 random allocate/free operations across TAGS = 1, 2, 3, 8, 32, checked against an independent set model — 0 disagreements, no double allocation, no invalid free accepted.

The accumulator compares next_rcv, not rcv_q — §17 measured the alternative at 100.0% failure to retire, and §18 explains why the number is total rather than merely high.

And err_unknown_tag implements §1's sourced requirement directly.

Failure — six. Freeing the Tag at transmission (§18). Comparing the current received count (§18). Matching against engine state rather than the Tag (§18). Aliasing an unknown Tag onto context 0. Accepting more bytes than expected. And ignoring cpl_status, counting an error Completion's payload as good data.

16. Assertions

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SVA over the host-access blocks. LOCAL contract only. Nothing asserts
// that Completions arrive, that Tags become available, or that the Link
// permits traffic.
 
// ---- CHUNKING ---------------------------------------------------------
 
// P1: A CHUNK NEVER CROSSES THE BOUNDARY. Section 17 verified 0 crossings
// across 5,680 fully-walked transfers.
property p_no_boundary_cross;
  @(posedge clk) disable iff (!rst_n)
  chunk_valid |-> ((addr[ADDR_W-1:BND_W]) == ((addr + chunk_bytes - 1) >> BND_W));
endproperty
a_bnd : assert property (p_no_boundary_cross);
 
// P2: bounded by the configured maximum and by what remains, and never
// zero while bytes remain (mutation 13 loops forever).
property p_chunk_bounded;
  @(posedge clk) disable iff (!rst_n)
  chunk_valid |-> ((chunk_bytes != '0) && (chunk_bytes <= remaining)
                && ((max_request == '0) || (chunk_bytes <= max_request)));
endproperty
a_chunk : assert property (p_chunk_bounded);
 
// ---- WRITE PATH -------------------------------------------------------
 
// P3: the write request is stable under stall.
property p_wr_stable;
  @(posedge clk) disable iff (!rst_n)
  (tx_valid && !tx_ready) |=> (tx_valid && $stable(tx_addr) && $stable(tx_len));
endproperty
a_wr : assert property (p_wr_stable);
 
// P4: progress advances only on the handshake.
property p_wr_fire;
  @(posedge clk) disable iff (!rst_n) issue_fire |-> (tx_valid && tx_ready);
endproperty
a_wrfire : assert property (p_wr_fire);
 
// P5: THE WRITE PATH NEVER WAITS FOR A COMPLETION -- structurally, it has
// no completion input (section 3, mutation 3).
property p_wr_no_cpl_wait;
  @(posedge clk) disable iff (!rst_n)
  (active && chunk_valid && payload_ready && link_ok) |-> tx_valid;
endproperty
a_nowait : assert property (p_wr_no_cpl_wait);
 
// ---- TAGS -------------------------------------------------------------
 
// P6: A TAG IS ALLOCATED BEFORE THE REQUEST IS OFFERED.
property p_tag_before_request;
  @(posedge clk) disable iff (!rst_n)
  (memrd_valid) |-> busy_map[read_tag];
endproperty
a_tagfirst : assert property (p_tag_before_request);
 
// P7: NO DOUBLE ALLOCATION -- an allocated Tag is not handed out again
// until it is freed.
property p_no_double_alloc;
  @(posedge clk) disable iff (!rst_n)
  (alloc_valid && !(free_req && (free_tag == alloc_tag))) |-> !busy_map[alloc_tag];
endproperty
a_dblalloc : assert property (p_no_double_alloc);
 
// P8: THE TAG IS NOT FREED UNTIL ITS CONTEXT RETIRES. Section 18's first
// counterexample is the version freed at transmission.
property p_tag_lease;
  @(posedge clk) disable iff (!rst_n)
  (busy_map[t] && !$past(retire_valid && (retire_tag == TAG_IDX_W'(t))))
    |=> (busy_map[t] || $past(!rst_n));
endproperty
a_lease : assert property (p_tag_lease);
 
// P9: freeing an unallocated or out-of-range Tag is REPORTED.
property p_bad_free;
  @(posedge clk) disable iff (!rst_n)
  (free_req && ((free_tag >= TAG_IDX_W'(TAGS)) || !busy_map[free_tag]))
    |=> err_double_free;
endproperty
a_badfree : assert property (p_bad_free);
 
// ---- COMPLETION MATCHING AND ACCUMULATION -----------------------------
 
// P10: A COMPLETION IS MATCHED BY TAG, and an unmatched one is reported
// rather than aliased (section 1's sourced requirement).
property p_unknown_reported;
  @(posedge clk) disable iff (!rst_n)
  (cpl_valid && ((cpl_tag >= TAG_IDX_W'(TAGS)) || !ctx_valid[cpl_tag]))
    |=> (err_unknown_tag && !retire_valid);
endproperty
a_unk : assert property (p_unknown_reported);
 
// P11: RECEIVED BYTES NEVER EXCEED EXPECTED.
property p_no_overrun;
  @(posedge clk) disable iff (!rst_n)
  ctx_valid[t] |-> (ctx_received[t] <= ctx_expected[t]);
endproperty
a_over : assert property (p_no_overrun);
 
// P12: RETIREMENT ON THE EXACT BYTE COUNT, using the NEXT value.
// Section 17 measured the current-value comparison at 100.0% failure.
property p_retire_exact;
  @(posedge clk) disable iff (!rst_n)
  (retire_valid && (retire_status == CPL_OK))
    |-> (retire_bytes == $past(ctx_expected[cpl_tag]));
endproperty
a_exact : assert property (p_retire_exact);
 
// P12b: and ONE retirement per context -- a split Completion does not
// retire on its first fragment (mutation 10).
property p_retire_once;
  @(posedge clk) disable iff (!rst_n)
  (retire_valid) |=> !ctx_valid[$past(cpl_tag)];
endproperty
a_once : assert property (p_retire_once);
 
// P13: A COMPLETION CANNOT UPDATE A CONTEXT OTHER THAN ITS OWN. The
// property section 18's third counterexample violates.
property p_no_cross_context;
  @(posedge clk) disable iff (!rst_n)
  (cpl_valid && (cpl_tag != TAG_IDX_W'(t))) |=> $stable(ctx_received[t]);
endproperty
a_cross : assert property (p_no_cross_context);
 
// P14: AN ERROR COMPLETION IS NOT COUNTED AS GOOD DATA.
property p_error_not_success;
  @(posedge clk) disable iff (!rst_n)
  (cpl_valid && (cpl_status != CPL_OK) && ctx_valid[cpl_tag])
    |=> (retire_status == CPL_ERR);
endproperty
a_err : assert property (p_error_not_success);
 
// P15: THE READ PATH NEVER RETIRES ON ISSUANCE (20.1 §19: 99.5% early).
property p_read_not_on_issued;
  @(posedge clk) disable iff (!rst_n)
  retire_valid |-> $past(cpl_valid);
endproperty
a_notissued : assert property (p_read_not_on_issued);
 
// P16: CONSERVATION -- requested bytes are either outstanding or returned.
property p_conservation;
  @(posedge clk) disable iff (!rst_n)
  ctx_valid[t] |-> ((ctx_expected[t] - ctx_received[t]) + ctx_received[t]
                    == ctx_expected[t]);
endproperty
a_cons : assert property (p_conservation);
 
// P17: reset clears every outstanding context and Tag -- no stale
// transaction state survives (section 20).
property p_reset;
  @(posedge clk)
  !rst_n |=> ((busy_map == '0) && (ctx_valid == '0) && !retire_valid);
endproperty
a_reset : assert property (p_reset);

P8 is the lease property (§9), and P13 is the isolation property (§6) — together they are what makes multiple outstanding reads safe.

P12 with P12b is the split-Completion pair: retire on the exact byte count, and exactly once.

And P15 restates Chapter 20.1's law at this layer — retirement requires a Completion to have arrived, so no amount of issuing can retire a read.

No liveness. "A Completion eventually arrives" is an environment property; a request whose Completion never returns is a completion-timeout condition this chapter does not own.

17. Same-Cycle Contracts

CaseDeclared resolution
request handshake + Link downthe handshake completes; the next request waits (P5's qualification)
Tag freed + allocated, same cyclefree applies first, so the freed Tag is immediately re-allocatable (§15)
final Completion + a new request needing a Tagretirement frees the Tag in the same cycle it can be re-allocated
Completion carrying an error and payloaderror wins; the payload is not counted (P14)
Completion + resetreset wins; no context or Tag survives (P17)
duplicate Completion for a retired contextunknown Tag — reported, not accumulated (P10)
overrun Completioncontext retires with an error status; bytes are not accumulated (§15)

18. Verification, Fault Injection, and Model Verification

Executed before publication.

The chunker — every bound, every offset

5,680 complete transfers walked to exhaustion, across maximum request sizes 128/256/512/1024/4096, address offsets stepping through a full 4 KiB range, and lengths from 1 to 16384:

CheckViolations
4 KiB boundary crossings0
chunks exceeding the maximum0
zero-size chunks while bytes remained0

Worked cases, computed (§7): 0x1000_0FF0 + 64 at max 512 → [16, 48]; 0x1000_0004 + 4096 at max 4096 → [4092, 4]; and 0x1000_0000 + 4096 at max 512 → 8 chunks of 512, reproducing §1's sourced vendor arithmetic exactly.

The Tag allocator — against an independent set model

300,000 random allocate/free operations across TAGS = 1, 2, 3, 8, 32, compared against a plain Python set: 0 disagreements. No Tag allocated twice, no invalid free accepted, exhaustion reported exactly when the set was full.

Completion accumulation — the comparison that must be right

40,000 random split-Completion partitions of transfers sized 64 to 4096 bytes, fragmented arbitrarily:

ComparisonFailed to retire
next = received + cpl_bytes, compare next0
compare received before adding40,000 — 100.0%

Error injection, computed: an oversize fragment ([64, 300] against 256 expected) → received stays 64, overrun raised; a duplicate final fragment ([128, 128, 128] against 256) → overrun raised rather than accumulated.

Directed tests

  • One Memory Write; stalled write for 1, 2, 50 cycles (P3); multi-chunk write.
  • One Memory Read with a single Completion; delayed Completion; split Completion in 2, 3 and many fragments (P12, P12b). Required.
  • Two outstanding reads, Completions interleaved — verify each updates only its own context (P13). Required.
  • Tag exhaustion — verify no request is issued without a Tag (P6).
  • Tag reuse after retirement — verify the same Tag is usable again (P8).
  • Unknown Tag Completion — verify reported, not aliased (P10). Required, and §1's sourced requirement.
  • Duplicate Completion after retirement — same verdict.
  • Completion with an error status carrying payload — verify the payload is not counted (P14). Required.
  • Boundary cases: 1 byte before a 4 KiB edge, exactly at the edge, exactly one maximum-size request, and a misaligned maximum-size request (§7's third case). Required.
  • Final short chunk; TAGS = 1, 2, 3, 8; max_request = 128 and 4096.
  • Reset with reads outstanding — verify no stale context (P17).

The scoreboard maintains an independent Tag dictionary and per-context byte model, driven only from observed requests and Completions, and never reads busy_map, ctx_valid or ctx_received.

Mutations

#MutationCaught bySystem symptom
1device→host issues MemRdscoreboardno data moves; Completions return nothing useful (§2)
2host→device issues MemWrscoreboardhost memory overwritten with device buffer contents
3write path waits for a CompletionP5engine hangs; a posted write has none (§3)
4read retires on issuanceP15buffer read while still filling — 99.5% (20.1 §19)
5Tag freed at request transmissionP8Completions become unattributable (§18's counterexample)
6Tag allocated twiceP7two requests share an identity; data crosses buffers
7Completion applied to the "current" descriptorP13B's data written into A's buffer (§18)
8received compared before incrementP12never retires — 100.0% (measured)
9Completion bytes exceeding expected accumulatedP11overrun into the device buffer
10split Completion retires on the first fragmentP12bpartial data treated as complete
11unknown Tag aliased onto context 0P10a stray Completion corrupts an unrelated transfer
12MPS used as the read request boundP2 + reviewover-large reads, or needlessly fragmented ones (§8)
13chunk crosses the 4 KiB boundaryP1illegal request; behaviour depends on the completer
14chunk of zero while bytes remainP2infinite loop with no progress
15write address advances under stallP4duplicate regions in host memory (§19)
16read request mutates under stallP3a request for one range answered into another
17error Completion counted as successP14corrupt data accepted as valid
18duplicate Completion acceptedP10, P11byte count overruns; context retires early
19Tag freed out of rangeP9frees a Tag belonging to a live request
20reset leaves stale outstanding contextsP17a post-reset Completion matches a pre-reset context

19. Debugging

Symptom → write path or read path? → signal → distinguishing experiment.

Host memory receives duplicate regions

A write-path accounting bug — mutation 15.

If the address or byte count advances on valid rather than the handshake, a stalled chunk is counted and re-sent repeatedly, writing the same bytes to successive addresses.

The distinguishing experiment: compare bytes_issued against the sum of request lengths accepted on an analyzer. If the device's count is larger, it is counting offers (Chapter 20.1 §19 measured 79.5%), and P4 is the permanent check.

DMA reads hang with every request visible on the analyzer

The analyzer showing requests and Completions is the key observation — the data arrived.

Read the context table. If contexts stay valid with received == expected, the retirement comparison is wrong — mutation 8, §18's second counterexample, and 100.0% fatal.

If received < expected and no more Completions are coming, the request was answered short: check cpl_status (mutation 17) and whether an overrun terminated it.

And check the Tag pool. A read path that stops issuing entirely, after working for a while, is the escalation of a stuck-context bug — contexts never retire, so Tags never free, so the pool exhausts. busy_map all ones with no Completions outstanding is conclusive.

Corruption only when several reads are outstanding

Suspect context association — §18's first and third counterexamples.

Two candidates, distinguished by whether Tags are being reused. If the same Tag is allocated while a previous request may still be answered, it is mutation 5 (freed at transmission). If Tags are unique but data lands in the wrong buffer, it is mutation 7 (matched against engine state).

The distinguishing experiment: constrain the engine to one outstanding read. If the corruption disappears entirely, it is an association bug — and note this is exactly why Chapter 20.1 §15's bounded model could not have found it.

Failures only near 4 KiB address boundaries

The chunker (§7), and the arithmetic tells you which bound is missing.

If failures occur only when a request would span a boundary, the boundary bound is absent (mutation 13). Distinguished by: aligning the transfer to 4 KiB — if it then works, it is the boundary.

And check the misaligned-maximum case specifically (§7's third row): address 0x1000_0004 with a maximum-size request must produce two requests. A design that produces one has an illegal request that some completers tolerate and others do not — which is why it can pass on one platform and fail on another.

A read finishes too early

Compare bytes_issued against bytes_completed (Chapter 20.1 §10).

If the transfer retired with bytes_issued == total but bytes_completed < total, it is mutation 4 — retirement on issuance, measured at 99.5%. P15 requires a Completion to have arrived, which no amount of issuing satisfies.

20. Common Misconceptions

  • "A DMA write gets a Completion." Posted; there is none (§3).
  • "Issuing a MemRd means the data is read." It means the request left (§4).
  • "One read request produces one Completion." It may produce several (§10).
  • "A Tag can be freed once the request leaves." The lease ends with the data (§9, §18).
  • "The current descriptor identifies a returning Completion." It does not; the Tag does (§6, §18).
  • "MPS and max read request size are interchangeable." Different fields, different things bounded (§8).
  • "Chunking is only about performance." It is about legality — bounds and boundaries (§7).
  • "Write progress may advance when valid is asserted." 79.5% overshoot (20.1 §19).
  • "Completion status can be ignored if payload is present." An error Completion's payload is not data (P14).
  • "An unknown Tag can be dropped quietly." §1: "User should signal…" (§11).
  • "Out-of-order Completions need reassembly buffers." They need contextslocal_offset is what places the data (§5).
  • "A misaligned maximum-size request is fine." It spans a boundary and must be split (§7).

21. Understanding Check

22. What's Next

An owned descriptor becomes PCIe traffic in two very different ways.

Writes are accounting (§3): build, hand over, advance on the handshake, and never wait for an answer that does not exist.

Reads are matching (§6): allocate a Tag, create a context, and hold it until the expected bytes have all arrived — possibly split across Completions (§10), possibly interleaved with other requests. The Tag is a lease that ends with the data (§9), and the accumulator's comparison must be written so the retirement decision and the state update agree — §18 measured the alternative at 100.0%.

And chunking is legality, not tuning (§7): a maximum request size and an address boundary, verified across 5,680 walked transfers.

Chapter 20.4 — Scatter-Gather takes the next step. One logical transfer, many non-contiguous memory segments — described by a list of descriptors that hardware must walk. And the twist that makes it a genuine architecture problem: fetching those descriptors is itself DMA, using every mechanism this chapter just built, with a control plane that can loop, terminate early, or arrive in pieces.

The idea to carry forward: an asynchronous response must carry its own identity, and the receiver must look it up.