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)."
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 landsThe 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
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 memoryWhat 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
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 TagEvery 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):
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:
bytes_to_boundary = 4096 - (addr & 0xFFF)Both bounds apply, plus what remains:
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:
| Address | Length | Max request | Chunks |
|---|---|---|---|
0x1000_0FF0 | 64 | 512 | [16, 48] — the boundary splits it, not the size |
0x1000_0000 | 4096 | 512 | [512 × 8] — matches §1's sourced example |
0x1000_0004 | 4096 | 4096 | [4092, 4] — a maximum-size request split by 4 bytes of misalignment |
0x1000_0FFF | 2 | 128 | [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 Size | Max Read Request Size | |
|---|---|---|
| Bounds | how much payload a TLP may carry | how much a read may ask for |
| Applies to | writes (and Completions) | read requests |
| §1's values | "128, 256, 512, 1024, 2048, and 4096-byte" | the example uses 512 |
| Register | Device Control MPS field | Device 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:
one Completion ≠ one read request completed
all requests issued ≠ all bytes returnedSo 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
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.
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 0Read 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
// 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
endpackageimport 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);
endmoduleimport 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).
endmoduleClassification: 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
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
endmoduleimport 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
endmoduleClassification: 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
// 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
| Case | Declared resolution |
|---|---|
| request handshake + Link down | the handshake completes; the next request waits (P5's qualification) |
| Tag freed + allocated, same cycle | free applies first, so the freed Tag is immediately re-allocatable (§15) |
| final Completion + a new request needing a Tag | retirement frees the Tag in the same cycle it can be re-allocated |
| Completion carrying an error and payload | error wins; the payload is not counted (P14) |
| Completion + reset | reset wins; no context or Tag survives (P17) |
| duplicate Completion for a retired context | unknown Tag — reported, not accumulated (P10) |
| overrun Completion | context 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:
| Check | Violations |
|---|---|
| 4 KiB boundary crossings | 0 |
| chunks exceeding the maximum | 0 |
| zero-size chunks while bytes remained | 0 |
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:
| Comparison | Failed to retire |
|---|---|
next = received + cpl_bytes, compare next | 0 |
compare received before adding | 40,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
| # | Mutation | Caught by | System symptom |
|---|---|---|---|
| 1 | device→host issues MemRd | scoreboard | no data moves; Completions return nothing useful (§2) |
| 2 | host→device issues MemWr | scoreboard | host memory overwritten with device buffer contents |
| 3 | write path waits for a Completion | P5 | engine hangs; a posted write has none (§3) |
| 4 | read retires on issuance | P15 | buffer read while still filling — 99.5% (20.1 §19) |
| 5 | Tag freed at request transmission | P8 | Completions become unattributable (§18's counterexample) |
| 6 | Tag allocated twice | P7 | two requests share an identity; data crosses buffers |
| 7 | Completion applied to the "current" descriptor | P13 | B's data written into A's buffer (§18) |
| 8 | received compared before increment | P12 | never retires — 100.0% (measured) |
| 9 | Completion bytes exceeding expected accumulated | P11 | overrun into the device buffer |
| 10 | split Completion retires on the first fragment | P12b | partial data treated as complete |
| 11 | unknown Tag aliased onto context 0 | P10 | a stray Completion corrupts an unrelated transfer |
| 12 | MPS used as the read request bound | P2 + review | over-large reads, or needlessly fragmented ones (§8) |
| 13 | chunk crosses the 4 KiB boundary | P1 | illegal request; behaviour depends on the completer |
| 14 | chunk of zero while bytes remain | P2 | infinite loop with no progress |
| 15 | write address advances under stall | P4 | duplicate regions in host memory (§19) |
| 16 | read request mutates under stall | P3 | a request for one range answered into another |
| 17 | error Completion counted as success | P14 | corrupt data accepted as valid |
| 18 | duplicate Completion accepted | P10, P11 | byte count overruns; context retires early |
| 19 | Tag freed out of range | P9 | frees a Tag belonging to a live request |
| 20 | reset leaves stale outstanding contexts | P17 | a 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
validis 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 contexts —
local_offsetis 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.