Skip to content

drivers/usbhost: Make the xHCI driver work, and support hubs. - #19745

Open
Fishwaldo wants to merge 15 commits into
apache:masterfrom
Fishwaldo:upstream-usbhost-xhci
Open

drivers/usbhost: Make the xHCI driver work, and support hubs.#19745
Fishwaldo wants to merge 15 commits into
apache:masterfrom
Fishwaldo:upstream-usbhost-xhci

Conversation

@Fishwaldo

Copy link
Copy Markdown
Contributor

Summary

The xHCI driver does not work on a conforming controller. On the one in-tree configuration that has one, qemu-intel64:jumbo with qemu-xhci, the controller fails to come up at all:

pci_xhci_probe: failed to initialize HW!

No root ports, no devices, nothing on the bus. This series makes it work, separates the driver from the PCI bus so that any hardware carrying an xHCI controller can use it rather than only PCI-attached ones, and adds hub support.

Fifteen commits, in four groups.

Making it work at all (1-8): register access widths that a conforming controller ignores when narrowed; an interrupt path that assumed a level-triggered wire and so never re-armed behind a message-signalled one; cache maintenance around DMA and transfer-length limits; device and endpoint context fields the specification requires and the driver left zero; TRB chaining across the ring wrap; endpoint allocation for interrupt endpoints; and asynchronous transfers that set up correctly but delivered no data.

Commit 2 is the separation from PCI. The controller is the same part wherever it is fitted, but the driver was written as a PCI device, so an SoC that wires one directly could not use it. The bus-specific work moves behind a small struct xhci_bus_ops_s (probe, interrupt attach, DMA address translation), leaving the controller logic bus-agnostic. It is a move rather than a rewrite, which is why it is kept as its own commit and why the diff is large.

Resource handling (9-11): a per-endpoint lock, because the controller lock is released across a transfer and two threads on one endpoint corrupted each other's completion state; releasing the device slot when enumeration fails, which otherwise leaks one per attempt until the controller has none left; and bounding the retries, since a device that cannot enumerate is otherwise retried for as long as it stays plugged in.

Preparing for hubs (12-14): stop using the root port as a device's identity, describe a device to the controller from the device rather than from the port, and fill in the route string and transaction translator that a device behind a hub needs.

Hub support (15): CONFIG_USBHOST_HUB was refused outright by an #error. It now works.

Impact

  • User visible: xHCI works. Before this, mass storage, keyboards and hubs on an xHCI controller do not enumerate.
  • Reusability: an SoC with an integrated xHCI controller can now use this driver by supplying a handful of bus operations, instead of the driver being usable only over PCI.
  • Configurations affected: qemu-intel64:jumbo is the one in-tree configuration that selects xHCI (CONFIG_USBHOST_XHCI_PCI=y, with MSC, HIDKBD, HIDMOUSE and COMPOSITE). Its behaviour changes: the controller initialises where it previously did not, and USB devices enumerate where previously none did. That is the purpose of the series, but it is a behaviour change and not merely an addition. Both that configuration and the EIC7700X port were used to test it, and no other in-tree configuration selects xHCI.
  • New option: CONFIG_USBHOST_XHCI_ENUM_RETRIES, default 3, bounding enumeration attempts per port.
  • Interface change: xhci_initialize() takes a bus number so an SoC driver can identify its controller. The PCI caller passes 0.
  • Documentation, security: unaffected.

Testing

Host: macOS 15.5 (Apple Silicon). qemu: 10.1.5 with KVM on Fedora 43 x86_64. Board: ESWIN EIC7700X (RISC-V, 4 cores SMP), whose port will be upstreamed later.

qemu - reproducible in tree

tools/configure.sh -E qemu-intel64:jumbo && make
qemu-system-x86_64 -enable-kvm -m 4G -smp 4 -kernel nuttx -nographic \
  -device qemu-xhci,id=xhci \
  -drive if=none,id=stick,format=raw,file=disk.img \
  -device usb-storage,bus=xhci.0,drive=stick

Before: pci_xhci_probe: failed to initialize HW!, and /dev holds no sda.

After: the controller initialises and the drive enumerates:

pci_xhci_probe: Enabled bus mastering
pci_xhci_probe: Enabled memory resources
/dev:
 sda

With a hub in the topology, three devices enumerate together and the data path works through it:

usb 0-5:   hub, driver attached           QEMU USB Hub
usb 0-5.2: mass storage, driver attached  QEMU USB HARDDRIVE
usb 0-5.3: keyboard, driver attached      QEMU USB Keyboard
mount -t vfat /dev/sda /mnt  ->  cat /mnt/HELLO.TXT  ->  qemu-xhci-regression-ok

