Skip to content

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
IDLE -> FETCH_DESC -> ISSUE_READ -> WAIT_CPL -> WRITE_DATA -> DONE -> IDLE

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

OwnerOwnsDoes not own
job controller (FSM, §11)which descriptor is live; its lifecycleany beat, any Tag, any packet
read issue (§5)turning remaining bytes into read requestswhen Completions arrive
write issue (§5)turning payload into write packetsanything about reads
completion placement (23.5)routing returned bytes by contextjob lifecycle
status publication (§9)the completion recordnotification 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 issueWrite issue
bounded byMRRS (22.4 §4)MPS (22.4 §3)
carries payloadnoyes
needs a Tagyes — it will be answeredno
needs a contextyes — to place the answerno
retires onreturned bytes (13.3)a stated local contract
back-pressured byTags, contexts, NPH creditpayload 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:

MetricResult
steps200,000
jobs terminated3,848
Completion errors injected921
moments an "issued == total" engine would retire early7,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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
read job done   <=>  bytes_completed >= bytes_total   OR   terminal error
write job done  <=>  the stated local handoff contract is satisfied

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 formBehaviour
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 rate18,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:

StalledMust not happen
TX (credits, scheduler)issued-byte counters advance (22.4 §7)
status/completion recordthe 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

A DMA engine block diagram. A host descriptor ring feeds descriptor fetch, which feeds a descriptor FIFO and then the job controller. The job controller drives read issue and write issue in parallel. Read issue draws a tag and a context from a shared resource pool and produces memory read requests. Write issue draws payload from a payload FIFO and produces memory write requests. Both feed a transmit scheduler and then the PCIe core. Returning completions enter completion placement, which uses the context to write into the destination buffer and updates per-job accounting. The job controller then publishes a status record, and a separate notification block raises an interrupt. A cleanup transaction returns tags, contexts and reservations to the shared pool.host descriptor ringdescriptor fetchjob controllerread issuewrite issueresource poolTX schedulercompletion placeper-job accountingstatus recordnotificationcleanup txn12
Figure 1 — a descriptor-driven DMA engine as five owners around a shared resource pool. The job controller claims and retires descriptors; read issue and write issue independently turn remaining bytes into requests; completion placement routes returned bytes by context; and status publication owns the completion record. Tags, contexts and buffer reservations are drawn from one pool and released by a single cleanup transaction.

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

The DMA job lifecycle state machine. From IDLE, claiming a descriptor moves to CLAIM. CLAIM moves to VALIDATE. Validation failure moves to ERROR; success moves to ACTIVE. ACTIVE is where read and write issue proceed concurrently. When all bytes are issued, ACTIVE moves to DRAIN, which waits for returned bytes. Completion criterion met moves DRAIN to REPORT. Any error from ACTIVE or DRAIN moves to ERROR, which moves to CLEANUP. CLEANUP releases all resources and moves to REPORT. REPORT holds the status record until it is accepted, then returns to IDLE.IDLECLAIM -snapshotdescriptorVALIDATEACTIVE -issueconcurrentlyDRAIN - await returned bytesDRAIN -awaitreturne…ERRORCLEANUP - one release pathCLEANUP- onerelease…REPORT -recordhelddescriptor availabledescriptor availablesnapshot takensnapshottakenvalidvalidmalformedmalformedall bytes issuedall bytes issuedfaultfaultcriterion metcriterion metcpl errorcpl errorterminalterminalresources releasedresources releasedrecord acceptedrecord accepted
Figure 2 — the job controller's FSM. It changes state on job-scale events only: a descriptor is claimed, validation passes or fails, the completion criterion is met, an error occurs, and the status record is accepted. No state waits for an individual beat or completion, which is what allows the datapath pipelines beneath it to run concurrently.

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

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

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

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

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

#MutationSymptomCaught by
1Read descriptor fields from host memory during the jobaddress or length changes mid-transferP3
2Advance bytes_issued on rq_validstalls inflate progress; job ends earlyP5, P6
3Retire a read when bytes_issued == length7,630 early-retire moments (§14)P13
4Make a write wait for a Completionwrites never retire — none is comingdesign review
5Bind the Tag on the offer, not the transferTags consumed by requests never sentP15
6Free Tags only on the success pathwedged after 14 jobs (§14)P24, P25
7Alias an unknown Tag onto context 0one job's data lands in another's buffer23.5, P18
8Apply a Completion to "the current job"reordering corrupts the wrong jobP9, P18
9Retire on the first Completion fragmentlater fragments become unknown TagsP13
10Drop the final short write packetthe tail of every unaligned transfer is lostP8
11Advance the address under stallpackets re-emitted at the wrong addressP6, P7
12Pulse done for one cyclea busy consumer misses the job entirelyP26
13Accept a new descriptor while reportingthe reporting job's record is overwrittenP2, P26
14Leak the buffer reservation on errorthroughput decays run over runP24
15Free the same Tag twice on the error paththe pool exceeds its size; two jobs get one TagP18, P19
16Leave contexts valid through resetstale Completions match after restartP28
17Execute a prefetched descriptor after aborta cancelled transfer runs anywayP29
18Read the MSI-X table from the DMA engine directlyboundary destroyed (23.1 §2)design review
19Loop on a zero-length descriptorthe engine spins foreverP30
20Truncate the local addressplacement wraps into the wrong bufferP21
21Take the cookie from the next descriptorsoftware attributes the result to the wrong jobP3
22Send a write packet with the source FIFO emptystale bytes on the wireP12
23Accept into a full destination bufferreturned data overwrites unread data23.5
24Count an errored Completion's bytes as successa failed job reports completeP9
25Release the descriptor before status is publishedthe record loses its identityP23
26Drop active work silently when bus mastering is disabledjobs vanish with no statusP26
27Discard a request when the Link stallsbytes never sent, job never completesP5
28Write the free bitmap from two always_ff branchesa free or an allocate is lost (§7)P16
29Change the status record while it is stalledthe consumer reads a different job's resultP26
30Use one outstanding-byte counter for all jobs62.6% wrong per-job verdicts (§14)P9, P10
31Let the conservation monitor gate issuethe instrument changes the engineP31
32Sequence every request from the job FSMpipelines 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 interfaceerr_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.