CXL · Module 7
Discovery Over CXL.io
How software builds a topology it has never seen: probing, the three answers a probe can give, bounded traversal, cycle protection, work queues, and why a device list is not a topology. Seven RTL models simulated, twenty mutations, twenty killed.
Chapter 5.5 walked a capability list: given a device you already have, follow the chain of structures inside it until you find CXL DVSEC. That is a walk within one function, and it assumes you already know the function is there.
This chapter is the step before it. Software has just been handed a machine it has never seen, and it has to find out what is in it.
1. The Engineering Problem — Nobody Told the Host What Is Plugged In
At power-on the host knows one thing: where its own root complex is. It does not know how many devices exist, what they are, how deep the tree goes, whether a slot is populated, or whether the thing in a slot is working. There is no manifest. The topology has to be discovered by asking.
Everything about discovery follows from the fact that the only tool available is a probe, and a probe is a question whose answer might not arrive.
That produces three problems that have nothing to do with each other:
| Problem | Why it is hard |
|---|---|
| Knowing what to ask | An unpopulated slot must be cheap to rule out |
| Interpreting the answer | Absent and broken are different findings |
| Knowing when to stop | The topology is data, and data can be malformed |
The third is the one that separates a walk from a hang. A traversal that terminates because the tree is well formed does not terminate — it just has not met a bad tree yet.
2. The One-Sentence Model
Discovery is a bounded traversal of untrusted data. The host walks a structure it did not build and cannot verify in advance, so every step must be safe against a topology that is malformed, cyclic, larger than expected, or partly unresponsive — and the walk must terminate and report what it could not see.
Call it the untrusted walk. Not "enumerate the devices", which makes it sound like reading a list. The list is what you are trying to produce, and the thing you are reading is a graph supplied by hardware that may be broken.
3. What This Chapter Owns
| Question | Owned by |
|---|---|
| What CXL.io carries | 7.1 |
| Register contracts, W1C, access types | 7.2 |
| Reporting events without losing evidence | 7.3 |
| Walking a hierarchy you have never seen | this chapter |
| Finding CXL DVSEC inside a known function | 5.5 |
| Posted versus non-posted transactions | 7.5 |
| The PCIe enumeration mechanism | PCIe: enumeration |
| PCIe device discovery | PCIe: device discovery |
| Configuration access mechanics | PCIe: configuration access |
4. A Probe Has Three Answers, Not Two
The instinct is that a probe either finds a device or does not. It has three outcomes, and collapsing any two of them loses information a human operator needs.
| Answer | Meaning | Correct response |
|---|---|---|
| Present | The node replied and it exists | Record it, walk its children |
| Absent | The node replied that nothing is there | Record nothing, move on |
| Unresponsive | No reply arrived before the bound | Record it as present and broken |
Absent is a normal, expected, successful outcome. Most of the address space a host probes is empty. An empty slot is not an error, it produces no log entry, and treating it as one buries the real errors under thousands of routine non-findings.
Unresponsive is not absent. A device that is plugged in and failing to answer is the single most interesting thing discovery can find, and a design that reports it as "nothing there" removes it from the topology entirely. The operator then looks at a slot that reports no fault, containing a card that does not work.
That distinction drives the first module.
5. Teaching-model boundary
6. One Probe, End to End
Three things in that picture are the whole chapter.
The probe is a configuration read over CXL.io. This is why discovery belongs to CXL.io and not to CXL.cache or CXL.mem — and why an unmodified host can enumerate a CXL device at all (7.1).
The second exchange never reaches a device, because there is no device. The switch answers on its behalf. The host learns "nothing there" and that is a successful probe, not a failure.
Neither exchange shows the third case: no answer at all. That one has no arrow because nothing comes back, which is exactly why it needs a timer rather than a response.
7. RTL 1 — Present, Absent, Unresponsive
module probe_timeout #(
parameter int unsigned LIMIT = 8,
parameter bit TIMEOUT_IS_ABSENT = 1'b0 // 1 = a timeout reports "not there"
) ( ... );
assign expired = busy_q && (cnt_q >= LIMIT[7:0]);
assign result_valid = busy_q && (resp_valid || expired);
assign res_present = busy_q && resp_valid && resp_present;
assign res_absent = (busy_q && resp_valid && !resp_present)
|| (expired && TIMEOUT_IS_ABSENT);
assign res_unresponsive = expired && !TIMEOUT_IS_ABSENT;
assign hot = 2'd0 + res_present + res_absent + res_unresponsive;hot counts how many of the three answers are asserted, and the checker requires it to be exactly one whenever a result is produced. That is a real property, not decoration: the natural way to write these three expressions produces overlapping conditions, and a probe that reports both absent and unresponsive leaves the caller to pick one arbitrarily.
=== EXP8: absent, present and unresponsive are three answers ===
a node that answers yes : present=1 absent=0 unresponsive=0
present, and exactly one of the three answers : ok
a node that answers no : present=0 absent=1 unresponsive=0
absent, and absence is an answer not a failure : ok
a node that never answers: timeouts=1 answered=2 unresponsive seen=1
unresponsive is its own answer, not absence : ok
two answers and one timeout, all bounded : ok
no probe ran past its bound : ok
the collapsing variant called a live device absent: okThe last line is the TIMEOUT_IS_ABSENT instance, running on identical stimulus and reporting the unresponsive node as absent. It never fails a functional test. It simply produces a topology with a device missing from it.
There is a second bound in this module, and it is deliberately not the same as the first:
// The bound must be independent of the response path, or a probe that
// hangs also disables the detector that is supposed to notice.
if (busy_q && cnt_q > LIMIT[7:0] + 8'd4) hang_err <= 1'b1;hang_err catches the case where the timeout mechanism itself fails to fire. If the diagnostic were derived from expired, then a broken expired would disable both the timeout and the detector for it — the shared-blind-spot failure. A watchdog that depends on the thing it is watching is not a watchdog.
8. RTL 2 — What Is Worth Probing
Most probes find nothing, so the cost of ruling out an empty location is a first-order concern. Boot time on a large machine is dominated by enumeration, and enumeration is dominated by probes that come back empty.
// The rule: function 0 must be present, and only a multi-function device
// has functions 1..7 worth probing at all.
assign want_more = PROBE_ALL_FUNCS ? 1'b1
: (IGNORE_F0_ABSENT ? f0_multifunc
: (f0_present && f0_multifunc));Two variants, two different kinds of wrong.
=== EXP7: how many functions are worth probing ===
single-function device : correct probes=1 | probe-all probes=8
guessing costs 8 probes where 1 suffices : ok
absent function 0 : correct probes=2 | ignore-absent probes=9
probing past an absent function 0 was flagged : ok
the correct iterator stopped at function 0 : okPROBE_ALL_FUNCS is not incorrect. It finds exactly the same devices. It costs eight probes where one suffices, and multiplied across every location in a large hierarchy that is the difference between a boot that feels instant and one that does not. This is worth naming because it is the clearest example in the batch of a change that no functional test can fail and that is still the wrong design.
IGNORE_F0_ABSENT is incorrect, and subtly so. If function 0 does not respond, the device is not there, and functions 1 through 7 of a device that is not there cannot be there either. Probing them is not merely wasteful — it is asking questions of an address with no owner, which is exactly the traffic most likely to produce a completion timeout (7.5 takes up what that costs).
9. RTL 3 — The Work Queue, and the Subtree You Did Not Walk
A traversal needs a list of places still to go. That list is finite, and what happens when it fills decides whether the walk is honest.
// NO_BOUND accepts unconditionally: the write wraps and silently overwrites
// an entry that has not been walked yet.
assign accepted = enq && (NO_BOUND || !full);
// A refused enqueue is an unexplored subtree, and it must be recorded.
if (enq && !accepted) begin
n_refused_q <= n_refused_q + 8'd1;
incomplete_flag <= 1'b1;
endThis is 7.3's silent-drop lesson with a much worse consequence. There, the loss was an event record. Here, the lost item is a device that will never appear in the topology — and the walk goes on to report success.
=== EXP5: the work queue refuses rather than overwrites ===
6 enqueues into depth 4 : level=4 peak=4 enq=4 refused=2 incomplete=1
peak occupancy recorded the true high-water mark : ok
every enqueue was accepted or counted as refused : ok
the skipped subtree is software-visible : ok
no-bound variant : enq=6 refused=0 overran=1
the unbounded variant overwrote unwalked entries : ok
the correct queue still holds its first entry : okThe last line matters. The correct queue still has node 10 at its head — it refused the two it had no room for and kept the four it had already accepted. The NO_BOUND variant wrapped and overwrote entries it had not yet walked, so it lost devices it had already found, which is worse than never finding them: the walk had the information and destroyed it.
The occupancy counter uses the same exhaustive case as 7.3's event queue, for the same reason:
=== EXP6: simultaneous enqueue and dequeue ===
3 cycles of enq+deq together : level 3->3 enq=7 deq=4
occupancy held across simultaneous traffic : ok
nothing was refused while a slot was free : okThat test needed an adjustment before it was meaningful. On the first attempt it opened with the queue full, so the first cycle legitimately refused the enqueue and accepted the dequeue, and the level fell by one. The measured behaviour was correct and the expectation was wrong — so the test now opens a slot first, and additionally asserts that nothing was refused while a slot was free. A test whose expected value is wrong is worse than no test, because it gets "fixed" by changing the design.
10. RTL 4 — Termination, and Why the Bound Must Stand Apart
The topology is data. It arrived from hardware. It can be malformed by a bug, by a marginal link, or by a device that is actively hostile.
// NO_VISITED reports every node as unseen, so a cycle is walked forever.
assign seen = NO_VISITED ? 1'b0 : bits_q[test_node[4:0]];A cyclic topology — a node whose child is its own ancestor — is walked forever by any traversal that trusts the tree to be a tree. Cycle protection is what makes the walk terminate on well-formed and malformed input.
And then the important part, which is what happens if cycle protection is itself broken:
// Independent watchdog: cycles, not nodes, not the visited set.
if (running_q && cycles_q >= CYCLE_BOUND[15:0]) begin
bound_hit_q <= 1'b1; running_q <= 1'b0; st_q <= S_DONE; done_q <= 1'b1;
endcycles_q counts clock cycles. It does not count nodes, it does not consult the visited set, and it does not depend on the topology being sane. This is deliberate. A bound expressed as "stop after N nodes" is computed from the same machinery that the cycle protection uses, so the fault that breaks one breaks the other — and the walk hangs with its own hang-detector disabled.
Measured on a topology whose third node points back at the root:
=== EXP3: a topology that points back at itself ===
cyclic topology : correct visited=3 bound_hit=0 | no-visited bound_hit=1
cycle protection terminated the walk : ok
without it the walk ran until the bound stopped it: ok
no-visited variant visited=80 before the bound firedRead those two flags together. The correct walk terminated on its own, with bound_hit clear — the bound was never needed. The variant without cycle protection visited the same three nodes eighty times and was stopped by the watchdog. Both terminated; only one was correct.
bound_hit clear is part of the pass criterion. A walk that finishes only because the watchdog fired has not enumerated the machine, and a design that treats hitting the bound as success is indistinguishable from one that works.
11. RTL 5 — The Walk
The traversal FSM ties the pieces together: pop a node, test whether it has been seen, probe it, record it, enqueue its children, repeat.
always_comb begin
q_enq = 1'b0; q_deq = 1'b0; v_mark = 1'b0; t_rec = 1'b0;
case (st_q)
S_POP: q_deq = !q_empty;
S_TEST: if (!v_seen) v_mark = 1'b1;
S_REC: t_rec = cur_present;
S_ENQ: if (ci_q < cur_nchild) begin
q_enq = 1'b1;
q_enq_node = cur_children[{4'd0, ci_q} * 8 +: 8];
end
default: ;
endcase
endThe first two nodes of a breadth-first walk
12 cyclesThe marker at cycle 5 is the invariant worth taking away. visited increments in the last cycle of a node's processing, after every child has been enqueued — not when the node is popped. The difference shows up the moment a node does not complete:
=== EXP2: counting nodes finished, not nodes started ===
correct visited=6 | count-pops variant=7
the two counters disagree, as they must : okSeven nodes were popped; six were finished. The seventh is an empty slot. A counter that increments on pop reports the machine as having seven devices, one of which does not exist — and it is the same defect class as 7.3's "count arrivals, not completions", which is the third time this shape has appeared in this track.
12. Reaching the End
The tail of the same walk shows both remaining behaviours: an empty slot, and termination.
An empty slot, and the walk ending on its own
8 cyclesNode 7 passes through REC without recording anything and goes straight back to POP — no record, no children, no error, no log entry. That is what a correct response to absence looks like: nothing happens.
Then the queue is empty and the machine enters DONE with bound_hit low. The walk ended because it ran out of work, which is the only ending that means the enumeration is complete.
=== EXP1: walking a well-formed tree ===
6 present nodes and 1 empty slot : visited=6 recorded=6 enqueued=6 bound_hit=0
the walk terminated on its own, not on the bound : ok
nothing was skipped and nothing was truncated : okSix enqueued children plus the root, seven nodes examined, six present, six recorded, and the walk stopped by itself.
13. RTL 6 — A Device List Is Not a Topology
The output of discovery is not a set of devices. It is a graph, and the edges are what every subsequent decision depends on.
node_m[count_q] <= node_id;
par_m[count_q] <= NO_PARENT ? 8'hFF : parent_id;
kind_m[count_q] <= node_kind;
...
if (node_kind != 2'd0 && !parent_known) orphan_err <= 1'b1;parent_known searches the table for the parent before accepting the record. Every node except the root must attach to something already discovered, which is the structural invariant that makes the result a tree rather than a bag.
=== EXP9: a device list is not a topology ===
index 1 : correct parent=0 | no-parent variant=255
the tree edge survived into the record : ok
the flat variant cannot say what sits behind what : ok
5 nodes into capacity 4 : count=4 refused=1 truncated=1
a partial topology says that it is partial : okLosing the parent pointer is not a cosmetic loss. Without it you cannot answer which switch a device sits behind — and that is the question behind bandwidth sharing, fault domains, power sequencing, hot-plug scope and error containment. A flat list of devices supports none of those decisions.
The capacity behaviour mirrors the work queue: refuse, count, and set a software-visible truncated_flag. Two different structures in this chapter can silently produce a partial topology, and both have to admit it.
14. RTL 7 — Four Numbers
// Every node found was recorded or skipped.
if (n_present_q != n_recorded_q + n_skipped_q) accounting_err <= 1'b1;
// The table cannot hold more nodes than were found.
if (n_recorded_q > n_present_q) recorded_exceeds_present_err <= 1'b1;=== EXP10: four numbers, and the law between them ===
probed=40 present=30 recorded=23 skipped=7
conservation present == recorded + skipped : 30 == 30
conservation held : ok
every count matched an independent oracle : ok
probed 40 slots and found 30 devices: 10 were emptyFour numbers, all four checked against an independent testbench oracle that counts in its own variables without touching the design's logic.
Read them as a report. Forty probes, thirty devices — ten probes found nothing, and that is the normal case, not a fault. Twenty-three recorded and seven skipped means the record ran out of room: the topology software receives is missing seven devices that were definitely found. Without skipped, that topology looks complete.
15. Assertions
Icarus Verilog 13.0 does not support concurrent SystemVerilog assertions, so every property below is implemented as synthesisable checker logic and verified in simulation. The assert property form states the intent.
| Property | Intent |
|---|---|
| Exactly one answer | result_valid |-> one of present/absent/unresponsive |
| Probe terminates | busy |-> ##[1:LIMIT+1] !busy |
| Watchdog independent | hang_err derived from cycles, not from expired |
| No probe past absent f0 | probe_fn > 0 |-> f0_present |
| Queue conservation | accepted + refused == offered |
| Skipped subtree visible | refused_any |-> incomplete_flag |
| Occupancy stable | enq && deq |-> level unchanged |
| Peak is the maximum | peak >= level always |
| No re-walk | seen |-> !recurse |
| No double mark | mark |-> !seen |
| Walk terminates | start |-> ##[1:BOUND] done |
| Bound not needed | done |-> !bound_hit |
| Nodes counted when finished | visited increments in the final state only |
| Every node has a parent | record && kind != root |-> parent in table |
| Truncation visible | cap_reached && record |-> truncated_flag |
| Accounting conserved | present == recorded + skipped |
Two of them are the ones that would catch a real design.
"Bound not needed" is stronger than "walk terminates". Every walk terminates if you put a watchdog on it. The property that says the enumeration actually worked is that the watchdog was never required.
"Peak is the maximum" was missing from the first testbench, and a mutation found the gap — see below.
16. Mutation Testing
Twenty mutations, each a plausible mistake. Twenty killed.
| Mutation | Result |
|---|---|
| Work queue accepts when full | killed |
| Skipped subtree not reported | killed |
| Simultaneous enqueue/dequeue as two increments | killed |
| Peak occupancy lags by one | killed |
| Visited set never remembers a node | killed |
| Double mark not flagged | killed |
| Walk has no independent bound | killed |
| Visited node walked again | killed |
| Children of an absent node enqueued | killed |
| Finished nodes never counted | killed |
| Nodes tested but never marked | killed |
| Functions probed past an absent function 0 | killed |
| All eight functions probed unconditionally | killed |
| Probe has no timeout | killed |
| Unresponsive collapsed into absent | killed |
| Tree edge not recorded | killed |
| Node table writes past capacity | killed |
| Truncated topology does not say so | killed |
| Every probed slot counted as a device | killed |
| Recorded-versus-present check disabled | killed |
The first run scored eighteen of twenty, and both survivors were genuine testbench gaps rather than equivalent mutants.
Peak occupancy lags by one — a missing check. peak_q was never printed and never asserted anywhere in the testbench. It was simply an output nobody looked at, which is the easiest gap of all to have: the signal exists, the design computes it, and no line of the testbench ever mentions it. Adding one assertion in EXP5 killed it. The general form is worth stating: a testbench that never names a signal cannot test it, however thoroughly it exercises the surrounding logic.
Double mark not flagged — a vacuous checker. double_mark_err fires when a node is marked visited twice. The correct traversal always tests before it marks, so through the FSM that condition is unreachable and deleting the checker changes nothing. The fix is the same pattern 7.3 needed: an instance reserved for illegal stimulus.
// The correct traversal always tests before it marks, so double_mark_err
// can never fire through the FSM. Prove it is reachable at all.
visited_set u_vx(...); abuse instance flagged a double mark (revisits=1) : ok
a re-mark did not inflate the distinct-node count : okThe second line is the extra property that fell out of writing the test: a re-mark must be counted as a revisit and must not increment the distinct-node count. Without it, a walk with a cycle-protection bug would over-report how many devices it found.
Two mutations are worth noting for what killed them rather than that they died. NO_VISITED and "nodes tested but never marked" were both caught by the correct walk hit its cycle bound — the assertion that says the enumeration finished on its own. That single property catches every failure of cycle protection regardless of mechanism, which is what makes it worth more than a check on the visited set itself.
17. Verification Plan
| Area | Approach |
|---|---|
| Topology shapes | Well-formed tree, cyclic, wider than the queue |
| Absence | Empty slot mid-walk, asserting no record and no enqueue |
| Probe outcomes | All three, plus the exactly-one-answer property |
| Termination | bound_hit clear on every correct walk, set on the broken one |
| Queue | Overflow, simultaneous traffic, peak, head preservation |
| Cost | Probe counts compared between iterator variants |
| Topology record | Parent pointer, orphan check, capacity truncation |
| Accounting | Independent oracle, conservation per cycle |
| Diagnostics | Positive test that every checker can fire |
The coverage model is a cross of topology shape against node outcome: tree, cycle and oversized, each crossed with present, absent and unresponsive. Random topology generation is worth adding on top, and its value is exactly that it produces shapes nobody thought to write down — but the directed cyclic case must be there explicitly, because a random generator that builds trees will never produce a cycle.
18. Debug Lab
Enumeration hangs on one machine in a thousand and cannot be reproduced
NO-CYCLE-GUARD// Walk step: no record of where we have been.
if (cur_present) begin
record_node();
enqueue_children();
endBoot hangs during device enumeration on rare units. The same unit sometimes boots. Replacing a card can move the problem to a different slot. No error is reported because the code never reaches a point where it could report one.
cyclic topology : correct visited=3 bound_hit=0 | no-visited bound_hit=1
no-visited variant visited=80 before the bound firedThe traversal trusts the topology to be a tree. A node whose reported child is one of its own ancestors creates a cycle, and the walk goes round it forever. The topology came from hardware — a marginal link, a firmware bug or a malformed configuration space is enough to produce one — so "it should not happen" is not a property the walk can rely on.
The rarity is the tell: it depends on a hardware condition, not on software state, which is why it does not reproduce on demand.
if (!seen(node)) begin
mark(node);
if (cur_present) begin record_node(); enqueue_children(); end
endA visited set, tested before the walk recurses. It bounds the traversal by the number of distinct nodes rather than by the shape of the graph, so the walk terminates on any input.
Treat any discovered structure as untrusted data. Include a deliberately cyclic topology in the verification plan — a random tree generator will never produce one, so it has to be written by hand. Then assert not merely that the walk finished, but that it finished without the watchdog.
The walk always completes, and the topology is sometimes wrong
BOUND-AS-SUCCESS// Walk terminates when the node budget runs out; that counts as done.
if (nodes_walked >= MAX_NODES) begin
done_q <= 1'b1; // no indication that this is not a normal ending
endEnumeration never hangs, which looks like the previous bug being fixed. On large configurations some devices are missing from the topology, with no error and no log entry. The set of missing devices changes with probe ordering.
cyclic topology : correct visited=3 bound_hit=0 | no-visited bound_hit=1Two completely different endings are reported identically. A walk that ran out of work has enumerated the machine; a walk that ran out of budget has not, and there is no way for the caller to tell them apart.
Worse, the bound is expressed in nodes walked, which is derived from the same traversal state that a cycle bug corrupts. A walk stuck in a two-node loop may never increment the node count at all, so the bound never fires and the watchdog is disabled by exactly the fault it exists to catch.
// Independent watchdog: cycles, not nodes, not the visited set.
if (running_q && cycles_q >= CYCLE_BOUND[15:0]) begin
bound_hit_q <= 1'b1; // a DIFFERENT ending, and it is visible
done_q <= 1'b1;
endCount something the fault cannot influence — elapsed cycles — and expose bound_hit as a distinct outcome. Then make done && !bound_hit the success criterion rather than done alone.
Any watchdog derived from the mechanism it is watching is not a watchdog. State the independence explicitly in review: what does this bound count, and can the failure it detects stop that count? This is the same shared-blind-spot failure that appeared in the retry logic in an earlier chapter of this track.
A failing card is missing from the topology entirely
TIMEOUT-AS-ABSENTassign res_absent = (resp_valid && !resp_present) || expired; // timeout == absentAn operator reports a card that does not work. Diagnostics show nothing wrong with the slot, the topology reports the slot as empty, and the logs contain no error. Reseating the card sometimes fixes it. Nobody can explain what was wrong because nothing was ever recorded.
a node that never answers: timeouts=1 answered=2 unresponsive seen=1
the collapsing variant called a live device absent: okTwo different findings mapped onto one result. "The node answered and said nothing is here" and "the node never answered" are opposite situations: the first is a successful probe of an empty location, the second is a device that is present and broken.
Collapsing them removes the broken device from the topology. Since an empty slot is unremarkable and generates no log entry, the failure is not merely unreported — it is actively made to look normal.
assign res_absent = resp_valid && !resp_present; // it answered: nothing there
assign res_unresponsive = expired && !TIMEOUT_IS_ABSENT; // it did not answer at allThree distinct outcomes, with a checker asserting exactly one is produced per probe. An unresponsive node is recorded as present-and-failing, which is the finding an operator needs.
For every enumeration outcome, ask what an operator would do with it. "Empty slot" and "broken card" call for opposite actions, so they cannot share an encoding. More generally: a timeout is a result, not an absence of one, and any code path that treats "no answer" as a specific answer is discarding information.
Devices are missing from large configurations and the walk reports success
QUEUE-TRUNCATION// Enqueue a discovered child.
mem_q[wr_q] <= child_id;
wr_q <= wr_q + 1; // no room check, and no record if there is noneSmall configurations enumerate perfectly. Large ones are missing devices, and which devices are missing depends on the order children are discovered. The topology reports no error and no truncation.
6 enqueues into depth 4 : level=4 peak=4 enq=4 refused=2 incomplete=1
no-bound variant : enq=6 refused=0 overran=1The work queue has finite depth and the code does not check it. The write pointer wraps and overwrites entries that have not been walked yet — so the walk loses devices it had already found, which is strictly worse than never finding them.
And because nothing records the loss, the walk drains the queue, finds it empty, and reports a complete enumeration of a topology that is missing subtrees.
assign accepted = enq && !full;
if (enq && !accepted) begin
n_refused_q <= n_refused_q + 8'd1;
incomplete_flag <= 1'b1; // software-visible: this topology is partial
endRefuse rather than overwrite, count the refusals, and set a flag software can read. A partial topology is usable if it says it is partial; a partial topology claiming to be complete is not.
Size the queue from the deepest and widest topology supported, instrument peak, and keep the overflow indication regardless. Test with a topology deliberately wider than the queue — the case only appears above a threshold, so a small test configuration will never reach it.
The device count is one higher than the number of devices
COUNT-POPSS_TEST: begin
n_visited_q <= n_visited_q + 8'd1; // counted on entry
...
endThe reported device count exceeds the number of devices actually in the topology record. The discrepancy equals the number of empty slots probed, so it varies with the machine configuration and looks like an intermittent off-by-N.
correct visited=6 | count-pops variant=7The counter increments when a node is started, not when it is finished. Every probed location increments it, including the ones that turn out to be empty and the ones whose processing is abandoned partway.
This is the same shape as counting arrivals instead of completions in an event path — a counter placed at the beginning of a process that can fail to complete.
S_ENQ: begin
if (ci_q < cur_nchild) ci_q <= ci_q + 2'd1;
else begin
n_visited_q <= n_visited_q + 8'd1; // finished: children all queued
st_q <= S_POP;
end
endIncrement in the final state of the node's processing, after every child has been enqueued. Then visited and recorded are comparable quantities and their difference is meaningful.
For every counter, ask what it counts and whether that thing can fail to complete. Place it at the point of completion. Then cross-check it against a second, independently derived quantity — here, recorded — so a drift between them is visible.
Nobody can tell which switch a device is behind
FLAT-LIST// Record what we found.
node_m[count_q] <= node_id;
kind_m[count_q] <= node_kind;
// parent is not recordedEvery device is discovered correctly and the count is right. Then bandwidth allocation cannot determine which devices share an uplink, error containment cannot determine the blast radius of a switch fault, and hot-plug cannot determine what is affected by removing a card.
index 1 : correct parent=0 | no-parent variant=255
the flat variant cannot say what sits behind what : okThe walk produced a set when the structure it traversed was a graph. The nodes were kept and the edges were discarded, and the edges are what every downstream decision needs.
The bug is invisible during discovery itself, because discovery's own correctness — did we find everything — does not depend on the edges. It surfaces later, in a different subsystem, as an apparently unrelated missing capability.
node_m[count_q] <= node_id;
par_m[count_q] <= parent_id; // the edge, not just the node
kind_m[count_q] <= node_kind;
if (node_kind != 2'd0 && !parent_known) orphan_err <= 1'b1;Record the parent, and assert that every non-root node attaches to something already in the table. That check is what keeps the result a tree rather than a bag of nodes with hopeful annotations.
Ask what the consumer of the discovery result needs, not merely what discovery needs to finish. Traversing a graph and recording only nodes is a recurring shape, and the loss is always noticed in a different subsystem months later.
Boot takes far longer than it should on a large machine
PROBE-ALL// Probe every function at every location.
assign want_more = 1'b1;Enumeration is correct — every device is found and the topology is right — and boot time scales badly with the number of slots. Profiling shows the time is in configuration reads, most of which return nothing. No test fails.
single-function device : correct probes=1 | probe-all probes=8Every location is probed for all eight functions regardless of what function 0 said. A single-function device costs eight probes instead of one, and an empty location costs eight probes to establish something one probe already established.
This is not a correctness bug and it will never be caught by a functional test. It is a design defect measured in a different unit.
assign want_more = f0_present && f0_multifunc;Function 0 gates the rest: if it is absent the device is not there, and if it is present but not multi-function there is nothing above function 0 to find.
Treat probe count as a first-class output and assert on it, exactly as you would assert on a functional result. Enumeration cost is a product requirement on any machine with many slots, and a property that is never measured will regress.
19. Design Review
- Does the walk terminate on a cyclic topology? If the answer relies on the tree being a tree, it does not.
- Is the termination bound independent of the mechanism that can fail? Count cycles, not nodes.
- Can the caller distinguish "finished" from "gave up"? If not, an incomplete topology is indistinguishable from a complete one.
- How many outcomes does a probe have? If two, unresponsive devices are being reported as empty slots.
- What happens when the work queue fills? Refuse and record, never overwrite.
- Are the edges recorded, or only the nodes? A list is not a topology.
- What does the device counter count — starts or completions? Empty slots expose the difference.
- How many probes does enumeration issue? Correct and slow is still a defect on a large machine.
- Which structures can silently truncate the result? Each needs its own software-visible flag.
20. How This Appears in Real Engineering
Bring-up. Discovery is the first thing that has to work, and a discovery bug looks like a hang with no output — the hardest possible failure to diagnose, because the mechanism that would report the problem is the one that failed.
Firmware. Most of this logic lives in host firmware, where the untrusted-data discipline matters most: firmware runs before any of the protections a running OS provides, and the data it is walking came from a device that may be faulty.
Hot plug. Every property here has to hold while the topology is changing. A device removed mid-walk produces exactly the unresponsive case, and a walk that treats it as absent produces a topology that silently disagrees with reality.
Large systems. Probe count is a boot-time budget. On a machine with many slots the difference between one probe and eight per location is measured in seconds of boot time, and it is the kind of regression that ships because nothing fails.
Fleet diagnostics. incomplete_flag, truncated_flag and bound_hit are the three bits that distinguish "this machine has four devices" from "this machine reported four devices and we do not know how many it has".
21. Common Misconceptions
| Belief | Correction |
|---|---|
| A probe finds a device or does not | Three outcomes: present, absent, unresponsive |
| An empty slot is an error | It is the most common successful result |
| The topology is a tree | It is data, and data can be malformed |
| A node budget bounds the walk | Only if the fault cannot stop the count |
| Finishing means enumerating | Not if the watchdog is what stopped it |
| Discovery output is a device list | It is a graph; the edges carry the value |
| Probing more is safer | It is the same result at eight times the cost |
| Enumeration is 5.5 with more nodes | 5.5 walks within a function; this walks between them |
22. Interview Reasoning
23. Exercises
-
Analysis. A machine reports
probed=512,present=64,recorded=64,skipped=0,bound_hit=0,incomplete=0, and an operator insists a card is missing from a populated slot. Nothing in those numbers is inconsistent. Name the two implementation faults still compatible with that report, and the one extra counter that would distinguish them. -
Design. Extend the walk to record depth, and enforce a maximum depth as a third independent bound. State what this catches that the visited set and the cycle watchdog do not, and give the topology that distinguishes all three.
-
RTL task. Change the traversal from breadth-first to depth-first by making the work queue a stack. State which properties in §15 remain true unchanged, which need restating, and what happens to the peak-occupancy requirement on a deep narrow topology versus a shallow wide one.
-
DV task. Write the coverage cross for this walk, then explain why a random topology generator cannot close it, and which two points must be directed tests.
-
Debug task. Enumeration is correct on every machine but boot time doubled after a firmware update that changed no enumeration logic. Give your investigation order and the single measurement that settles it.
-
Design review. A colleague proposes dropping
incomplete_flagbecause "the walk sizes its queue from the maximum supported topology, so it can never fill". Give the strongest version of that argument, then name the two conditions under which it is false and what the flag costs.
24. Summary
Discovery is a bounded traversal of untrusted data, and every property follows from not trusting the topology.
- A probe has three answers: present, absent, unresponsive. Collapsing the third into the second deletes a broken device from the topology and leaves an operator with a slot that reports no fault.
- Absence is the common, successful case. Correct handling of it is that nothing happens.
- Cycle protection makes the walk terminate on malformed input, and the watchdog behind it must count something the fault cannot stop — cycles, not nodes.
- Finishing is not enumerating.
done && !bound_hitis the property that means the topology is whole;donealone is satisfied by giving up. - A full work queue must refuse and record, never overwrite: overwriting loses devices the walk had already found.
- A device list is not a topology. Without the parent edge, nothing downstream can answer what sits behind what.
- Count nodes finished, not nodes started — the third appearance of this defect in the track, and empty slots are what expose it.
- Correct and slow is still a defect: eight probes where one suffices is a boot-time regression no functional test can catch.
- Verification lesson: a testbench cannot test a signal it never names, and a checker the correct design cannot trigger needs an instance reserved for illegal stimulus.
Chapter 7.5 takes the transaction underneath all of this apart: why a configuration read blocks and a memory write does not, what a completion is for, and what it costs when one never arrives.
Standards & specifications
- Governing standard
- CXL Specification (CXL Consortium)(opens CXL Consortium in a new tab)
Defines CXL.io, CXL.cache and CXL.mem, and the coherence and memory-pooling behaviour built on them. System design and deployment topology are not mandated.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the CXL curriculum.