Hardware - the non-PCI path

The EIC7700X attaches its xHCI controllers directly, not over PCI, so it exercises the separation in commit 2. Both controllers drive real devices concurrently: a low-speed keyboard on one, and on the other a hub carrying a 59 GB mass storage device (mounted, directory listed, file read back correctly), a composite CDC device presenting four ttyACM nodes, and a Realtek Ethernet adapter with no driver in this tree, which is enumerated and reported as unclaimed rather than wedging the bus.

Every commit builds and links individually against qemu-intel64:jumbo.

Four faults that between them kept this driver from reaching a device on
any controller that enforces the specification rather than tolerating
the driver's assumptions.

The width of a register access is part of the register interface.  xHCI
asks for aligned accesses of the register's own size, and a controller
may answer anything narrower with nothing at all; QEMU's does.  A
volatile load does not pin the width: when only one bit of the value is
used, GCC 16.1.0 at -Os narrows "load 32 bits, test bit zero" into a one
byte testb, the read comes back zero, and a poll of USBSTS for the
halted bit never sees it.  The binary happened to work only because a
diagnostic consumed all 32 bits of the polled value and pinned that one
load full width.  Proven through QEMU's gdbstub against an unmodified
binary: with the diagnostics compiled out the halt poll times out while
the controller's true state, read 32 bits at a time from outside, is
USBSTS=0x9, halted throughout; a 4-byte read of the register returns 0x9
and a 1-byte read of the same address is refused.  Every accessor now
forces the value through a register with an empty asm, loads and stores
both, so the access can only be the full-width one the source wrote.

A controller is entitled to want no scratchpad buffers, and QEMU's
reports zero.  The driver worked that count into a size and asked the
allocator for it, and a zero-byte allocation returns NULL,
indistinguishable from being out of memory, so a controller asking for
no scratch space was refused for lack of it before it was ever started.
Skip the allocation when none is wanted and leave the first device
context base address array entry zero, which is what it means.

Halting waited for something that had already happened.  The driver
wrote the whole of USBCMD zero and waited a second for the halted bit,
but a controller that was never started is already halted and says so,
so there is no transition to wait for.  Writing the whole register zero
also cleared the interrupt and host system error enables along with
Run/Stop.  Look first, stop it only if it is running, and clear the one
bit that was meant.

Resetting a port disabled it.  Eight bits of PORTSC are
write-one-to-clear, so writing back what was just read acts on every one
that happened to be set: setting Port Reset that way also cleared Port
Enabled and discarded every change the port was reporting.  Mask them
out first, and name the set in the header for the next read-modify-write
of this register.  The wait afterwards judged itself by its own counter
rather than by the port, so a port coming up on the last attempt was
reported as a timeout, and only when the timing landed that way, which
reads as intermittent rather than wrong. Decide on Port Enabled, and say
what the register held when it does fail.

Finally, a command whose completion the fallback poll did find, after a
late or missed interrupt, still returned the timeout, so a command that
had demonstrably succeeded was reported unanswered and its caller
unwound work the controller had done.  The result is what the completion
event said. This does not make a missing interrupt harmless, but it
stops the driver contradicting the evidence in front of it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
The controller driver and the PCI bus it happened to be found on were one
file.  Nothing in the driver is PCI-specific beyond finding the registers
and the interrupt, so an SoC that wires an xHCI controller directly could
not use any of it.

Move the driver to usbhost_xhci.c and leave usbhost_xhci_pci.c as the PCI
attachment: the ID table, the BAR mapping and the MSI-X vector.  What
passes between them is in include/nuttx/usb/xhci.h: a bus supplies the
register base, a way to attach the interrupt, and a name to report the
controller by, since a system may have more than one and "port 1" alone
does not say which.

The interrupt belongs to the bus entirely: the bus attaches it, the bus
detaches it, and the controller driver never holds an interrupt number,
so there is no number for the two sides to disagree about.

USBHOST_XHCI is the driver and is not selectable on its own;
USBHOST_XHCI_PCI selects it.  Another bus adds its own symbol beside it.

No functional change intended.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
This driver has only ever run behind QEMU's message signalled interrupt,
and it holds assumptions that are safe only there, and some not even
there.

It never silences the source.  The handler reads the status, queues the
work that will answer it, and returns with everything still asserted.
On a level triggered line the interrupt controller sees the condition
still true and raises it again at once, forever, and the work that would
have cleared it never runs.  Mask the interrupter on the way out and let
the worker unmask when it is done.

It acknowledges after walking the event ring rather than before.  An
event arriving during the walk sets the pending bit again, and clearing
the bit afterwards discards it.  That event is the last this controller
will raise until something else happens, so whatever was waiting on it
waits forever: transfers have no timeout, only commands do, and a lost
transfer event is therefore a hang rather than an error.  Acknowledge
first; a spurious second pass over an empty ring costs nothing.

