PCIe · Module 20
Scatter-Gather — One Logical Transfer Across Many Segments
A buffer that looks contiguous to an application is usually not contiguous in memory. Scatter-gather walks a list of segments — and the list itself has to be fetched by DMA, which makes the control plane a data plane.
Chapter 20.3 moved one contiguous region. Real transfers rarely are one.
An application allocates what it believes is a single large buffer. Underneath, the operating system has assembled it from pages scattered across physical memory — and a DMA engine addressing host memory sees the scattered version, not the tidy one.
The naive fix is to copy. Assemble the pieces into one contiguous staging buffer, DMA that, and copy back. Which reintroduces exactly the CPU copying that DMA existed to eliminate (Chapter 20.1 §2).
Scatter-gather is the alternative: describe the pieces, and let hardware walk them. And that raises a question with more depth than it first appears — because the description itself lives in host memory, so fetching it is DMA too.
1. Sources and Scope
2. Why the Pieces Are Scattered
An application's buffer is contiguous in its own address space. That says nothing about physical memory.
application's view what the DMA engine addresses
────────────────── ─────────────────────────────
one 256 KB buffer → 0x4A21_2000 16 KB
0x7F03_8000 64 KB
0x1C88_C000 4 KB
...Two ways to handle it.
Copy into one contiguous region, DMA that, copy back. Correct, and it costs exactly what DMA was adopted to avoid — the CPU touching every byte.
Describe the pieces. Software builds a list of (address, length) segments; hardware walks it. The transfer is logically one operation and physically many.
This chapter does not claim the second is universally better or that it eliminates copying everywhere — systems and buffer sources vary. What it claims is the mechanism, and that the mechanism is what makes large transfers into ordinary application memory practical.
3. Scatter and Gather
Two directions, and the terms describe where the many side is.
Gather — many source segments, one logical stream out. The engine reads segment after segment and presents them as one continuous transfer.
Scatter — one logical stream in, distributed across many destination segments.
In PCIe terms (Chapter 20.3 §2): gathering from host memory issues Memory Reads; scattering into host memory issues Memory Writes. The walking machinery is identical; only the per-segment transaction type differs.
The terms are used loosely in practice — "scatter-gather" usually names the whole capability rather than one direction — and this chapter uses it that way, distinguishing the directions only where the transaction type matters.
4. Two Ways to Chain
| Linked list | Ring | |
|---|---|---|
| Next descriptor | a pointer inside the descriptor | the next index, implicitly |
| Storage | anywhere, any topology | one fixed array |
| Termination | an end flag or a null pointer (§7) | producer/consumer boundary (20.2 §12) |
| Can it loop? | yes — accidentally (§8) | no — indices are bounded |
| Fetch cost | one fetch per descriptor | can fetch several contiguously |
| Flexibility | high — arbitrary structure | fixed depth |
§1's sourced example is a ring — a descriptor queue with write and read pointers, depth 8. Chapter 20.2 §§11–13 built exactly that, including the full/empty ambiguity and the wrap arithmetic.
This chapter therefore concentrates on the linked list (§§7–8, §11), because it introduces problems the ring does not have — termination and loops — and §15 shows the ring alternative for contrast.
Neither is a PCIe mechanism, and neither is universally better (§1).
5. Fetching Descriptors Is DMA
6. A Descriptor Is Not Usable Until It Is Whole
A descriptor fetch is a Memory Read, so its Completions may be split (Chapter 20.3 §10).
§18 measured how often this matters: across 40,000 random fragmentations of a 32-byte descriptor fetch, 96.9% arrived in more than one fragment.
So publishing a descriptor as soon as any data returns publishes a record that is part new and part whatever was in the buffer before — a length from one descriptor with a next-pointer from another, or a valid-looking address with an uninitialized length.
§12's assembler therefore accumulates bytes and publishes exactly once, when the full record has arrived — the same discipline as Chapter 20.3 §13's Completion accumulator, applied to the control plane instead of the data plane.
And it must compare the next value, for the same reason and with the same measured consequence.
7. Ending the List
A walker needs an unambiguous stop condition, and it must be explicit.
Common mechanisms, none of them a PCIe rule (§1):
| Mechanism | How it ends |
|---|---|
| end-of-chain flag in the descriptor | the flag is set on the last one |
| null next pointer | a reserved value means stop |
| descriptor count | supplied with the job |
| ring boundary | consumer catches the producer (20.2 §12) |
This chapter's architecture uses an explicit end_of_chain flag (§9), and §11's walker checks it after the current segment retires (§10).
8. Lists Can Be Wrong in Ways Single Descriptors Cannot
Three failure shapes that only exist once descriptors point at each other.
A loop. A → B → C → B, or a self-loop A → A. The walker never reaches an end.
§18 measured it starkly: 10,000 deliberately cyclic chains walked without a bound hung 100.0% of the time. Every one. And software wrote those chains — a driver bug, a use-after-free of a descriptor slot, or a partially-updated list — so "software would not do that" is a statement about intent, not about hardware.
A zero-length segment. A descriptor asking for nothing. Without a policy it can produce no progress forever, or be silently skipped in a way that makes byte accounting wrong.
An unreachable next pointer. A pointer into memory the engine cannot fetch, or a fetch that errors (§5).
9. Segment Completion Is Not Job Completion
Two distinct events, and conflating them is mutation 9.
segment_done this segment's bytes have all moved
job_done every segment completed AND the end condition was reachedSoftware cares about the job. It submitted one logical transfer; it wants one completion — which is also why interrupting after every segment would be wasteful (Chapter 19.5 §8).
But per-segment completion may still be requested — §10's descriptor carries an irq flag, so a long chain can raise an interrupt at a chosen point without ending the job.
And job bytes are the sum of segment bytes, which §18 verified across 20,000 random jobs: 0 violations.
10. The Architecture
Three things to read out of the figure.
The fetch path and the data path both reach PCIe (§5) — the control plane is not a shortcut.
The return arrow from the segment engine to the walker is a retirement, not a start — the walker advances only when the segment is done (§13's counterexample).
And the assembler sits between the fetch and the walker, because a descriptor that is partly present is not a descriptor (§6).
11. RTL — Types, Descriptor Fetch and Assembler
// SYNTHESIZABLE. Normalized scatter-gather types.
// NOT A PCIe FORMAT (section 1). PCIe defines no scatter-gather mechanism,
// no descriptor layout and no chaining convention.
package sg_pkg;
parameter int ADDR_W = 64;
parameter int LEN_W = 24;
parameter int DESC_BYTES = 32; // this layout's record size
parameter int DBYTE_W = $clog2(DESC_BYTES+1);
typedef struct packed {
logic [ADDR_W-1:0] host_addr; // segment address (device-visible, 20.1 §11)
logic [LEN_W-1:0] length; // segment length
logic [ADDR_W-1:0] next_desc; // pointer to the next descriptor (§4)
logic end_of_chain; // §7's explicit termination
logic irq; // request an interrupt at this segment (§9)
} sg_desc_t;
typedef enum logic [2:0] {
SG_OK = 3'd0,
SG_ERR_ZERO_LEN = 3'd1, // §8 -- a segment that cannot progress
SG_ERR_CHAIN_LIMIT = 3'd2, // §8 -- bounded walk exceeded
SG_ERR_FETCH = 3'd3, // §5 -- the descriptor read itself failed
SG_ERR_SEGMENT = 3'd4, // a segment's data transfer failed
SG_ERR_ABORT = 3'd5
} sg_status_e;
endpackageimport sg_pkg::*;
// SYNTHESIZABLE. Fetch one descriptor from host memory.
// THIS IS A MEMORY READ (section 5). It allocates a Tag and creates a
// context exactly as Chapter 20.3 section 15 does for data -- the control
// plane has no privileged path.
module sg_desc_fetch (
input logic clk,
input logic rst_n,
input logic start,
input logic [ADDR_W-1:0] desc_addr,
// Memory Read request, to the same transmit path as data (§5).
output logic rd_valid,
output logic [ADDR_W-1:0] rd_addr,
output logic [LEN_W-1:0] rd_len,
input logic rd_ready,
input logic link_ok,
input logic tag_granted,
input logic busy_downstream,
output logic fetch_outstanding
);
logic pending_q, out_q;
assign fetch_outstanding = out_q;
// The descriptor read is a fixed-size request for this layout. A larger
// descriptor, or a prefetch of several, would be chunked exactly as data
// is (Chapter 20.3 §7) -- and prefetch depth belongs to Chapter 20.5.
assign rd_valid = pending_q && tag_granted && link_ok && !busy_downstream;
assign rd_addr = desc_addr;
assign rd_len = LEN_W'(DESC_BYTES);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin pending_q <= 1'b0; out_q <= 1'b0; end
else begin
if (start && !pending_q && !out_q) pending_q <= 1'b1;
if (rd_valid && rd_ready) begin pending_q <= 1'b0; out_q <= 1'b1; end
// out_q is cleared by the assembler when the record completes.
end
end
endmoduleimport sg_pkg::*;
// SYNTHESIZABLE. Assemble a descriptor from Completion fragments.
// SECTION 6: 96.9% of 32-byte fetches arrive in more than one fragment
// (section 18), so publishing on the first fragment publishes a record
// that is part new and part stale.
//
// SAME DISCIPLINE as Chapter 20.3 section 15's data accumulator, applied
// to the control plane -- including comparing the NEXT byte count.
module sg_desc_assembler (
input logic clk,
input logic rst_n,
input logic fetch_started,
input logic cpl_valid,
input logic [DBYTE_W-1:0] cpl_bytes,
input logic [DESC_BYTES*8-1:0] cpl_data,
input logic cpl_error,
output sg_desc_t desc,
output logic desc_valid, // asserted ONLY when whole
output logic desc_err_fetch
);
logic [DBYTE_W-1:0] got_q;
logic [DESC_BYTES*8-1:0] buf_q;
logic v_q, err_q;
assign desc = sg_desc_t'(buf_q);
assign desc_valid = v_q;
assign desc_err_fetch = err_q;
// NEXT value, not current -- Chapter 20.3 §18 measured the alternative
// at 100.0% failure to retire.
wire [DBYTE_W-1:0] next_got = got_q + cpl_bytes;
wire complete = cpl_valid && !cpl_error && (next_got == DBYTE_W'(DESC_BYTES));
wire overrun = cpl_valid && (next_got > DBYTE_W'(DESC_BYTES));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin got_q <= '0; v_q <= 1'b0; err_q <= 1'b0; end
else begin
if (fetch_started) begin got_q <= '0; v_q <= 1'b0; err_q <= 1'b0; end
else if (cpl_valid) begin
if (cpl_error || overrun) begin
// A FAILED DESCRIPTOR FETCH IS NOT A DATA ERROR (section 5): the
// engine does not know what to do next, which ends the job.
err_q <= 1'b1;
end else begin
// Accumulate the fragment into the record buffer. Placement by
// offset is elided here; what matters is that nothing is
// published until the whole record has arrived.
buf_q <= buf_q | cpl_data;
got_q <= next_got;
// ==========================================================
// PUBLISH EXACTLY ONCE, WHEN COMPLETE (section 6).
// ==========================================================
if (complete) v_q <= 1'b1;
end
end
end
end
endmoduleClassification: all three synthesizable.
desc_valid asserts only at complete — §18 measured that 96.9% of fetches arrive in more than one fragment, so publishing early is the common case, not the corner case.
And a fetch error is a distinct status (SG_ERR_FETCH), because §5's point is that failing to read a descriptor is worse than failing to move a segment: the engine no longer knows what to do next.
Failure — four. Publishing on the first fragment (§18's counterexample). Comparing the current byte count rather than the next. Treating a fetch error as a data error, continuing the walk with an unknown descriptor. And not clearing the accumulator at fetch_started, so a new fetch inherits the previous record's bytes.
12. RTL — The Walker and the Chain-Limit Guard
import sg_pkg::*;
// SYNTHESIZABLE. Walk a descriptor chain -- bounded, and with the next
// pointer taken only from an owned snapshot.
//
// THIS IS NOT A COMPLETE DMA ENGINE. Segment execution is Chapter 20.3's;
// prefetch and pipelining are Chapter 20.5's; a full FPGA composition is
// Chapter 20.6's.
module sg_walker #(
parameter int MAX_DESCRIPTORS_PER_JOB = 256,
parameter int CNT_W = $clog2(MAX_DESCRIPTORS_PER_JOB + 1)
) (
input logic clk,
input logic rst_n,
// ---- Job input ----------------------------------------------------------
input logic job_valid,
input logic [ADDR_W-1:0] job_first_desc,
input logic [15:0] job_id,
output logic job_ready,
// ---- Descriptor fetch (section 11) --------------------------------------
output logic fetch_start,
output logic [ADDR_W-1:0] fetch_addr,
input sg_desc_t fetch_desc,
input logic fetch_desc_valid,
input logic fetch_err,
// ---- Segment execution (Chapter 20.3) -----------------------------------
output logic seg_start,
output logic [ADDR_W-1:0] seg_addr,
output logic [LEN_W-1:0] seg_len,
input logic seg_done_valid,
output logic seg_done_ready,
input logic seg_error,
input logic [LEN_W-1:0] seg_bytes,
// ---- Job completion (section 13) ----------------------------------------
output logic job_done_valid,
input logic job_done_ready,
output sg_status_e job_status,
output logic [15:0] job_done_id,
output logic [CNT_W-1:0] segments_completed,
output logic [31:0] bytes_completed,
output logic [ADDR_W-1:0] failing_desc_addr // §19's key diagnostic
);
typedef enum logic [2:0] {
S_IDLE, S_FETCH, S_WAIT_DESC, S_VALIDATE, S_SEGMENT, S_ADVANCE, S_REPORT
} st_e;
st_e st_q;
sg_desc_t desc_q; // THE SNAPSHOT (section 13)
logic [ADDR_W-1:0] cur_addr_q, first_q;
logic [CNT_W-1:0] seen_q, segs_q;
logic [31:0] bytes_q;
logic [15:0] id_q;
sg_status_e st_stat_q;
logic jd_v_q;
assign job_ready = (st_q == S_IDLE) && !jd_v_q; // §17
assign fetch_start = (st_q == S_FETCH);
assign fetch_addr = cur_addr_q;
assign seg_start = (st_q == S_SEGMENT);
assign seg_addr = desc_q.host_addr; // FROM THE SNAPSHOT
assign seg_len = desc_q.length;
assign seg_done_ready = (st_q == S_SEGMENT);
assign job_done_valid = jd_v_q;
assign job_status = st_stat_q;
assign job_done_id = id_q;
assign segments_completed= segs_q;
assign bytes_completed = bytes_q;
assign failing_desc_addr = cur_addr_q;
// ==================================================================
// THE CHAIN LIMIT (section 8).
//
// Section 18: 10,000 deliberately cyclic chains without a bound hung
// 100.0% of the time. A DMA engine cannot detect a cycle -- that needs
// unbounded state -- so the defence is a bound, and the failure must be
// REPORTED with the descriptor address (section 19).
//
// IMPLEMENTATION POLICY, not a PCIe requirement (section 1).
// ==================================================================
wire chain_exceeded = (seen_q >= CNT_W'(MAX_DESCRIPTORS_PER_JOB));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
st_q <= S_IDLE; desc_q <= '0; cur_addr_q <= '0; first_q <= '0;
seen_q <= '0; segs_q <= '0; bytes_q <= '0; id_q <= '0;
st_stat_q <= SG_OK; jd_v_q <= 1'b0;
end else begin
if (jd_v_q && job_done_ready) jd_v_q <= 1'b0;
unique case (st_q)
S_IDLE :
if (job_valid && job_ready) begin
cur_addr_q <= job_first_desc; first_q <= job_first_desc;
id_q <= job_id; seen_q <= '0; segs_q <= '0; bytes_q <= '0;
st_stat_q <= SG_OK;
st_q <= S_FETCH;
end
S_FETCH :
if (chain_exceeded) begin
st_stat_q <= SG_ERR_CHAIN_LIMIT; st_q <= S_REPORT;
end else st_q <= S_WAIT_DESC;
S_WAIT_DESC :
if (fetch_err) begin
st_stat_q <= SG_ERR_FETCH; st_q <= S_REPORT;
end else if (fetch_desc_valid) begin
// ======================================================
// THE SNAPSHOT. The whole descriptor, in one assignment.
// Everything afterwards -- the segment address, the length,
// AND THE NEXT POINTER -- comes from desc_q, never from a
// re-read of host memory (section 13, mutation 1).
// ======================================================
desc_q <= fetch_desc;
seen_q <= seen_q + CNT_W'(1);
st_q <= S_VALIDATE;
end
S_VALIDATE :
// A ZERO-LENGTH SEGMENT ENDS THE JOB WITH AN ERROR (section 8).
// Skipping silently makes byte accounting wrong; retrying makes
// no progress forever.
if (desc_q.length == '0) begin
st_stat_q <= SG_ERR_ZERO_LEN; st_q <= S_REPORT;
end else st_q <= S_SEGMENT;
S_SEGMENT :
// ADVANCE ONLY ON THE SEGMENT-DONE HANDSHAKE (section 13's
// counterexample). A pulse would be lost; starting the next
// fetch at seg_start destroys ownership of the current segment.
if (seg_done_valid) begin
segs_q <= segs_q + CNT_W'(1);
bytes_q <= bytes_q + 32'(seg_bytes);
if (seg_error) begin
st_stat_q <= SG_ERR_SEGMENT; st_q <= S_REPORT;
end else st_q <= S_ADVANCE;
end
S_ADVANCE :
// ======================================================
// END-OF-CHAIN WINS OVER A NON-NULL NEXT POINTER (section 7's
// declared policy). The flag is the explicit statement of
// intent; a leftover pointer is the likelier accident.
// ======================================================
if (desc_q.end_of_chain) begin
st_stat_q <= SG_OK; st_q <= S_REPORT;
end else begin
cur_addr_q <= desc_q.next_desc; // FROM THE SNAPSHOT
st_q <= S_FETCH;
end
S_REPORT :
if (!jd_v_q) begin jd_v_q <= 1'b1; st_q <= S_IDLE; end
default : st_q <= S_IDLE;
endcase
end
end
endmoduleClassification: synthesizable.
Three decisions, all measured or declared. The chain limit — §18's 100.0% hang rate without one. The next pointer from desc_q, never from a re-read. And S_ADVANCE reached only through the segment-done handshake, never at seg_start.
failing_desc_addr is the field that makes §19 possible. A bounded failure that reports which descriptor gives an engineer the list to inspect; a hang gives them nothing.
Failure — five. No chain bound (§18). The next pointer re-read from host memory. Advancing at segment start (§13). A zero-length descriptor skipped silently, corrupting byte accounting. And end_of_chain losing to a non-null pointer by accident (§7).
13. Assertions
// SVA over the scatter-gather blocks. LOCAL contract only. Nothing asserts
// that Completions arrive, that software supplies well-formed lists, or
// that a job ever completes.
// ---- DESCRIPTOR FETCH AND ASSEMBLY ------------------------------------
// P1: the fetch request is stable under stall.
property p_fetch_stable;
@(posedge clk) disable iff (!rst_n)
(rd_valid && !rd_ready) |=> (rd_valid && $stable(rd_addr) && $stable(rd_len));
endproperty
a_fetch : assert property (p_fetch_stable);
// P2: A DESCRIPTOR IS PUBLISHED ONLY WHEN WHOLE. Section 18: 96.9% of
// fetches arrive in more than one fragment.
property p_publish_when_complete;
@(posedge clk) disable iff (!rst_n)
$rose(desc_valid) |-> ($past(got_q) + $past(cpl_bytes) == DBYTE_W'(DESC_BYTES));
endproperty
a_whole : assert property (p_publish_when_complete);
// P2b: and exactly once per fetch.
property p_publish_once;
@(posedge clk) disable iff (!rst_n)
(desc_valid && !fetch_started) |=> (desc_valid || $past(fetch_started));
endproperty
a_once : assert property (p_publish_once);
// P3: A DESCRIPTOR-FETCH ERROR ENDS THE JOB and is distinguishable from a
// segment error (section 5).
property p_fetch_err_status;
@(posedge clk) disable iff (!rst_n)
(fetch_err && (st_q == S_WAIT_DESC)) |=> (job_status == SG_ERR_FETCH);
endproperty
a_ferr : assert property (p_fetch_err_status);
// ---- THE SNAPSHOT -----------------------------------------------------
// P4: THE WORKING DESCRIPTOR IS IMMUTABLE while its segment executes.
// The fifth appearance of this law in the curriculum (20.2 §6).
property p_snapshot_immutable;
@(posedge clk) disable iff (!rst_n)
(st_q inside {S_VALIDATE, S_SEGMENT, S_ADVANCE}) |=> $stable(desc_q)
|| (st_q == S_REPORT) || !rst_n;
endproperty
a_snap : assert property (p_snapshot_immutable);
// P5: THE NEXT POINTER COMES FROM THE SNAPSHOT, not from host memory.
property p_next_from_snapshot;
@(posedge clk) disable iff (!rst_n)
((st_q == S_ADVANCE) && !desc_q.end_of_chain)
|=> (cur_addr_q == $past(desc_q.next_desc));
endproperty
a_next : assert property (p_next_from_snapshot);
// P6: SEGMENT ADDRESS AND LENGTH COME FROM THE SNAPSHOT.
property p_seg_from_snapshot;
@(posedge clk) disable iff (!rst_n)
seg_start |-> ((seg_addr == desc_q.host_addr) && (seg_len == desc_q.length));
endproperty
a_seg : assert property (p_seg_from_snapshot);
// ---- WALK ORDERING ----------------------------------------------------
// P7: NO ADVANCE BEFORE THE SEGMENT RETIRES. Section 18's counterexample.
property p_advance_after_retire;
@(posedge clk) disable iff (!rst_n)
((st_q == S_SEGMENT) && !seg_done_valid) |=> (st_q == S_SEGMENT) || !rst_n;
endproperty
a_adv : assert property (p_advance_after_retire);
// P8: NO NEXT FETCH while a segment is still executing.
property p_no_overlap_fetch;
@(posedge clk) disable iff (!rst_n)
(st_q == S_SEGMENT) |-> !fetch_start;
endproperty
a_ovl : assert property (p_no_overlap_fetch);
// P9: THE SEGMENT COUNT INCREMENTS EXACTLY ONCE PER COMPLETED SEGMENT.
property p_seg_count_once;
@(posedge clk) disable iff (!rst_n)
(segments_completed > $past(segments_completed))
|-> ($past(st_q) == S_SEGMENT) && $past(seg_done_valid);
endproperty
a_cnt : assert property (p_seg_count_once);
// ---- BOUNDS AND POLICY ------------------------------------------------
// P10: THE WALK IS BOUNDED. Section 18: 100.0% hang rate without this.
property p_chain_bounded;
@(posedge clk) disable iff (!rst_n)
seen_q <= CNT_W'(MAX_DESCRIPTORS_PER_JOB);
endproperty
a_bound : assert property (p_chain_bounded);
// P10b: and exceeding it REPORTS rather than hangs, with the address.
property p_limit_reports;
@(posedge clk) disable iff (!rst_n)
((st_q == S_FETCH) && chain_exceeded)
|=> (job_status == SG_ERR_CHAIN_LIMIT);
endproperty
a_limit : assert property (p_limit_reports);
// P11: A ZERO-LENGTH SEGMENT ENDS THE JOB (section 8's declared policy).
property p_zero_len_policy;
@(posedge clk) disable iff (!rst_n)
((st_q == S_VALIDATE) && (desc_q.length == '0))
|=> (job_status == SG_ERR_ZERO_LEN);
endproperty
a_zero : assert property (p_zero_len_policy);
// P12: END-OF-CHAIN WINS over a non-null next pointer (section 7).
property p_eoc_wins;
@(posedge clk) disable iff (!rst_n)
((st_q == S_ADVANCE) && desc_q.end_of_chain) |=> (st_q == S_REPORT);
endproperty
a_eoc : assert property (p_eoc_wins);
// ---- JOB COMPLETION ---------------------------------------------------
// P13: JOB COMPLETION IS NOT SEGMENT COMPLETION (section 9).
property p_job_not_segment;
@(posedge clk) disable iff (!rst_n)
($rose(job_done_valid) && (job_status == SG_OK))
|-> $past(desc_q.end_of_chain);
endproperty
a_job : assert property (p_job_not_segment);
// P14: the completion record is HELD until accepted, and stable.
property p_job_held;
@(posedge clk) disable iff (!rst_n)
(job_done_valid && !job_done_ready)
|=> (job_done_valid && $stable(job_status) && $stable(job_done_id));
endproperty
a_held : assert property (p_job_held);
// P14b: A NEW JOB CANNOT OVERWRITE AN UNCLAIMED COMPLETION (section 17).
property p_no_overwrite;
@(posedge clk) disable iff (!rst_n) job_done_valid |-> !job_ready;
endproperty
a_nowr : assert property (p_no_overwrite);
// P15: CONSERVATION -- job bytes are the sum of completed segment bytes.
// Section 18 verified this across 20,000 random jobs.
property p_byte_conservation;
@(posedge clk) disable iff (!rst_n)
(bytes_completed > $past(bytes_completed))
|-> (bytes_completed == $past(bytes_completed) + 32'($past(seg_bytes)));
endproperty
a_cons : assert property (p_byte_conservation);
// P16: a segment error propagates into the job status.
property p_seg_err_propagates;
@(posedge clk) disable iff (!rst_n)
((st_q == S_SEGMENT) && seg_done_valid && seg_error)
|=> (job_status == SG_ERR_SEGMENT);
endproperty
a_serr : assert property (p_seg_err_propagates);
// P17: an interrupt is requested only after the job completion ownership
// point -- never at segment retirement unless the descriptor asked.
property p_irq_after_job;
@(posedge clk) disable iff (!rst_n)
irq_event_valid |-> (job_done_valid || $past(desc_q.irq));
endproperty
a_irq : assert property (p_irq_after_job);
// P18: reset clears the walk -- no stale descriptor or job survives.
property p_reset;
@(posedge clk)
!rst_n |=> ((st_q == S_IDLE) && !job_done_valid && (seen_q == '0));
endproperty
a_reset : assert property (p_reset);P4 through P6 are the snapshot group — and P5 is the one specific to this chapter: the next pointer must come from the owned copy, because re-reading it means the chain can change under the walker.
P7 with P9 are the advance-ordering pair, and P10 with P10b are the bound: bounded and reported.
And P15 is the conservation equation (§9), verified across 20,000 random jobs.
No liveness. "A job completes" depends on Completions arriving and on software supplying a terminating list — and §8 is precisely about lists that do not terminate.
14. The Ring Alternative, and Writeback
§4's second family, shown for contrast rather than developed — Chapter 20.2 §16 already built the pointer and occupancy logic.
import sg_pkg::*;
// SYNTHESIZABLE. Ring-based segment walking -- no next pointer at all.
// THE TRADE (section 4): a ring cannot loop, because indices are bounded
// by construction -- so no chain-limit guard is needed. What it gives up
// is topology: the list is a fixed array of fixed depth.
module sg_ring_walker #(
parameter int DEPTH = 8,
parameter int PTR_W = (DEPTH <= 1) ? 1 : $clog2(DEPTH)
) (
input logic clk,
input logic rst_n,
input logic [PTR_W-1:0] producer_ptr, // published by software (20.2 §7)
input logic seg_done_valid,
output logic [PTR_W-1:0] consumer_ptr,
output logic work_available,
output logic fetch_start
);
logic [PTR_W-1:0] cons_q;
assign consumer_ptr = cons_q;
// Work exists when the consumer has not caught the producer. Section
// 20.2 §12's ambiguity is avoided here because software owns one
// pointer and hardware the other, and hardware never passes the
// producer -- P11 of Chapter 20.2.
assign work_available = (cons_q != producer_ptr);
assign fetch_start = work_available;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) cons_q <= '0;
// ADVANCE ON RETIREMENT, exactly as the linked walker does (§12).
// EXPLICIT WRAP at DEPTH-1 -- Chapter 20.2 §19 measured binary
// overflow at 19.9% out-of-range for non-power-of-two depths.
else if (seg_done_valid)
cons_q <= (cons_q == PTR_W'(DEPTH-1)) ? '0 : (cons_q + PTR_W'(1));
end
endmoduleClassification: synthesizable.
The ring cannot loop (§8), because there is no pointer to loop with — which is the strongest argument for it, and the reason a ring design needs no chain-limit guard.
What it gives up is topology. A linked list can describe an arbitrary structure assembled from wherever the buffers happen to be; a ring is a fixed array of fixed depth. Neither is universally better (§4).
On descriptor writeback, bounded as §1 promised: some engines write status back into the descriptor or into a separate completion ring (Chapter 20.2 §1 sources a status queue for exactly this). When they do, that writeback is an ordinary DMA Memory Write — posted, credit-consuming, and subject to Chapter 19.2 §8's ordering requirement relative to the completion interrupt. It is not a local register write, and mutation 19 is the design that treats it as one.
15. Same-Cycle Contracts
| Case | Declared resolution |
|---|---|
| segment completes + reset | reset wins; no job or descriptor survives (P18) |
| segment completes + next descriptor already fetchable | the walker enters S_ADVANCE first; the fetch starts the following cycle (P7, P8) |
end_of_chain set and next pointer non-null | the flag wins — declared policy (§7, P12) |
zero length and end_of_chain | zero length wins; the job ends with SG_ERR_ZERO_LEN (P11), because a segment that cannot progress is an error regardless of position |
| chain limit reached + a segment succeeding | the limit is checked in S_FETCH, after the current segment retired — so the successful segment is counted (P9) |
| job completion stalled + a new job offered | job_ready stays low (P14b); the new job waits |
segment error + end_of_chain | error wins; the job reports SG_ERR_SEGMENT (P16) |
16. Verification, Fault Injection, and Model Verification
Executed before publication.
The walker — 30,000 random descriptor graphs
Linear chains, loops, self-loops, zero-length segments and unreachable pointers, walked with MAX_DESCRIPTORS = 16:
| Outcome | Count |
|---|---|
JOB_DONE | 6,763 |
ERR_CHAIN_LIMIT | 11,139 |
ERR_ZERO_LEN | 6,098 |
ERR_BAD_PTR | 6,000 |
Walks that did not terminate: 0. Descriptors visited twice in a completed job: 0.
And without the bound: 10,000 deliberately cyclic chains — hung 10,000 times, 100.0%.
The descriptor assembler — 40,000 random fragmentations
A 32-byte descriptor fetch, fragmented arbitrarily:
| Publication rule | Premature publications |
|---|---|
| when fully assembled | 0 |
| on the first fragment | 38,747 — 96.9% |
96.9% is the fraction of fetches that arrive in more than one fragment — so publishing early is the normal case, not an edge case.
Byte conservation — 20,000 random jobs
job_bytes == sum(segment_bytes): 0 violations.
Advance-on-start versus advance-on-retire — 30,000 chains with one failing segment
| Advance point | Pointer identifies the failing segment |
|---|---|
| on retirement | always |
| on segment start | fails in 22,759 — 75.9% |
Directed tests
- One descriptor; two; a long chain near the limit.
- Descriptor fetch stalled (P1); split descriptor fetch in 2, 3 and many fragments (P2). Required.
- Descriptor fetch error — verify
SG_ERR_FETCH, distinct from a segment error (P3). Required. - Zero-length descriptor, at the start, middle and end of a chain (P11). Required.
end_of_chainwith a non-null next pointer — verify the flag wins (P12). Required.- Self-loop
A → A, and cycleA → B → A— verifyERR_CHAIN_LIMITwith the failing address (P10, P10b). Required. - Segment error mid-chain — verify propagation and that the walk stops (P16).
- Completion output stalled — verify the job record is held and no new job is accepted (P14, P14b). Required.
- Ring wrap at
DEPTH= 1, 2, 3, 5, 8 (§14, Chapter 20.2 P9). - Producer updated while the walker is active; reset mid-chain (P18).
MAX_DESCRIPTORS_PER_JOB= 1 — the boundary.
The scoreboard runs an independent graph walker over the same descriptor structures — a plain dictionary traversal with its own visit set and its own byte sum — and never reads desc_q, seen_q, segs_q or the walker state.
Mutations
| # | Mutation | Caught by | System symptom |
|---|---|---|---|
| 1 | next pointer re-read from host memory | P5 | chain changes under the walker; segments skipped or repeated |
| 2 | pointer advanced at segment start | P7 | ownership destroyed — 75.9% (measured, §17) |
| 3 | descriptor published on the first fragment | P2 | part-old, part-new record — 96.9% of fetches (measured) |
| 4 | zero-length segment skipped silently | P11 | byte accounting wrong; job reports success with missing data |
| 5 | no chain-limit guard | P10 | engine hangs forever — 100.0% on cyclic chains (measured) |
| 6 | self-loop not caught | P10 | same, on the simplest possible bad list |
| 7 | segment count incremented twice | P9 | job reports more segments than the chain contains |
| 8 | segment error ignored | P16 | job reports success with a failed segment |
| 9 | job completes on the first segment | P13 | software told the transfer finished after one segment (§9) |
| 10 | job completion pulsed, not held | P14 | job done, software never told (20.2 §19) |
| 11 | new job accepted while a completion is unclaimed | P14b | the previous job's status overwritten |
| 12 | ring full treated as empty | 20.2 P10b | unpublished descriptors consumed |
| 13 | ring DEPTH=5 wrapping at 8 | 20.2 P9 | reads past the ring — 19.9% (measured) |
| 14 | descriptor fetch bytes miscounted | P2 | descriptor published short or never |
| 15 | descriptor address truncated | P1, scoreboard | fetches from the wrong place |
| 16 | end_of_chain ignored | P12 | the walk continues past the intended end |
| 17 | end flag + non-null pointer left to if order | P12 | the same list behaves differently depending on code order (§7) |
| 18 | interrupt raised before the final segment retires | P17 | software reads a buffer still being filled (19.2 §8) |
| 19 | descriptor writeback treated as a local register write | review + §14 | status never reaches host memory |
| 20 | producer published before descriptor contents visible | 20.2 P11 | partially-initialized descriptor executed (20.2 §8) |
| 21 | next descriptor fetched twice after a stall | P8 | duplicate segment; data written twice |
| 22 | stale descriptor survives reset | P18 | a post-reset job starts mid-chain |
17. Debugging
Symptom → fetch, walk or segment? → signal → distinguishing experiment.
The three registers to read first are failing_desc_addr, segments_completed and job_status — they localize to a descriptor before anything else is examined.
Descriptors 0, 1, 2, 4 processed — 3 skipped
A walk-ordering fault, and there are three candidates.
If job_status is SG_OK, the walker believed it visited everything. Check whether the next pointer was re-read (mutation 1): if descriptor 2's next was fetched from host memory rather than from the snapshot, and software rewrote it, the walker followed a pointer that no longer meant what it did at fetch time.
If a segment error was reported against descriptor 4, suspect mutation 2 — the pointer advanced at segment start (§17's counterexample, 75.9%), so the failure at 3 is attributed to 4 and 3 appears skipped.
The distinguishing experiment: log cur_addr_q at seg_start and at seg_done_valid. If they differ, the pointer moved during execution.
The engine hangs after several minutes of normal operation
"After a while" points at the descriptor list, not the data path — the data path either works or fails quickly.
Check seen_q against MAX_DESCRIPTORS_PER_JOB. If there is no bound at all, this is mutation 5 (§17's second counterexample, 100.0%).
If the bound exists and SG_ERR_CHAIN_LIMIT was reported, the list is genuinely cyclic — and failing_desc_addr names where the walk was when it gave up. Dump the chain from the job's first descriptor and follow the pointers; a cycle is immediately visible.
A useful confirming observation: an analyzer showing continuous, legal Memory Reads with no data transfers between them is a walker fetching descriptors forever.
Only descriptors crossing page boundaries corrupt
Separate the control plane from the data plane first (§5) — both use Memory Reads, and both can be affected by a chunking fault.
If the descriptor fetch is crossing a boundary, it is Chapter 20.3 §7's chunker applied to the fetch path (mutation 15 there). The symptom is a descriptor that assembles wrongly, so check desc_valid timing and the assembled contents.
If the segment data is crossing, it is the same bug on the data path.
The distinguishing experiment: align the descriptor list itself to a boundary and re-run. If the corruption moves from descriptors to data, the fetch path was the problem, and the two are now separated.
The completion interrupt arrives before the last data
A job/segment retirement fault — mutation 18, and it is Chapter 19.2 §8's ordering requirement at job scope.
Check whether the interrupt event is gated on job_done_valid (P17) or on the last segment's seg_done_valid. The two differ by the final S_ADVANCE and S_REPORT transitions — and if the last segment's data writes are still in the transmit path, the interrupt can overtake them.
Failures only when the ring wraps
Chapter 20.2 §20 owns this, and the two candidates are the same: pointer arithmetic at non-power-of-two depths (19.9% out of range), or full/empty ambiguity at the wrap.
The distinguishing experiment is the same: reconfigure to a power-of-two depth. If the failure disappears, it is the wrap arithmetic.
18. Common Misconceptions
- "Scatter-gather is defined by PCIe." It defines no descriptor format and no chaining mechanism (§1).
- "Every SG implementation uses linked lists." Rings are equally common, and cannot loop (§4, §14).
- "Descriptor fetch is not DMA." §1: hardware "fetch[es] the descriptor entries" — a Memory Read (§5).
- "A descriptor can be parsed as soon as data arrives." 96.9% of fetches arrive in more than one fragment (§16).
- "The next pointer can be taken when the segment starts." 75.9% ownership loss (§17).
- "A zero-length descriptor is harmless." Without a policy it makes no progress, or corrupts accounting (§8).
- "Linked lists cannot loop because software wrote them." 100.0% hang rate on the lists that do (§17).
- "Producer equals consumer always means empty." Chapter 20.2 §12 measured 66,032 counterexamples.
- "SG completion means every Memory Write got a Completion." Posted writes never do (20.3 §3).
- "Segment completion is job completion." Two distinct events (§9).
- "SG always improves performance." It avoids a copy; whether that wins depends on the system (§2).
- "Software may edit a descriptor it has published." It has handed it over (20.2 §3).
- "A descriptor writeback is a local register update." It is a posted Memory Write with ordering obligations (§14).
- "A fetch error and a segment error are the same failure." One means the engine does not know what to do next (§5).
19. Understanding Check
20. Module 20 So Far
| Chapter | The question it answers |
|---|---|
| 20.1 DMA over PCIe | Who is the Requester, and what does DMA mean? |
| 20.2 DMA Concepts | What work does hardware own, and when? |
| 20.3 Host Memory Access | How does that work become PCIe memory traffic? |
| 20.4 Scatter-Gather | How do many non-contiguous segments become one job? |
| 20.5 High-Speed Data Movement | How do we make it fast? |
| 20.6 FPGA Examples | How do the pieces become a real engine? |
And one law has now appeared five times — 19.2, 19.3, 19.4 at 57.8%, 20.2, and here: a structure two agents share must be sampled once, whole, at a defined boundary.
21. What's Next
Scatter-gather turns many physical segments into one logical job.
The list itself is fetched by DMA (§5), which makes the control plane subject to every failure of the data plane — and a descriptor is not usable until it is entirely present (§6), which §16 showed matters in 96.9% of fetches.
The walk must be bounded (§8). Lists can loop, hardware cannot detect a cycle, and §17 measured a 100.0% hang rate without a limit.
And ownership must not be released early (§17): advancing the pointer at segment start reports the wrong descriptor in 75.9% of failures, destroying the one piece of state an engineer needs.
Chapter 20.5 — High-Speed Data Movement takes everything this chapter deliberately kept simple and asks how to make it fast: many outstanding requests instead of one, descriptor prefetch instead of fetch-on-demand, payload sizing, and the credit-limited ceiling that decides what throughput is actually available.
The idea to carry forward: do not release ownership of state you may still need to explain what happened.