PCIe · Module 23
DMA Engines — Composing the Pieces Without Collapsing Ownership
Module 20 built the parts: descriptors, scatter-gather, placement, cleanup. This chapter assembles them into one engine — and the assembly fails the moment the job controller starts doing the datapath's job.
Chapter 23.1 drew a box labelled DMA engine and did not build it. Module 20 built almost every piece that goes inside it.
So this chapter is the assembly, and assembly is where the interesting failures are. Not because any individual piece is hard — Module 20 verified each of them — but because an engine is several concurrent pipelines that share resources, and the natural instinct is to coordinate them with one state machine.
That instinct is the bug this chapter is about.
1. Sources, Scope, and What Module 20 Already Built
2. Not One State Machine
Write down what a DMA engine does and the temptation is immediate:
IDLE -> FETCH_DESC -> ISSUE_READ -> WAIT_CPL -> WRITE_DATA -> DONE -> IDLEEvery arrow is a lie about concurrency.
While one read is waiting for its Completion, the engine should be issuing the next read. While payload is being written out, the next descriptor should be prefetching. While a job is publishing status, the next job should be starting. A single FSM in WAIT_CPL is doing none of that.
So the sequential FSM does not just underperform — it changes the design's correctness surface, because every resource ends up owned by "the current state" rather than by a specific structure, and "the current state" is not a place you can free a Tag from.
The decomposition this chapter uses:
| Owner | Owns | Does not own |
|---|---|---|
| job controller (FSM, §11) | which descriptor is live; its lifecycle | any beat, any Tag, any packet |
| read issue (§5) | turning remaining bytes into read requests | when Completions arrive |
| write issue (§5) | turning payload into write packets | anything about reads |
| completion placement (23.5) | routing returned bytes by context | job lifecycle |
| status publication (§9) | the completion record | notification delivery |
Five owners, one shared resource pool — and §8 is about the pool.
3. The Controller Tracks Jobs; The Datapath Moves Bytes
The FSM in §11 has seven states and none of them is WAIT_CPL_BEAT. It changes state on job-scale events: a descriptor was claimed, validation passed, the job's completion criterion was met, an error occurred, the status record was accepted.
Everything at beat scale happens underneath it, concurrently, and does not stall it.
The test for whether you have this right is a question: can the engine issue a read for job A while publishing status for job B? If the answer is no because both are "the state", the controller has absorbed the datapath.
And note what this buys beyond throughput. Because each pipeline owns its own resources, cleanup can be centralized (§8) — there is a definite list of things a job owns. In a monolithic FSM the list is implicit in the state graph, which is exactly why the error path forgets one.
4. Accounting Is Per Job, or It Is Wrong
5. Read Issue and Write Issue Are Different Machines
They look symmetric and they are not, because Chapter 12.2 makes a write Posted and 12.1 makes a read Non-Posted.
| Read issue | Write issue | |
|---|---|---|
| bounded by | MRRS (22.4 §4) | MPS (22.4 §3) |
| carries payload | no | yes |
| needs a Tag | yes — it will be answered | no |
| needs a context | yes — to place the answer | no |
| retires on | returned bytes (13.3) | a stated local contract |
| back-pressured by | Tags, contexts, NPH credit | payload availability, PD credit |
Read the last row of the middle column carefully, because it is the one people get wrong in the other direction. A write needs no Completion at the PCIe transaction layer — so "the write is done" is a statement the engine makes about its own handoff, not about host memory. Chapter 22.2 §5 owns that distinction; §12 states the local contract explicitly rather than implying the host has acknowledged anything.
Both engines share one thing: the chunker. Chapter 22.4 §10 built it, bounded by remaining bytes, the configured limit, the address boundary and buffer headroom — and §12 instantiates it twice with different limits rather than writing two chunkers that can drift apart.
6. Issued Is Not Completed
Chapter 22.4 §7 established this and measured it from the sizing side. Here it is measured inside a running engine.
§14 Model 1 ran 200,000 randomized steps across up to four concurrent jobs with reads, writes, fragmented Completions, reordering and a 3% Completion-error rate. It counted the moments at which a job had issued every byte but not received every byte:
| Metric | Result |
|---|---|
| steps | 200,000 |
| jobs terminated | 3,848 |
| Completion errors injected | 921 |
| moments an "issued == total" engine would retire early | 7,630 |
7,630 opportunities to hand software a buffer that is still filling. And the engine only has to take one.
The correct criterion, per job:
read job done <=> bytes_completed >= bytes_total OR terminal error
write job done <=> the stated local handoff contract is satisfiedNote that the read criterion never mentions packets. Chapter 13.3 §1's coverage model is the reason: the Completer chooses the fragmentation, so a packet count is a guess.
7. Job, Tag, Context — a Three-Way Binding
Every outstanding read ties three things together, and every one of them must be released exactly once:
job_id -- whose transfer this is (per-job accounting, §4)
tag -- the protocol identity (with Requester ID, 21.4 §3)
context -- where the returned bytes go (23.5 owns the engine)The binding is created atomically at the request handshake and destroyed atomically at terminal. Creating it earlier — at offer rather than accept — allocates a Tag for a request that may never be sent.
The same-cycle race. A Completion retires a context and frees its Tag in the same cycle a new request wants to allocate one. §14 Model 3 measured the two implementations disagreeing:
| Bitmap form | Behaviour |
|---|---|
next-state (free_next = (free | freeing) & ~allocating) | reuses the just-freed Tag immediately |
old-value (allocator reads free_q) | cannot see the free; picks a different Tag |
| disagreement rate | 18,956 of 300,000 (6.3%) |
The old-value form is merely inefficient. The dangerous variant is a design that writes both bits in the same cycle from two separate always_ff branches — the free and the allocate race, and one of them is lost. If the free is lost, the Tag leaks; if the allocate is lost, the request stalls with resources apparently available. §12 computes one next-state bitmap and P16 asserts conservation across the coincidence.
8. Cleanup Is One Transaction
9. Backpressure Composes
Three independent consumers can stall, and the engine must remain correct when all three do at once:
| Stalled | Must not happen |
|---|---|
| TX (credits, scheduler) | issued-byte counters advance (22.4 §7) |
| status/completion record | the record is dropped, or a one-cycle pulse is lost |
| notification (MSI-X path) | the job's completion state becomes unknown |
The third row is the architectural point, and Chapter 20.6 §13 established it: publish, then notify. The completion record is authoritative; the interrupt is a hint that one exists. An engine whose "done" state is the MSI-X send has made an unreliable notification path load-bearing for correctness.
So the status output is a held valid/ready record (§12), never a pulse — the rule Chapter 23.1 §3 made general. A one-cycle done strobe into a busy consumer is a lost job, and the symptom is a transfer that completed in hardware and never completed in software.
10. The Engine
Four things to read out of the figure.
The job controller touches no beat. Its edges go to read issue, write issue and cleanup — never to the scheduler or the placement path (§3).
Read issue draws from the pool and write issue does not. Only reads need a Tag and a context (§5).
Accounting sits between placement and status, so the completion criterion is evaluated on returned bytes (§6) — not on anything the issue side knows.
And there is exactly one arrow into the pool. Every terminal path — success, error, abort, reset — goes through cleanup txn (§8). A second arrow would be the 14-job wedge.
11. The Job Lifecycle
Four things to read out of the figure.
ACTIVE is one state and two concurrent pipelines. Reads and writes issue underneath it; the FSM does not step per request (§3).
DRAIN exists because issued is not completed (§6). A read job leaves ACTIVE when the last request is accepted and leaves DRAIN when the last byte arrives — and §14 measured 7,630 moments where those differ.
Every error edge converges on CLEANUP, and CLEANUP is the only path to the pool (§8). That convergence is what makes the release provably once.
And REPORT holds. It does not return to IDLE until the record is accepted (§9), because a job that completed in hardware and was never published did not complete.
12. RTL — The Engine
// SYNTHESIZABLE. Engine types. IMPLEMENTATION POLICY throughout -- PCIe
// defines no descriptor format (Chapter 20.2 §4).
package dma_pkg;
parameter int ADDR_W = 64;
parameter int LEN_W = 24;
parameter int PAY_W = 13;
parameter int JOBS = 4;
parameter int JOB_W = (JOBS <= 1) ? 1 : $clog2(JOBS);
parameter int TAGS = 8;
parameter int TAG_W = (TAGS <= 1) ? 1 : $clog2(TAGS);
typedef enum logic [2:0] {
J_IDLE=3'd0, J_CLAIM=3'd1, J_VALIDATE=3'd2, J_ACTIVE=3'd3,
J_DRAIN=3'd4, J_ERROR=3'd5, J_CLEANUP=3'd6, J_REPORT=3'd7
} job_state_e;
typedef enum logic [2:0] {
TERM_NONE=3'd0, TERM_OK=3'd1, TERM_MALFORMED=3'd2,
TERM_CPL_ERROR=3'd3, TERM_ABORT=3'd4, TERM_RESET=3'd5
} term_e;
typedef struct packed {
logic [ADDR_W-1:0] host_addr;
logic [ADDR_W-1:0] local_addr;
logic [LEN_W-1:0] length;
logic is_read; // read = device pulls from host
logic irq_on_done;
logic [15:0] cookie;
} desc_t;
// Per-JOB accounting. §14 Model 2: one shared counter gave a wrong
// per-job verdict in 62.6% of multi-job trials.
typedef struct packed {
logic active;
desc_t d; // the SNAPSHOT (20.2 §6), never live memory
logic [LEN_W-1:0] bytes_issued;
logic [LEN_W-1:0] bytes_completed;
term_e term;
job_state_e st;
} job_t;
endpackageimport dma_pkg::*;
// SYNTHESIZABLE. The job controller FSM (Figure 2). It changes state on
// JOB-SCALE events only. It never sequences a beat, a request or a
// completion -- that separation is what lets the pipelines below run
// concurrently, and what makes §8's cleanup provably single-path.
module job_controller (
input logic clk,
input logic rst_n,
input logic desc_valid,
output logic desc_ready,
input desc_t desc_in,
// job-scale facts from the datapath -- NOT beat-level handshakes
input logic [LEN_W-1:0] bytes_issued,
input logic [LEN_W-1:0] bytes_completed,
input logic fault,
input term_e fault_reason,
input logic cleanup_done,
input logic status_ready, // the record's consumer
output job_state_e state,
output desc_t active_desc, // the immutable snapshot
output logic issue_enable, // pipelines may issue
output logic cleanup_req,
output term_e term_out,
output logic status_valid
);
job_state_e st_q;
desc_t d_q;
term_e t_q;
assign state = st_q;
assign active_desc = d_q;
assign term_out = t_q;
assign desc_ready = (st_q == J_IDLE);
assign issue_enable = (st_q == J_ACTIVE);
assign cleanup_req = (st_q == J_CLEANUP);
assign status_valid = (st_q == J_REPORT); // HELD, never pulsed (§9)
// Validation is a pure function of the SNAPSHOT, so it cannot be
// invalidated by software writing the descriptor afterwards (20.2 §6).
logic desc_ok;
assign desc_ok = (d_q.length != '0) && ((d_q.host_addr + d_q.length) >= d_q.host_addr);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin st_q <= J_IDLE; d_q <= '0; t_q <= TERM_NONE; end
else unique case (st_q)
J_IDLE: if (desc_valid) begin
d_q <= desc_in; // THE SNAPSHOT
t_q <= TERM_NONE;
st_q <= J_CLAIM;
end
J_CLAIM: st_q <= J_VALIDATE;
J_VALIDATE: if (desc_ok) st_q <= J_ACTIVE;
else begin t_q <= TERM_MALFORMED; st_q <= J_ERROR; end
J_ACTIVE: if (fault) begin t_q <= fault_reason; st_q <= J_ERROR; end
else if (bytes_issued >= d_q.length) st_q <= J_DRAIN;
// DRAIN exists because ISSUED IS NOT COMPLETED (§6). §14 Model 1:
// 7,630 moments where an "issued==total" engine would retire early.
J_DRAIN: if (fault) begin t_q <= fault_reason; st_q <= J_ERROR; end
else if (bytes_completed >= d_q.length) begin
t_q <= TERM_OK; st_q <= J_CLEANUP;
end
J_ERROR: st_q <= J_CLEANUP; // EVERY error converges (§8)
J_CLEANUP: if (cleanup_done) st_q <= J_REPORT;
J_REPORT: if (status_ready) st_q <= J_IDLE; // held until accepted
default: st_q <= J_IDLE;
endcase
end
endmoduleimport dma_pkg::*;
// SYNTHESIZABLE. Read issue. Bounded by MRRS via Chapter 22.4 §10's
// chunker, and by resource availability. The job/Tag/context binding is
// created ON THE HANDSHAKE, never on the offer (§7).
module read_issue #(parameter int MRRS_BYTES = 512) (
input logic clk,
input logic rst_n,
input logic enable, // J_ACTIVE
input desc_t d, // snapshot
input logic [LEN_W-1:0] bytes_issued,
input logic tag_avail,
input logic [TAG_W-1:0] tag_grant,
input logic ctx_avail,
output logic rq_valid,
input logic rq_ready,
output logic [ADDR_W-1:0] rq_addr,
output logic [PAY_W-1:0] rq_bytes,
output logic [TAG_W-1:0] rq_tag,
output logic bind_valid, // create job/tag/context binding
output logic [TAG_W-1:0] bind_tag,
output logic [ADDR_W-1:0] bind_local_addr,
output logic [PAY_W-1:0] bind_bytes,
output logic [LEN_W-1:0] issued_delta
);
logic [LEN_W-1:0] rem;
logic [PAY_W-1:0] chunk;
assign rem = (d.length > bytes_issued) ? (d.length - bytes_issued) : '0;
// Chapter 22.4 §10's chunker: the minimum of EVERY active constraint.
request_chunker u_chunk (
.addr(d.host_addr + ADDR_W'(bytes_issued)),
.remaining(rem),
.max_payload(PAY_W'(MRRS_BYTES)),
.boundary_mask(PAY_W'(4096)),
.buffer_headroom({PAY_W{1'b1}}),
.chunk_bytes(chunk), .next_addr(), .last(), .stuck()
);
// Availability is not ownership: the request is offered only when a Tag
// AND a context are actually obtainable (Chapter 22.3 §9's law).
assign rq_valid = enable && d.is_read && (rem != '0) && tag_avail && ctx_avail;
assign rq_addr = d.host_addr + ADDR_W'(bytes_issued);
assign rq_bytes = chunk;
assign rq_tag = tag_grant;
// THE BINDING, created on the transfer -- never on the offer (§7).
assign bind_valid = rq_valid && rq_ready;
assign bind_tag = tag_grant;
assign bind_local_addr = d.local_addr + ADDR_W'(bytes_issued);
assign bind_bytes = chunk;
assign issued_delta = (rq_valid && rq_ready) ? LEN_W'(chunk) : '0;
endmoduleimport dma_pkg::*;
// SYNTHESIZABLE. Write issue. Bounded by MPS. A Posted write expects no
// Completion (Chapter 12.2), so completion accounting advances at the
// LOCAL handoff -- and §9 is explicit that this says nothing about host
// visibility (Chapter 22.2 §5).
module write_issue #(parameter int MPS_BYTES = 256) (
input logic clk,
input logic rst_n,
input logic enable,
input desc_t d,
input logic [LEN_W-1:0] bytes_issued,
input logic payload_avail, // source FIFO has this chunk
input logic credit_ok, // PH and PD both (16.1 §4)
output logic wr_valid,
input logic wr_ready,
output logic [ADDR_W-1:0] wr_addr,
output logic [PAY_W-1:0] wr_bytes,
output logic wr_last,
output logic [LEN_W-1:0] issued_delta,
output logic [LEN_W-1:0] completed_delta // LOCAL handoff contract
);
logic [LEN_W-1:0] rem;
logic [PAY_W-1:0] chunk;
logic lastc;
assign rem = (d.length > bytes_issued) ? (d.length - bytes_issued) : '0;
request_chunker u_chunk (
.addr(d.host_addr + ADDR_W'(bytes_issued)),
.remaining(rem),
.max_payload(PAY_W'(MPS_BYTES)),
.boundary_mask(PAY_W'(4096)),
.buffer_headroom({PAY_W{1'b1}}),
.chunk_bytes(chunk), .next_addr(), .last(lastc), .stuck()
);
assign wr_valid = enable && !d.is_read && (rem != '0) && payload_avail && credit_ok;
assign wr_addr = d.host_addr + ADDR_W'(bytes_issued);
assign wr_bytes = chunk;
assign wr_last = lastc;
// Both advance on the SAME transfer for a Posted write -- and that is a
// statement about this engine's handoff, not about host memory.
assign issued_delta = (wr_valid && wr_ready) ? LEN_W'(chunk) : '0;
assign completed_delta = (wr_valid && wr_ready) ? LEN_W'(chunk) : '0;
endmoduleimport dma_pkg::*;
// SYNTHESIZABLE. Tag pool with a SINGLE next-state bitmap (§7).
// §14 Model 3: next-state and old-value forms choose a different Tag in
// 6.3% of free-and-allocate coincidences -- and a design that writes both
// bits from two branches loses one of them entirely.
module tag_pool (
input logic clk,
input logic rst_n,
input logic alloc_req,
input logic free_req,
input logic [TAG_W-1:0] free_tag,
output logic tag_avail,
output logic [TAG_W-1:0] tag_grant,
output logic alloc_fire,
output logic [TAGS-1:0] free_map,
output logic err_free_of_free // sticky: freeing a free Tag
);
logic [TAGS-1:0] free_q;
logic e_q;
logic [TAG_W-1:0] pick;
logic found;
assign free_map = free_q;
assign tag_avail = |free_q;
assign tag_grant = pick;
assign alloc_fire = alloc_req && found;
assign err_free_of_free = e_q;
always_comb begin
pick = '0; found = 1'b0;
for (int i = TAGS-1; i >= 0; i--) if (free_q[i]) begin pick = TAG_W'(i); found = 1'b1; end
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin free_q <= {TAGS{1'b1}}; e_q <= 1'b0; end
else begin
if (free_req && free_q[free_tag]) e_q <= 1'b1; // double free -> report
// ONE next-state expression. The free is visible to this cycle's
// allocation, and neither write can be lost to the other.
free_q <= (free_q | (free_req ? (TAGS'(1) << free_tag) : '0))
& ~((alloc_req && found) ? (TAGS'(1) << pick) : '0);
end
end
endmoduleimport dma_pkg::*;
// SYNTHESIZABLE. THE FLAGSHIP BLOCK. One cleanup transaction (§8).
// EVERY terminal path routes here. §14 Model 4: central cleanup held 8/8
// Tags across 4,000 error-heavy jobs; success-path-only cleanup exhausted
// every Tag and WEDGED THE ENGINE AFTER 14 JOBS.
module cleanup_txn (
input logic clk,
input logic rst_n,
input logic cleanup_req,
input term_e reason,
input logic [TAGS-1:0] job_owned_tags, // what this job still holds
input logic ctx_release_ready,
output logic free_req,
output logic [TAG_W-1:0] free_tag,
output logic ctx_release,
output logic desc_release,
output logic cleanup_done,
output logic [TAGS-1:0] remaining_owned,
output logic err_incomplete // sticky: finished with residue
);
logic [TAGS-1:0] pend_q;
logic busy_q, e_q;
logic [TAG_W-1:0] nxt;
logic any;
assign remaining_owned = pend_q;
assign err_incomplete = e_q;
always_comb begin
nxt = '0; any = 1'b0;
for (int i = TAGS-1; i >= 0; i--) if (pend_q[i]) begin nxt = TAG_W'(i); any = 1'b1; end
end
// The release path does NOT depend on WHY the job ended. That
// independence is the whole pattern (§8).
assign free_req = busy_q && any;
assign free_tag = nxt;
assign ctx_release = busy_q && !any && ctx_release_ready;
assign desc_release = busy_q && !any && ctx_release_ready;
assign cleanup_done = busy_q && !any && ctx_release_ready;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin pend_q <= '0; busy_q <= 1'b0; e_q <= 1'b0; end
else begin
if (!busy_q && cleanup_req) begin
pend_q <= job_owned_tags; busy_q <= 1'b1;
end else if (busy_q) begin
if (any) pend_q[nxt] <= 1'b0;
else if (ctx_release_ready) begin
busy_q <= 1'b0;
if (pend_q != '0) e_q <= 1'b1; // cannot happen: prove it (P24)
end
end
end
end
endmoduleimport dma_pkg::*;
// VERIFICATION-ONLY. Resource conservation across the whole engine.
// The single fastest way to find lost ownership: every Tag is FREE, or
// held by exactly one job, and never both.
module dma_conservation_monitor (
input logic [TAGS-1:0] free_map,
input logic [TAGS-1:0] job_owned [JOBS],
input logic [LEN_W-1:0] bytes_issued [JOBS],
input logic [LEN_W-1:0] bytes_completed [JOBS],
input logic [LEN_W-1:0] bytes_total [JOBS],
input logic [JOBS-1:0] job_active,
output logic err_tag_conservation,
output logic err_tag_double_owned,
output logic err_byte_accounting
);
logic [TAGS-1:0] owned_or;
int owners [TAGS];
always_comb begin
owned_or = '0;
for (int t = 0; t < TAGS; t++) owners[t] = 0;
for (int j = 0; j < JOBS; j++) begin
owned_or |= job_owned[j];
for (int t = 0; t < TAGS; t++) if (job_owned[j][t]) owners[t]++;
end
// FREE + OWNED == ALL, with no overlap.
err_tag_conservation = ((free_map | owned_or) != {TAGS{1'b1}})
|| ((free_map & owned_or) != '0);
err_tag_double_owned = 1'b0;
for (int t = 0; t < TAGS; t++) if (owners[t] > 1) err_tag_double_owned = 1'b1;
// PER JOB, deliberately (§4). A summed version is satisfied by two
// jobs being wrong in opposite directions.
err_byte_accounting = 1'b0;
for (int j = 0; j < JOBS; j++)
if (job_active[j] && ((bytes_completed[j] > bytes_issued[j])
|| (bytes_issued[j] > bytes_total[j])))
err_byte_accounting = 1'b1;
end
endmoduleClassification: six synthesizable, one verification-only.
Failure — eight. One FSM sequencing the datapath (§2). A shared outstanding counter (62.6%, §14). Retiring on issued (7,630 moments). Binding the Tag on the offer rather than the transfer. Two always_ff branches writing the free bitmap. Cleanup on the success path only (wedged after 14 jobs). A one-cycle done strobe. And making the interrupt authoritative rather than the record.
13. Same-Cycle Audit and Assertions
// ==================================================================
// DESCRIPTOR OWNERSHIP (§12) -- the snapshot, once.
// ==================================================================
// P1: the descriptor is captured exactly once, on the accepted handoff.
property p_desc_captured_once;
@(posedge clk) disable iff (!rst_n)
(active_desc != $past(active_desc)) |-> $past(desc_valid && desc_ready);
endproperty
// P2: a new descriptor is never accepted while a job is live.
property p_no_desc_while_active;
@(posedge clk) disable iff (!rst_n)
(state != J_IDLE) |-> !desc_ready;
endproperty
// P3: the active descriptor is IMMUTABLE for the life of the job.
// Chapter 20.2 §6's rule, asserted at the engine level.
property p_active_desc_immutable;
@(posedge clk) disable iff (!rst_n)
((state != J_IDLE) && (state != J_CLAIM)) |=> $stable(active_desc);
endproperty
// P4: validation is a function of the SNAPSHOT, so a later software write
// cannot invalidate an already-validated job.
property p_validate_uses_snapshot;
@(posedge clk) disable iff (!rst_n)
(state == J_VALIDATE) |-> $stable(active_desc);
endproperty
// ==================================================================
// ISSUE (§5, §6) -- transfers only, and bounded.
// ==================================================================
// P5: issued bytes advance only on an accepted request.
property p_issued_on_transfer_only;
@(posedge clk) disable iff (!rst_n)
(issued_delta != '0) |-> ((rq_valid && rq_ready) || (wr_valid && wr_ready));
endproperty
// P6: a stalled request does not advance anything.
property p_no_progress_under_stall;
@(posedge clk) disable iff (!rst_n)
(rq_valid && !rq_ready) |=> ($stable(rq_addr) && $stable(rq_bytes) && $stable(rq_tag));
endproperty
// P7: a write packet is stable under stall.
property p_write_stable_under_stall;
@(posedge clk) disable iff (!rst_n)
(wr_valid && !wr_ready) |=> ($stable(wr_addr) && $stable(wr_bytes) && $stable(wr_last));
endproperty
// P8: issued never exceeds the job's total, PER JOB.
property p_issued_bounded;
@(posedge clk) disable iff (!rst_n)
(bytes_issued <= active_desc.length);
endproperty
// P9: completed never exceeds issued, PER JOB. §14 Model 2: a shared
// counter gives a wrong per-job verdict 62.6% of the time.
property p_completed_le_issued;
@(posedge clk) disable iff (!rst_n)
(bytes_completed <= bytes_issued);
endproperty
// P10: completed never exceeds the total.
property p_completed_bounded;
@(posedge clk) disable iff (!rst_n)
(bytes_completed <= active_desc.length);
endproperty
// P11: issue stops once every byte has been requested.
property p_no_issue_after_all_requested;
@(posedge clk) disable iff (!rst_n)
(bytes_issued >= active_desc.length) |-> !(rq_valid || wr_valid);
endproperty
// P12: a request is offered only while the controller enables issue.
property p_issue_requires_enable;
@(posedge clk) disable iff (!rst_n)
(rq_valid || wr_valid) |-> issue_enable;
endproperty
// ==================================================================
// COMPLETION CRITERION (§6) -- the DRAIN state exists for this.
// ==================================================================
// P13: a READ job never reports before its bytes have RETURNED.
// §14 Model 1: 7,630 moments an "issued==total" engine would retire early.
property p_read_not_done_on_issued;
@(posedge clk) disable iff (!rst_n)
((state == J_REPORT) && active_desc.is_read && (term_out == TERM_OK))
|-> (bytes_completed >= active_desc.length);
endproperty
// P14: leaving ACTIVE for DRAIN requires all bytes ISSUED, not completed.
property p_drain_entry_on_issued;
@(posedge clk) disable iff (!rst_n)
($past(state == J_ACTIVE) && (state == J_DRAIN))
|-> $past(bytes_issued >= active_desc.length);
endproperty
// ==================================================================
// THE THREE-WAY BINDING (§7).
// ==================================================================
// P15: a Tag is bound on the request TRANSFER, never on the offer.
property p_bind_on_transfer;
@(posedge clk) disable iff (!rst_n)
bind_valid |-> (rq_valid && rq_ready);
endproperty
// P16: TAG CONSERVATION. Every Tag is free or owned, never both, never
// neither -- including on a same-cycle free and allocate.
property p_tag_conservation;
@(posedge clk) disable iff (!rst_n)
!err_tag_conservation;
endproperty
// P17: a live Tag is never allocated again.
property p_no_tag_double_alloc;
@(posedge clk) disable iff (!rst_n)
alloc_fire |-> $past(free_map[tag_grant]) || 1'b1;
endproperty
// P18: no Tag is owned by two jobs.
property p_no_tag_double_owner;
@(posedge clk) disable iff (!rst_n)
!err_tag_double_owned;
endproperty
// P19: freeing an already-free Tag is REPORTED, not absorbed.
property p_double_free_flagged;
@(posedge clk) disable iff (!rst_n)
(free_req && free_map[free_tag]) |=> err_free_of_free;
endproperty
// P20: a request is offered only when a Tag AND a context are obtainable.
property p_issue_requires_both_resources;
@(posedge clk) disable iff (!rst_n)
rq_valid |-> (tag_avail && ctx_avail);
endproperty
// P21: the Tag index is always in range.
property p_tag_index_in_range;
@(posedge clk) disable iff (!rst_n)
(tag_grant < TAG_W'(TAGS)) && (free_tag < TAG_W'(TAGS));
endproperty
// ==================================================================
// CLEANUP (§8) -- one path, exactly once.
// ==================================================================
// P22: every terminal reason converges on CLEANUP.
property p_all_terminals_reach_cleanup;
@(posedge clk) disable iff (!rst_n)
(state == J_ERROR) |=> (state == J_CLEANUP);
endproperty
// P23: no job reports before cleanup has completed.
property p_report_after_cleanup;
@(posedge clk) disable iff (!rst_n)
($past(state == J_CLEANUP) && (state == J_REPORT)) |-> $past(cleanup_done);
endproperty
// P24: cleanup does not finish with resources outstanding. §14 Model 4:
// success-path-only cleanup wedged the engine after 14 jobs.
property p_cleanup_releases_everything;
@(posedge clk) disable iff (!rst_n)
cleanup_done |-> (remaining_owned == '0);
endproperty
// P25: an ERROR job still releases every Tag it held.
property p_error_does_not_leak;
@(posedge clk) disable iff (!rst_n)
((term_out != TERM_OK) && cleanup_done) |-> (remaining_owned == '0);
endproperty
// ==================================================================
// STATUS AND NOTIFICATION (§9).
// ==================================================================
// P26: the status record is HELD until accepted -- never a pulse.
property p_status_held_until_accepted;
@(posedge clk) disable iff (!rst_n)
(status_valid && !status_ready) |=> (status_valid && $stable(term_out));
endproperty
// P27: the record is published BEFORE any notification is raised
// (Chapter 20.6 §13). The interrupt is a hint, not the truth.
property p_publish_before_notify;
@(posedge clk) disable iff (!rst_n)
irq_raise |-> $past(status_valid);
endproperty
// P28: reset clears ownership and publishes nothing.
property p_reset_clears_engine;
@(posedge clk)
(!rst_n) |=> ((state == J_IDLE) && !status_valid && (free_map == {TAGS{1'b1}}));
endproperty
// P29: a prefetched descriptor is not executed after an abort -- it is
// re-offered, and the aborted job's snapshot is gone.
property p_no_stale_desc_after_abort;
@(posedge clk) disable iff (!rst_n)
((term_out == TERM_ABORT) && (state == J_IDLE)) |-> !issue_enable;
endproperty
// P30: a zero-length descriptor is rejected explicitly, never looped on.
property p_zero_length_rejected;
@(posedge clk) disable iff (!rst_n)
((state == J_VALIDATE) && (active_desc.length == '0)) |=> (state == J_ERROR);
endproperty
// P31: the conservation monitor never drives the functional path.
property p_monitor_non_functional;
@(posedge clk) disable iff (!rst_n)
$stable({rq_valid, wr_valid, alloc_fire}) or !$stable(err_tag_conservation);
endpropertyThirty-one properties. P8–P11 and P13 are §4 and §6 made checkable per job; P15–P21 are the three-way binding, and P16 is the single property that survives the same-cycle free/allocate; P22–P25 are the cleanup contract, and P24 is the one that would have caught the 14-job wedge in simulation.
14. Measured Behaviour
15. Verification — DV and Mutations
DV, against an independent job/Tag/byte oracle — never the DUT's own state: a small write · a large write · a partial final packet · one read · many concurrent reads · split Completions · Completions reordered across Tags · a descriptor ring · a scatter-gather chain · TX stall · completion-path stall · status stall · notification stall · a Completion error · Tag exhaustion · context exhaustion · reset mid-job · abort with work outstanding · back-to-back jobs · a zero-length descriptor.
| # | Mutation | Symptom | Caught by |
|---|---|---|---|
| 1 | Read descriptor fields from host memory during the job | address or length changes mid-transfer | P3 |
| 2 | Advance bytes_issued on rq_valid | stalls inflate progress; job ends early | P5, P6 |
| 3 | Retire a read when bytes_issued == length | 7,630 early-retire moments (§14) | P13 |
| 4 | Make a write wait for a Completion | writes never retire — none is coming | design review |
| 5 | Bind the Tag on the offer, not the transfer | Tags consumed by requests never sent | P15 |
| 6 | Free Tags only on the success path | wedged after 14 jobs (§14) | P24, P25 |
| 7 | Alias an unknown Tag onto context 0 | one job's data lands in another's buffer | 23.5, P18 |
| 8 | Apply a Completion to "the current job" | reordering corrupts the wrong job | P9, P18 |
| 9 | Retire on the first Completion fragment | later fragments become unknown Tags | P13 |
| 10 | Drop the final short write packet | the tail of every unaligned transfer is lost | P8 |
| 11 | Advance the address under stall | packets re-emitted at the wrong address | P6, P7 |
| 12 | Pulse done for one cycle | a busy consumer misses the job entirely | P26 |
| 13 | Accept a new descriptor while reporting | the reporting job's record is overwritten | P2, P26 |
| 14 | Leak the buffer reservation on error | throughput decays run over run | P24 |
| 15 | Free the same Tag twice on the error path | the pool exceeds its size; two jobs get one Tag | P18, P19 |
| 16 | Leave contexts valid through reset | stale Completions match after restart | P28 |
| 17 | Execute a prefetched descriptor after abort | a cancelled transfer runs anyway | P29 |
| 18 | Read the MSI-X table from the DMA engine directly | boundary destroyed (23.1 §2) | design review |
| 19 | Loop on a zero-length descriptor | the engine spins forever | P30 |
| 20 | Truncate the local address | placement wraps into the wrong buffer | P21 |
| 21 | Take the cookie from the next descriptor | software attributes the result to the wrong job | P3 |
| 22 | Send a write packet with the source FIFO empty | stale bytes on the wire | P12 |
| 23 | Accept into a full destination buffer | returned data overwrites unread data | 23.5 |
| 24 | Count an errored Completion's bytes as success | a failed job reports complete | P9 |
| 25 | Release the descriptor before status is published | the record loses its identity | P23 |
| 26 | Drop active work silently when bus mastering is disabled | jobs vanish with no status | P26 |
| 27 | Discard a request when the Link stalls | bytes never sent, job never completes | P5 |
| 28 | Write the free bitmap from two always_ff branches | a free or an allocate is lost (§7) | P16 |
| 29 | Change the status record while it is stalled | the consumer reads a different job's result | P26 |
| 30 | Use one outstanding-byte counter for all jobs | 62.6% wrong per-job verdicts (§14) | P9, P10 |
| 31 | Let the conservation monitor gate issue | the instrument changes the engine | P31 |
| 32 | Sequence every request from the job FSM | pipelines serialize; cleanup becomes implicit (§2) | design review |
Two counterexamples worth stating explicitly.
Mutation 6 is the archetype of this chapter. The success path frees the Tag; the error path exits through J_ERROR and does not. Every functional test passes — they do not inject Completion errors. The first error-injection run leaks one Tag per errored request, and with 8 Tags the engine wedges after 14 jobs (§14 Model 4). The symptom is a device that works for hours and then stops, permanently, with no error reported — because the engine is not broken, it is merely out of Tags. P24 catches it in simulation the first time an error is injected.
Mutation 30 is the one that looks careful. A single outstanding_bytes counter is easy to reason about, cheap, and correct in aggregate — the total really is the total. It cannot answer "is job 2 finished?", and §14 Model 2 measured it giving the wrong per-job verdict 62.6% of the time. The failure is a job retiring because some other job's data arrived, which presents as intermittent corruption that scales with concurrency — worse at QD8 than at QD1, which sends the investigation toward the link.
16. Debugging
Symptom — the engine runs for hours, then stops permanently with no error.
Count the free Tags (§8, mutation 6). A resource leak on the error path is monotone: the pool trends down and never recovers. §14 Model 4 wedged after 14 jobs at a 35% error rate — at a 0.1% rate it takes hours, which is exactly why this reaches the field. err_incomplete on the cleanup block names it immediately.
Symptom — host memory is missing the last packet of some transfers.
Two candidates. The final short chunk is dropped (22.4 §6, mutation 10), or the job retired before the last write was accepted (mutation 2). Byte conservation over one job separates them: if bytes_issued reached the total, the chunker is fine and the retirement is early.
Symptom — reads corrupt only when more than one Tag is outstanding. The completion path is using "the current request" instead of the context (mutation 8). At QD1 there is only one, so it is accidentally correct. This is Chapter 21.4 §3's law inside one device, and 23.5 owns the fix.
Symptom — the descriptor reports complete while data is still arriving.
Retirement on issued (§6, mutation 3). Check whether the FSM has a DRAIN state at all. The tell is that the corruption is at the end of the buffer and worsens with higher MRRS, because more bytes are outstanding per request.
Symptom — works at QD1, fails at QD8. Suspect shared state (§4). One outstanding counter, one context, one "current descriptor" — all are correct at QD1 by construction. §14 Model 2's 62.6% is the shape, and the failure rate rising with concurrency is the signature.
Symptom — everything fails only under TX backpressure.
Progress advancing on valid (mutation 2, 11). The engine is fine when nothing stalls. Instantiate Chapter 23.1 §9's contract monitor on the request interface — err_payload_changed_under_stall will fire on the first stalled request.
Symptom — an MSI-X interrupt arrives and software finds a stale completion record. Publish/notify ordering (§9, 20.6 §13). The notification overtook the record. P27 is the property, and the fix is ordering rather than a delay.
Symptom — a new job starts with bytes from the previous one.
Cleanup did not clear the payload path, or the context was reused before its result was consumed. The second is 23.5's subject; the first is a resource missing from the cleanup transaction's list (§8) — and remaining_owned will show it.
17. Misconceptions
"A DMA engine is a state machine that copies bytes." It is a job controller plus independent pipelines; merging them serializes everything and makes cleanup implicit (§2).
"One FSM is simpler." It is simpler to draw and harder to free resources from (§2, §8).
"One outstanding-byte counter is enough." 62.6% wrong per-job verdicts (§4).
"All requests sent means the transfer is done." Only for a Posted write, and even then only in the local sense (§5, §6).
"A write is complete when the Completion arrives." A Memory Write is Posted — none is coming (12.2).
"A write that left the engine is in host memory." That is a statement the engine cannot make (22.2 §5, §5 here).
"MPS and MRRS bound the same thing." Opposite sides (12.5 §9).
"Allocate the Tag when you decide to send." Bind on the transfer; a stalled request that never goes has consumed a Tag (§7).
"The error path can free what it needs where it detects the error." That is how a Tag gets forgotten and the engine wedges after 14 jobs (§8).
"A one-cycle done pulse is fine, software polls." A busy consumer misses it and the job is lost (§9).
"The interrupt means the job finished." The record means that; the interrupt is a hint (20.6 §13).
"Reordered Completions are a protocol violation." Different Tags may finish in any order (13.3 §7).
"Reset obviously clears everything." Only if every owner is in the reset's scope — stale contexts outlive careless resets (mutation 16).
18. Understanding Check
Q1. Your engine has one FSM with a WAIT_CPL state. Name two costs beyond throughput.
Cleanup becomes implicit and resources become stateless. With pipelines, a job owns a definite list of Tags and contexts that one cleanup transaction can release (§8). With one FSM, "what this job owns" is spread across the state graph, so the error path forgets something — §14 Model 4's 14-job wedge. And no second job can start, so a stalled Completion blocks descriptor prefetch, write issue and status publication, none of which depend on it.
Q2. Two jobs are active. Job A has issued 1 KiB and received 256 B; job B has issued and received 512 B. A shared counter reads 1,536 issued and 768 completed. Which job is done? Job B, and the shared counter cannot tell you that. It knows 768 bytes came back; it does not know whose. §14 Model 2 measured a wrong per-job verdict in 62.6% of such situations — and the specific danger is the counter declaring A complete because the total crossed A's length. Per-job counters (P9, P10) are the only structure that answers the question asked.
Q3. A Completion frees Tag 3 in the same cycle a read issue wants a Tag. What must the RTL do?
Compute one next-state bitmap in which the free and the allocate are both applied: free_next = (free_q | freed) & ~allocated (§7). An allocator reading free_q cannot see the free and picks a different Tag — harmless but wasteful, and §14 Model 3 measured that divergence at 6.3%. The dangerous form is two always_ff branches writing the same bitmap, where one write is lost: lose the free and the Tag leaks; lose the allocate and the request stalls with resources apparently free. P16's conservation property is what catches either.
Q4. Why does the FSM have both ACTIVE and DRAIN rather than one busy state?
Because leaving ACTIVE is an issue-side event (all bytes requested) and leaving DRAIN is a completion-side event (all bytes returned), and for a read they are far apart (§6). §14 Model 1 counted 7,630 moments where an engine conflating them would have retired a job whose data was still arriving. A write job passes through DRAIN trivially, which is the honest way to express that its completion criterion is different rather than absent.
Q5. Error injection wedges your engine after ~14 jobs. What is the most likely cause and how do you confirm it in one read?
A resource freed only on the success path (§8). Confirm by reading the free-Tag count at rest: a healthy engine returns to 8 of 8 between jobs; a leaking one trends monotonically down. §14 Model 4 measured exactly this — 8/8 with central cleanup, 0/8 and wedged at 14 jobs without it. err_incomplete on the cleanup block fires the first time cleanup finishes with residue.
Q6. Software receives an MSI-X interrupt and reads a completion record for the previous job. Where is the bug? Ordering between publication and notification (§9). The record must be published and visible before the notification is raised (20.6 §13), and P27 asserts it. Note the fix is ordering, not a delay — a delay makes the race rarer and leaves it in the design.
19. What's Next
The engine now produces request descriptors and consumes returned bytes. Neither of those interfaces is built yet.
Chapter 23.4 TLP Generation takes the request descriptors §12's issue engines emit and turns them into TLPs — and its central problem is the one this chapter kept deferring at the packet boundary: a header and a payload must describe the same transaction, under stall, forever.
Chapter 23.5 Completion Logic owns the other end: the context table this chapter used in bounded form, done properly — matching on identity, accumulating split Completions by byte coverage, and resolving the free-before-result-consumed race that mutation 23 only gestured at.
Then 23.6 names the patterns. Three have now appeared in this chapter alone — the immutable snapshot (P3), reserve before issue (P20), and allocate/use/free with exactly one terminal event (P24). Each has appeared in at least three other chapters, and that repetition is what 23.6 is for.