It attaches the interrupt before there is anything to answer it with.
The handler defers to a worker that walks the event ring, and the ring
is not allocated until the controller is started, several steps later.
A controller that a boot loader left running has an interrupt pending
the instant the line is enabled.  Attach after the start instead.

Attaching late then loses the first interrupt behind a message, because
a message is sent once, on the pending flag's transition from clear to
set, and a flag raised while nobody was attached has already spent it.
A wire is still asserted when the handler finally arrives, so it costs a
wire nothing.  Clear the status and pending flags once the handler is in
place, so the next event is a fresh transition.

The same rule governs the worker's unmask.  Events that arrived while
the interrupter was masked have left the pending flag set, and enabling
with it still set gives a message nothing to transition on.  Clear it in
the same write, then drain the ring again: clearing can discard an event
that arrived a moment earlier, and repeating until a drain comes back
empty is the only state in which none was lost.

And while reading ports, do not disable them.  xhci_probe_ports() writes
PORTSC back to clear the change bits, including PED, which is
write-one-to-clear.  A port that came up enabled, which is what a device
attached at power up produces once the controller settles, is switched
off by the act of looking at it.  The port status worker already gets
this right and says so in a comment; this path did not.

Once the interrupts arrive at all, they arrive too late.  The
interrupter moderation interval is how long a controller waits after an
event before reporting it, in 250ns units, and it resets to 4000, a full
millisecond.  The driver never wrote the register.  A transfer therefore
cost a millisecond before its completion was even reported, and mass
storage spends three transfers on each request, so a request waited
three milliseconds no matter how little it asked for.  Measured on a
Synopsys DWC3 with a USB 2.0 drive, timestamping from the doorbell to
the interrupt: 986-1021us before, 13-56us after.

    reading 1MiB          before        after
    512 byte blocks      166 KB/s     775 KB/s
    32 KiB blocks      10666 KB/s   18618 KB/s

    mounting a FAT32 volume: 92.7s before, 21.1s after

Set it to 160, which is 40us, rather than to zero.  Zero puts no bound
on how often a controller may interrupt: measured with a keyboard on an
interrupt endpoint, it took interrupts continuously and spent an entire
processor doing it.  It is the same value Linux uses, for the same
reason.

Found on two controllers: the level triggered half on a Synopsys DWC3
whose PLIC line re-fired forever, the message signalled half on QEMU,
where enumeration stopped dead after the port reset with no error and no
further interrupts.  With both, the same driver serves both.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
The controller moves every byte itself; there is no programmed-I/O path
in xHCI to fall back to.  On a machine whose caches are not coherent
with it, and on one whose addresses are not flat, that makes cache
maintenance and address validation part of getting a transfer right
rather than an optimisation.  This driver was doing neither, beyond its
own descriptors.

Its rings were published with up_flush_dcache_all() before the
controller was pointed at them.  That reads as thorough and is the
opposite: an architecture whose cache can only be maintained by address
implements the whole-cache variants as a barrier and nothing more, so
the event ring segment table, the device context base address array and
the scratchpad pointers were never written out at all.  The controller
then reads whatever those addresses held before, which presents as every
command timing out with no events ever arriving, a failure that looks
like the interrupt is missing rather than like the ring is unreadable.
Flush each structure by name instead.

xhci_ring_init() had the same shape of bug from the other direction: it
clears the whole ring and then flushes one descriptor, the link entry it
writes afterwards, leaving the rest of the clearing in the cache.  This
is memory the controller writes into itself, so a dirty line written
back later lands on top of whatever the controller has put there since.
What gets destroyed is an event somebody is waiting for, and the ring
then looks permanently empty while the controller believes it has
reported everything.  Flush the whole ring, which is what the clearing
was for.

Data buffers got no maintenance whatsoever: nothing pushed before an
OUT, nothing dropped after an IN.  On a coherent host this cannot be
seen, which is presumably why it survived.

And the buffers a class driver hands down are not all its own to
maintain.  Cache operations work a whole line at a time because that is
all the hardware offers, so dropping a line that a buffer only partly
covers also drops whatever else lives in it, and writing one back over
memory the controller has just filled destroys the transfer.  Mass
storage passes a thirty-one byte command block and a thirteen byte
status straight out of its instance structure, sharing lines with
everything around them.  So a buffer that does not own its lines is
copied through one that does, and only the small transfers ever need it:
anything large comes from a filesystem or from xhci_ioalloc(), already
aligned.  While here, make xhci_ioalloc() round its length up as well as
aligning its start, so what it returns owns its last line too.

