Skip to main content

usbtest

Use when bringing up examples/device/usbtest (cafe:4010) on a new MCU or DCD, or when a usbtest case fails — a red usbtest cell in a HIL report, testusb errno 110/32/5/71, NOTRUN or HUNG cases, device "did not bind", SET_CONFIGURATION fails, halt/toggle-clear/unlink/interrupt/isochronous failures. Load it before reproducing or debugging the case. A Linux PC hosts the link and drives TinyUSB in device role: it exercises the DCD, not the TinyUSB host stack.

Source facts

Repository
hathach/tinyusb
Last source activity
October 8, 2026 at 06:35
Detected SKILL.md language
English
Stars
7,192
Forks
1,552

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
4 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
usbtest
description
Use when bringing up examples/device/usbtest (cafe:4010) on a new MCU or DCD, or when a usbtest case fails — a red usbtest cell in a HIL report, testusb errno 110/32/5/71, NOTRUN or HUNG cases, device "did not bind", SET_CONFIGURATION fails, halt/toggle-clear/unlink/interrupt/isochronous failures. Load it before reproducing or debugging the case. A Linux PC hosts the link and drives TinyUSB in device role: it exercises the DCD, not the TinyUSB host stack.
# usbtest — bring-up and failed cases of the Linux kernel USB battery `examples/device/usbtest` is the device-side peer of the kernel's `usbtest.ko`/`testusb` (Gadget Zero source/sink): 30 cases over EP0, bulk, interrupt and isochronous, including halt, data-toggle and unlink storms. The example README describes every case and tier; the host runner is `test/hil/usbtest.py`. **The battery tests the DCD, not the firmware.** A failed case points at the DCD path it exercises (map below). Reproduce that one case, root-cause it on hardware before changing anything (agentrc's `hw-debugger`), one variable at a time. A fix is proven when the case passes and the full battery still passes across reflash cycles. ## Choose the path | Situation | Do | |--------------------------------------------------|-------------------------------------------------------------------------------------| | Routine full battery on rig boards | The HIL contract (`hil` skill): `hil_test.py -b <board> -t device/usbtest <config>` | | Chosen cases on a board you have not taken | `scripts/run_case.py` (below) | | Chosen cases inside a lock you hold, firmware on | `python3 test/hil/usbtest.py --serial <uid> --tests 13,29 --json` | | What a case does, or whether its hang can clear | `scripts/kernel_src.py` (below), then read the functions the case calls | | Prove a board's in-run hang recovery | `scripts/wedge_drill.py` (below) | | New MCU or DCD | Bring-up ladder | Build first, through the build contract; `--variants` gives each roster variant its own `cmake-build-<variant>` dir, the one HIL and `run_case.py` flash from. A board not yet in the roster builds without `--variants` and is flashed by hand until it is added: ```bash python3 .claude/skills/build/scripts/check_build.py --board <board> -e device/usbtest -e device/board_test --shared --variants <this host's config> ``` ```bash # lock, flash usbtest from the roster, wait for cafe:4010, run, park on board_test, release python3 .claude/skills/usbtest/scripts/run_case.py --config <this host's config> --board <board> --tests 13,29 --after park ``` `--after leave` keeps usbtest running for a debug session. `--variant` is required when the board has several. It refuses with exit 2 and the reason before touching hardware, including while any battery or another `run_case.py` runs on the host; pass `--allow-concurrent` only when another battery's load on the board's host controller does not matter for what you are measuring. A wedged device is never parked. The last stdout line is its JSON verdict; each of its `cases` carries `usbtest.py`'s `detail`, and `stderr` and `dmesg` when it captured them. ```bash # wedge the board on purpose (halt its core mid case 27) and require usbtest.py's recovery to clear it python3 .claude/skills/usbtest/scripts/wedge_drill.py --config <this host's config> --board <board> ``` Only `pass` is evidence that the board's recovery works: HUNG, probe reset rc 0, the original testusb reaped, a fresh enumeration and case 1 passing. `inconclusive` means no wedge was produced (the case ended first, the halt failed or nothing hung); try another `--delay`, do not count it. Case 27 often ends within 1 s: `--delay 0` suits HS boards (max32666fthr, mimxrt1064_evk, ch32v307v_r1_1v0-usbhs). On a WCH-Link (`target/wch-riscv.cfg`) openocd's shutdown resumes the core, so the drill SIGKILLs a halted openocd instead. On nanoch32v203-fsdev the WCH-LinkE attach (openocd 0ce743125) itself rewrote RCC, taking the USB clock off 48 MHz, so case 27 failed -EPIPE instead of hanging and the drill stayed inconclusive (2026-10-06). The halt runs through openocd. A board whose recovery flasher is not openocd (the hfp stlink boards) names the openocd halt with `--halt-openocd`, whose interface and target cfgs must match its probe and MCU, e.g. `--halt-openocd "-f interface/stlink.cfg -f target/stm32l4x.cfg"` for stm32l412nucleo; it injects the wedge only, and the recovery under test stays the board's own. `STM32_Programmer_CLI -halt` cannot stand in: the core ran again after the CLI exited (DHCSR, stm32g0b1nucleo, CubeProgrammer v2.23.0). Its `cleanup` is the drill's own reset and never stands in for the harness's recovery. Boards whose recovery flasher is not convoy-safe or has no probe reset are refused. ## Rig hazards - **Batteries share host controllers.** `hil_test.py` does not limit batteries per controller: each carries as many as it has boards in flight, and a second `hil_test.py` or a battery started outside it adds to them. Know what else runs on the controller before reading a timeout or a slow 27/28 as a DCD bug. Case 9 has slowed under concurrent batteries behind one hub; consider contention when it nears its bound (`PARAMS` in `test/hil/usbtest.py` sets its iterations). A marginal DUT port bouncing under concurrent batteries has killed a uPD720201 (2026-07-16): fix the port or pull the board. - **Run testusb with `-A <node> -D <node>`, never `-D` alone.** Stock `testusb` opens every usbfs node while scanning, even with `-D` (`tools/usb/testusb.c` find_testdev), and opening a node takes its device lock, so one stuck peer or hub stalls every case on the host. `-A <node>` confines the scan to the DUT (`usbtest.py` does this; `-A` must come first, it clears `-D`). When testusb runs without `sudo -n`, a hold on the DUT's own lock that ends within the 30 s watch is a FAIL `timeout after Ns (the kill landed Ns late; ...)` that stops the battery; one that outlasts the watch becomes HUNG. - **The id and binding stay.** `usbtest.py` registers `cafe 4010` once and never unbinds or removes it: those writes take the uninterruptible device lock. Recovery of a HUNG case is one step: a probe reset, or a reflash where the flasher has none (esptool); never a root-port cycle (`usb-kernel-recover`). Only the killed testusb reaping afterwards clears `wedged`, so under `sudo -n` (node not writable, the child is the wrapper) it stays set. A manual run without `--recover-board` leaves a HUNG device wedged. `wedged` is also set with no HUNG case when the serial matches two devices or cannot be read after a case; stderr names which (`reported wedged: ...`). ## Failed case **Step 0: read the case in the rig's kernel.** The flags do not mean what they look like, and whether a hang can clear depends on the wait the case reaches, not on the rig: ```bash python3 .claude/skills/usbtest/scripts/kernel_src.py --release "$(ssh <rig> uname -r)" --case 24 ``` It prints the `case N:` block of `usbtest_do_ioctl()` and every completion wait with its function. A Debian release maps only to an upstream candidate, which the distro may have patched. `testusb -c` is iterations, `-s` length, `-g` sglen, `-v` vary; the runner's per-speed values are its `PARAMS` table. In v6.12, `test_ctrl_queue` (case 10), `unlink1` (11, 12), `unlink_queued` (24) and `test_queue` (15, 16, 22, 23, 27, 28) wait with no timeout while holding the device lock, so a device that stops answering there leaves `testusb` in D state for good; scatter-gather (5-8) runs under a timer. | Verdict or errno | Meaning | |--------------------|--------------------------------------------------------------------------------------------| | 110 | Timeout: an endpoint NAKs forever or the device wedged | | 32 | EPIPE: unexpected STALL | | 5 | EIO: isochronous packet errors; dmesg says "N errors out of M" | | 71 | EPROTO: the device answered wrong or too slowly after host retries | | NOTRUN | testusb opened the device and the kernel skipped the case: profile or parameter gate | | FAIL "did not run" | testusb never reached the ioctl (open or usage error): read the case's captured `stderr` | | HUNG | testusb not reaped 35 s after SIGKILL; under `sudo -n` 5 s and unconfirmed (wrapper only) | | BUDGET | never dispatched: the battery stopped first; its detail names why | | Failing case(s) | Exercises | First suspect | |--------------------|--------------------------------------------|----------------------------------------------------------------------------| | 9, 10 | EP0 control, queued control | EP0 state machine, ZLP and status stage, control starvation under load | | 1-8, 17-20, 27, 28 | bulk source/sink, sg, perf | FIFO handling, multi-packet transfers, ZLP tolerance | | 11, 12, 24 | URB unlink mid-transfer | abort and close paths that leave an endpoint half-armed | | 13 | set/clear halt | a stall must kill the armed transfer and flush a loaded IN FIFO | | 29 | clear-halt on an armed, un-halted bulk OUT | toggle reset that also disarms the queued receive: NAKs forever, errno 110 | | 14, 21 | vendor EP0 write and readback | multi-packet control-OUT data stages | | 25, 26 | interrupt source/sink | usually clean once bulk works | | 15, 16, 22, 23 | isochronous | the isochronous rules below | Case 29 (`test_toggle_sync`: clear halt, write, clear halt, write) is the most common DCD bug here: reset the toggle to DATA0 **and** keep the pending transfer armed (fixed that way in rp2040 ad7acc849, fsdev 046463687, ch32_usbhs d63a45509). Escalate in order: 1. The case's `detail` and captured `dmesg` in the JSON verdict (`TEST n` lines bracket each case). 2. usbmon (`usb-kernel-debug`): URB-level truth. It cannot show data toggles or NAKs, so a toggle desync and a dead endpoint look the same (Submits without Completes); tell them apart on the target. 3. Target-side evidence: `target-debug` (`esp-target-debug` on Espressif) on the failing case. 4. The reference manual and errata (`read-doc`) before changing any register-level code: DCD comments have been wrong about what the hardware can do. ## Isochronous rules From USB 2.0 §8.5.5 (Calibre book 775, p. 229-230): - **No handshake, no retry.** An isochronous endpoint never NAKs or STALLs; parts with a response field use their "no response" encoding. - **No toggle sequencing.** A full-speed device sends only DATA0 and should accept DATA0 or DATA1: skip bulk-style toggle logic on isochronous endpoints in both directions (no toggle flip on IN, no toggle-mismatch drop on OUT). Symptom of breaking it: every other packet lost. - **Full-speed sizes:** isochronous up to 1023 bytes per endpoint (§5.6.3), interrupt up to 64 (§5.7.3). `TUD_OPT_HIGH_SPEED` is a build capability, not the live speed, so the full-speed and other-speed descriptors need full-speed sizes even on a high-speed build (`_FS`/`_HS` macros in the example's `src/usb_descriptors.h`). - On ports that define `TUP_DCD_EDPT_ISO_ALLOC`, `dcd_edpt_iso_alloc`/`dcd_edpt_iso_activate` must work: a stub returning false fails the vendor interface open, SET_CONFIGURATION is refused and the runner reports "did not bind". Before accepting "the hardware has no isochronous", check the reference manual: two such claims in this tree were false. - A multi-packet isochronous IN submit is legal: the DCD sends one packet per frame and refills in its ISR. Slow cores may need double buffering to meet the frame deadline. ## Bring-up ladder 1. **Tier 1, bulk and EP0** (`USBTEST_TIER 1`): enumeration, then cases 0, 9, 10 first (everything else reports through EP0), then 1-8, 11-13, 17-20, 24, 27-29 (`TIER_CASES` in `usbtest.py`). 2. **Tier 2** control-OUT (14, 21), **tier 3** interrupt (25, 26), **tier 4** isochronous (15, 16, 22, 23): raise the tier only when the layer below is clean, and run the full battery after each. 3. **Fit the endpoints.** Tier 4 needs six endpoints plus EP0. Small parts take per-MCU sizes in the example's `src/usb_descriptors.h` (`USBTEST_INT_EP_MPS_FS`, `USBTEST_ISO_EP_MPS_FS`) and `src/tusb_config.h` (`CFG_TUD_VENDOR_TX_EPSIZE`), as the CH32 and LPC11 entries do. A part whose DCD cannot serve a tier lowers its default `USBTEST_TIER` there, with the reason (RA2A1 is tier 3); one that cannot fit at all goes in `skip.txt`. 4. **Sign-off is every case of the justified tier, across 3-10 flash-and-run cycles.** The device advertises its tier in `bcdDevice`, so the battery is 30 cases only at tier 4. One pass proves nothing on a flaky bring-up; a deterministic partial count (exactly 1 in 8 lost) is a signature to chase, not noise. 5. Add the board to the HIL roster (`test/hil/*.json`) when roster edits are in the task's scope; otherwise hand it to the owner. Automated PR repair never touches the rosters. ## Wrong profile `/sys/bus/usb/drivers/usbtest/new_id` lists only `cafe 4010`, never the profile behind it, and a bound interface keeps the profile it was probed with. The right one probes as `Linux gadget zero` in dmesg; the user-mode profile (`0525 a4a4`) passes bulk but reports 14, 21, 25, 26 and 15, 16, 22, 23 as NOTRUN. Repair is a module reload on a reserved, idle rig: 1. Reserve the whole fleet as the `hil` skill's rig-wide rule says, and confirm no `testusb` or `usbtest.py` is running: `modprobe -r` detaches every bound interface and blocks, unkillably, behind any case still in flight. It is never refused for an interface in use. 2. `sudo modprobe -r usbtest && sudo modprobe usbtest`. 3. The next `usbtest.py` or `hil_test.py` run registers the id again. 4. Confirm a `Linux gadget zero` probe for the next board, then release. ## Traps that pass gcc and desk review Carried over from earlier ports, not re-verified: - clang `-Wunused-function` and IAR `Pe177` reject an unused `static inline` that gcc accepts: `TU_ATTR_UNUSED`. - A symbol referenced only from naked asm is dropped by `-flto` make builds: keep a `TU_ATTR_USED` C reference. - Nested USB IRQs on cores with a hardware context stack (QingKe HWSTK): a plain `__attribute__((interrupt))` corrupts the return; use naked handlers. - Dedicated USB RAM (PMA, USB-RAM) budgets differ per part and per build system's section placement: check the link map, not only that it builds. ## Red flags - "One pass means done": run reflash cycles. - "The DCD comment says the hardware can't": open the reference manual. - "usbmon shows no toggle problem": usbmon cannot see toggles. - "Fixed isochronous IN": apply the same exemption to OUT. - "It works on gcc": clang, IAR, LTO and make builds are still pending. - "A clean single-board run": fleet runs put several batteries per controller plus concurrent flashes on shared hub uplinks. - Reasoning about a case from its name or a table row: run step 0.
View on GitHub