Merge pull request #3775 from hathach/claude/add-usb-debug-sniff

usb-target-debug/usb-sniffer skills
This commit is contained in:
Ha Thach
2026-07-19 00:33:14 +07:00
committed by GitHub
17 changed files with 938 additions and 51 deletions

View File

@ -0,0 +1,68 @@
---
name: target-debugger
description: Root-cause one USB misbehavior on real HIL hardware by instrumenting the TinyUSB device side — TU_LOG/RTT, RAM ring-buffer trace, GDB autopsy, J-Link PC-sampling — correlated with host-side and wire-level capture. Long serial debug loop under one held board lock; strictly one instance. Produces a diagnosis with on-target evidence (plus a candidate fix when one emerges), never a merged patch.
model: opus
---
You debug one failing USB behavior on one physical board until you can name the
mechanism — or report exactly what you ruled out. These repo skills are your
source of truth; read the relevant SKILL.md BEFORE acting:
- `.claude/skills/usb-target-debug/SKILL.md` — your primary playbook: technique
choice by intrusiveness, capture recipes, GDB autopsy, all rig warnings.
- `.claude/skills/hil/SKILL.md` — host/config selection, board lock protocol,
`hil_test.py` invocation.
- `.claude/skills/usbmon/SKILL.md` — host-side URB capture (the default posture
is dual-side: host + target simultaneously).
- `.claude/skills/usb-sniffer/SKILL.md` — wire-level capture with the hardware
tap, when the host can't see the bus (device never enumerates, pre-URB
failures) or when usbmon and device logs disagree — the wire arbitrates.
- `.claude/skills/usb-debug/SKILL.md` — why the host acted (dmesg/dynamic debug).
- `.claude/skills/usb-recover/SKILL.md` — only when the DUT or fixture wedges
the host stack.
## The loop (deliberately serial — no fan-out)
hypothesis → least-intrusive technique that can test it → instrument → build →
flash → trigger the failing case → capture both sides → correlate → refine.
One hypothesis per cycle. A disproven hypothesis is progress — record it and
what disproved it. If instrumentation makes the bug vanish, that IS a finding
(timing-sensitive): move DOWN in intrusiveness, not up.
## Diagnosis standard
A theory becomes a diagnosis only when (a) captured evidence directly shows the
mechanism, or (b) a change validated against the ORIGINAL failing case flips it
on hardware. A plausible fix that "should" explain it counts for nothing until
the original case passes with it and fails without it. Stop and hand back a
partial diagnosis when two consecutive instrument→capture cycles yield no new
evidence: report what was ruled out, the strongest surviving hypothesis, and
the next technique you would try.
## Lock discipline
- Hold the board lock for the WHOLE session (`board_lock.py hold <board>
--reason "target debug: <bug>"`). Multi-hour holds are fine; never stop the
actions-runner. Locks held by others: report holder/reason, never force
unless your prompt states the user authorized it.
- `hil_test.py` self-locks: release your hold before any `hil_test.py` run,
re-hold immediately after.
- You cannot ask the user anything mid-session.
## Hard rule — fix stays, probe goes, re-verify clean
Instrumentation is temporary. Before releasing the lock at session end:
1. Revert every instrumentation change (ring buffers, extra logging, temporary
tier/skip edits). The candidate fix, if one emerged, stays in the working
tree — uncommitted.
2. Rebuild clean (fix only, no probes) and re-run the original failing case on
it — `fixVerified` means verified on THIS build, not an instrumented one.
3. Reflash pristine firmware so the next CI run inherits nothing.
Anything you could not revert or verify goes in `notes`, explicitly.
## Output contract
Your final message is parsed by a program. Return ONLY this JSON — no prose,
no code fences:
{"board": "...", "bug": "<one-line original failing case>", "diagnosis": "<mechanism, or strongest surviving hypothesis>", "confirmed": true, "ruledOut": ["<hypothesis what disproved it>"], "evidence": ["<artifact path or capture what it shows>"], "fixDiffstat": "<git diff --stat, or empty>", "fixVerified": false, "instrumentationReverted": true, "lockReleased": true, "notes": "..."}

View File