Also drop the device output context before reading the address out of
it.  The controller chose that address and wrote it there; reading
without invalidating returns whatever the processor had cached, and the
driver then addresses the device by a number it was never assigned.

Separately, two ways a transfer could be programmed that the controller
will not honour.

A buffer that cannot be reached.  The driver turns a caller's address
into a physical one and hands it to the controller, and on a system with
an address environment that translation is only meaningful for some
addresses.  A userspace buffer under CONFIG_BUILD_KERNEL is neither
mapped address-for-address nor physically contiguous, so what the
controller gets is a number that names the wrong memory.  The transfer
then completes successfully, having read or written somewhere else
entirely.  That is the worst shape a fault can take, and it is
indistinguishable from a cache problem when the data comes back wrong.
Whether an address can be used this way is a property of the system the
controller was fitted into, not of the controller, so it is asked rather
than assumed: a platform may supply dmacapable, and one that does not is
taken to mean every address works, which is what every existing user
has.  A buffer that is refused gives -EFAULT, which is a redirection
rather than a failure: the FAT filesystem answers it by retrying through
its own DMA-safe sector buffer, and the read succeeds by DMA either way.

And transfers longer than one descriptor can describe.  A Normal TRB
carries one run of memory that may not cross a 64K boundary, while the
block layer above hands down whole multi-sector reads whose length is
bounded by nothing here.  A single TRB was being programmed regardless,
so a long enough read, or merely one starting near the wrong side of a
boundary, produced a descriptor the controller is entitled to reject or
to satisfy partly.  Chain as many as the run needs instead, asking for
the completion interrupt only on the last so that one event still
arrives for the whole transfer.

The cache half compiles to nothing on an architecture with no cache to
maintain, and dmacapable is NULL on PCI, so the existing user is
unaffected.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
Everything a controller is told about a device before it will accept it,
found by bringing this driver up on a Synopsys DWC3 core, which checks
what QEMU's controller does not.

Contexts came in one size only.  A controller says in HCCPARAMS1 whether
its context structures are thirty-two or sixty-four bytes, and this
driver refused the wider form outright with -EIO, so it worked on
exactly the controllers reporting the narrower one.  QEMU's is one; DWC3
cores are not, and the EIC7700X reports HCCPARAMS1 = 0x0220fe45 on both
of its controllers, so this driver could not have driven either.  The
difference is smaller than the refusal suggests: a wide context is the
same fields with reserved space after them, so nothing about the layout
changes, only the distance from one entry to the next.  Read the size at
start up and let the three places that walk a context array use it.
Contexts also have to be allocated aligned; every entry of the device
context base address array points at one and must be 64 byte aligned,
and the output context was coming from kmm_zalloc(), which promises
nothing of the sort.

The event ring segment table came out sized zero.  How many segments a
controller allows is a power of two reported as its exponent, and the
exponent can be 15, so computing 1 << exponent into the uint8_t that
holds it wraps to zero on any controller offering more than 128.  A
controller told its event ring table holds no entries has nowhere to
report anything: every command times out, and the first thing to notice
is a host controller error with no explanation.  Work it out at full
width and narrow it afterwards.

The slot context never carried the device speed.  It is the only place a
controller is told how fast the device it is about to address runs, and
the field has no meaningful zero, so the context described nothing and a
controller that validates it answers Address Device with a parameter
error rather than guessing.  The speed was known: the endpoint context
built next to it had the right maximum packet size all along.  The
numbering is xHCI's own and unrelated to the values the USB host stack
uses, hence the mapping.

The output device context was cleared and never flushed.  That context
is the controller's to write, which is exactly why clearing it has to
reach memory: what stays behind is a dirty line of zeros that the
processor writes back whenever it next needs the line, on top of
whatever the controller has put there since.  The slot state lives in
that context, so what is destroyed is the record of the device having
been addressed at all, and the next command against the slot is refused
with a context state error.  The symptom is enumeration reaching
SET_ADDRESS and stopping.

A buffer copied through an aligned stand-in was copied back using the
wrong length.  buflen means the length of a data transfer and control
transfers deliberately leave it zero, so a descriptor read copied
nothing back and the caller was handed whatever its buffer held before.
That reads as a device returning nonsense, and is followed by the
endpoint being configured with a garbage maximum packet size.  Keep the
requested length separately.  Cache maintenance on such a buffer is also
rounded to the whole of it rather than the part in use, since the
operation works a line at a time and an architecture may reasonably
refuse a range stopping part way through one.

A buffer the controller cannot reach is now copied rather than refused.
Answering -EFAULT works for a caller with somewhere better to put the
data, which the FAT filesystem has, and fails outright for one without:
reading a block device directly from a user program returned an error
where the transfer could simply have gone through a stand-in.

