Skip to content

PCIe · Module 20

DMA Concepts — Ownership, Descriptors and the Lifecycle

A descriptor is not a data structure problem. It is an ownership protocol between two agents that never lock, and every serious DMA bug is a moment where both of them, or neither, believed they owned the same work.

Chapter 20.1 established that a DMA-capable Function is a Requester, and deliberately deferred one question: how does software tell it what to move?

The tempting answer is "a struct in memory." That is the least interesting part.

The interesting part is that two agents share that struct with no lock. Software writes descriptors; hardware reads them. Software marks work available; hardware marks it done. Neither can stop the other, and there is no mutual-exclusion primitive between a CPU store and a DMA engine's fetch.

So how does software describe work, how does hardware take ownership of it, and how do the two hand it back and forth without ever both believing they own the same descriptor — or neither?

1. The Verified Sources

2. Command and Descriptor Are Not the Same Thing

A command is an abstract request for work. Direction, address, length — what Chapter 20.1 §14 modelled.

A descriptor is a representation of that work in memory or registers, which hardware must fetch and interpret.

Register-programmed commandDescriptor in memory
How work arrivessoftware writes device registerssoftware writes host memory, then signals
Hardware mustlatch registersDMA the descriptor itself (§1)
Queue depthone, or a small FIFOas deep as memory allows
CPU cost per transferseveral MMIO writesone pointer update for many descriptors
Suitssimple enginesscalable engines

The second row is why descriptors exist at all, and it is the fact most often missed: fetching a descriptor is a Memory Read (Chapter 20.3), with all the outstanding-state machinery that implies. The control plane runs on the same PCIe machinery as the data plane.

And the fourth row is why they win. MMIO writes are expensive; a driver that must write four registers per transfer is CPU-bound long before the Link is. Writing 32 descriptors into memory and advancing one pointer costs one MMIO write — §1's example does exactly that.

3. Ownership Is the Whole Problem

4. PCIe Defines No Descriptor Format

Restated from Chapter 20.1 §8 because this is the chapter where it matters.

PCIe defines transaction types, routing, ordering and flow control. It does not define how a device is told what to transfer.

§1's example descriptorlength, direction, source_addr, destination_addr in 16 bytes — is that vendor's design, quoted as such. A NIC's descriptors, an NVMe submission queue entry and an FPGA data mover's command look nothing alike.

Which means the driver and the hardware are co-designed, and the descriptor layout is part of the device's architecture rather than something inherited. §14's type is illustrative for exactly this reason.

5. A Normalized Descriptor