@ -0,0 +1,149 @@
---
name: usb-sniffer
description: Use when you need wire-level USB evidence that host-side capture can't provide — a device that never enumerates (usbmon shows nothing or only Submits), suspected NAK storms/STALL/babble/bad handshakes, bus-reset or enumeration timing, split-transaction issues, or a usbmon-vs-device-log disagreement the wire must arbitrate. Captures LS/FS/HS packets (PIDs, tokens, handshakes, SE0/line states) with the ataradov usb-sniffer hardware into Wireshark pcapng.
---
# usb-sniffer — wire-level capture with the ataradov hardware analyzer
Extends the debugging trio with the layer below URBs:
| Skill | Answers |
|---|---|
| `usbmon` | what the host software exchanged (URBs) |
| `usb-debug` | why the host acted (dmesg / dynamic debug) |
| `usb-target-debug` | what the device firmware did |
| **`usb-sniffer`** | **what actually crossed D+/D-** (PIDs, handshakes, resets, timing) |
Reach for it when usbmon can't see (device never binds, pre-enumeration
failures) or can't be trusted (URB completed but did the wire really ACK?).
For everything visible in URBs, usbmon is cheaper — no hardware, no locks.
## Rig inventory — find the sniffer and what it taps
```bash
lsusb -d 6666:6620 # sniffer present? (github.com/ataradov/usb-sniffer)
```
The sniffer is a passive tap: host-side and device-side connectors pass
through, the capture port is a separate USB device. What it taps is a cabling
fact you must confirm every session, not assume: start a capture (below),
provoke known control traffic to a candidate (`lsusb -v -s <bus>:<dev>
>/dev/null`), and see whether those requests appear on the wire. The DUT's
link speed (`cat /sys/bus/usb/devices/<port>/speed`) picks `--speed`.
The tapped board is rig hardware: hold its board lock for any session that
resets or reflashes it (`hil` skill). The sniffer itself is not lockable and
capture alone perturbs nothing.
## Capture
The tool is `usb_sniffer` (installed in `~/.local/bin`, extcap-symlinked so
Wireshark's GUI also shows a "USB Sniffer" interface). Headless recipe:
```bash
timeout 15s usb_sniffer --capture --fifo /tmp/cap.pcapng --speed hs # or fs / ls
```
- `--speed` MUST match the DUT's link speed (default is fs!). Wrong speed =
no USB packets, only Syslog pseudo-packets ("Line state: SE0", "VBUS ON").
If you see only those, fix `--speed` before doubting the hardware.
- ALWAYS bound the capture: `timeout` and/or `--limit N` (packets). HS runs
1520 MB/s even with `--fold` when any device on the bus is busy (`--fold`
only collapses truly empty frames). Unbounded HS captures reach GB fast.
- The output is valid pcapng the moment the process dies; a plain file path
works (no FIFO needed). `--trigger low|high|falling|rising` arms capture
on the external trigger pin instead of starting immediately.
- Tool diagnostics: `USB_SNIFFER_LOG=/tmp/sniffer.log usb_sniffer ...`
Start the capture FIRST, then trigger the event you care about. The proven
one-pass enumeration recipe (`--limit` makes the tool exit by itself; on a
busy HS bus ~470k packets/s ≈ 20 MB/s, so 3M packets ≈ 67 s ≈ 120 MB — do
NOT capture for 20+ s "to be safe", the raw balloons and every later tshark
pass pays for it; but do NOT go below ~3M either: J-Link connect latency
varies run-to-run (0.54 s) and a 3 s window has provably missed the ladder):
```bash
usb_sniffer --capture --fifo raw.pcapng --speed hs --fold --limit 3000000 &
sleep 1
# trigger: full ladder incl. SET_ADDRESS (needs board lock; J-Link resets the MCU):
printf 'r\ng\nqc\n' | JLinkExe -device $JLINK_DEVICE -SelectEmuBySN <probe-uid> \
-if swd -speed 4000 -autoconnect 1 -nogui 1
wait # tool prints "Capture limit reached" and exits
```
No-probe trigger alternative — kernel-side re-enumeration (may reuse the
xHCI address and skip parts of the ladder; fine for descriptor reads, weak
for reset timing):
`echo 0 | sudo tee /sys/bus/usb/devices/<port>/authorized; sleep 1; echo 1 | sudo tee ...`
## Reading the capture
```bash
tshark -r cap.pcapng -Y 'usb.bmRequestType' # the control ladder
tshark -r cap.pcapng -Y 'usb.bDescriptorType == 1' \
-T fields -e usb.idVendor -e usb.idProduct # VID:PID off the wire
tshark -r cap.pcapng -Y 'usbll.pid' # raw token/handshake level
editcap -r cap.pcapng slice.pcapng <first>-<last> # trim huge captures
```
On a capture >100 MB, make exactly ONE filtered pass (the ladder filter
above) to find the frame numbers of your event window, `editcap -r` to that
window, and do all further analysis on the slice — repeated broad tshark
passes over a 300 MB raw are what turn a 5-minute job into 15.
Find the DUT's wire address from the capture, not from lsusb: the
SET ADDRESS request payload carries it (`00 05 <addr> 00 ...`), and all
subsequent traffic goes to `<addr>.<ep>` (`usbll.addr`). **On xHCI hosts the
lsusb device number is NOT the wire address** — they diverge routinely.
Filter analysis to the DUT: `-Y 'usbll.addr contains "4."'`.
## What the wire really shows (read before concluding anything)
- **Downstream is broadcast.** Tokens, SETUP and OUT data addressed to EVERY
device on the tapped bus segment appear in the capture; upstream (DATA in
response to IN) appears only from devices on the tapped branch. Lone
IN→ACK pairs without DATA to some other address are normal, not corruption.
- **The sniffer can capture its own upload.** If its capture port shares the
host controller bus with the tap, its bulk-IN polling floods the capture
(easily >90% of packets) — filter it out by address; for surgically clean
captures move the capture cable to a different host controller.
- **Port-reset visibility depends on the tap point.** Tapping the DUT's own
cable: a reset reaches the sniffer PHY and you get explicit
`--- Bus Reset ---` / `Detected speed:` Syslog records. Tapping a hub
upstream: the hub isolates downstream port resets — no marker appears. Anchor reset timing on the hub choreography instead:
SetPortFeature(PORT_RESET) to the hub's address = reset start,
ClearPortFeature(C_PORT_RESET) = reset end (start the capture before
triggering, or the initiating SetPortFeature is missing from the file).
The DUT's silence gap corroborates, but do not read every gap as a
reset — idle captures contain benign multi-ms gaps.
- **FS device behind an HS hub**: the upstream tap shows SPLIT transactions,
not native FS packets. Tap the DUT's own cable and capture at `fs` for
clean full-speed traffic.
## Setup (one-time)
```bash
# udev: tools/88-tinyusb.rules covers 6666:6620 + blank FX2LP 04b4:8613
sudo cp tools/88-tinyusb.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger -s usb
# binary (ataradov repo bin/usb_sniffer_linux) + Wireshark extcap symlink (needs Wireshark >= 4.x)
cp usb_sniffer_linux ~/.local/bin/usb_sniffer && chmod +x ~/.local/bin/usb_sniffer
mkdir -p ~/.local/lib/wireshark/extcap
ln -sf ~/.local/bin/usb_sniffer ~/.local/lib/wireshark/extcap/usb_sniffer
```
Never run `--mcu-eeprom` / `--fpga-flash` / `--fpga-erase` against a working sniffer — those program NEW hardware.
## Warnings
- **Bound every capture** (`timeout` / `--limit`) and delete or `editcap`-trim
multi-hundred-MB raws before handing off; a forgotten capture process fills
the disk at HS rates.
- The tap is passive — capturing, or unplugging the capture port, does not
disturb the DUT's link. Unplugging the pass-through DOES.
- Answers must come from packet payloads (SETUP/DATA hex), not from host-side
logs — that is the whole point of being on the wire; if an answer isn't in
the capture, say so rather than approximating from sysfs/dmesg.
- Release the board lock and leave no capture processes running at session
end (`pgrep -a usb_sniffer`).

View File

@ -0,0 +1,198 @@
---
name: usb-target-debug
description: Use when a TinyUSB device misbehaves on real hardware and host-side capture can't explain it — a HIL test fails but usbmon shows only Submits with no Completes, the device silently NAKs, wedges, STALLs, babbles, or drops data, EP0 starves, an ISR or DCD/HCD state bug is suspected — and you need device-side evidence: TU_LOG/RTT logs, GDB state dumps, a RAM ring-buffer event trace, or PC-sampling of where the core spins.
---
# usb-target-debug — device-side capture & debugging on the HIL rig
Completes the debugging trio (the `usb-sniffer` skill adds a fourth,
wire-level view when hardware tapping is available):
| Skill | Answers |
|---|---|
| `usbmon` | what the host actually exchanged (URBs) |
| `usb-debug` | why the host acted (dmesg / dynamic debug) |
| **`usb-target-debug`** | **what the device did** (logs, driver state, PC) |
| `usb-sniffer` | what crossed the wire (PIDs, handshakes, resets — hardware tap) |
For enumeration/transfer bugs the default posture is **dual-side capture**
usbmon on the host *and* a target-side channel, simultaneously — not
host-first-then-escalate.
## Rig discipline — lock first, always
Hold the board lock for the WHOLE manual session; never stop the
actions-runner (see the `hil` skill for the full lock protocol):
```bash
python3 test/hil/board_lock.py hold <board> --reason "target debug: <bug>"
# ... instrument / build / flash / capture / GDB ...
python3 test/hil/board_lock.py release <board>
```
Board → probe mapping: `test/hil/tinyusb.json``flasher.name` is the probe
family, `flasher.uid` the **probe serial** (many identical probes on the rig:
J-Link needs `-SelectEmuBySN <uid>` / GDB server `-select usb=<uid>`; OpenOCD
`-c 'adapter serial <uid>'`). `JLINK_DEVICE` / `OPENOCD_OPTION` come from
`hw/bsp/<family>/boards/<board>/board.cmake` (or `board.mk`); find the family
with `ls -d hw/bsp/*/boards/<board>`. Run on the host that owns the probe —
config is `test/hil/tinyusb.json` on ci, `local.json` on htpc (`hil` skill).
## Pick the least intrusive technique that can answer the question
Observation can mask the bug — the ch32v307 Heisenbug changed behavior under
logging *and* under the debugger. If the bug disappears when instrumented,
that IS a finding (timing-sensitive): move down in intrusiveness, not up.
| Technique | Intrusiveness | Reach for it when |
|---|---|---|
| PC-sampling | none — no halt, no code change | core wedged/spinning somewhere unknown (rusb2 FRDY) |
| RAM ring-buffer | ~tens of cycles per event | ISR ordering/timing bugs (musb babble) |
| TU_LOG (RTT) | µs per line | logic bugs that survive logging |
| TU_LOG (UART) | ms per line — blocking write | same, when no J-Link on the board |
| GDB halt / breakpoints | stops USB service entirely | post-mortem state autopsy once wedged |
## TU_LOG capture
Build with `LOG=2` (`LOG=3` adds per-transfer noise and much more timing skew).
`LOGGER=rtt` routes it over the debug probe (J-Link only) — no UART wiring:
```bash
# RTT: JLinkGDBServer from AGENTS.md "GDB Debugging" + -RTTTelnetPort, then:
timeout 20s JLinkRTTClient > /tmp/rtt.log # non-interactive capture
# UART (board's debug serial, if wired):
stty -F /dev/ttyACM<N> 115200 raw && timeout 20s cat /dev/ttyACM<N> | tee /tmp/uart.log
```
An RTT-built firmware that has since wedged still holds a log tail in RAM —
but ONLY what fits the drain model: the default SEGGER mode (NO_BLOCK_SKIP)
**drops** writes once the ring fills with no reader, so an undrained target
holds the first KB after boot, not the wedge tail. There is no overwrite mode
in stock SEGGER RTT (only SKIP/TRIM/BLOCK): post-mortem RTT is evidence only
if a live drain was running — otherwise instrument with the RAM ring below.
Use `JLinkGDBServer -RTTTelnetPort 19021` + `JLinkRTTClient` for the drain
(proven; note the server briefly halts the core on connect). `JLinkRTTLogger`
fails to find the control block on some parts (LPC4088) even when it exists
and even given `-RTTAddress`; don't fight it — `nm` the ELF for `_SEGGER_RTT`,
read the aUp[0] descriptor (`mem32`), `savebin` the buffer — debug-AP RAM
reads don't halt the target.
## GDB — state autopsy and watchpoints
Connect/load recipes per probe family (J-Link, OpenOCD for ST-Link /
CMSIS-DAP / WCH-Link) are in AGENTS.md "GDB Debugging". Release builds keep
DWARF (`MinSizeRel`), so `p`/struct access works on HIL firmware.
**Autopsy of a wedged board: attach and halt ONLY** — skip AGENTS.md's
`monitor reset halt` + `load` (those are for fresh starts; a reset destroys
the evidence). Symbolize with the ELF that is actually flashed —
`<build root>/cmake-build-<board>/<example>/<example>.elf` from the run that
wedged; do not rebuild while the wedge is still on the board. The debug-loop
specifics:
```gdb
p/x _usbd_dev.ep_status # usbd core [epnum][dir] (1=IN): busy/stalled/claimed
p/x <port's private state> # per-port names — read the board's dcd_*.c first
x/32wx <USB peripheral base> # raw EP/FIFO regs; base = the macro the dcd uses
watch xfer_status[2][1].total_len # HW watchpoint (Cortex-M: ~4); dwc2 names shown
break dcd_int_handler # works, but see warning below
```
While halted the device answers **nothing**: host control transfers time out
in ~5 s and the OS may reset/re-enumerate — after `continue`, the bus traffic
shows recovery, not the original bug. Prefer one halt for a post-mortem dump
over stepping through live USB traffic.
## RAM ring-buffer trace
The zero-print instrument (cracked the musb babble): a small event ring in the
dcd/hcd, dumped over GDB after the failure. Single-writer (ISR) — no locking:
```c
typedef struct { uint16_t ev; uint16_t a; uint32_t b; } dbg_ev_t;
#define DBG_N 512 // power of two
static volatile dbg_ev_t dbg_ring[DBG_N]; // volatile REQUIRED: -Os dead-store-
static volatile uint32_t dbg_wr; // eliminates a write-only static array
static inline void DBG_EV(uint16_t ev, uint16_t a, uint32_t b) {
uint32_t i = dbg_wr++;
dbg_ring[i & (DBG_N - 1)] = (dbg_ev_t){ ev, a, b };
}
// call sites: DBG_EV(__LINE__, ep_addr, count); — __LINE__ as event id
```
After building, `nm` the ELF for `dbg_ring`/`dbg_wr` — if they're missing the
compiler deleted your instrument and the run will "reproduce" with an empty ring.
Order is the index; if durations matter add a `uint32_t t = DWT->CYCCNT` field
(enable once: `CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= 1;`
RISC-V: read `mcycle`). Let the failure happen, halt, then:
```gdb
p dbg_wr # total events; oldest slot = dbg_wr & (DBG_N-1) once wrapped
p dbg_ring
dump binary memory /tmp/ring.bin &dbg_ring[0] &dbg_ring[512]
```
## PC-sampling (J-Link) — find where the core spins, without halting
`DWT_PCSR` (0xE000101C) returns the current PC on every read, target running
(Cortex-M3+; optional on M0+, reads 0 if absent; 0xFFFFFFFF = core halted or
WFI-asleep — `mem32 E000EDF0, 1`, DHCSR bit 17 S_HALT, tells which). One
probe serves one client: quit JLinkExe before starting JLinkGDBServer on the
same probe. Nailed the rusb2 FRDY wedge:
```bash
for i in $(seq 300); do echo 'mem32 E000101C, 1'; done \
| JLinkExe -device $JLINK_DEVICE -SelectEmuBySN <uid> -if swd -speed 4000 -autoconnect 1 -nogui 1 \
| awk '/E000101C = /{print $3}' | sort | uniq -c | sort -rn | head
arm-none-eabi-addr2line -e <firmware.elf> -f -a 0x<hot-pc> ... # PCs → functions
```
OpenOCD variant: repeat `mdw 0xE000101C` over telnet :4444. The histogram's
top entries are the spin site; a flat histogram = core is servicing normally.
## Dual-side capture — the default for enumeration/transfer bugs
Start both channels, then trigger the failing test:
```bash
.claude/skills/usbmon/scripts/usbcap.sh cafe: 30 /tmp/host.pcapng & # host URBs (usbmon skill)
timeout 30s JLinkRTTClient > /tmp/target.rtt & # target (or ring dump after)
wait
```
RTT lines and ring events carry no wall-clock: correlate on unambiguous
anchors — bus reset, SET_ADDRESS, the first transfer on the failing EP — then
lay device events between anchors in host-URB order. Logging the SOF/frame
number on the target gives a shared clock when you need finer alignment.
When host and target evidence disagree, or the host sees nothing at all, add
the wire itself: `usb-sniffer` skill (hardware tap, PID-level).
## Warnings
- **Halting/resetting via the probe does NOT disconnect the device**: a DWC2
soft-connect pullup stays up through core halt *and* reset, so the host's
stuck URBs stay stuck and a wedged DUT stays wedged — recover the host side
with the `usb-recover` skill.
- **A bug that vanishes under LOG=2 is a timing bug**, not fixed: switch to
the ring buffer; if it vanishes under GDB too, PC-sampling only.
- **UART TU_LOG blocks in the write path** (worst perturbation, including
inside the ISR); RTT is much cheaper but not free; `LOG=3` multiplies both.
- Flash/GDB only with the board lock held; a `hold` refused with reason
`hil_test.py` means CI is mid-test on that board — wait, don't force.
- **Instrumentation is temporary**: before `release`, reflash pristine
firmware (the next CI run must not inherit a debug build) and revert the
instrumentation diff — or hand it over explicitly with the diagnosis.
- **A register snapshot without a validity anchor lies**: J-Link tool sessions
can reset or briefly halt the DUT as a side effect, and a snapshot of a
freshly-reset chip (e.g. NVIC ISER = 0) reads like a smoking gun. Read DHCSR
(0xE000EDF0: bit 17 S_HALT, bit 25 S_RESET_ST) with every snapshot, and
cross-check against something the device demonstrably still does.
- **A marginal link can fake a deterministic firmware bug** — down to failing
the same test at the same iteration twice. "USB disconnect" in dmesg on a
freshly re-cabled port (high devnum = churn) means the plug, not the code:
first sustained bulk traffic is when a bad contact drops. Before declaring a
regression, re-run the OLD build on the SAME link state — and if a bisect
exonerates every hunk, believe it: re-test the exact failing binary.
- **Release your manual lock before `hil_test.py`** — it self-locks each board
and fails immediately on your own hold (`hil` skill).

1
.gitignore vendored
View File

@ -60,6 +60,7 @@ BrowseInfo
README_processed.rst
docs/examples/
.worktrees
.claude/worktrees/
cmake-metrics/
# Directories fetched by tools/get_deps.py - not to be committed
lib/CMSIS_5/

View File

@ -181,7 +181,7 @@ Some examples require udev permissions to access USB devices:
.. code-block:: bash
$ cp `examples/device/99-tinyusb.rules <https://github.com/hathach/tinyusb/tree/master/examples/device/99-tinyusb.rules>`_ /etc/udev/rules.d/
$ sudo cp examples/device/99-tinyusb-examples.rules /etc/udev/rules.d/
$ sudo udevadm control --reload-rules && sudo udevadm trigger
Next Steps

View File

@ -0,0 +1,125 @@
# Hand-off: `usb-target-debug` skill + `target-debugger` agent
**Status: agreed but NOT started.** Design discussion happened 2026-07-13 in session
`c31a4617-43b1-491d-9865-3e35f393996b` (post-merge of the agents/workflows harness,
PR #3762 / `ac595bc5c`). This document is the implementation brief for a fresh session.
**Agreed sequencing: skill first → dogfood on 1-2 real HIL failures → then the agent
as its own small PR.** Do not build both at once — the agent charter's hard parts are
exactly what dogfooding the skill answers.
## The gap being filled
When HIL fails today, *what failed* is covered (hil-validate workflow, hil-operator
agent) but the deep *why* loop — instrument the target, capture on both sides,
correlate — has no skill and no agent. Every hard case so far (musb babble, rusb2
FRDY wedge, ch32v307 Heisenbug) fell back to interactive main-session work.
Why no existing agent can do it:
- **hil-operator** (sonnet) is deliberately mechanical: lock → flash → `hil_test.py`
→ recover. It never edits source, so it cannot inject instrumentation.
- **port-dev** can edit source but its charter is scoped changes verified by a
*build*; it has no hardware mandate.
- The host-side capture knowledge lives in skills (`usbmon`, `usb-debug`); the
device-side half exists only as CLAUDE.md recipes plus session memory.
The skill completes the debugging trio:
| Skill | Answers | Status |
|---|---|---|
| `usbmon` | what the host actually exchanged (URBs) | on master |
| `usb-debug` | why the host acted (dmesg / dynamic debug) | ships in PR #3758 (untracked copy in tree) |
| `usb-target-debug` | what the device did | **this hand-off** |
## Part 1 — `usb-target-debug` skill (do this first)
Create `.claude/skills/usb-target-debug/SKILL.md`. Match the style of
`.claude/skills/usbmon/SKILL.md` and `usb-debug/SKILL.md`: frontmatter `name` +
`description` where the description states concretely *when* to reach for it
(HIL test fails and host-side capture can't explain it; device silently NAKs,
wedges, or misbehaves; need TU_LOG/device-state evidence from real hardware).
Playbook to codify — all techniques already proven on this rig:
1. **TU_LOG capture** — build with `LOG=2` (add `LOGGER=rtt` for RTT); UART capture
from the board's debug serial; RTT via `JLinkGDBServer -RTTTelnetPort 19021` +
`JLinkRTTClient` (non-interactive: `timeout 20s JLinkRTTClient > rtt.log`).
Note which log level perturbs timing (see warning #6).
2. **GDB recipes per probe family** — J-Link, OpenOCD (ST-Link / CMSIS-DAP /
WCH-Link). Base connect/load recipes already exist in CLAUDE.md "GDB Debugging";
the skill adds the debug-loop specifics: breakpoints in ISR context, dumping
endpoint/FIFO registers, watchpoints on driver state variables.
3. **RAM ring-buffer trace pattern** (used to crack the musb babble): instrument
the dcd/hcd with a small RAM ring of event records instead of TU_LOG when
printing perturbs timing; let the failure happen; halt and dump the ring via
GDB. Include a minimal C snippet (fixed-size struct ring, no allocation,
ISR-safe single-writer).
4. **J-Link PC-sampling** (nailed the rusb2 FRDY wedge): statistically sample PC
without halting to find where the core spins — the non-intrusive option when
halting or logging masks the bug.
5. **Dual-side capture**: usbmon on the host + RTT/ring-buffer on the target,
simultaneously; correlate host URBs against device events on one timeline.
This is the default posture for enumeration/transfer bugs, not an escalation.
6. **Warnings**: observation can mask the bug (the ch32v307 case changed behavior
under logging/debug — prefer ring-buffer over TU_LOG, PC-sampling over halting,
and say so explicitly); a J-Link core reset does NOT drop a DWC2 soft-connect
pullup, so a wedged DUT stays wedged on the host side (cross-ref
`usb-recover/SKILL.md`).
7. **Rig discipline**: hold the board lock for the whole manual session —
`python3 test/hil/board_lock.py hold <board> --reason "target debug: <bug>"`
… work … `release <board>`. Never stop the actions-runner. Board → probe
mapping via `test/hil/tinyusb.json`; `JLINK_DEVICE`/`OPENOCD_OPTION` via
`hw/bsp/*/boards/*/board.cmake` or `board.mk`.
**Where to ship**: its own small PR (usb-recover/usb-debug already belong to
PR #3758 — don't grow that one), or fold into #3758 if it is still open and being
rebased anyway. User's call at the time.
## Part 2 — `target-debugger` agent (later, after dogfooding)
Create `.claude/agents/target-debugger.md` as its own PR once the skill has been
through at least one real debug session.
Agreed charter outline:
- **Frontmatter**: `model: opus`; omit `tools:` (= all tools — it must edit source
AND drive hardware). Note the registry supports no `effort` field — the agreed
opus/**xhigh** tier is requested per `agent()` call by whichever workflow or
session spawns it.
- **Loop**: instrument → build → flash under one held board lock → dual-side
capture (host usbmon + target RTT/ring-buffer/GDB) → correlate → refine
hypothesis → repeat. Deliberately serial: no fan-out win; the value is
backgrounding a long debug session and the codified playbook.
- **Strictly one instance**, holds the board lock for the entire session — its work
is exactly the "hardware work outside hil_test.py" case in the lock protocol.
- **Skills are its source of truth** (mirror hil-operator's pattern): read
`usb-target-debug`, `usbmon`, `usb-debug`, `usb-recover`, `hil` SKILL.md files
before acting.
- **Hard rule — instrumentation is temporary**: the instrumentation diff must be
reverted (or explicitly listed in the hand-back report) at session end; the *fix*
itself goes to port-dev. Keeps charters clean: this agent produces a diagnosis
and evidence, not a merged patch.
Questions dogfooding must answer before the charter is written (do NOT guess these
now — that was the whole reason for skill-first):
1. When to stop instrumenting and report a partial diagnosis vs keep digging.
2. Maximum board-lock hold time / check-in cadence for a backgrounded session.
3. What "revert instrumentation" means when a partial fix emerged mid-debug
(revert + attach diff? keep on a branch?).
## Conventions and references for the implementing session
- Skill style exemplars: `.claude/skills/usbmon/SKILL.md`, `usb-debug/SKILL.md`,
`usb-recover/SKILL.md` (the latter two are #3758's copies, present untracked).
- Agent style exemplars: `.claude/agents/hil-operator.md` (lock discipline,
skills-as-source-of-truth), `port-dev.md` (source-edit + verify charter).
- When the agent lands, update the harness spec's agent roster:
`docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md`
(convention: spec evolves in-repo; plans like this file are per-effort records).
- Agents register from `.claude/agents/*.md` at session start — a new agent file
is only visible to sessions launched after it exists.
- Past cases to mine for the skill's examples: musb babble (ring-buffer trace),
rusb2 FRDY wedge (J-Link PC-sampling), ch32v307 Heisenbug (observation
sensitivity) — details in session memory and the referenced session transcript.

View File

@ -29,10 +29,12 @@ Layered: **agents** (who does the work, with baked-in domain knowledge) ×
### Worker agents — `.claude/agents/*.md`
Tiered models (owner revision 2026-07-09; originally all-opus): `port-dev`
and `driver-reviewer` on **opus** at **xhigh**; `hil-operator`, `pr-monitor`
and `static-analyzer` on **sonnet**; `builder` on **haiku** (mechanical,
log-heavy).
Tiered models (owner revision 2026-07-09; originally all-opus): `port-dev`,
`driver-reviewer` and `target-debugger` on **opus** at **xhigh**;
`hil-operator`, `pr-monitor` and `static-analyzer` on **sonnet**; `builder`
on **haiku** (mechanical, log-heavy). The registry has no effort field —
xhigh is requested per `agent()` call by whichever workflow or session spawns
the agent.
| Agent | Effort | Role |
|---|---|---|
@ -40,6 +42,7 @@ log-heavy).
| `port-dev` | xhigh | Implement one well-scoped change in one port / file set. Follows repo rules: C99, 2-space indent, snake_case, `TU_ASSERT`, no dynamic allocation, ISR work deferred to task context. Runs `clang-format` (repo `.clang-format`) on touched files before finishing. Cross-checks the MCU datasheet in `$HOME/Documents/calibre-library` when changing dcd/hcd register logic. Verifies with a targeted build of one board using the port. Returns `{item, diffstat, buildOk, notes}`. |
| `driver-reviewer` | xhigh | Review one dcd/hcd directory against dimensions: correctness, ISR safety, register use vs. datasheet AND MCU errata (calibre library; missing erratum workarounds are findings), style. Returns structured findings `{file, line, snippet, why, severity, confidence}` — coverage-first (report everything; filtering happens downstream). |
| `hil-operator` | default | All rig interaction — the actions-runner service is NEVER stopped; per-board flock locks arbitrate with concurrent CI. `hil_test.py` runs rely on its per-board self-locking; manual hardware work (JLink/GDB, usbtest, serial) is wrapped in `test/hil/board_lock.py hold/release`; rig-wide ops (uhubctl, pci-rebind) require `hold --all`; on wedge `usb_recover.sh` + dmesg. Used strictly serially — never two instances concurrently. |
| `target-debugger` | xhigh | Root-cause one USB misbehavior on one board by instrumenting the device side (TU_LOG/RTT, RAM ring-buffer trace, GDB autopsy, J-Link PC-sampling) with dual-side host+target capture, per `.claude/skills/usb-target-debug/SKILL.md`, plus wire-level capture via the ataradov hardware tap (`.claude/skills/usb-sniffer/SKILL.md`) when the host side can't see or is disputed. Deliberately serial loop under one held board lock (released around `hil_test.py` runs, which self-lock); strictly one instance. Diagnosis standard: evidence shows the mechanism, or a fix flips the ORIGINAL failing case on hardware; stops after two evidence-free cycles with a partial report. Hard rule "fix stays, probe goes, re-verify clean": instrumentation reverted, candidate fix left uncommitted and re-verified on a clean build, pristine firmware reflashed before lock release. Returns `{board, bug, diagnosis, confirmed, ruledOut[], evidence[], fixDiffstat, fixVerified, instrumentationReverted, lockReleased, notes}`. |
| `pr-monitor` | default | Triage one GitHub PR via `gh`: check CI status (`gh pr checks`), read failing run logs and classify each failure infra/flake vs real; re-run infra failures (`gh run rerun --failed`); harvest automated review comments (Codex/Copilot/Claude bots — knows their signals: Codex posts a "Didn't find any major issues" issue comment when clean; Copilot drops out of `requested_reviewers` when done; bot logins differ across APIs); adversarially validate each finding against the actual code. Returns structured triage `{ci: {status, infraRerun[], realFailures[]}, findings: [{source, file, line, claim, verdict, fixHint}]}`. Read/triage/re-run/reply only — never edits code. |
| `static-analyzer` | low | Run PVS-Studio (SAST + MISRA C:2023/C++:2008) for one board: build with exported `compile_commands.json` (via `run_pvs.sh` solo, or a dedicated `cmake-build-pvs` dir when parallel builders run), analyze against `.PVS-Studio/.pvsconfig`, gate on diagnostics in files changed vs a base ref. Returns `{pass, ga1, ga2, changedFindings[], detail}`; `pass=false` only on GA:1 in changed files or tool failure. Read-only. |
@ -120,8 +123,8 @@ carries the judgment; JS carries the orchestration.
## Model & effort policy
- Tiered worker models: `port-dev`/`driver-reviewer` **opus** `xhigh`;
`hil-operator`/`pr-monitor` **sonnet**; `builder` **haiku**.
- Tiered worker models: `port-dev`/`driver-reviewer`/`target-debugger` **opus**
`xhigh`; `hil-operator`/`pr-monitor` **sonnet**; `builder` **haiku**.
- Inline workflow stages: unit/size **haiku**; pvs **sonnet** (low effort);
pr-babysit push/replies **sonnet**.
- Agent frontmatter `model:` is canonical for `agentType` calls; it is read

View File

@ -1,5 +1,6 @@
# udev rules for running the TinyUSB device examples as a non-root user.
# Copy this file to the location of your distribution's udev rules, for example on Ubuntu:
# sudo cp 99-tinyusb.rules /etc/udev/rules.d/
# sudo cp 99-tinyusb-examples.rules /etc/udev/rules.d/
# Then reload udev configuration by executing:
# sudo udevadm control --reload-rules
# sudo udevadm trigger

View File

@ -65,7 +65,15 @@ enum {
};
// Place bulk endpoints on EP>=8 for MAX32690 class parts (bigger FIFO, DPB-capable).
#if CFG_TUD_ENDPOINT_ONE_DIRECTION_ONLY
#if CFG_TUSB_MCU == OPT_MCU_LPC175X_6X || CFG_TUSB_MCU == OPT_MCU_LPC177X_8X || CFG_TUSB_MCU == OPT_MCU_LPC40XX
// LPC 17xx and 40xx endpoint type (bulk/interrupt/iso) are fixed by its number
// 0 control, 1 In, 2 Bulk, 3 Iso, 4 In, 5 Bulk etc ...
#define EPNUM_CDC_NOTIF 0x81
#define EPNUM_CDC_OUT 0x02
#define EPNUM_CDC_IN 0x82
#define EPNUM_MSC_OUT 0x05
#define EPNUM_MSC_IN 0x85
#elif CFG_TUD_ENDPOINT_ONE_DIRECTION_ONLY
#if TU_CHECK_MCU(OPT_MCU_MAX32650, OPT_MCU_MAX32666, OPT_MCU_MAX32690, OPT_MCU_MAX78002)
// Put bulk on EP>=8 so the 2048/4096-byte FIFOs can back double packet buffering
#define EPNUM_CDC_NOTIF 0x81

View File

@ -67,7 +67,15 @@ uint8_t const *tud_descriptor_device_cb(void) {
//--------------------------------------------------------------------+
// Endpoint numbers
#if CFG_TUD_ENDPOINT_ONE_DIRECTION_ONLY
#if CFG_TUSB_MCU == OPT_MCU_LPC175X_6X || CFG_TUSB_MCU == OPT_MCU_LPC177X_8X || CFG_TUSB_MCU == OPT_MCU_LPC40XX
// LPC 17xx and 40xx endpoint type (bulk/interrupt/iso) are fixed by its number
// 0 control, 1 In, 2 Bulk, 3 Iso, 4 In, 5 Bulk etc ...
#define EPNUM_CDC_NOTIF 0x81
#define EPNUM_CDC_OUT 0x02
#define EPNUM_CDC_IN 0x82
#define EPNUM_PRINTER_OUT 0x05
#define EPNUM_PRINTER_IN 0x85
#elif CFG_TUD_ENDPOINT_ONE_DIRECTION_ONLY
#if TU_CHECK_MCU(OPT_MCU_MAX32650, OPT_MCU_MAX32666, OPT_MCU_MAX32690, OPT_MCU_MAX78002)
// Put bulk on EP>=8 so the 2048/4096-byte FIFOs can back double packet buffering
#define EPNUM_CDC_NOTIF 0x81

View File

@ -4,8 +4,6 @@ mcu:SAMD11
# DCD has no isochronous support (dcd_edpt_iso_alloc refuses), tier-4 cannot enumerate:
mcu:CXD56
mcu:FT90X
mcu:LPC175X_6X
mcu:LPC40XX
mcu:NUC100
mcu:NUC120
mcu:NUC505

View File

@ -51,4 +51,4 @@ make BOARD=raspberry_pi_pico all
After flashing, open the landing page (`https://example.tinyusb.org/webusb-serial/index.html`) in a WebUSB-capable browser such as Chrome, click **Connect**, and select the device — the on-board LED lights solid once connected. Characters typed in the web page are echoed back, and are also mirrored to the CDC serial port (e.g. `/dev/ttyACM0`) and vice versa.
On Linux/macOS you may need to install the udev rules from `examples/device/99-tinyusb.rules` for the browser to access the device.
On Linux/macOS you may need to install the udev rules from `examples/device/99-tinyusb-examples.rules` for the browser to access the device.

View File

@ -39,7 +39,7 @@
* is done automatically by firmware.
*
* - On Linux/macOS, udev permission may need to be updated by
* - copying '/examples/device/99-tinyusb.rules' file to /etc/udev/rules.d/ then
* - copying 'examples/device/99-tinyusb-examples.rules' file to /etc/udev/rules.d/ then
* - run 'sudo udevadm control --reload-rules && sudo udevadm trigger'
*/

View File

@ -122,6 +122,19 @@ uint32_t board_button_read(void)
return Chip_GPIO_GetPinState(LPC_GPIO, BUTTON_PORT, BUTTON_PIN) ? 0 : 1;
}
size_t board_get_unique_id(uint8_t id[], size_t max_len)
{
// IAP ReadUID (cmd 58) returns status + 4 words = full 128-bit UID
unsigned int command[5] = { IAP_READ_UID_CMD, 0, 0, 0, 0 };
unsigned int result[5];
iap_entry(command, result);
TU_ASSERT(result[0] == IAP_CMD_SUCCESS, 0);
size_t const len = tu_min32(max_len, 16);
memcpy(id, &result[1], len);
return len;
}
int board_uart_read(uint8_t* buf, int len)
{
(void) buf; (void) len;

View File

@ -135,6 +135,19 @@ uint32_t board_button_read(void) {
return BUTTON_ACTIV_STATE == Chip_GPIO_GetPinState(LPC_GPIO, BUTTON_PORT, BUTTON_PIN);
}
size_t board_get_unique_id(uint8_t id[], size_t max_len) {
// IAP ReadUID (cmd 58) returns status + 4 words = full 128-bit UID
// (lpcopen's Chip_IAP_ReadUID() only returns the first word)
unsigned int command[5] = { IAP_READ_UID_CMD, 0, 0, 0, 0 };
unsigned int result[5];
iap_entry(command, result);
TU_ASSERT(result[0] == IAP_CMD_SUCCESS, 0);
size_t const len = tu_min32(max_len, 16);
memcpy(id, &result[1], len);
return len;
}
int board_uart_read(uint8_t *buf, int len) {
//return UART_ReceiveByte(BOARD_UART_PORT);
(void) buf;

View File

@ -19,6 +19,12 @@
//--------------------------------------------------------------------+
#define DCD_ENDPOINT_MAX 32
// The iso machinery (5th DD word + packet-size memory) costs USB RAM on every build;
// compile it only when a class that can open an iso endpoint is enabled. Keep this in
// sync with the classes that actually arm an iso endpoint: audio, video, BTH (voice),
// and vendor (its optional CFG_TUD_VENDOR_EP_ISO_* endpoints, exercised by usbtest).
#define DCD_ISO_ENABLED (CFG_TUD_AUDIO || CFG_TUD_VIDEO || CFG_TUD_VENDOR || CFG_TUD_BTH)
typedef struct TU_ATTR_ALIGNED(4)
{
//------------- Word 0 -------------//
@ -48,11 +54,37 @@ typedef struct TU_ATTR_ALIGNED(4)
volatile uint16_t present_count; // For non-iso : The number of bytes transferred by the DMA engine
// For iso : number of packets
#if DCD_ISO_ENABLED
//------------- Word 4 -------------//
// uint32_t iso_packet_size_addr; // iso only, can be omitted for non-iso
volatile uint32_t iso_packet_size_addr; // iso only: pointer into iso packet-size memory,
// advanced by hardware after each packet
#endif
}dma_desc_t;
TU_VERIFY_STATIC( sizeof(dma_desc_t) == 16, "size is not correct"); // TODO not support ISO for now
TU_VERIFY_STATIC( sizeof(dma_desc_t) == (DCD_ISO_ENABLED ? 20 : 16), "size is not correct");
// Hardware fixes endpoint type by number: 3, 6, 9, 12 are the iso-capable ones.
// Constant per ep_id (= 2*epnum + dir) — unlike dd->isochronous, which dcd_edpt_xfer
// transiently zeroes while rebuilding the DD, this is safe to dispatch on from the ISR.
// TU_ATTR_UNUSED: every caller is under #if DCD_ISO_ENABLED, so non-iso builds don't
// reference it and clang -Wunused-function (fatal) would otherwise reject the build.
TU_ATTR_UNUSED TU_ATTR_ALWAYS_INLINE static inline bool ep_id_is_iso(uint8_t ep_id) {
uint8_t const epnum = (uint8_t)(ep_id >> 1);
return (epnum % 3) == 0 && (epnum != 0) && (epnum != 15);
}
#if DCD_ISO_ENABLED
// Isochronous packet-size memory (UM10562 12.15.6.3): one word per packet.
// IN : software fills Packet_length (bits 15:0), 0 = ZLP
// OUT: hardware writes Frame_number (31:17) | Packet_valid (16) | Packet_length (15:0)
// Iso-capable endpoint numbers are 3, 6, 9, 12 -> 8 slots (x2 directions).
// One packet moves per FRAME, so a deep queue only adds latency: 8 frames is plenty.
#define ISO_MAX_PACKETS 8
#define ISO_SLOT_COUNT 8
TU_ATTR_ALWAYS_INLINE static inline uint8_t iso_slot(uint8_t ep_id) {
return (uint8_t)(((ep_id / 6) - 1) * 2 + (ep_id & 1)); // ep_id = 2*epnum + dir, epnum in {3,6,9,12}
}
#endif
typedef struct
{
@ -66,11 +98,17 @@ typedef struct
{
uint8_t* out_buffer;
uint8_t out_bytes;
volatile bool out_queued; // an OUT xfer is queued; out_buffer may legitimately be NULL (status ZLP)
volatile bool out_received; // indicate if data is already received in endpoint
uint8_t in_bytes;
} control;
#if DCD_ISO_ENABLED
// iso packet-size memory, must be DMA-reachable like the DDs
volatile uint32_t iso_psize[ISO_SLOT_COUNT][ISO_MAX_PACKETS];
#endif
} dcd_data_t;
CFG_TUD_MEM_SECTION TU_ATTR_ALIGNED(128) static dcd_data_t _dcd;
@ -79,6 +117,29 @@ CFG_TUD_MEM_SECTION TU_ATTR_ALIGNED(128) static dcd_data_t _dcd;
//--------------------------------------------------------------------+
// SIE Command
//--------------------------------------------------------------------+
// The SIE command protocol (CmdCode + CCEMPTY/CDFULL handshake) and the
// slave-mode Ctrl/RxData/TxData registers are shared between thread-mode API
// calls and dcd_int_handler, and are not reentrant: an ISR preempting a
// thread-mode SIE sequence consumes its handshake flags and overwrites
// CmdCode (symptom: EP0 wedges/answers stale data right after SET_INTERFACE
// stall/clear-stall bursts overlapping bulk EOT interrupts). Mask only the
// USB interrupt around those sequences; safe to nest, including from the ISR.
static inline bool usb_irq_lock(void)
{
bool const enabled = NVIC_GetEnableIRQ(USB_IRQn) != 0;
if (enabled)
{
NVIC_DisableIRQ(USB_IRQn); // CMSIS already ends this with DSB+ISB
}
return enabled;
}
static inline void usb_irq_unlock(bool enabled)
{
if (enabled) NVIC_EnableIRQ(USB_IRQn);
}
static void sie_cmd_code (sie_cmdphase_t phase, uint8_t code_data)
{
LPC_USB->DevIntClr = (DEV_INT_COMMAND_CODE_EMPTY_MASK | DEV_INT_COMMAND_DATA_FULL_MASK);
@ -92,19 +153,28 @@ static void sie_cmd_code (sie_cmdphase_t phase, uint8_t code_data)
static void sie_write (uint8_t cmd_code, uint8_t data_len, uint8_t data)
{
bool const lock = usb_irq_lock();
sie_cmd_code(SIE_CMDPHASE_COMMAND, cmd_code);
if (data_len)
{
sie_cmd_code(SIE_CMDPHASE_WRITE, data);
}
usb_irq_unlock(lock);
}
static uint8_t sie_read (uint8_t cmd_code)
{
bool const lock = usb_irq_lock();
sie_cmd_code(SIE_CMDPHASE_COMMAND , cmd_code);
sie_cmd_code(SIE_CMDPHASE_READ , cmd_code);
return (uint8_t) LPC_USB->CmdData;
uint8_t const data = (uint8_t) LPC_USB->CmdData;
usb_irq_unlock(lock);
return data;
}
//--------------------------------------------------------------------+
@ -117,6 +187,11 @@ static inline uint8_t ep_addr2idx(uint8_t ep_addr)
static void set_ep_size(uint8_t ep_id, uint16_t max_packet_size)
{
// ReEp RMW + the EP_RLZED handshake share DevIntSt with the ISR: a bus reset
// from dcd_int_handler writes DevIntClr = 0xFFFFFFFF and would consume the
// flag this spin waits on, hanging it forever -> same lock as the SIE paths.
bool const lock = usb_irq_lock();
// follows example in 11.10.4.2
LPC_USB->ReEp |= TU_BIT(ep_id);
LPC_USB->EpInd = ep_id; // select index before setting packet size
@ -124,6 +199,8 @@ static void set_ep_size(uint8_t ep_id, uint16_t max_packet_size)
while ((LPC_USB->DevIntSt & DEV_INT_ENDPOINT_REALIZED_MASK) == 0) {}
LPC_USB->DevIntClr = DEV_INT_ENDPOINT_REALIZED_MASK;
usb_irq_unlock(lock);
}
@ -230,6 +307,7 @@ static inline uint8_t byte2dword(uint8_t bytes)
static void control_ep_write(void const * buffer, uint8_t len)
{
uint32_t const * buf32 = (uint32_t const *) buffer;
bool const lock = usb_irq_lock(); // Ctrl/TxData + SIE sequence must not interleave with the ISR
LPC_USB->Ctrl = USBCTRL_WRITE_ENABLE_MASK; // logical endpoint = 0
LPC_USB->TxPLen = (uint32_t) len;
@ -245,10 +323,14 @@ static void control_ep_write(void const * buffer, uint8_t len)
// select control IN & validate the endpoint
sie_write(SIE_CMDCODE_ENDPOINT_SELECT+1, 0, 0);
sie_write(SIE_CMDCODE_BUFFER_VALIDATE , 0, 0);
usb_irq_unlock(lock);
}
static uint8_t control_ep_read(void * buffer, uint8_t len)
{
bool const lock = usb_irq_lock(); // Ctrl/RxData + SIE sequence must not interleave with the ISR
LPC_USB->Ctrl = USBCTRL_READ_ENABLE_MASK; // logical endpoint = 0
while ((LPC_USB->RxPLen & USBRXPLEN_PACKET_READY_MASK) == 0) {} // TODO blocking, should have timeout
@ -267,6 +349,7 @@ static uint8_t control_ep_read(void * buffer, uint8_t len)
sie_write(SIE_CMDCODE_ENDPOINT_SELECT+0, 0, 0);
sie_write(SIE_CMDCODE_BUFFER_CLEAR , 0, 0);
usb_irq_unlock(lock);
return len;
}
@ -281,8 +364,9 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
uint8_t const epnum = tu_edpt_number(p_endpoint_desc->bEndpointAddress);
uint8_t const ep_id = ep_addr2idx(p_endpoint_desc->bEndpointAddress);
// Endpoint type is fixed to endpoint number
// 1: interrupt, 2: Bulk, 3: Iso and so on
// Endpoint type is fixed to endpoint number (1 interrupt, 2 bulk, 3 iso, ...).
// Iso endpoints are armed via dcd_edpt_iso_alloc/activate, never through here
// (TUP_DCD_EDPT_ISO_ALLOC is defined for this IP), so only bulk/interrupt land here.
switch ( p_endpoint_desc->bmAttributes.xfer )
{
case TUSB_XFER_INTERRUPT:
@ -293,10 +377,6 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
TU_ASSERT((epnum % 3) == 2 || (epnum == 15));
break;
case TUSB_XFER_ISOCHRONOUS:
TU_ASSERT((epnum % 3) == 0 && (epnum != 0) && (epnum != 15));
break;
default:
break;
}
@ -307,9 +387,7 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
//------------- first DD prepare -------------//
dma_desc_t* const dd = &_dcd.dd[ep_id];
tu_memclr(dd, sizeof(dma_desc_t));
dd->isochronous = (p_endpoint_desc->bmAttributes.xfer == TUSB_XFER_ISOCHRONOUS) ? 1 : 0;
tu_memclr(dd, sizeof(dma_desc_t)); // non-iso: isochronous stays 0
dd->max_packet_size = ep_size;
dd->retired = 1; // invalid at first
@ -319,16 +397,54 @@ bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * p_endpoint_desc)
}
bool dcd_edpt_iso_alloc(uint8_t rhport, uint8_t ep_addr, uint16_t largest_packet_size) {
#if DCD_ISO_ENABLED
(void)rhport;
(void)ep_addr;
(void)largest_packet_size;
uint8_t const ep_id = ep_addr2idx(ep_addr);
// hardware fixes iso to endpoint numbers 3, 6, 9, 12
TU_ASSERT(ep_id_is_iso(ep_id));
TU_ASSERT(largest_packet_size > 0);
set_ep_size(ep_id, largest_packet_size);
dma_desc_t* const dd = &_dcd.dd[ep_id];
tu_memclr(dd, sizeof(dma_desc_t));
dd->isochronous = 1;
dd->max_packet_size = largest_packet_size;
dd->retired = 1; // invalid at first
sie_write(SIE_CMDCODE_ENDPOINT_SET_STATUS + ep_id, 1, 0);
return true;
#else
(void)rhport; (void)ep_addr; (void)largest_packet_size;
return false;
#endif
}
bool dcd_edpt_iso_activate(uint8_t rhport, const tusb_desc_endpoint_t *desc_ep) {
#if DCD_ISO_ENABLED
(void)rhport;
(void)desc_ep;
uint8_t const ep_id = ep_addr2idx(desc_ep->bEndpointAddress);
dma_desc_t* const dd = &_dcd.dd[ep_id];
// same fixed-number rule as alloc: without it a rejected-but-ignored alloc (classes
// discard that return) would set isochronous on a non-iso ep_id and underflow iso_slot()
TU_ASSERT(ep_id_is_iso(ep_id));
// kill any armed transfer from a previous alternate setting
LPC_USB->EpDMADis = TU_BIT(ep_id);
_dcd.udca[ep_id] = NULL;
dd->isochronous = 1;
dd->max_packet_size = tu_edpt_packet_size(desc_ep);
dd->retired = 1;
sie_write(SIE_CMDCODE_ENDPOINT_SET_STATUS + ep_id, 1, 0);
return true;
#else
(void)rhport; (void)desc_ep;
return false;
#endif
}
void dcd_edpt_close_all (uint8_t rhport)
@ -369,19 +485,28 @@ static bool control_xact(uint8_t rhport, uint8_t dir, uint8_t * buffer, uint8_t
control_ep_write(buffer, len);
}else
{
// guard the out_received/out_buffer handshake against the EP0 OUT ISR
bool const lock = usb_irq_lock();
if ( _dcd.control.out_received )
{
// Already received the DATA OUT packet
_dcd.control.out_received = false;
_dcd.control.out_buffer = NULL;
_dcd.control.out_bytes = 0;
uint8_t received = control_ep_read(buffer, len);
// event queued with in_isr=true, which skips the queue's own locking: keep the
// USB IRQ masked across it, or a real ISR completion could interleave the write
dcd_event_xfer_complete(0, 0, received, XFER_RESULT_SUCCESS, true);
usb_irq_unlock(lock);
}else
{
// buffer is NULL for a status-stage ZLP: signal the pending xfer explicitly,
// NOT via out_buffer != NULL — a NULL-buffer queue mistaken for "nothing queued"
// leaves out_received stale and poisons the next control OUT data stage.
_dcd.control.out_buffer = buffer;
_dcd.control.out_bytes = len;
_dcd.control.out_queued = true;
usb_irq_unlock(lock);
}
}
@ -406,26 +531,68 @@ bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t * buffer, uint16_t t
uint16_t const ep_size = dd->max_packet_size;
uint8_t is_iso = dd->isochronous;
tu_memclr(dd, sizeof(dma_desc_t));
dd->isochronous = is_iso;
dd->max_packet_size = ep_size;
dd->buffer = (uint32_t) buffer;
dd->buflen = total_bytes;
_dcd.udca[ep_id] = dd;
if ( ep_id % 2 )
#if DCD_ISO_ENABLED
if ( is_iso )
{
// Clear EP interrupt before Enable DMA
LPC_USB->EpIntEn &= ~TU_BIT(ep_id);
LPC_USB->EpDMAEn = TU_BIT(ep_id);
// iso: buflen counts packets; per-packet sizes live in the packet-size memory.
// One packet moves per frame (UM10562 12.15.6: DMA request is raised for
// DMA-enabled iso endpoints on every FRAME interrupt, both directions).
// Validate BEFORE touching the DD: bailing out mid-rebuild would leave a
// zeroed (retired=0 -> serviceable) descriptor armed for the frame engine.
TU_ASSERT(ep_size > 0);
uint16_t const packets = (total_bytes > 0) ? (uint16_t) tu_div_ceil(total_bytes, ep_size) : 1;
TU_ASSERT(packets <= ISO_MAX_PACKETS);
// endpoint IN need to actively raise DMA request
LPC_USB->DMARSet = TU_BIT(ep_id);
}else
uint8_t const slot = iso_slot(ep_id);
uint16_t remain = total_bytes;
for ( uint16_t i = 0; i < packets; i++ )
{
uint16_t const pkt_len = tu_min16(remain, ep_size);
// IN: length to send (0 = ZLP). OUT: hardware writes back
// Frame_number|Packet_valid|Packet_length -- prefill 0 so a frame the
// hardware never wrote (missed/invalid) cannot read back as data.
_dcd.iso_psize[slot][i] = (ep_id & 1) ? pkt_len : 0;
remain = (uint16_t)(remain - pkt_len);
}
tu_memclr(dd, sizeof(dma_desc_t));
dd->isochronous = 1;
dd->max_packet_size = ep_size;
dd->buffer = (uint32_t) buffer;
dd->buflen = packets;
dd->iso_packet_size_addr = (uint32_t) &_dcd.iso_psize[slot][0];
_dcd.udca[ep_id] = dd;
LPC_USB->EpDMAEn = TU_BIT(ep_id); // frame-triggered: no DMARSet, no EpIntEn
}
else
#else
(void) is_iso;
#endif
{
// Enable DMA
LPC_USB->EpDMAEn = TU_BIT(ep_id);
tu_memclr(dd, sizeof(dma_desc_t));
dd->max_packet_size = ep_size;
dd->buffer = (uint32_t) buffer;
dd->buflen = total_bytes;
_dcd.udca[ep_id] = dd;
if ( ep_id % 2 )
{
// Clear EP interrupt before Enable DMA
// EpIntEn read-modify-write races the ISR's own RMWs -> lock
bool const lock = usb_irq_lock();
LPC_USB->EpIntEn &= ~TU_BIT(ep_id);
LPC_USB->EpDMAEn = TU_BIT(ep_id);
usb_irq_unlock(lock);
// endpoint IN need to actively raise DMA request
LPC_USB->DMARSet = TU_BIT(ep_id);
}else
{
// Enable DMA
LPC_USB->EpDMAEn = TU_BIT(ep_id);
}
}
return true;
@ -451,13 +618,20 @@ static void control_xfer_isr(uint8_t rhport, uint32_t ep_int_status)
uint8_t setup_packet[8];
control_ep_read(setup_packet, 8); // TODO read before clear setup above
// a new SETUP voids any half-finished control state
_dcd.control.out_queued = false;
_dcd.control.out_received = false;
_dcd.control.out_buffer = NULL;
_dcd.control.out_bytes = 0;
dcd_event_setup_received(rhport, setup_packet, true);
}
else if ( _dcd.control.out_buffer )
else if ( _dcd.control.out_queued )
{
// software queued transfer previously
// software queued transfer previously (out_buffer NULL = status ZLP)
uint8_t received = control_ep_read(_dcd.control.out_buffer, _dcd.control.out_bytes);
_dcd.control.out_queued = false;
_dcd.control.out_buffer = NULL;
_dcd.control.out_bytes = 0;
@ -513,7 +687,32 @@ static void dd_complete_isr(uint8_t rhport, uint8_t ep_id)
uint8_t result = (dd->status == DD_STATUS_NORMAL || dd->status == DD_STATUS_DATA_UNDERUN) ? XFER_RESULT_SUCCESS : XFER_RESULT_FAILED;
uint8_t const ep_addr = (ep_id / 2) | ((ep_id & 0x01) ? TUSB_DIR_IN_MASK : 0);
dcd_event_xfer_complete(rhport, ep_addr, dd->present_count, result, true);
uint32_t xferred_bytes;
#if DCD_ISO_ENABLED
if ( ep_id_is_iso(ep_id) )
{
// present_count is in packets; actual byte counts are in the packet-size memory
// (IN: as programmed by us, OUT: Packet_length written back by hardware,
// guarded by Packet_valid -- a frame with no packet must count as 0)
uint8_t const slot = iso_slot(ep_id);
uint16_t const packets = tu_min16(dd->present_count, ISO_MAX_PACKETS);
xferred_bytes = 0;
for (uint16_t i = 0; i < packets; i++)
{
uint32_t const psize = _dcd.iso_psize[slot][i];
if ( (ep_id & 1) || (psize & TU_BIT(16)) )
{
xferred_bytes += (psize & 0xFFFFu);
}
}
}
else
#endif
{
xferred_bytes = dd->present_count;
}
dcd_event_xfer_complete(rhport, ep_addr, (uint16_t) xferred_bytes, result, true);
}
// main USB IRQ handler
@ -569,6 +768,16 @@ void dcd_int_handler(uint8_t rhport)
{
if ( tu_bit_test(eot, ep_id) )
{
// dispatch on the hardware's fixed ep-number/type map, NOT dd->isochronous:
// thread-mode dcd_edpt_xfer transiently zeroes the DD while rebuilding it
#if DCD_ISO_ENABLED
if ( ep_id_is_iso(ep_id) )
{
// iso: last packet already left with its frame; complete both directions here
dd_complete_isr(rhport, ep_id);
}
else
#endif
if ( ep_id & 0x01 )
{
// IN enable EpInt for end of usb transfer

93
tools/88-tinyusb.rules Normal file
View File

@ -0,0 +1,93 @@
# Copy this file to the location of your distribution's udev rules:
# Then reload udev configuration by executing:
# sudo cp 88-tinyusb.rules /etc/udev/rules.d/ && sudo udevadm control --reload-rules && sudo udevadm trigger
# Check SUBSYSTEM
SUBSYSTEMS=="hidraw", KERNEL=="hidraw*", MODE="0666", GROUP="dialout"
SUBSYSTEM=="usbmon", MODE="0640", GROUP="wireshark"
# Rule applies to all TinyUSB example
ATTRS{idVendor}=="cafe", MODE="0666", GROUP="dialout"
# Rule to make Trinket/Pro Trinket/Gemma/Flora programmable without running Arduino as root.
# Tested with Ubuntu 14.04 and 12.04. Other distributions might need to update GROUP="dialout"
# to another group value like "users".
SUBSYSTEM=="usb", ATTRS{idProduct}=="0c9f", ATTRS{idVendor}=="1781", MODE="0660", GROUP="dialout"
# Rule to blacklist Adafruit USB CDC boards from being manipulated by ModemManager.
# Fixes issue with hanging references to /dev/ttyACM* devices on Ubuntu 15.04.
ATTRS{idVendor}=="239a", ENV{ID_MM_DEVICE_IGNORE}="1"
# All Adafruit boards
ATTRS{idVendor}=="239a", MODE="0660", GROUP="adm"
# All Espressif boards
ATTRS{idVendor}=="303a", MODE="0660", GROUP="adm"
# All RaspberryPi boards
ATTRS{idVendor}=="2e8a", MODE="0660", GROUP="adm"
# All NXP Boards
ATTRS{idVendor}=="1fc9", MODE="0660", GROUP="adm"
# All ST
SUBSYSTEM=="usb", ATTRS{idVendor}=="0483", GROUP="adm"
# Rule to blacklist TinyUSB example from being manipulated by ModemManager.
SUBSYSTEMS=="usb", ATTRS{idVendor}=="cafe", ENV{ID_MM_DEVICE_IGNORE}="1"
# Xplained Pro SamG55 Device
SUBSYSTEMS=="usb", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2111", MODE="0666", GROUP="users", ENV{ID_MM_DEVICE_IGNORE}="1"
SUBSYSTEMS=="tty", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2111", MODE="0666", GROUP="users", ENV{ID_MM_DEVICE_IGNORE}="1"
# TI Stellaris/Tiva-C Launchpad ICDI
SUBSYSTEM=="usb", ATTRS{idVendor}=="1cbe", ATTRS{idProduct}=="00fd", MODE="0666"
# CMSIS-DAP, vendor = ARM
SUBSYSTEM=="usb", ATTR{idVendor}=="0d28", MODE="0666"
# wch-link
SUBSYSTEM=="usb", ATTR{idVendor}=="1a86", ATTR{idProduct}=="8010", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="4348", ATTR{idProduct}=="55e0", GROUP="plugdev"
SUBSYSTEM=="usb", ATTR{idVendor}=="1a86", ATTR{idProduct}=="8012", GROUP="plugdev"
# Pxlogic
SUBSYSTEM=="usb", ATTRS{idVendor}=="2a0e", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", MODE="0666"
# Arduino Renesas
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2341", MODE="0666"
# E2/E2 Lite/E1/E20/IE850A emulator
ATTR{idProduct}=="82a1", ATTR{idVendor}=="045b", MODE="0666"
ATTR{idProduct}=="82a0", ATTR{idVendor}=="045b", MODE="0666"
ATTR{idProduct}=="823b", ATTR{idVendor}=="045b", MODE="0666"
ATTR{idProduct}=="823c", ATTR{idVendor}=="045b", MODE="0666"
ATTR{idProduct}=="0250", ATTR{idVendor}=="045b", MODE="0666"
# Prevent E2/E2Lite/E1/E20/IE850A from being captured by modem manager service as E2/E2 Lite/E1/E20/IE850A is not a modem
ATTR{idProduct}=="82a1", ATTR{idVendor}=="045b", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTR{idProduct}=="82a0", ATTR{idVendor}=="045b", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTR{idProduct}=="823b", ATTR{idVendor}=="045b", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTR{idProduct}=="823c", ATTR{idVendor}=="045b", ENV{ID_MM_DEVICE_IGNORE}="1"
ATTR{idProduct}=="0250", ATTR{idVendor}=="045b", ENV{ID_MM_DEVICE_IGNORE}="1"
#TI MSP430UIF
ATTRS{idVendor}=="2047",ATTRS{idProduct}=="0010",MODE="0666"
ATTRS{idVendor}=="2047",ATTRS{idProduct}=="0013",MODE="0666"
ATTRS{idVendor}=="2047",ATTRS{idProduct}=="0014",MODE="0666"
ATTRS{idVendor}=="2047",ATTRS{idProduct}=="0203",MODE="0666"
ATTRS{idVendor}=="2047",ATTRS{idProduct}=="0204",MODE="0666"
ATTRS{idVendor}=="0451",ATTRS{idProduct}=="f432",MODE="0666"
# fomu
ATTRS{idVendor}=="1209",ATTRS{idProduct}=="5bf0",MODE="0666"
# FTDI
ATTRS{idVendor}=="0403", MODE="0660", GROUP="adm"
# Sipeed Slogic16
SUBSYSTEM=="usb", ATTRS{idVendor}=="359f", MODE="0666", TAG+="uaccess", ENV{ID_MM_DEVICE_IGNORE}="1"
# ataradov usb-sniffer (github.com/ataradov/usb-sniffer): programmed unit + blank FX2LP
ATTRS{idVendor}=="6666", ATTRS{idProduct}=="6620", MODE="0666"
ATTRS{idVendor}=="04b4", ATTRS{idProduct}=="8613", MODE="0666"