Two diagnostics while here.  The register dump read HCIVERSION with a
32-bit access at offset two; it is a 16-bit register sharing a word with
CAPLENGTH, so that is an unaligned read of a device register, harmless
where the bus permits it and a fault where it does not.  And a rejected
command now says which command it was: the difference between a refused
Address Device and a refused Evaluate Context is most of the diagnosis,
and the completion code alone does not give it.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
Four things, all found by driving a USB drive and a keyboard on a DWC3
controller, and all invisible on a coherent host with a flat address
space.

A link TRB inside a transfer was not chained.  A transfer described by
more than one TRB can reach the end of the ring part way through, and
the link that sends the controller back to the beginning is then inside
the transfer rather than between two of them.  Written without the chain
bit, that link ends the transfer where it stands: the controller follows
it, considers the work finished, and reports nothing, because the
descriptor that asked for the completion interrupt is on the far side of
the join and is never reached.  Nothing waiting is woken, and transfers
have no timeout, so the symptom is a read that never returns.  It only
appears once transfers need more than one descriptor.

The effect is not subtle.  Reading a megabyte from a USB drive:

    512 byte blocks     166 KB/s
    4 KiB blocks       1333 KB/s
    32 KiB blocks     10666 KB/s
    64 KiB blocks     15515 KB/s

Before the fix the last two did not complete at all.  The same board
reads its SD card at 16000 KB/s and its eMMC at 42666 KB/s, so a USB 2.0
drive now sits where it ought to between them.

A stand-in buffer was copied back in the wrong context.  That work was
being done in the completion handler, which runs on a work queue, while
the buffer it copies into may belong to a user process whose addresses
mean nothing there.  Reading a block device directly from a user program
faulted.  The caller is blocked until the transfer finishes anyway, so
the copy belongs there instead.

An asynchronous transfer cannot use a stand-in at all, for the same
reason: there is no caller to come back to, and the copy would have to
happen in the completion.  Refuse a buffer that would need one.  The
callers of that path are class drivers using kernel memory, which do
not.

And say what is attached.  A controller now reports each device once,
by name, as it comes up, and from the end of the port enable rather than
at connect, because the speed field in PORTSC only means anything
once the port has been reset: before that a USB2 port reports its reset
default, which reads as full speed, and every device would be announced
at 12Mbps regardless of what it negotiates a moment later.  Verified
with both at once: a high speed drive on one controller announces
480Mbps while a low speed keyboard on the other announces 1.5Mbps.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
Two faults a low speed keyboard meets in succession, the second only
reachable because of the first.

The Interval field of an endpoint context is an exponent: the controller
services the endpoint every 2^Interval microframes.  An endpoint
descriptor does not say it that way, and what it does say depends on how
fast the device is, so the number cannot be copied across, which is what
this did.  A low or full speed interrupt endpoint counts in frames, so a
keyboard asking to be polled every 10ms was programmed as 2^10
microframes, an interval it never agreed to and one the controller would
not accept: the Configure Endpoint command went unanswered, endpoint
allocation failed with -EIO, and the keyboard never enumerated.

Convert instead.  Low and full speed interrupt endpoints state a period
in frames, so the exponent is the highest bit of that period in
microframes, kept inside the range the specification allows.  Everything
else periodic already states an exponent, one greater than the one
wanted here.  Control and bulk endpoints are not periodic and the field
means nothing to them.

A root hub port whose enumeration failed that way is then enumerated
again, and the slot the failed attempt was using has been given back
before that happens, so the port has no device context behind it any
more.  xhci_epalloc() took that pointer and wrote the new endpoint
through it without looking.  Storing through NULL costs the whole
system, and it does it in answer to a device that merely failed to come
up: the first attempt reports the error correctly and the retry then
panics the kernel.  Check for the device, and give back the endpoint
that has no home rather than leaking it.

With both, a low speed keyboard configures its interrupt endpoint and
enumerates, where before it failed every time and took the system down
on the retry.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
@github-actions github-actions Bot added Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces. Area: USB labels Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

No memory changes detected for:

An asynchronous transfer never completed.  Submitting one refused any
buffer that would need a cache line stand-in, and the test for that also
refuses every buffer whose length is not a whole number of cache lines,
which an interrupt transfer's essentially never is: a HID keyboard reads
eight bytes at a time.  Every submission came straight back with -EFAULT
before a descriptor was written.  The refusal is silent in a normal
build, because the only report of it is error logging that is usually
compiled out, and a class driver in its interrupt-driven mode resubmits
from the completion callback, so the first refusal is also the last
word.  A keyboard enumerated, registered its devices, and never produced
a byte.

