usbtest — porting & debugging the Linux kernel USB battery
Overview
examples/device/usbtest is the device-side peer of the Linux kernel's usbtest.ko/testusb
(gadget-zero source/sink protocol): 30 cases over bulk, EP0, interrupt, and isochronous, including
halt, data-toggle, and unlink storms. It is the most adversarial exerciser a DCD gets — every port
so far surfaced at least one real driver bug. Host runner: test/hil/usbtest.py; HIL integration
runs it per board and reports ✅ 30/30 cells.
Core principle: the battery is a DCD test, not a firmware test. When a case fails, suspect the
DCD path it exercises (table below), reproduce that one case, and root-cause on hardware before
changing anything (superpowers:systematic-debugging). One variable at a time; a fix is proven by
the failing case passing and the full battery still at 30/30 across reflash cycles.
Run
# build (cmake); descriptor sizes auto-adapt per MCU via the example's own
# src/usb_descriptors.h + src/tusb_config.h (paths below are relative to it)
cd examples/device/usbtest && cmake -B build -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build build
# flash, wait ~3-5 s for enumeration to settle, then:
python3 test/hil/usbtest.py --serial <uid> --keep-binding # full battery for the advertised tier
python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case
- Always
--keep-binding: the cleanup unbind path has wedged host xHCIs (usb_hcd_alloc_bandwidth).
- CI (
hil_test.py) additionally passes --budget and
--recover-board/--recover-fw: on a HUNG case the battery aborts, RESETS the DUT
through its roster probe (non-destructive, ~130 ms) and reflashes only if that does not
clear the wedge (see usb-kernel-recover). Manual runs without those flags leave a HUNG
device wedged and skip cleanup — expected; reset or reflash it yourself.
- Always settle a few seconds after flashing — enumeration can bounce once; testusb into the gap sees
the device drop mid-case.
- On a CI rig: hold the board lock before touching hardware and release it after — never stop the
actions runner. It keeps running; the per-board flock is what arbitrates (see the
hil skill).
Never start a battery by hand next to a running one: hil_test.py budgets 2 concurrent batteries
per host controller (HIL_USBTEST_PARALLEL). The width itself is a profiled throughput/bandwidth
trade, not a safety ceiling (the concurrency note above FLASH_PARALLEL in hil_lock.py) — but
a battery outside the budget is a real hazard: unbudgeted concurrent batteries have hard-frozen
the rig with a fatal PCIe error on a VFIO-passed xHCI, and a marginal DUT port bouncing under
concurrent batteries has killed a uPD720201 outright, which lowering the widths does not fix
(that note records every such death).
Porting ladder — new MCU/DCD to 30/30
- Tier 1 (bulk): set
USBTEST_TIER 1, get enumeration + cases 0,9,10 (EP0) + 1–8,17–20,27,28
solid. EP0 correctness first — everything else reports through it.
- Tier 2 (ctrl_out 14/21), tier 3 (interrupt 25/26), tier 4 (iso 15/16/22/23) — raise
the tier only when the layer below is clean; run the full battery after each layer.
- Fit the endpoints: tier 4 needs 6 endpoints + EP0. Small parts need per-MCU mps/epbuf
overrides in the example's own
src/usb_descriptors.h (USBTEST_INT/ISO_EP_MPS_FS) and
src/tusb_config.h (CFG_TUD_VENDOR_TX_EPSIZE) — follow the existing CH32/LPC11 patterns.
Parts that can't fit go in skip.txt.
- Sign-off = reliability, not one pass: 3–10 full flash→battery cycles. One 30/30 proves
nothing on a flaky bring-up; deterministic partial counts (e.g. exactly 1-in-8 lost) are a
signature, not noise — chase them.
- Register the board in
test/hil/tinyusb.json so the HIL suite runs it.
Case → DCD subsystem map
| Failing case(s) |
Exercises |
First suspect |
| 9, 10 |
EP0 control storms |
EP0 state machine, ZLP/status stage, control starvation under load |
| 1–8, 17–20, 27, 28 |
bulk source/sink, sg, perf |
FIFO handling, multi-packet, ZLP tolerance |
| 11, 12, 24 |
URB unlink mid-transfer |
abort/close paths leaving state half-armed |
| 13 |
set/clear halt |
stall must kill the transfer; halt on armed IN must flush the TX FIFO |
| 29 |
clear-halt on an armed, un-halted ep |
the classic: dcd_edpt_clear_stall resets toggle but disarms the queued receive → NAKs forever, errno 110. Fix: reset toggle to DATA0 and re-arm/preserve the pending transfer. Found independently on rp2040, fsdev, ch32_usbhs, rusb2 |
| 14, 21 |
vendor EP0 write/readback |
multi-packet control-OUT chunking, DCP flow control |
| 25, 26 |
interrupt src/sink |
usually free once bulk works |
| 15, 16, 22, 23 |
isochronous |
see iso rules below |
Iso rules (most-violated contract)
- DATA0-only in BOTH directions at FS — never run bulk-style toggle logic on an iso endpoint
(manual-toggle parts: skip the ISR toggle flip for iso IN and the toggle-mismatch drop for iso
OUT). Symptom of violating it: exactly every-other packet lost.
- No handshake — iso never NAKs/STALLs; parts with response fields use their "no response"
encoding (e.g. NYET on WCH).
dcd_edpt_iso_alloc/iso_activate must not be stubs returning false — usbd fails the
interface open and the kernel logs "did not bind"/SET_CONFIG times out. If a DCD refuses iso
"because the hardware can't", verify against the datasheet — the manual outranks the code
comment (two "no iso support" claims in this tree were false, incl. a per-endpoint exception
the RM documents for one endpoint number only).
- A multi-packet iso IN submit is legal: the DCD streams it one packet per frame, refilling in the
ISR. Slow cores may need double-buffered iso to make the frame deadline.
Debug ladder (escalate in order)
| errno |
Meaning |
| 110 |
timeout — endpoint NAKing forever / device wedged |
| 32 |
EPIPE — unexpected STALL |
| 5 |
EIO — iso packet errors (check dmesg: "N errors out of M") |
| 71 |
EPROTO — device answered wrong / too slow (after HC retries) |
Step 0 — read what the case actually does. The kernel module is ground truth;
the table above is a summary. Do this before theorising, and always before deciding
whether a hung case is recoverable. Fetch the rig's exact version (uname -r):
curl -sO "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/drivers/usb/misc/usbtest.c?h=v6.12.96"
# case N lives under `case N:` in the kernel's usbtest_do_ioctl()
# (drivers/usb/misc/usbtest.c); kernel tools/usb/testusb.c maps the flags:
# -c = param.iterations, -s = param.length, -g = param.sglen (NOT what they read like)
- Real traffic and pass criteria. Case 24 at
-c 256 -s 1024 -g 8 is 256 rounds
of 8 bulk-OUT URBs, unlinking urbs[num-4]/urbs[num-2] and requiring
-ECONNRESET on those two plus normal completion on the other 6 — not the
"256 URBs" the flags suggest.
- Whether the wait is bounded — decisive for recovery.
simple_io uses
wait_for_completion_timeout (:481); the unlink paths use a bare
wait_for_completion (:1502, :1615). A device stalling there wedges the ioctl in
D state permanently — it holds the device lock, so nothing recovers it
(usb-kernel-recover, "The terminal case"). Knowing this first stops you burning
the rig on attempts that cannot work.
- Which DCD path is implicated, precisely rather than by category.
usbtest.py per-case output + its captured dmesg (TEST n markers bracket each case).
- usbmon (
usbmon skill): URB-level ground truth. It cannot show data toggles or NAKs —
a toggle desync and a dead endpoint look identical (Submits without Completes); distinguish
device-side with GDB.
- On-device gdb/openocd: read the EP control registers and DCD structs at the hang.
- Heisenbugs (vanish under logging): RAM ring-buffer trace dumped over openocd; for silent lockups
JLink PC-sampling (
halt+regs repeatedly — a pinned PC names the spin).
- Cross-check the reference manual (
read-doc skill) before changing any register-level code —
per CLAUDE.md, and because comments/assumptions in DCDs have been wrong about hardware caps.
- Check the vendor's silicon errata early for timing/DMA hangs (an unimplemented erratum
workaround caused a case-10 hang on one port).
Traps that pass gcc/desk review but fail elsewhere
TUD_OPT_HIGH_SPEED is a compile-time capability, not the live speed: the FS config
descriptor (and OTHER_SPEED) must use FS-legal sizes (int ≤ 64, iso IN+OUT ≤ 1023 B/frame) even
on HS builds — use separate _FS/_HS descriptor macros.
- Unused
static inline helpers: clang -Wunused-function and IAR Pe177 error where gcc stays
quiet → TU_ATTR_UNUSED.
- A symbol referenced only inside naked asm is invisible to LTO and gets dropped in
-flto make
builds → keep a TU_ATTR_USED C reference to it.
- Nested USB IRQs on cores with hardware context stacks (QingKe HWSTK): plain
__attribute__((interrupt)) corrupts the return — use naked handlers relying on the HW stack.
- Dedicated USB RAM budgets (PMA/USB-RAM) differ per part and per build system section placement:
check the link map, not just that it builds.
Red flags — stop and re-examine
- "One pass = done" → run reflash cycles.
- "The DCD comment says the hardware can't" → open the datasheet.
- "usbmon shows no toggle problem" → usbmon can't see toggles.
- "It works on gcc" → clang/IAR/LTO/make still pending.
- "Fixed iso IN" → apply the same exemption to iso OUT (toggle logic is symmetric).
- A clean single-board run does not validate concurrent/fleet behavior — a fleet run puts up to 2
batteries per host controller (
HIL_USBTEST_PARALLEL) plus concurrent flashes on the same hub
uplinks, which one board never exercises.
- Reasoning about a case from its name or table row → open
usbtest.c (step 0). The
flags don't mean what they look like, and recoverability is a property of that
case's wait, not of the rig.
1---2name: usbtest3description: Use when running, debugging, or porting the Linux usbtest/testusb battery (examples/device/usbtest, cafe:4010) — device "did not bind", SET_CONFIGURATION fails, a case fails with errno 110/32/5/71, toggle-clear/halt/unlink/iso failures, iso packets dropped, or a new MCU/DCD needs the full 30/30 sign-off. Needs a Linux PC as the link's host driving TinyUSB in device role — it exercises the DCD, not the TinyUSB host stack.4---56# usbtest — porting & debugging the Linux kernel USB battery78## Overview910`examples/device/usbtest` is the device-side peer of the Linux kernel's `usbtest.ko`/`testusb`11(gadget-zero source/sink protocol): 30 cases over bulk, EP0, interrupt, and isochronous, including12halt, data-toggle, and unlink storms. It is the most adversarial exerciser a DCD gets — every port13so far surfaced at least one real driver bug. Host runner: `test/hil/usbtest.py`; HIL integration14runs it per board and reports `✅ 30/30` cells.1516**Core principle: the battery is a DCD test, not a firmware test.** When a case fails, suspect the17DCD path it exercises (table below), reproduce that one case, and root-cause on hardware before18changing anything (`superpowers:systematic-debugging`). One variable at a time; a fix is proven by19the failing case passing *and* the full battery still at 30/30 across reflash cycles.2021## Run2223```bash24# build (cmake); descriptor sizes auto-adapt per MCU via the example's own25# src/usb_descriptors.h + src/tusb_config.h (paths below are relative to it)26cd examples/device/usbtest && cmake -B build -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build build27# flash, wait ~3-5 s for enumeration to settle, then:28python3 test/hil/usbtest.py --serial <uid> --keep-binding # full battery for the advertised tier29python3 test/hil/usbtest.py --serial <uid> --keep-binding --tests 29 # one case30```3132- **Always `--keep-binding`**: the cleanup unbind path has wedged host xHCIs (`usb_hcd_alloc_bandwidth`).33- CI (`hil_test.py`) additionally passes `--budget` and34 `--recover-board`/`--recover-fw`: on a HUNG case the battery aborts, RESETS the DUT35 through its roster probe (non-destructive, ~130 ms) and reflashes only if that does not36 clear the wedge (see usb-kernel-recover). Manual runs without those flags leave a HUNG37 device wedged and skip cleanup — expected; reset or reflash it yourself.38- Always settle a few seconds after flashing — enumeration can bounce once; testusb into the gap sees39 the device drop mid-case.40- On a CI rig: hold the board lock before touching hardware and release it after — never stop the41 actions runner. It keeps running; the per-board flock is what arbitrates (see the `hil` skill).42 Never start a battery by hand next to a running one: `hil_test.py` budgets 2 concurrent batteries43 per host controller (`HIL_USBTEST_PARALLEL`). The width itself is a profiled throughput/bandwidth44 trade, not a safety ceiling (the concurrency note above `FLASH_PARALLEL` in `hil_lock.py`) — but45 a battery outside the budget is a real hazard: unbudgeted concurrent batteries have hard-frozen46 the rig with a fatal PCIe error on a VFIO-passed xHCI, and a marginal DUT port bouncing under47 concurrent batteries has killed a uPD720201 outright, which lowering the widths does not fix48 (that note records every such death).4950## Porting ladder — new MCU/DCD to 30/3051521. **Tier 1 (bulk)**: set `USBTEST_TIER 1`, get enumeration + cases 0,9,10 (EP0) + 1–8,17–20,27,2853 solid. EP0 correctness first — everything else reports through it.542. **Tier 2 (ctrl_out 14/21)**, **tier 3 (interrupt 25/26)**, **tier 4 (iso 15/16/22/23)** — raise55 the tier only when the layer below is clean; run the *full* battery after each layer.563. **Fit the endpoints**: tier 4 needs 6 endpoints + EP0. Small parts need per-MCU mps/epbuf57 overrides in the example's own `src/usb_descriptors.h` (`USBTEST_INT/ISO_EP_MPS_FS`) and58 `src/tusb_config.h` (`CFG_TUD_VENDOR_TX_EPSIZE`) — follow the existing CH32/LPC11 patterns.59 Parts that can't fit go in `skip.txt`.604. **Sign-off = reliability, not one pass**: 3–10 full flash→battery cycles. One 30/30 proves61 nothing on a flaky bring-up; deterministic partial counts (e.g. exactly 1-in-8 lost) are a62 signature, not noise — chase them.635. Register the board in `test/hil/tinyusb.json` so the HIL suite runs it.6465## Case → DCD subsystem map6667| Failing case(s) | Exercises | First suspect |68|---|---|---|69| 9, 10 | EP0 control storms | EP0 state machine, ZLP/status stage, control starvation under load |70| 1–8, 17–20, 27, 28 | bulk source/sink, sg, perf | FIFO handling, multi-packet, ZLP tolerance |71| 11, 12, 24 | URB unlink mid-transfer | abort/close paths leaving state half-armed |72| 13 | set/clear halt | stall must kill the transfer; halt on armed IN must flush the TX FIFO |73| **29** | clear-halt on an **armed, un-halted** ep | **the classic**: `dcd_edpt_clear_stall` resets toggle but disarms the queued receive → NAKs forever, errno 110. Fix: reset toggle to DATA0 *and* re-arm/preserve the pending transfer. Found independently on rp2040, fsdev, ch32_usbhs, rusb2 |74| 14, 21 | vendor EP0 write/readback | multi-packet control-OUT chunking, DCP flow control |75| 25, 26 | interrupt src/sink | usually free once bulk works |76| 15, 16, 22, 23 | isochronous | see iso rules below |7778## Iso rules (most-violated contract)7980- **DATA0-only in BOTH directions** at FS — never run bulk-style toggle logic on an iso endpoint81 (manual-toggle parts: skip the ISR toggle flip for iso IN *and* the toggle-mismatch drop for iso82 OUT). Symptom of violating it: exactly every-other packet lost.83- **No handshake** — iso never NAKs/STALLs; parts with response fields use their "no response"84 encoding (e.g. NYET on WCH).85- `dcd_edpt_iso_alloc`/`iso_activate` **must not be stubs returning false** — usbd fails the86 interface open and the kernel logs "did not bind"/SET_CONFIG times out. If a DCD refuses iso87 "because the hardware can't", **verify against the datasheet — the manual outranks the code88 comment** (two "no iso support" claims in this tree were false, incl. a per-endpoint exception89 the RM documents for one endpoint number only).90- A multi-packet iso IN submit is legal: the DCD streams it one packet per frame, refilling in the91 ISR. Slow cores may need double-buffered iso to make the frame deadline.9293## Debug ladder (escalate in order)9495| errno | Meaning |96|-------|--------------------------------------------------------------|97| 110 | timeout — endpoint NAKing forever / device wedged |98| 32 | EPIPE — unexpected STALL |99| 5 | EIO — iso packet errors (check `dmesg`: "N errors out of M") |100| 71 | EPROTO — device answered wrong / too slow (after HC retries) |101102**Step 0 — read what the case actually does.** The kernel module is ground truth;103the table above is a summary. Do this before theorising, and always before deciding104whether a hung case is recoverable. Fetch the rig's exact version (`uname -r`):105106```bash107curl -sO "https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/drivers/usb/misc/usbtest.c?h=v6.12.96"108# case N lives under `case N:` in the kernel's usbtest_do_ioctl()109# (drivers/usb/misc/usbtest.c); kernel tools/usb/testusb.c maps the flags:110# -c = param.iterations, -s = param.length, -g = param.sglen (NOT what they read like)111```112113- **Real traffic and pass criteria.** Case 24 at `-c 256 -s 1024 -g 8` is 256 rounds114 of 8 bulk-OUT URBs, unlinking `urbs[num-4]`/`urbs[num-2]` and requiring115 `-ECONNRESET` on those two plus normal completion on the other 6 — not the116 "256 URBs" the flags suggest.117- **Whether the wait is bounded** — decisive for recovery. `simple_io` uses118 `wait_for_completion_timeout` (:481); the unlink paths use a bare119 `wait_for_completion` (:1502, :1615). A device stalling there wedges the ioctl in120 **D state permanently** — it holds the device lock, so nothing recovers it121 (usb-kernel-recover, "The terminal case"). Knowing this first stops you burning122 the rig on attempts that cannot work.123- **Which DCD path is implicated**, precisely rather than by category.1241251. `usbtest.py` per-case output + its captured `dmesg` (`TEST n` markers bracket each case).1262. **usbmon** (`usbmon` skill): URB-level ground truth. **It cannot show data toggles or NAKs** —127 a toggle desync and a dead endpoint look identical (Submits without Completes); distinguish128 device-side with GDB.1293. **On-device gdb/openocd**: read the EP control registers and DCD structs at the hang.1304. Heisenbugs (vanish under logging): RAM ring-buffer trace dumped over openocd; for silent lockups131 JLink PC-sampling (`halt`+`regs` repeatedly — a pinned PC names the spin).1325. **Cross-check the reference manual** (`read-doc` skill) before changing any register-level code —133 per CLAUDE.md, and because comments/assumptions in DCDs have been wrong about hardware caps.1346. Check the vendor's **silicon errata** early for timing/DMA hangs (an unimplemented erratum135 workaround caused a case-10 hang on one port).136137## Traps that pass gcc/desk review but fail elsewhere138139- `TUD_OPT_HIGH_SPEED` is a **compile-time capability, not the live speed**: the FS config140 descriptor (and OTHER_SPEED) must use FS-legal sizes (int ≤ 64, iso IN+OUT ≤ 1023 B/frame) even141 on HS builds — use separate `_FS`/`_HS` descriptor macros.142- Unused `static inline` helpers: clang `-Wunused-function` and IAR `Pe177` error where gcc stays143 quiet → `TU_ATTR_UNUSED`.144- A symbol referenced only inside naked asm is invisible to LTO and gets dropped in `-flto` make145 builds → keep a `TU_ATTR_USED` C reference to it.146- Nested USB IRQs on cores with hardware context stacks (QingKe HWSTK): plain147 `__attribute__((interrupt))` corrupts the return — use naked handlers relying on the HW stack.148- Dedicated USB RAM budgets (PMA/USB-RAM) differ per part *and* per build system section placement:149 check the link map, not just that it builds.150151## Red flags — stop and re-examine152153- "One pass = done" → run reflash cycles.154- "The DCD comment says the hardware can't" → open the datasheet.155- "usbmon shows no toggle problem" → usbmon can't see toggles.156- "It works on gcc" → clang/IAR/LTO/make still pending.157- "Fixed iso IN" → apply the same exemption to iso OUT (toggle logic is symmetric).158- A clean single-board run does not validate concurrent/fleet behavior — a fleet run puts up to 2159 batteries per host controller (`HIL_USBTEST_PARALLEL`) plus concurrent flashes on the same hub160 uplinks, which one board never exercises.161- Reasoning about a case from its name or table row → open `usbtest.c` (step 0). The162 flags don't mean what they look like, and recoverability is a property of that163 case's wait, not of the rig.