For teaching, with every field justified:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
host_addr      where in host memory        -- device-visible (20.1 §11)
local_addr     where in device memory
length_bytes   how much
dir_read       direction -- decides MemRd vs MemWr (§1's sourced sentence)
irq_on_done    should completion raise an interrupt?
cookie         software's identifier, returned in the completion

cookie is the field engineers most often omit and most often need. Software gets it back in the completion record (§10), so it can match a completion to the request it made without re-reading the descriptor — which by then may have been reused.

irq_on_done exists because not every descriptor deserves an interrupt (Chapter 19.5 §8). A batch of 32 descriptors may set it only on the last, which is coalescing expressed in the descriptor rather than in a timer.

6. Snapshot at the Ownership Boundary

7. Publication: How Software Says "There Is Work"

§1's sourced mechanism is a pointer update:

"When the descriptor write pointer is updated, the Core would detect that the descriptor queue is not empty and therefore fetch the descriptor entries…"

So publication is a single write that changes hardware's view of what is available. Everything before it is preparation; everything after it is hardware's.

Which makes the order of operations load-bearing (§8), and it makes the pointer update the atomic act — the moment ownership transfers.

Two publication styles exist, and both appear in real devices:

A pointer or index — as in §1's example. Hardware compares producer against consumer to know what is available.

An ownership bit inside each descriptor — hardware polls or fetches, and a bit says whose it is. Neither is a PCIe mechanism; both are device architecture.

8. Publish Order, and Where This Chapter Stops

9. Lifecycle: Six States, Not Three

Collapsing these is the most common architectural error in DMA engines.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
descriptor available   published by software, not yet claimed

validated              length, direction, local policy checks (§16)

active                 hardware owns it; snapshot taken (§6)

issued                 requests handed to the transmit path

completed              the work actually happened  (§20.1 §7: NOT the same)

retired                the completion record has been accepted by software

Three separate byte counters follow (§17):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bytes_completed  <=  bytes_issued  <=  bytes_total

And "retired" is separate from "completed" because the completion record itself is a handoff that can stall (§10). A descriptor whose work is complete but whose completion has not been consumed is still hardware's — releasing it earlier means the slot can be reused before software has learned the result.

10. Completion Records Are a Handoff Too

§1 sources a separate structure for results: a status queue, with its own base address and its own depth — 16 entries where the descriptor queue had 8.

Why separate depths make sense: completions may be consumed more slowly than descriptors are produced, or a single descriptor may generate more than one status entry in some architectures. They are independent flows.

And the completion record must be held, not pulsed (§18's counterexample). The consumer — a status-queue writer, an interrupt block (Chapter 19.5 §16), or a software-visible register — can be busy, and a one-cycle assertion into a busy consumer is a lost completion.

A lost completion is the "neither owns it" failure (§3): the work is done, the data is in memory, and software is never told.

11. The Ring

§1's example is a ring, and the structure is worth naming precisely.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
descriptor queue        depth 8, base address programmed
  write pointer         advanced by SOFTWARE  (producer)
  read pointer          advanced by HARDWARE  (consumer)
 
status queue            depth 16, base address programmed
  ...results flow the other way

Software produces descriptors and consumes status. Hardware consumes descriptors and produces status. Two rings, opposite directions, and each has exactly one producer and one consumer — which is what makes lock-free operation possible at all.

12. Full and Empty Are the Same Picture

13. Wrapping Is Not Overflow

§1's example wraps explicitly: "if (desc_wr_pointer == 7) desc_wr_pointer = 0."

The shortcut is to let the pointer overflow its bit width — which works only when the depth is a power of two.

§17 measured it. Over 40,000 increments across depths 1, 2, 3, 4, 5, 8, 16 and 17, relying on binary overflow produced an out-of-range pointer 19.9% of the time:

DepthPointer widthOverflow wraps atShould wrap at
32 bits43
53 bits85
175 bits3217

An out-of-range pointer indexes past the ring — reading whatever memory follows it as a descriptor. And the bug is invisible at every power-of-two depth, so a design tested at 8 and 16 passes everything and fails the first time someone configures 5.

§15 compares against DEPTH-1 explicitly, and P9 asserts the pointer stays in range.

14. The Architecture

Descriptor-driven DMA architecture. Software writes descriptors into a descriptor ring in host memory and advances a producer pointer to publish them. The descriptor fetch block reads a descriptor, the validator checks it, and a working snapshot is captured. The execution engine performs the transfer and tracks bytes total, issued and completed separately. A completion record is produced and held until accepted, then the consumer pointer advances and an optional interrupt event is raised.softwaredescriptor ringdescriptor fetchvalidatorworking snapshotexecution enginecompletion recordstatus ringinterrupt eventpublish12
Figure 1 — descriptor-driven DMA. Software writes descriptors into a ring and publishes them by advancing a producer pointer. Hardware fetches a descriptor — itself a DMA operation — validates it, and captures an immutable working snapshot before execution begins. On completion a status record is produced and held until the consumer accepts it, and only then is the descriptor slot released. The optional interrupt is a normalized event, not an MSI-X write formatted here.

Three things to read out of the figure.

The snapshot sits between the shared structure and everything that uses it (§6). Nothing right of it ever reads the ring.

The completion record is a separate held stage (§10), not a wire from the execution engine to the status ring.

And the interrupt path leaves as a normalized event (Chapter 20.1 §12) — this block does not know what an MSI-X table is.

15. RTL — Types, Descriptor Owner and Validator

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE. Normalized DMA descriptor and status types.
// THIS IS NOT A PCIe FORMAT (section 4). The DIRECTION field's meaning is
// the sourced part: section 1's vendor text states "the direction of
// transfer would determine what kind of TLP is generated (MWr or MRd)".
package dma_desc_pkg;
 
  parameter int ADDR_W  = 64;
  parameter int LADDR_W = 32;
  parameter int LEN_W   = 24;   // illustrative: up to 16 MiB per descriptor
 
  typedef struct packed {
    logic [ADDR_W-1:0]  host_addr;    // DEVICE-VISIBLE address (20.1 §11)
    logic [LADDR_W-1:0] local_addr;
    logic [LEN_W-1:0]   length_bytes;
    logic               dir_read;     // 1 = host->device (MemRd), 0 = device->host (MemWr)
    logic               irq_on_done;  // section 5
    logic [15:0]        cookie;       // returned in the completion record
  } dma_desc_t;
 
  typedef enum logic [2:0] {
    DST_OK          = 3'd0,
    DST_ERR_LEN     = 3'd1,   // zero length -- local contract
    DST_ERR_ALIGN   = 3'd2,   // local alignment policy
    DST_ERR_CTRL    = 3'd3,   // unsupported control bits
    DST_ERR_XFER    = 3'd4,   // transaction-level failure during execution
    DST_ERR_ABORT   = 3'd5
  } dma_status_e;
 
  typedef struct packed {
    logic [15:0]      cookie;         // matches the descriptor that produced it
    dma_status_e      status;
    logic [LEN_W-1:0] bytes_done;
  } dma_result_t;
 
endpackage
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import dma_desc_pkg::*;
 
// SYNTHESIZABLE. Own exactly one descriptor, with an immutable snapshot.
// SECTION 6: once accepted, nothing re-reads the input. Section 17
// measured the alternative -- a live re-read mutates the transfer
// mid-flight, and every request it produces is well-formed.
module dma_desc_owner (
  input  logic clk,
  input  logic rst_n,
 
  // ---- Descriptor input stream -------------------------------------------
  input  dma_desc_t desc_in,
  input  logic      desc_valid,
  output logic      desc_ready,
 
  // ---- The owned, immutable working copy ---------------------------------
  output dma_desc_t desc_active,
  output logic      active,
 
  // Execution reports completion of the owned descriptor.
  input  logic      exec_done,
  input  dma_status_e exec_status,
  input  logic [LEN_W-1:0] exec_bytes,
 
  // ---- Completion record, HELD until accepted (section 10) ---------------
  output dma_result_t result,
  output logic        result_valid,
  input  logic        result_ready
);
 
  dma_desc_t   desc_q;
  dma_result_t res_q;
  logic        act_q, res_v_q;
 
  assign desc_active  = desc_q;
  assign active       = act_q;
  assign result       = res_q;
  assign result_valid = res_v_q;
 
  // ==================================================================
  // SINGLE-ENTRY OWNERSHIP.
  //
  // ready falls while a descriptor is owned OR while its completion
  // record is still unclaimed. The second half is section 9's point:
  // a descriptor is not finished with until its RESULT has been taken,
  // because the cookie and status still belong to it.
  //
  // A design that reopened `ready` at exec_done would let the next
  // descriptor overwrite the result of the last (section 17, mutation 6).
  // ==================================================================
  assign desc_ready = !act_q && !res_v_q;
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      desc_q <= '0; res_q <= '0; act_q <= 1'b0; res_v_q <= 1'b0;
    end else begin
      if (desc_valid && desc_ready) begin
        // SNAPSHOT: the whole descriptor, in one assignment (section 6).
        desc_q <= desc_in;
        act_q  <= 1'b1;
      end else if (act_q && exec_done) begin
        act_q   <= 1'b0;
        // The cookie comes from the OWNED descriptor, not from the input --
        // desc_in may already hold the next one (mutation 7).
        res_q   <= '{ cookie: desc_q.cookie,
                      status: exec_status,
                      bytes_done: exec_bytes };
        res_v_q <= 1'b1;
      end
 
      if (res_v_q && result_ready) res_v_q <= 1'b0;
    end
  end
 
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import dma_desc_pkg::*;
 
// SYNTHESIZABLE. Local validity checks -- LOCAL CONTRACT, not PCIe rules.
// A zero-length descriptor is the important one: without an explicit
// policy it produces a transfer that can never make progress and never
// retires (section 17, mutation 3).
module dma_desc_validate #(
  parameter int ALIGN_BYTES = 1     // 1 = no alignment requirement
) (
  input  dma_desc_t  desc,
  output logic       desc_ok,
  output dma_status_e err
);
  wire zero_len   = (desc.length_bytes == '0);
  wire misaligned = (ALIGN_BYTES > 1)
                 && ((desc.host_addr & ADDR_W'(ALIGN_BYTES-1)) != '0);
 
  always_comb begin
    if      (zero_len)   begin desc_ok = 1'b0; err = DST_ERR_LEN;   end
    else if (misaligned) begin desc_ok = 1'b0; err = DST_ERR_ALIGN; end
    else                 begin desc_ok = 1'b1; err = DST_OK;        end
  end
  // NOTE: an invalid descriptor still needs CLEAN RETIREMENT (section 16).
  // Refusing it silently would stall the ring; it must produce a result
  // record with an error status so software learns and the slot frees.
endmodule

Classification: all three synthesizable.

desc_ready falls for both an active descriptor and an unclaimed result. §9's point: a descriptor is not done with until its result has been taken, because the cookie and status still belong to it.

And the cookie comes from desc_q, never desc_in — by the time execution finishes, the input may already present the next descriptor (mutation 7).

Failure — five. Re-reading desc_in during execution (§17's counterexample). ready reopening at exec_done, letting the next descriptor overwrite the pending result. Cookie taken from the input. A zero-length descriptor entering execution and never retiring. And an invalid descriptor refused without a result record, which stalls the ring.

16. RTL — Progress Accounting and Ring Pointers

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import dma_desc_pkg::*;
 
// SYNTHESIZABLE. Three quantities, never conflated (section 9).
// FOR POSTED WRITES "completed" is the DMA engine's declared retirement
// boundary, NOT a PCIe Completion (20.1 §6). For reads it is Completion
// data actually received (20.3).
module dma_progress (
  input  logic clk,
  input  logic rst_n,
  input  logic start,
  input  logic [LEN_W-1:0] total,
 
  input  logic issue_fire,                  // request handshake
  input  logic [LEN_W-1:0] issue_bytes,
  input  logic complete_fire,               // retirement event
  input  logic [LEN_W-1:0] complete_bytes,
 
  output logic [LEN_W-1:0] bytes_total,
  output logic [LEN_W-1:0] bytes_issued,
  output logic [LEN_W-1:0] bytes_completed,
  output logic             all_issued,
  output logic             all_completed
);
  logic [LEN_W-1:0] tot_q, iss_q, cpl_q;
  assign {bytes_total, bytes_issued, bytes_completed} = {tot_q, iss_q, cpl_q};
 
  // ==================================================================
  // all_issued IS NOT all_completed, and only the second may retire a
  // transfer. Chapter 20.1 section 19 measured the conflation for reads:
  // it declares completion early in 99.5% of cases.
  // ==================================================================
  assign all_issued    = (iss_q >= tot_q) && (tot_q != '0);
  assign all_completed = (cpl_q >= tot_q) && (tot_q != '0);
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n)      begin tot_q<='0; iss_q<='0; cpl_q<='0; end
    else if (start)  begin tot_q<=total; iss_q<='0; cpl_q<='0; end
    else begin
      // ON HANDSHAKES ONLY. Chapter 20.1 measured advancing on `valid`:
      // it exceeds the total in 79.5% of cases.
      if (issue_fire)    iss_q <= iss_q + issue_bytes;
      if (complete_fire) cpl_q <= cpl_q + complete_bytes;
    end
  end
endmodule
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SYNTHESIZABLE. Ring pointer with EXPLICIT wrap and an EXPLICIT count.
// SECTION 13: binary overflow produced an out-of-range pointer in 19.9%
// of increments across non-power-of-two depths (section 17).
// SECTION 12: pointers alone cannot distinguish full from empty --
// 66,032 ambiguous states measured -- so occupancy is a counter.
module dma_ring_ptr #(
  parameter int DEPTH = 8,
  parameter int PTR_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH),
  parameter int CNT_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH + 1)
) (
  input  logic clk,
  input  logic rst_n,
  input  logic push,          // producer advances
  input  logic pop,           // consumer advances
 
  output logic [PTR_W-1:0] wr_ptr,
  output logic [PTR_W-1:0] rd_ptr,
  output logic [CNT_W-1:0] occupancy,
  output logic             full,
  output logic             empty
);
  generate
    if (DEPTH < 1) $error("DEPTH must be at least 1");
    if ((1 << CNT_W) < (DEPTH + 1)) $error("CNT_W too narrow to hold DEPTH");
  endgenerate
 
  logic [PTR_W-1:0] w_q, r_q;
  logic [CNT_W-1:0] c_q;
  assign {wr_ptr, rd_ptr, occupancy} = {w_q, r_q, c_q};
 
  // UNAMBIGUOUS by construction -- no phase bit needed, no slot sacrificed.
  assign full  = (c_q == CNT_W'(DEPTH));
  assign empty = (c_q == '0);
 
  // EXPLICIT WRAP AT DEPTH-1, matching section 1's sourced pseudo-code
  // ("roll over to 0 when pointer == max queue size-1"). NOT binary
  // overflow, which is only correct for power-of-two depths.
  function automatic logic [PTR_W-1:0] nxt(input logic [PTR_W-1:0] p);
    return (p == PTR_W'(DEPTH-1)) ? '0 : (p + PTR_W'(1));
  endfunction
 
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin w_q <= '0; r_q <= '0; c_q <= '0; end
    else begin
      // PUSH AND POP IN THE SAME CYCLE leave occupancy unchanged -- both
      // pointers advance. A design that handled only one would corrupt the
      // count on simultaneous producer/consumer activity (section 18).
      if (push && !full)  w_q <= nxt(w_q);
      if (pop  && !empty) r_q <= nxt(r_q);
      case ({push && !full, pop && !empty})
        2'b10:   c_q <= c_q + CNT_W'(1);
        2'b01:   c_q <= c_q - CNT_W'(1);
        default: c_q <= c_q;                 // 2'b00 and 2'b11
      endcase
    end
  end
endmodule

Classification: both synthesizable.

Verified (§17): the explicit wrap produced 0 out-of-range pointers across 40,000 increments at depths 1–17; binary overflow produced 19.9%. And the explicit count removes all 66,032 measured full/empty ambiguities.

Simultaneous push and pop leaves occupancy unchanged — the 2'b11 case — which is the state a busy ring spends most of its time in.

Failure — four. Binary overflow at non-power-of-two depths. Full/empty from pointer equality. Mishandling simultaneous push and pop, corrupting the count. And DEPTH = 1, where the guarded widths keep both pointer and counter one bit wide rather than zero.

17. Assertions

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// SVA over the descriptor blocks. LOCAL contract only. Nothing asserts
// that software supplies descriptors, consumes results, or that execution
// ever finishes.
 
// ---- ENVIRONMENT ------------------------------------------------------
assume property (@(posedge clk) disable iff (!rst_n)
  (desc_valid && !desc_ready) |=> (desc_valid && $stable(desc_in)));
 
// ---- OWNERSHIP AND THE SNAPSHOT ---------------------------------------
 
// P1: THE WORKING DESCRIPTOR IS IMMUTABLE WHILE ACTIVE. Section 6's law,
// and section 19's counterexample is its violation.
property p_snapshot_immutable;
  @(posedge clk) disable iff (!rst_n)
  (active && !$rose(active)) |-> $stable(desc_active);
endproperty
a_snap : assert property (p_snapshot_immutable);
 
// P2: a descriptor is captured EXACTLY ONCE, on the handshake.
property p_capture_once;
  @(posedge clk) disable iff (!rst_n)
  $rose(active) |-> (desc_active == $past(desc_in))
                 && ($past(desc_valid) && $past(desc_ready));
endproperty
a_cap : assert property (p_capture_once);
 
// P3: NO SECOND DESCRIPTOR while one is owned or its result unclaimed.
property p_single_entry;
  @(posedge clk) disable iff (!rst_n)
  (active || result_valid) |-> !desc_ready;
endproperty
a_single : assert property (p_single_entry);
 
// ---- VALIDATION -------------------------------------------------------
 
// P4: A ZERO-LENGTH DESCRIPTOR NEVER REACHES EXECUTION, and produces an
// error result exactly once (section 15).
property p_zero_len_refused;
  @(posedge clk) disable iff (!rst_n)
  (desc_valid && desc_ready && (desc_in.length_bytes == '0))
    |=> (!active && result_valid && (result.status == DST_ERR_LEN));
endproperty
a_zero : assert property (p_zero_len_refused);
 
// ---- PROGRESS ---------------------------------------------------------
 
// P5: the ordering of the three quantities (section 9).
property p_progress_ordered;
  @(posedge clk) disable iff (!rst_n)
  (bytes_completed <= bytes_issued) && (bytes_issued <= bytes_total);
endproperty
a_order : assert property (p_progress_ordered);
 
// P6: both counters advance ONLY on their handshake.
property p_issued_on_fire;
  @(posedge clk) disable iff (!rst_n)
  (bytes_issued > $past(bytes_issued)) |-> $past(issue_fire);
endproperty
a_iss : assert property (p_issued_on_fire);
 
// P7: RETIREMENT REQUIRES COMPLETED BYTES, never issued bytes.
property p_done_on_completed;
  @(posedge clk) disable iff (!rst_n)
  (exec_done && (exec_status == DST_OK)) |-> all_completed;
endproperty
a_done : assert property (p_done_on_completed);
 
// ---- COMPLETION RECORD ------------------------------------------------
 
// P8: the result is HELD and STABLE until accepted (section 10). Section
// 19's second counterexample is the pulsed version.
property p_result_held;
  @(posedge clk) disable iff (!rst_n)
  (result_valid && !result_ready) |=> (result_valid && $stable(result));
endproperty
a_held : assert property (p_result_held);
 
// P8b: THE COOKIE MATCHES THE DESCRIPTOR THAT PRODUCED IT, not whatever
// is on the input now (mutation 7).
property p_cookie_matches;
  @(posedge clk) disable iff (!rst_n)
  $rose(result_valid) |-> (result.cookie == $past(desc_active.cookie));
endproperty
a_cookie : assert property (p_cookie_matches);
 
// P8c: every accepted descriptor eventually produces exactly one result --
// stated as conservation rather than as liveness (section 20).
property p_conservation;
  @(posedge clk) disable iff (!rst_n)
  accepted_count == (CNT_W'(active) + CNT_W'(result_valid) + retired_count);
endproperty
a_conserve : assert property (p_conservation);
 
// ---- RING -------------------------------------------------------------
 
// P9: THE POINTER IS ALWAYS IN RANGE. Section 17: binary overflow put it
// out of range in 19.9% of increments at non-power-of-two depths.
property p_ptr_range;
  @(posedge clk) disable iff (!rst_n)
  (wr_ptr < PTR_W'(DEPTH)) && (rd_ptr < PTR_W'(DEPTH));
endproperty
a_ptr : assert property (p_ptr_range);
 
// P10: occupancy is bounded and cannot underflow or overflow.
property p_occ_bounded;
  @(posedge clk) disable iff (!rst_n) occupancy <= CNT_W'(DEPTH);
endproperty
a_occ : assert property (p_occ_bounded);
 
// P10b: FULL AND EMPTY ARE UNAMBIGUOUS -- the property that pointer-only
// designs cannot state (section 12).
property p_full_empty_distinct;
  @(posedge clk) disable iff (!rst_n)
  !(full && empty) && (full |-> (occupancy == CNT_W'(DEPTH)))
                   && (empty |-> (occupancy == '0));
endproperty
a_fe : assert property (p_full_empty_distinct);
 
// P11: HARDWARE DOES NOT CONSUME PAST THE PUBLISHED BOUNDARY (section 8's
// hardware half).
property p_no_speculative_consume;
  @(posedge clk) disable iff (!rst_n)
  (pop && !empty) |-> (occupancy != '0);
endproperty
a_pub : assert property (p_no_speculative_consume);
 
// P12: simultaneous push and pop leaves occupancy unchanged.
property p_push_pop;
  @(posedge clk) disable iff (!rst_n)
  (push && !full && pop && !empty) |=> $stable(occupancy);
endproperty
a_pp : assert property (p_push_pop);
 
// P13: reset clears ownership -- no stale descriptor or result survives.
property p_reset;
  @(posedge clk)
  !rst_n |=> (!active && !result_valid && (occupancy == '0));
endproperty
a_reset : assert property (p_reset);

P1 is §6's law as a property, and P2 pins the capture to the handshake so a design cannot satisfy P1 by never updating.

P8c is the conservation equation (§9): every accepted descriptor is active, awaiting result collection, or retired — never anywhere else, and never in two places.

And P10b is the property a pointers-only ring literally cannot express, which is the cleanest argument for the explicit count.

No liveness. "Execution finishes", "software consumes the result" and "descriptors arrive" are all environment properties.

18. Same-Cycle Contracts

CaseDeclared resolution
descriptor accepted + resetreset wins; no descriptor is owned (P13)
execution completes + result output stalledthe result is held; desc_ready stays low (P3, P8)
result accepted + next descriptor availabledesc_ready rises the following cycle; the new descriptor waits one cycle rather than racing the result
software publishes + hardware consumes, same cycleboth pointers advance, occupancy unchanged (P12)
invalid descriptor + execution idlevalidation refuses; a result record with an error status is still produced (P4)
ring wrap + simultaneous push and popboth wrap independently; occupancy unchanged
exec_done + resetreset wins; the result is not published

19. Verification, Fault Injection, and Model Verification

Executed before publication.

Ring pointer wrap — across power-of-two and non-power-of-two depths

40,000 increments over depths 1, 2, 3, 4, 5, 8, 16, 17:

ImplementationOut-of-range pointers
explicit wrap at DEPTH-10
binary overflow on $clog2 bits7,965 — 19.9%
DepthPointer widthOverflow wraps atShould wrap at
3243
5385
1753217

Invisible at every power-of-two depth — so a design tested at 8 and 16 passes and fails at 5.

Full/empty ambiguity

300,000 random enqueue/dequeue operations over depths 1, 2, 3, 5, 8: 66,032 states had the ring full with the pointers equal — indistinguishable, by pointers alone, from empty. With an explicit count: none, by construction.

Descriptor lifecycle conservation

30,000 random lifecycles, checking accepted == active + awaiting-collection + retired: 0 violations.

Directed tests

  • One descriptor, end to end, with cookie returned (P8b).
  • Zero-length descriptor — verify refusal and an error result (P4). Required.
  • Descriptor input stalled — verify payload stability (environment assumption + P2).
  • Execution stalled — verify the snapshot is unchanged (P1). Required.
  • Result output stalled — verify the result is held and desc_ready stays low (P3, P8). Required.
  • A new descriptor presented while the previous result is unclaimed — verify it is not accepted (P3).
  • Back-to-back descriptors with different cookies — verify no cookie crosses over (P8b).
  • DEPTH = 1, 2, 3, 5, 8 — pointer range and full/empty (P9, P10b). Required, and §19's measured hazard.
  • Ring full, ring empty, simultaneous push and pop (P12).
  • Reset while active, and while a result is pending (P13).

The scoreboard maintains an independent descriptor queue and byte model and never reads desc_active, occupancy, full or empty.

Mutations

#MutationCaught bySystem symptom
1descriptor fields re-read live during executionP1transfer address or length mutates mid-flight (§19's counterexample)
2desc_ready stays high while a descriptor is activeP3the next descriptor overwrites the active one
3zero-length descriptor enters executionP4engine never makes progress; ring stalls forever
4result pulsed for one cycleP8work complete, software never told — ring stalls with data in memory
5bytes_completed allowed to exceed bytes_issuedP5accounting reports more moved than sent
6descriptor released at exec_done, before result collectionP3next descriptor's result overwrites the pending one
7cookie taken from desc_in rather than desc_activeP8bsoftware matches a completion to the wrong request
8pointer wraps by binary overflow at DEPTH=5P9reads past the ring — 19.9% of increments (measured)
9full inferred from pointer equalityP10bring treated as empty when full; unpublished descriptors consumed
10reset leaves active setP13a stale descriptor executes after reset
11invalid descriptor refused with no result recordP4slot never freed; software waits on a descriptor hardware discarded
12empty inferred from pointer equalityP10bring wedges permanently when it is actually full
13hardware consumes past the published boundaryP11partially-initialized descriptor executed (§8)
14result status changes while the result is stalledP8software reads a status that has since been overwritten
15consumer pointer advances twice for one descriptorP10, scoreboarda descriptor skipped entirely
16occupancy decremented when emptyP10underflow; ring reports huge occupancy
17simultaneous push and pop mishandledP12occupancy drifts under sustained load

20. Debugging

Symptom → which ownership boundary → signal → distinguishing experiment.

The first three registers are always bytes_total, bytes_issued, bytes_completed (Chapter 20.1 §10), plus the cookie of the active descriptor.

DMA occasionally uses the wrong address or length

Suspect the snapshot (§19's counterexample) — mutation 1.

The tell is correlation with ring pressure. It needs software to reuse a descriptor slot while hardware is still executing it, so it appears under load and vanishes in a quiet test.

The distinguishing experiment: capture desc_active at $rose(active) and compare it against desc_active at exec_done. If they differ, the working copy is not a copy — P1 is that check made permanent.

Hardware finished but the driver waits forever

§19's second counterexample — and the giveaway is that the data is correct.

Check result_valid and result_ready together. If result_valid was asserted for exactly one cycle while result_ready was low, the completion was lost (mutation 4).

Then check the consumer. A status-queue writer blocked on credits, or an interrupt path stalled by the Link being in a power state (Chapter 18.8 §11), will legitimately hold result_ready low for a long time — and a held result survives that; a pulse does not.

Failures only at ring wrap

Two candidates, and the depth tells you which.

If the depth is not a power of two, suspect the pointer arithmetic first (§13): 19.9% of increments go out of range with binary overflow, and the bug is invisible at 8 or 16. Distinguished by: reconfiguring to a power-of-two depth — if the failure disappears, it is the wrap.

If the depth is a power of two, suspect full/empty (§12). At wrap the pointers become equal, and a pointers-only design must choose an interpretation. Reading the occupancy counter separates them immediately — if occupancy says full and the design behaves as empty, that is mutation 9.

Descriptor contents look partly old and partly new

This is the publication race (§8), and it is software's half of the contract.

Hardware read a descriptor before its fields were visible — the producer index advanced too early. The device cannot detect this: the record is structurally valid, only its contents are stale.

The distinguishing experiment: have software write a distinctive sentinel into every descriptor field, publish, and have hardware log the snapshot. A snapshot mixing sentinel and stale values proves the ordering, and the fix is entirely on the software side.

Software gets a completion for the wrong request

Read the cookie path (mutation 7). If the result's cookie came from desc_in rather than desc_active, it identifies whatever descriptor was presented at completion time — usually the next one.

The symptom is a driver freeing the wrong buffer, which is far worse than a missing completion: the correct buffer stays allocated and a buffer still in use is released.

21. Common Misconceptions

  • "PCIe defines the DMA descriptor format." It defines none (§4).
  • "A descriptor is safe to modify after the producer index advances." It has been handed over (§3).
  • "Issued means complete." Three separate quantities (§9).
  • "Hardware and software can both hold a descriptor briefly." Exactly one owner, always (§3).
  • "Equal producer and consumer pointers means empty." 66,032 measured states where it meant full (§12).
  • "A one-cycle completion pulse is enough." §19's second counterexample.
  • "The cookie can be read from the ring after execution." The slot may already be reused (§20).
  • "Queue depth must be a power of two." No PCIe rule says so — but non-power-of-two demands explicit wrap (§13).
  • "An invalid descriptor can just be dropped." It still needs a result record, or the slot never frees (§15).
  • "Descriptor fetch is not really DMA." §1: hardware "fetch[es] the descriptor entries" — a Memory Read (Chapter 20.4).
  • "A full ring means software is too fast." It may mean completions are not being collected (§10).
  • "The snapshot is unnecessary if the driver promises not to touch the descriptor." A promise is not a mechanism, and §19 measured what happens.

22. Understanding Check

23. What's Next

A descriptor is an ownership protocol, not a data structure.

Exactly one owner, always (§3), with a publication that transfers it and a completion record that transfers it back — and both failures are silent: a descriptor edited mid-flight produces well-formed requests to the wrong place, and a lost completion leaves finished work unacknowledged.

Snapshot at the boundary (§6) — the fourth appearance of that law, now with four measured failure rates behind it.

Never collapse issued, completed and retired (§9). And a ring needs more than two pointers (§12): 66,032 ambiguous states measured, and 19.9% out-of-range pointers when the wrap relies on binary overflow.

Chapter 20.3 — Host Memory Access takes the layer beneath. How does an owned descriptor become actual PCIe traffic? Memory Writes that need no answer, Memory Reads that create outstanding state, Tags that cannot be reused, and Completions that arrive in pieces — where the accounting stops being addition and starts being matching.

The idea to carry forward: publication is the handoff; after it, the description belongs to the other side.