The refusal was there because the completion side had nowhere to bring
the data back: the copy out of a stand-in is done by the blocked caller,
and an asynchronous transfer has no blocked caller.  But the work queue
thread that handles the completion is a fine place for it.  A buffer
given to DRVR_ASYNCH must come from DRVR_ALLOC, which is kernel memory
reachable from any thread, so the addresses mean the same thing there as
in the submitting context.  Let the submission use the same stand-in
machinery as every other transfer, and finish the DMA in the completion,
just before the callback: invalidate, copy back, give up the stand-in.
A transfer that is cancelled instead gives its stand-in back on
cancellation.

The callback also moves outside the spinlock.  It is class driver code:
it queues work and takes locks of its own, and the completion must now
also be free to return a stand-in to the heap, none of which has any
business happening with interrupts masked.  Whether a completion is
synchronous or asynchronous is still decided under the lock, because the
moment a synchronous waiter is posted the endpoint may be carrying a new
transfer, and that one is not complete.

The byte count went the same way.  The completion callback is handed the
number of bytes transferred, worked out from the residue in the transfer
event and the length that was asked for, and only the synchronous setup
recorded that length; the asynchronous one left whatever was there from
before, which is nothing on an endpoint that has only ever carried
asynchronous transfers.  The HID keyboard class gets away with a zero,
because it treats any non-negative count as a report worth parsing and
reads the buffer regardless, but the count is part of what DRVR_ASYNCH
promises.  Record the length in the asynchronous setup exactly as the
synchronous setup does.

Verified on QEMU with a keyboard and a drive on the same bus: keys
injected from the monitor arrive through /dev/kbda while the drive
mounts and reads back its file.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
xhci_ctrl_xfer() and xhci_transfer() release the controller lock before
calling xhci_transfer_wait(), so the lock does not cover the interval in
which a transfer is outstanding.  Two threads issuing requests on the same
endpoint therefore both reach xhci_ioc_setup(), and the second one trips
the DEBUGASSERT(!epinfo->iocwait) that guards it.  Where the assertion is
compiled out the second thread overwrites the first thread's completion
state instead.

A device's default control endpoint reaches this readily.  Every interface
driver on a composite device speaks through endpoint 0, so a two interface
HID keyboard runs two poll threads that both issue GET_REPORT there.

Every other host controller driver in the tree holds its controller lock
across the wait.  Doing that here would serialise the whole controller and
give up the per endpoint rings that xHCI provides, so add a mutex to
struct xhci_epinfo_s instead and hold it across the wait.  It is taken
before the controller lock on both paths, so the order is always endpoint
then controller and never the reverse.

xhci_epfree() also freed the endpoint container without destroying
iocsem.  Destroy both objects there.

The fault predates the preceding commits and is reachable on any xHCI
controller.  It was found on an EIC7700X board, where a two interface USB
keyboard tripped the assertion on every boot and does not with this change
applied.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
A device slot is a finite controller resource: HCSPARAMS1 reports how many
exist, and Enable Slot fails with No Slots Available once they are gone.
Two paths took a slot and returned without giving it back.

xhci_device_init() enables a slot before it initialises the transfer ring,
the slot context and the device address.  Each of those three can fail, and
each returned directly.  The same function also treated a slot number
larger than the controller supports as success, because Enable Slot itself
had succeeded, and returned a slot the driver cannot address.

The larger leak is in xhci_enumerate().  The device is addressed by the
time usbhost_enumerate() runs, so a failure there, a device whose
descriptor cannot be read or one no class driver claims, leaves the slot
held.  That path then clears hport->connected so the port is retried,
which asks for another slot, and the retry never stops on a device that
cannot be enumerated.

Release the slot on both paths with xhci_device_deinit(), which already
issues Disable Slot, clears the DCBAA entry and resets the context.  The
endpoint ring is deliberately left allocated: xhci_ring_init() reuses an
existing ring and only allocates when there is none.

Tested on an EIC7700X board with a USB hub, which no class driver claims
because this driver does not yet support hubs, so the port retries
indefinitely.  Before, the eighth attempt failed with Enable Slot
completion code 9 (No Slots Available) and the controller enumerated
nothing further, including on its other port.  After, 1104 consecutive
attempts produced no slot failure and a keyboard on the second port
enumerated normally throughout.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
xhci_enumerate() reports a failure by marking the hub port disconnected,
which is what makes xhci_wait() return and the attempt repeat.  The root
port itself is still connected, so the two disagree again immediately and
the attempt repeats for as long as the device stays plugged in.

Nothing bounded that.  A device that fails every time, one whose
descriptors cannot be read or that no class driver claims, is retried
forever.  Measured on an EIC7700X board with a USB hub, which no class
driver claims because this driver does not support hubs yet: 1055
attempts in 90 seconds, enough console traffic to stop the board being
usable at all.

