USB · Module 7
Interface Descriptor
Where a device becomes a set of functions: alternate settings, why endpoint association is positional and load-bearing, and the byte-streamer RTL where one off-by-one decides whether the last descriptor survives.
Chapter 7.2 established the configuration as the header of a tree and proved the tree self-consistent at build time. But the walk that proved it only counted descriptors and stepped over them. It never looked inside one.
This chapter opens the first node beneath the configuration, and the node turns out to carry the idea that makes USB universal rather than merely shared:
An interface is the unit a driver binds to. A device is not one thing that software talks to; it is a collection of functions, each independently claimable.
It also introduces a mechanism with no analogue anywhere else in the tree — several descriptors sharing one interface number — and the RTL that finally emits these bytes, where a single off-by-one decides whether the last descriptor in the tree arrives intact.
1. Why a Level Between Configuration and Endpoint
Chapter 7.1 §2 argued that each level of the tree exists because the level above can have more than one of it. Apply that here and ask what would break without interfaces.
A configuration would own endpoints directly. A device would be a bag of communication resources with nothing saying which belong together.
Consider a USB headset. It has audio output, audio input, and buttons — three genuinely different functions, using different endpoints, that different pieces of software want to handle. Without a grouping level, the host sees a flat list of endpoints and has no way to know which serve the microphone and which the volume control.
So the interface is the grouping, and it exists to answer a specific question: which endpoints belong to the same function, and what does that function do?
And it is the unit of driver binding, which is the consequence that matters. The host does not load one driver per device. It loads a driver per interface, so a single physical device can have its audio handled by the audio stack and its buttons by the input stack, concurrently, with neither aware of the other.
2. The Descriptor, Field by Purpose
The interface descriptor is 9 bytes and its type code is 4. It is never requested on its own — Chapter 7.2 §1 established that it arrives inside the configuration tree, which is why 7.2's lookup deliberately has no entry for it.
"Which interface is this?" — bInterfaceNumber. Numbered from zero within the configuration, and the value the host uses to refer to the interface afterwards.
"Which variant of this interface is this?" — bAlternateSetting. §3 is entirely about this field, and it is the one that surprises people.
"How many endpoints follow?" — bNumEndpoints. The endpoint descriptors that follow this one, up to the next interface descriptor, belong to it. This is the only thing that associates an endpoint with an interface — §4 is about why that matters more than it sounds.
"What kind of function is this?" — bInterfaceClass, bInterfaceSubClass, bInterfaceProtocol. The triple the host matches against its drivers. A standard class means a driver already exists on virtually every host; the example device uses a vendor-specific class, which means it needs its own driver.
"Is there a human-readable name?" — iInterface, a string index, owned by Chapter 7.5.
Notice what is absent. There is no length field covering the interface and its endpoints, and no pointer to where the interface's endpoints are. The association is purely positional: an endpoint descriptor belongs to the most recent interface descriptor that preceded it. §4 shows what that costs.
3. Alternate Settings
Here is the mechanism with no analogue elsewhere in the tree.
Several interface descriptors may share the same bInterfaceNumber while differing in bAlternateSetting. They are not different interfaces. They are different versions of the same interface, of which exactly one is active at a time, and the host chooses which.
Why would a device need this? Because a function's resource requirements can vary with what it is doing, and USB bandwidth for some transfer types is reserved in advance.
The canonical case is a camera. A camera streaming video needs substantial guaranteed bandwidth. A camera sitting idle needs none. If its interface permanently declared the bandwidth for streaming, the host would reserve that bandwidth for as long as the camera was plugged in — whether or not anyone was using it — and might refuse to enumerate a second camera at all.
With alternate settings, the interface offers both. Alternate setting 0 declares no bandwidth; alternate setting 1 declares what streaming requires. The host selects setting 0 by default and switches to 1 only when the application actually starts capturing.
In the tree, the settings appear consecutively: all descriptors for interface 0 — alternate 0, then alternate 1 — then interface 1's. Each alternate setting is followed by its own endpoint descriptors, which is where §4's positional association becomes genuinely load-bearing.
The example device keeps this simple: two interfaces, each with only alternate setting 0. §5's bytes show that, and §8's checker verifies it.
4. Positional Association, and What It Costs
§2 noted there is no pointer from an interface to its endpoints. The association is order: an endpoint descriptor belongs to the most recent interface descriptor before it.
That is an elegant encoding. It costs zero bytes, requires no addressing scheme, and falls out naturally from a tree serialised depth-first.
And it is fragile in one specific way: order is load-bearing data. Every other structural fact in the tree is stated explicitly by a field, and can be checked against the data. This one is stated only by position.
Consider what a reordering does. Move one endpoint descriptor from after interface 1 to after interface 0's endpoints, and you have not corrupted a single byte of any descriptor. Every bLength is right. Every type code is right. wTotalLength is unchanged — the tree is the same length. Chapter 7.2 §8's T2 walk passes, because the arithmetic is untouched. Its T3 count passes, because there are still two interface descriptors.
What changed is which function owns which endpoint, and the host will bind a driver to an interface and hand it an endpoint belonging to the other function.
The check that catches it is comparing each interface's bNumEndpoints against the number of endpoint descriptors that actually follow it before the next interface descriptor. That is a third structural invariant, independent of length and count, and §8 implements it.
5. The Example Device's Interface Descriptors
From Chapter 7.2 §5's tree, the two interface descriptors and what follows each.
tree offset 9..17 09 04 00 00 02 FF 00 00 00 INTERFACE 0
│ │ │ │ │ │ │ │ └─ iInterface = 0 (no string)
│ │ │ │ │ │ │ └──── bInterfaceProtocol = 0
│ │ │ │ │ │ └─────── bInterfaceSubClass = 0
│ │ │ │ │ └────────── bInterfaceClass = 0xFF (vendor)
│ │ │ │ └───────────── bNumEndpoints = 2
│ │ │ └──────────────── bAlternateSetting = 0
│ │ └─────────────────── bInterfaceNumber = 0
│ └────────────────────── bDescriptorType = 4 (INTERFACE)
└───────────────────────── bLength = 9
18..24 07 05 81 02 40 00 00 ENDPOINT 0x81 ┐ belong to
25..31 07 05 01 02 40 00 00 ENDPOINT 0x01 ┘ interface 0
tree offset 32..40 09 04 01 00 01 FF 00 00 00 INTERFACE 1
bInterfaceNumber = 1, bAlternateSetting = 0,
bNumEndpoints = 1, class 0xFF
41..47 07 05 82 03 08 00 0A ENDPOINT 0x82 — interface 1Read the association. Nothing in interface 0's nine bytes mentions endpoint 0x81. The only reason 0x81 belongs to interface 0 is that it appears after interface 0 and before interface 1 — and the only reason there are exactly two is that bNumEndpoints says 2 and two endpoint descriptors follow.
6. Streaming the Bytes — the RTL
Chapter 7.1 stored descriptors; 7.2 selected them. Something must now emit them, one byte at a time, and stop at the right moment.
This is the module's densest block, because termination is where descriptor engines actually go wrong.
// ─────────────────────────────────────────────────────────────────────────
// usb_desc_streamer
//
// Classification: SIMPLIFIED SYNTHESIZABLE TEACHING RTL. It models reading
// a bounded byte range out of the descriptor ROM and terminating exactly.
//
// WHAT IT MODELS. Given a base and a length (from Chapter 7.2's lookup), it
// emits exactly `length` bytes from ROM addresses base .. base+length-1, in
// ascending order, and asserts `done` on the cycle the LAST byte is valid.
// It also models abort: a reset or a new request terminates a response in
// progress without emitting the rest of it.
//
// WHAT IT DOES NOT MODEL. Storage (Chapter 7.1), selection (Chapter 7.2),
// limiting the length to what the host asked for (Chapter 7.4 -- `length`
// arrives here ALREADY limited), control transfers (Module 13), packets
// (Modules 11-12), or flow control from the transfer engine. Adding
// backpressure is section 6.1's exercise, deliberately left out of the
// block so the counter logic stays legible.
//
// ── INTERFACE CONTRACT (one choice, stated once, obeyed everywhere) ──────
// * `start` is a 1-cycle pulse. `base` and `length` must be stable during
// it and are CAPTURED; the caller may change them immediately after.
// * A request with length == 0 emits NO bytes and pulses `done` for one
// cycle. This case is real: Chapter 7.4 shows the host can legitimately
// ask for zero bytes, and a streamer that emits one byte anyway is the
// single most common descriptor-engine bug.
// * `byte_valid` is high for exactly `length` cycles in total.
// * `done` is high on the SAME cycle as the final `byte_valid`, not the
// cycle after. The alternative is defensible; what is not defensible is
// documenting one and implementing the other, so this is stated here
// and asserted in section 7.
//
// ── THROUGHPUT: ONE BYTE EVERY TWO CYCLES ───────────────────────────────
// The read is NOT pipelined: this block issues a read, waits a cycle for the
// ROM, emits the byte, then issues the next read. Figure 1 is traced from
// the RTL and shows the resulting alternation. That is deliberate -- the
// counter logic is the lesson, and a pipelined version obscures it (see the
// exercise in section 6.2) -- but it is a real property, not an accident,
// and a descriptor engine on a fast link would need the pipelined form.
// ─────────────────────────────────────────────────────────────────────────
module usb_desc_streamer
import usb_desc_pkg::*;
(
input logic clk,
input logic rst_n,
input logic start, // 1-cycle pulse
input logic [15:0] base, // first ROM address
input logic [15:0] length, // number of bytes to emit; may be 0
input logic abort, // bus reset or a new control request
// ROM interface (Chapter 7.1): 1-cycle read latency.
output logic rom_rd_en,
output logic [15:0] rom_rd_addr,
input logic [7:0] rom_rd_data,
input logic rom_rd_valid,
output logic [7:0] byte_data,
output logic byte_valid,
output logic done, // with the final byte, not after it
output logic busy
);
typedef enum logic [1:0] {
ST_IDLE = 2'b00,
ST_READ = 2'b01, // a ROM read is outstanding
ST_DONE = 2'b10
} stream_state_e;
stream_state_e state;
logic [15:0] cur_addr;
logic [15:0] remaining; // bytes still to emit, INCLUDING the one in flight
assign busy = (state != ST_IDLE);
assign byte_data = rom_rd_data;
// A ROM byte is emitted only while a read is genuinely outstanding. Gating
// on the state as well as rom_rd_valid matters: an abort must suppress the
// byte whose read was already issued.
assign byte_valid = (state == ST_READ) && rom_rd_valid;
// The final byte is the one that leaves nothing remaining after it.
assign done = (state == ST_DONE)
|| (byte_valid && (remaining == 16'd1));
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= ST_IDLE;
cur_addr <= 16'd0;
remaining <= 16'd0;
rom_rd_en <= 1'b0;
end else begin
rom_rd_en <= 1'b0;
if (state == ST_DONE) state <= ST_IDLE; // ST_DONE lasts one cycle
if (abort) begin
// Abandon without emitting the remainder. Clearing `remaining` is
// what makes byte_valid fall: the read in flight is discarded.
state <= ST_IDLE;
remaining <= 16'd0;
rom_rd_en <= 1'b0;
end else if (start) begin
cur_addr <= base;
remaining <= length;
if (length == 16'd0) begin
// Zero-length: no ROM read at all, one cycle of done.
state <= ST_DONE;
rom_rd_en <= 1'b0;
end else begin
state <= ST_READ;
rom_rd_en <= 1'b1;
rom_rd_addr <= base;
end
end else if (state == ST_READ && rom_rd_valid) begin
// A byte is being emitted THIS cycle. Decide what follows it.
if (remaining == 16'd1) begin
// That was the last one. done is already asserted combinationally
// alongside it; the state moves out of ST_READ so no further byte
// can be emitted.
state <= ST_IDLE;
remaining <= 16'd0;
end else begin
remaining <= remaining - 16'd1;
cur_addr <= cur_addr + 16'd1;
rom_rd_en <= 1'b1;
rom_rd_addr <= cur_addr + 16'd1;
end
end
end
end
endmoduleWhat it models. Bounded byte emission from a base and a length, with exact termination and abort.
Why this block exists. Because emit N bytes starting at B contains three independent off-by-one opportunities — the first address, the last address, and when done asserts — and each produces a different, recognisable failure.
Inputs. Clock and reset; a start pulse with a base and length; an abort; and the ROM's read-data interface.
State retained. The state, the current address, and the remaining count.
Outputs. The byte and its valid, a done indication, and a busy flag.
Reset behaviour. Returns to idle, clears the counter and drops the read enable. A response in progress is abandoned, which is correct: after a bus reset the host is not waiting for those bytes.
Hardware implied. A three-state machine, two 16-bit registers and an incrementer.
Assumptions. That base and length are stable during start; that length has already been limited to what the host asked for (Chapter 7.4 owns that); that the ROM honours its one-cycle latency; and that the consumer accepts a byte whenever byte_valid is high, since this block has no backpressure.
Deliberately omits. Storage, selection, length limiting, control transfers, packets, and flow control.
What DV should verify. That exactly length bytes are emitted; that they come from base through base + length - 1 inclusive and in order; that done coincides with the final byte; that length = 0 emits nothing and still completes; that abort suppresses the remainder including a read already issued; and that a second request starts from its own base rather than continuing the previous one.
6.1 Deriving the address sequence by hand
Before trusting the RTL, derive what it must do. This is the exercise that catches off-by-one before simulation does.
For base = B, length = N, the bytes emitted must be at addresses:
B, B+1, B+2, ..., B+N-1 — that is N addresses, LAST is B+N-1, not B+NNow check the edges against the contract:
length | addresses read | byte_valid cycles | done asserts |
|---|---|---|---|
| 0 | none | 0 | once, with no byte |
| 1 | B only | 1 | on that single byte |
| 2 | B, B+1 | 2 | on the byte from B+1 |
| N | B … B+N-1 | N | on the byte from B+N-1 |
The length = 1 row is the one to check first in any implementation, because it is where a counter that decrements before testing and a counter that tests before decrementing diverge most visibly: one emits a single byte, the other emits none or two.
6.2 Exercise — pipelining the read
The block as written achieves one byte every two cycles, because it issues a read, waits for the ROM, emits the byte, and only then issues the next read. Figure 1 shows that alternation, traced from the RTL.
The exercise: issue read i+1 in the same cycle that byte i is emitted, so a byte emerges every cycle once the pipeline fills.
What gets harder, and this is the point of the exercise. With a read in flight at all times, abort must now suppress two things — the byte being emitted and the byte whose read has already been issued. The termination condition also shifts: the last read is issued two cycles before the last byte appears, so the counter that decides "stop issuing reads" and the counter that decides "this is the final byte" are no longer the same counter.
That is why the unpipelined form is the one taught here. The off-by-one lessons of §9 are about the counter, and a pipelined version has two counters whose interaction obscures them. Real descriptor engines on fast links do pipeline; they also have more assertions, for exactly this reason.
Descriptor streamer — three-byte response, then an aborted request
14 cycles7. The Assertions
// ─────────────────────────────────────────────────────────────────────────
// Classification: TEACHING ASSERTIONS about the streamer's contract.
// Each one states a clause of the section 6 interface contract, so that
// prose and implementation cannot drift apart silently.
// ─────────────────────────────────────────────────────────────────────────
// A helper counter: how many bytes this response has emitted so far.
// It belongs to the CHECKER, not the design -- a property that reused the
// design's own `remaining` could not detect that counter being wrong.
logic [15:0] emitted, expected;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin emitted <= '0; expected <= '0; end
else if (abort) begin emitted <= '0; expected <= '0; end
else if (start) begin emitted <= '0; expected <= length; end
else if (byte_valid) emitted <= emitted + 16'd1;
end
// S1 -- COUNT. A response emits exactly the requested number of bytes.
// Checked at `done`, which is the only moment the total is final.
property p_exact_byte_count;
@(posedge clk) disable iff (!rst_n)
(done && !abort) |-> (emitted + (byte_valid ? 16'd1 : 16'd0) == expected);
endproperty
assert property (p_exact_byte_count);
// S2 -- DONE TIMING. done coincides with the final byte, per the contract.
// An implementation that asserts done one cycle later fails this -- which is
// the point: the contract is a choice, and the assertion pins the choice.
property p_done_with_final_byte;
@(posedge clk) disable iff (!rst_n)
(done && (expected != 16'd0)) |-> byte_valid;
endproperty
assert property (p_done_with_final_byte);
// S3 -- BOUNDS. No byte may be emitted once the requested count is reached.
// This is the off-by-one property: an extra byte reads base+length, which is
// the first byte of whatever follows the descriptor in ROM.
property p_no_overrun;
@(posedge clk) disable iff (!rst_n)
byte_valid |-> (emitted < expected);
endproperty
assert property (p_no_overrun);
// S4 -- ADDRESS ORDER. Successive reads are consecutive and ascending. A
// streamer that re-read an address or skipped one would still emit the right
// COUNT of bytes, so the count property alone cannot see it.
property p_ascending_addresses;
@(posedge clk) disable iff (!rst_n)
(rom_rd_en && $past(rom_rd_en) && !$past(start) && !abort)
|-> (rom_rd_addr == $past(rom_rd_addr) + 16'd1);
endproperty
assert property (p_ascending_addresses);
// S5 -- ZERO LENGTH. A zero-length request completes without emitting.
property p_zero_length_emits_nothing;
@(posedge clk) disable iff (!rst_n)
(start && (length == 16'd0)) |=> (done && !byte_valid);
endproperty
assert property (p_zero_length_emits_nothing);
// S6 -- ABORT. Nothing is emitted in the cycle after an abort, including a
// byte whose ROM read was already in flight.
property p_abort_suppresses;
@(posedge clk) disable iff (!rst_n)
abort |=> !byte_valid;
endproperty
assert property (p_abort_suppresses);
// S7 -- NO STALE STATE. A new request starts from its own base, never
// continuing the previous response's address.
property p_new_request_uses_new_base;
@(posedge clk) disable iff (!rst_n)
(start && (length != 16'd0)) |=> (rom_rd_addr == $past(base));
endproperty
assert property (p_new_request_uses_new_base);
// S8 -- COMPLETION. A response must not end without `done`. Every property
// above has `done` or `byte_valid` as its ANTECEDENT, so a design that never
// asserts done satisfies all of them VACUOUSLY. This is the only property
// here that requires something to happen rather than constraining something
// that does; section 9 measured why it had to be added.
property p_response_completes;
@(posedge clk) disable iff (!rst_n)
($fell(busy) && !$past(abort)) |-> $past(done);
endproperty
assert property (p_response_completes);S1 and S4 are complementary in the same way Chapter 7.2 §8's T2 and T3 were. S1 checks how many; S4 checks which ones. A streamer that read B, B, B+1, B+2 emits the right count of bytes from the wrong addresses and passes S1 completely.
S3 is the off-by-one property, and its consequence is worth stating: a byte emitted past the end reads base + length, which in a packed descriptor ROM is the first byte of the next descriptor. The host receives a descriptor with one extra byte appended that looks like a plausible bLength.
And note the checker's own counter. It is deliberately independent of the design's remaining. Chapter 6.3 §5 reached the same conclusion: a property that borrows the signal it is meant to police cannot police it.
S8 is the odd one out, and deliberately so. Every other property here constrains something that happens; S8 requires that something happens. §9 measured why that distinction is not academic — a mutant that never completes satisfies S1 through S7 on every cycle of every test, because each of them has an event as its antecedent and the event never occurs.
8. Static Check: Endpoint Grouping
§4 argued that positional association needs its own invariant. Here it is, extending Chapter 7.2 §8's walk.
// ─────────────────────────────────────────────────────────────────────────
// Classification: STATIC DATA-INTEGRITY CHECK (elaboration-time).
//
// Walks the configuration tree and verifies that each interface descriptor
// is followed by exactly as many endpoint descriptors as it declares. This
// is the ASSOCIATION invariant of section 4 -- independent of Chapter 7.2's
// length check (T2) and count check (T3), and catching a corruption neither
// of them can see.
// ─────────────────────────────────────────────────────────────────────────
initial begin : interface_grouping_check
int unsigned walk;
int unsigned total;
int unsigned declared_eps; // bNumEndpoints of the interface being walked
int unsigned seen_eps; // endpoint descriptors actually found after it
int unsigned cur_ifnum;
int unsigned cur_alt;
bit in_interface;
total = {rom[OFF_CONFIG + 3], rom[OFF_CONFIG + 2]};
walk = rom[OFF_CONFIG]; // step over the 9-byte header
in_interface = 1'b0;
declared_eps = 0;
seen_eps = 0;
cur_ifnum = 0;
cur_alt = 0;
while (walk < total) begin
int unsigned blen;
logic [7:0] dtype;
blen = rom[OFF_CONFIG + walk];
dtype = rom[OFF_CONFIG + walk + 1];
if (dtype == DT_INTERFACE) begin
// Close out the previous interface before opening this one.
if (in_interface)
assert (seen_eps == declared_eps)
else $fatal(1, {"interface %0d alt %0d declares %0d endpoints but ",
"%0d endpoint descriptors follow it"},
cur_ifnum, cur_alt, declared_eps, seen_eps);
// Remember these rather than recomputing the offset later: `walk` has
// already moved on by the time this interface is closed out.
cur_ifnum = rom[OFF_CONFIG + walk + 2];
cur_alt = rom[OFF_CONFIG + walk + 3];
declared_eps = rom[OFF_CONFIG + walk + 4];
seen_eps = 0;
in_interface = 1'b1;
end
else if (dtype == DT_ENDPOINT) begin
// An endpoint descriptor before ANY interface descriptor is orphaned:
// positional association gives it no owner at all.
assert (in_interface)
else $fatal(1, {"endpoint descriptor at tree offset %0d precedes ",
"any interface descriptor -- it has no owner"}, walk);
seen_eps++;
end
// Any other descriptor type (class-specific, and so on) is stepped over
// without disturbing the grouping: it belongs to the current interface
// but is not an endpoint.
walk += blen;
end
// The last interface in the tree has no following interface to close it.
if (in_interface)
assert (seen_eps == declared_eps)
else $fatal(1, {"final interface %0d alt %0d declares %0d endpoints ",
"but %0d endpoint descriptors follow it"},
cur_ifnum, cur_alt, declared_eps, seen_eps);
$display("interface grouping OK");
endThe final-interface case deserves its comment. Every interface but the last is closed by the next interface descriptor appearing. The last one is closed by the tree ending — and a checker that only closes on the next interface never validates the last interface at all, which is precisely the interface a truncation or reordering is most likely to damage. §9 measures that.
9. Mutation Test
Four mutations. All were run; the results are measured.
I1 — emit one byte too many
The off-by-one, introduced the way it actually happens: testing the counter after decrementing rather than before.
if (remaining == 16'd0) begin // MUTANT I1: was == 16'd1Result. S3 fires on the extra byte and S1 fires at done. The emitted byte comes from base + length — in the packed ROM, the first byte of whatever descriptor follows.
Why the symptom is confusing in the lab. The host receives a configuration tree one byte longer than wTotalLength said, and the extra byte is a plausible-looking bLength. Depending on the host, that manifests as a rejected configuration, a parse that runs off the end, or — if the extra byte happens to be zero — a parser that stops cleanly and never reports anything at all.
I2 — remove the coincident term from done
assign done = (state == ST_DONE); // MUTANT I2: no coincident termThis mutation was written expecting done to arrive one cycle late. It does something worse, and the difference is the most valuable finding in this chapter.
Traced from the RTL, against a three-byte response:
GOLDEN I2
cyc 4 byte d0 cyc 4 byte d0
cyc 6 byte d1 cyc 6 byte d1
cyc 8 byte d2 done=1 cyc 8 byte d2 done=0
cyc 9 busy=0 cyc 9 busy=0 done never assertedST_DONE is only ever entered on the zero-length path; the normal path runs ST_READ → ST_IDLE. So the mutant does not delay done — it removes it entirely for every nonzero-length response. Every byte is still correct, in the right order, from the right addresses, and busy still falls at the right moment.
And S1 through S7 all passed.
The contract point still stands, and is worth separating from the vacuity finding. Asserting done one cycle after the last byte — a genuine delay rather than an omission — is a perfectly reasonable interface contract used by many real designs. It would be a defect here only because §6 documents the coincident form and the consumer was written against it. The choice is free; documenting one while implementing the other is not.
I3 — drop the zero-length case
// (the `if (length == 16'd0)` branch removed; always enter ST_READ)Result, measured. S5 is the property written for this case, and in the simulation it is S3 that fires first — repeatedly — because the mutant does not merely emit one extra byte. With remaining set to zero and no zero-length branch, the response never reaches its termination condition and runs away, emitting bytes continuously from ascending addresses until something stops it — eleven bytes in the measured run, ending only because the next request preempted it.
That is worth noting precisely: a missing branch for the degenerate case did not produce a degenerate failure. It produced an unbounded read walking through the entire descriptor ROM and out the other side — which, in a controller whose descriptor table shares memory with anything else, is the information-disclosure shape Chapter 7.2 §7 warned about, arriving from a completely different direction.
This case is not hypothetical. Chapter 7.4 shows the host can legitimately request zero bytes, and a device that returns a byte anyway has desynchronised the transfer — the extra byte is interpreted as belonging to whatever comes next.
I4 — close interface grouping only on the next interface descriptor
Not an RTL mutation but a checker mutation: remove the final-interface case from §8.
Result, measured on the same corrupted image:
bNumEndpoints on interface 0 corrupted (says 3, tree has 2)
full checker FATAL: interface 0 alt 0 declares 3 endpoints but 2
endpoint descriptors follow it
I4 checker FATAL: (same -- interface 0 is closed by interface 1)
bNumEndpoints on interface 1 corrupted (says 2, tree has 1) <- the LAST one
full checker FATAL: final interface 1 alt 0 declares 2 endpoints but 1
endpoint descriptors follow it
I4 checker interface grouping OK <- reports the tree as validThe I4 checker passes on the correct image, catches a grouping error in interface 0, and reports corrupted data as valid when the corruption is in the last interface. §8's closing check after the loop is the only thing covering it.
10. Verification
This chapter's commit point is the tree's bytes actually reach the host — the first chapter in this module where anything is emitted at all.
Stimulus. Responses of length 0, 1, 2, the full configuration tree, and the largest the engine supports; a request whose base is the very start and the very end of the ROM; abort at each cycle of a response — before the first byte, between bytes, on the final byte, and after done; back-to-back requests with different bases; and a second request issued while the first is still busy.
Boundary values are mandatory here, not advisory. §6.1's table exists to be turned into tests: length = 1 is where counter-order bugs diverge most visibly, and length = 0 is where §9's I3 lives.
Observation. The byte stream together with its addresses — not the bytes alone. §7's S1 and S4 are separate properties because a streamer can emit the right number of bytes from wrong addresses.
Reference model. Given a base and a length, the expected stream is ROM[base .. base+length-1]. That is a two-line model, and it is worth building precisely because it is trivial: it catches every address and count error, and it is driven from the same ROM image the DUT reads, so nothing can drift.
Scoreboard thinking. A byte-for-byte match is necessary and not sufficient. It cannot see when done asserted, whether an abort suppressed the remainder, or whether a second request continued the first one's address. Those are control-timing properties, and they need the assertions of §7 alongside the data comparison. §9's I2 passes a pure byte comparison completely.
Representative coverage — crosses:
length∈ {0, 1, 2, typical, maximum} × base at the start, middle and end of ROM- abort position × each cycle of a response, including the final byte
- back-to-back requests × same base and different base
- descriptor type × its actual length, so every real descriptor is streamed at least once
- static checker × trees with grouping errors in the first, a middle, and the last interface
Negative cases with defined outcomes: a zero-length request emits nothing and completes; an abort mid-response emits no further bytes and returns the engine to idle; and a new request after an abort starts from its own base.
11. Common Misconceptions
12. Reason It Through
A device with two interfaces enumerates. Interface 0 works. Interface 1's driver binds but immediately fails, reporting that the endpoint it was given does not behave as expected. The configuration tree passes Chapter 7.2's length and count checks.
What do the passing checks eliminate? wTotalLength is right, so nothing is truncated or over-declared. The interface count is right, so both interfaces are present. Whatever is wrong preserves both the size and the count of the tree.
What does the driver binds but the endpoint misbehaves tell you? The host found interface 1 and its class triple matched a driver — so the interface descriptor itself is intact. The problem is the endpoint the host associated with it.
Which invariant is left? Association. §4's third relationship: which endpoints follow which interface descriptor.
What are the two candidate causes? Either bNumEndpoints on one interface is wrong — so the host attributes the wrong number of endpoint descriptors and the boundary shifts — or an endpoint descriptor sits in the wrong position in the tree. Both preserve length and count.
How would you tell them apart? §8's grouping check reports the mismatch and which interface it belongs to. If interface 0 declares 2 and three endpoint descriptors follow it, the extra one is interface 1's, stolen by position.
Why might the grouping check have missed it? If the checker was written like §9's I4 — closing each interface only when the next one appears — then a wrong bNumEndpoints on the last interface is never checked. Interface 1 is the last interface. That is the specific gap I4 exists to demonstrate.
And the wider point? Three chapters have now each added one independent structural invariant, and each was needed because a corruption existed that preserved all the others. A descriptor checker is complete when every relationship the format encodes has a check — not when it has many checks.
13. Understanding Check
14. Summary
An interface is the unit a driver binds to, and it exists because a device is not one thing but a set of functions sharing a cable. That reframe makes composite devices ordinary rather than exceptional, and explains why a device sets bDeviceClass = 0 to defer the question of what it is to its interfaces.
The descriptor is 9 bytes, type 4, never requested on its own. It declares an interface number, an alternate setting, an endpoint count, and the class triple the host matches against drivers.
Alternate settings are the tree's one conditional claim: several descriptors sharing an interface number, exactly one active, so a function can declare resources only while it is using them. Setting 0 is mandatory and is the default.
The association between an interface and its endpoints is purely positional — an endpoint belongs to the most recent interface descriptor before it. That costs zero bytes and makes order load-bearing data, the one structural relationship no field states. A reordering preserves every length and every count while changing which function owns which endpoint.
Which completes a pattern three chapters in the making: length, count and association are three independent invariants, and each was needed because a corruption existed that preserved all the others.
In hardware, this chapter's block is the streamer, and its whole difficulty is termination. §6 states a contract — done coincides with the final byte, length = 0 emits nothing — because the choice is free and the inconsistency is not. §9 measured all three failure modes: one byte too many reads into the adjacent descriptor; done one cycle late is a different contract rather than a bug, and fails only against the documented one; and a missing zero-length branch emits a byte the host never asked for.
And two findings came out of running the mutations rather than predicting them. Removing done's coincident term does not delay completion — it removes completion entirely, and every property in the set passed, because each has an event as its antecedent and a design that never completes satisfies them all vacuously. That forced S8, the only property that requires something to happen. Separately, deleting the zero-length branch did not produce a degenerate failure but a runaway read walking out through the ROM — the information-disclosure shape of Chapter 7.2 §7, reached from the opposite direction.
And the checker mutation is the one to remember: a loop that closes out state when the next item arrives never validates the last item — which is precisely the item that truncation, reordering and drift damage.
15. What Comes Next
The tree now has functions, and the bytes describing them can be emitted correctly and provably. One node remains, and it is the one where the description stops being a statement about data and becomes a promise about hardware.
Chapter 7.4 opens the endpoint descriptor. Every field in it is a commitment the device controller must actually keep: an endpoint at this address, in this direction, of this type, able to carry packets of this size. Nothing verifies any of it, and the host will begin transferring data on the strength of the claim alone.
It is also where Chapter 7.1 §11's third level stops being abstract — the gap between a descriptor that is byte-perfect and structurally valid, and hardware that does something else.
Browse the full path on the USB tutorials index.
Continue learning
Related tutorials
- Related topic
Descriptor Discovery
Why the host's first descriptor read is deliberately incomplete, why that partial read is a recurring pattern rather than a workaround, why the walk order is forced, and the gated sequencer RTL that intuition gets wrong.
- Related topic
Endpoint Buffers
Why an endpoint needs storage, why one buffer forces a choice between filling and being collected, and the ping-pong structure that removes it — with the occupancy-versus-flags invariant a single comparison breaks.
- Related topic
The Parallel Port
Why presenting eight data lines at once forces an explicit data/strobe/acknowledge handshake, what that costs in timing discipline, a synthesizable teaching FSM that implements it with the assertions that protect it, and why an interface shaped around one peripheral's operational model cannot generalise.
- Related topic
PS/2 Keyboard / Mouse
The dedicated input port where the device supplies the clock and the host receives on someone else's timing. The two-wire open-drain bus, the framed byte, why sampling a foreign clock directly is a real hardware bug, and a synthesizable teaching receiver with its synchroniser, recovery timeout and assertions.
Standards & specifications
- Governing standard
- USB-IF (Universal Serial Bus Specification)(opens USB Implementers Forum (USB-IF) in a new tab)
Defines the USB bus — its electrical signalling, connectors, packet and transaction model, device framework and the descriptors a device must expose — together with the device-class specifications layered on it. It does not define host-controller register interfaces (xHCI and EHCI are separate documents) nor any operating system's driver architecture.
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 USB curriculum.