Count consecutive failures per root port and stop at
CONFIG_USBHOST_XHCI_ENUM_RETRIES, leaving the port as it is so xhci_wait()
blocks until something physically changes.  A new connection clears the
count, as does a successful enumeration, so a device that needs a second
attempt still gets one.  The default of three rides out a slow device or
a marginal reset without spinning.

The same board with the same hub now makes three attempts, reports that
it has given up, and falls silent; a keyboard on the other port enumerates
throughout and the shell responds in 1.5 s.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
A root hub port and a device were the same thing in this driver.  The
slot, the default control endpoint and the device context all lived in
struct xhci_rhport_s, and anything needing a device reached it as
rhport->dev.  That holds only while every device is plugged straight into
the controller.  A hub puts several behind one root port, each with its
own slot and context, so the port cannot go on being the identity.

Two identities replace it, because there are two questions.  An endpoint
records the slot it was opened on, so xhci_dev_from_ep() answers "which
device does this transfer belong to".  A hub port belongs to one device
wherever it sits, so xhci_dev_from_hport() answers "which device is on
this port" for the case with no endpoint to ask yet: the first one, whose
allocation is what needs the device in the first place.

The functions converted here were using both keys at once.
xhci_ep0configure() issued Evaluate Context for epinfo->slot while filling
in the context belonging to rhport->dev, and xhci_ctrl_xfer() reached the
endpoint ring as rhport->dev->rhport->ep0.td, a round trip through the
port back to the endpoint the caller had supplied.  xhci_slot_init() read
the device's speed and control ring through the port as well, which is the
one that would have failed quietly: the slot context speed field has no
valid zero, and a low speed device behind a high speed hub does not have
its hub's speed.

No functional change for a directly attached device: its port's slot and
its endpoint's slot are the same one, and its hub port is the root port's
own.  Tested on an EIC7700X board, where a two interface USB keyboard
enumerates through the full Address Device path and both of its interfaces
still work.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
Some host controllers have to be told about the hubs in a topology, not
only about the device at the end of it.  xHCI is one: a hub's slot context
carries a hub flag, its downstream port count and the think time of its
transaction translator, and the controller uses them to route to anything
behind that hub.

The hub class driver already reads all of this from the hub descriptor and
keeps it privately.  Publish the two values a controller can act on, on the
hub's own hub port, beside the speed and function address that already
describe the device attached there rather than the port itself.  A driver
setting up a device behind a hub finds them on that device's parent.

They are written before the hub activates any downstream port, so they are
in place before there is anything behind the hub to set up, and a port with
no hub attached reports zero ports because the hub class clears each child
before use.  Nothing is required to read them, so a controller that does
not need them is unaffected.

Fields rather than a new driver method: a method would need a null check at
the call site and would define an order in which it must be called, and
neither can be got wrong here.  Both are inside CONFIG_USBHOST_HUB, as
struct usbhost_hubport_s's parent pointer already is, so a build without
hub support is unchanged.

Multi-TT is not included.  It comes from the hub's interface protocol
rather than the hub descriptor, and treating a multi-TT hub as single-TT
costs bandwidth behind that hub but is correct.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
A controller reaches a device by the path to it and, for a slow device,
through the hub that translates for it.  Neither was described, so a
device behind a hub was addressed as though it were on the root port.

The route string is that path: each hub between the device and the root
contributes a nibble holding the port the next thing down is plugged into,
tier nearest the root in the lowest nibble.  Walking up from the device
reaches the deepest tier first, so shifting what is already there left by a
nibble each time leaves them in the order the field wants.  The walk stops
after five, which is both what the field holds and what USB allows, and a
port above fifteen is clamped rather than carrying into the tier below it.

Slot context dword 2 names the transaction translator that carries a low
or full speed device behind a high speed hub.  It reports the hub by slot,
where EHCI reports it by USB address, and it names the nearest high speed
ancestor rather than the immediate parent: a full speed hub below a high
speed one is itself carried by the translator above it, so the device's
own hub is not always the one doing the work.  The think time comes from
the hub descriptor by way of the hub class driver, and both count in the
same units, so it carries across unchanged.

What was there instead came from EHCI.  xhci_epalloc() carried a copy of
sam_ehci.c's block, writing epinfo->hubaddr and epinfo->hubport, which are
how EHCI describes a split transaction in its queue head.  This driver
never read either field, so the work was thrown away, and the place xHCI
wants it is the slot context rather than the endpoint.  Both fields and
the code setting them are gone.

Multi-TT is not set.  It comes from the hub's interface protocol rather
than its descriptor, and driving a multi-TT hub as single-TT costs
bandwidth behind that hub but is correct.

No functional change: hubs cannot be enabled yet, and a device on a root
port has neither hubs above it nor a translator, so both fields are zero
as before.  Tested on an EIC7700X board with a directly attached low speed
keyboard, the case that would use a translator if a hub were in the way.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
The driver refused CONFIG_USBHOST_HUB outright.  Everything needed to
describe a device behind a hub is now in place, so implement the rest and
let the configuration build.

A device is created wherever it sits.  xhci_device_init() took a root hub
port and read the slot, the control endpoint and the device out of it, all
of which belong to the device.  It now takes the hub port and the control
endpoint too, and records the device on the root port only when that is
where it is: the device a root port names, once a hub is plugged in, is
the hub.  xhci_address_set() and xhci_device_deinit() likewise work on a
device, and xhci_disconnect() finds the device by the port going away
rather than assuming the root port's.

The hub asks for a port's control endpoint before it reports the
connection, so xhci_epalloc() has nothing to attach one to.  It returns an
endpoint with no slot in that case, and xhci_connect() gives it one when
it creates the device, which is the hub's next action.

A hub also has to be described to the controller as a hub before anything
behind it can be reached, and nothing knows it is one when its slot is
created: it is addressed and configured like any other device, and only
then does its class driver read the descriptor saying how many ports it
has.  xhci_hub_update() corrects the slot context with a Configure
Endpoint command the first time something appears behind it.

A hub reports each port whose state changed one after another without
waiting for any to be dealt with, so the connect method queues them.
Holding one pointer, as it first did, meant the second report overwrote
the first and the device on it was never enumerated, silently.  No more
can be outstanding than the controller has slots to put devices in.

Report the geometry the hardware describes rather than leaving it to a
debug build: the root port and slot counts from HCSPARAMS1, and the port
count a hub gives in its descriptor.

Tested on an EIC7700X board with a hub on one controller and a keyboard on
the other.  Behind the hub, a 59 GB mass storage device mounts and reads a
file back correctly, a composite CDC device gives four ttyACM nodes, and a
Realtek adapter with no driver in this tree is enumerated and reported as
unclaimed.  Both interfaces of the keyboard keep working throughout.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Justin Hammond <justin@dynam.ac>
/****************************************************************************
* Included Files
****************************************************************************/

#include <nuttx/config.h>

#include <assert.h>
#include <nuttx/debug.h>
#include <debug.h>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why change


__asm__ __volatile__("" : "+r"(value));
*((FAR volatile uint64_t *)addr) = value;
pci_release_irq(pcix->dev, &pcix->irq, 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need up_disable_irq

interval = 1;
}

for (exp = 0; (1u << (exp + 1)) <= (unsigned int)interval * 8; exp++);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the cast

exp = 10;
}

return (uint8_t)exp;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove the cast too


uinfo("no slots = %d, no ports = %d\n",
priv->no_slots, priv->no_ports);
syslog(LOG_INFO, "%s: %d root port%s, %d device slot%s\n",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why change


dev->ishub = true;

syslog(LOG_INFO, "%s: port %d: hub with %d port%s\n",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use syslog, not uxxx

@raiden00pl

Copy link
Copy Markdown
Member

@acassis did you even look through this massive PR or did you blindly approved?

this PR should be tested on real intel64 HW before merge

@raiden00pl raiden00pl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

319aadf touches too many things. Please break this down into smaller parts because right now it's unclear what fixes are for gcc16 and what fixes qemu. Your description is misleading because this driver works with gcc version lower than 16. It looks like AI ​​couldn't find a real problem and fixed everything one by one and put it in one commit. The real issue here is gcc16 and memory access.

Was this PR completely generated by AI or did you verify its output? Some of the claims in it seem like BS, like this:

The driver worked that count into a size and asked the
allocator for it, and a zero-byte allocation returns NULL,
indistinguishable from being out of memory, so a controller asking for
no scratch space was refused for lack of it before it was ever started.

@@ -0,0 +1,6041 @@
/****************************************************************************

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this way you've lost all git history of original file. You should use git mv and then add your changes on top of it

@acassis

acassis commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@acassis did you even look through this massive PR or did you blindly approved?

this PR should be tested on real intel64 HW before merge

@raiden00pl I look the diff code and for some files that github didn't accepted to show the diff I saw the final file, nothing too suspect here. He also provided the QEMU test results. Are you planing to do real test on real HW?

@linguini1 linguini1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please split this patch into multiple PRs, this is a massive code change for just one.

@raiden00pl

Copy link
Copy Markdown
Member

@acassis I'll test it on intel HW probably tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: USB Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants