From eca6caf673452c8ec940e2acf5e46d0631fb72bf Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Fri, 28 Aug 2026 14:16:02 +0700 Subject: Add RTT console/capture tooling (tools/rtt.py), rtt skill, and HIL harness support (#3853) Promote SEGGER RTT from an inline debugging technique to a standalone skill backed by one stdlib-only implementation in tools/rtt.py: a CLI and importable module for console/capture over J-Link (RTTTelnetPort) and OpenOCD (rtt server) probes, with probe selection by serial or VID:PID, control-block address via --elf or --addr, bidirectional console, post-mortem ring dump, and --reset-before-attach for boot-time capture. The HIL harness reads a board's console over RTT when its probe has no VCOM ("logger": "rtt" plus a LOGGER=rtt variant define), covering device_info, pool-check aliveness, and CI wiring. Validated on 22 boards across both backends; 26 unit tests run in pre-commit. --- .claude/skills/hil/SKILL.md | 2 + .claude/skills/rtt/SKILL.md | 201 ++++++ .claude/skills/rtt/boards.md | 78 +++ .claude/skills/target-debug/SKILL.md | 46 +- .github/scripts/hil_ci_set_matrix.py | 11 +- .github/workflows/build.yml | 1 + .gitignore | 1 + .pre-commit-config.yaml | 4 +- CLAUDE.md | 2 +- .../followup/pr3853-board-putchar-logger.md | 57 ++ .../followup/pr3853-rtt-harness-adoption.md | 62 ++ docs/superpowers/plans/2026-08-24-rtt-skill.md | 423 ++++++++++++ .../specs/2026-08-24-rtt-skill-design.md | 164 +++++ test/hil/helper/hil_pool_check.py | 42 +- test/hil/helper/hil_util.py | 26 +- test/hil/hil_ci.sh | 3 + test/hil/hil_test.py | 154 ++++- test/hil/test/test_ci_select.py | 5 + test/hil/test/test_hil_rtt.py | 506 ++++++++++++++ test/hil/test/test_hil_util.py | 6 +- tools/ci_select.py | 26 +- tools/rtt.py | 727 +++++++++++++++++++++ 22 files changed, 2483 insertions(+), 64 deletions(-) create mode 100644 .claude/skills/rtt/SKILL.md create mode 100644 .claude/skills/rtt/boards.md create mode 100644 docs/superpowers/followup/pr3853-board-putchar-logger.md create mode 100644 docs/superpowers/followup/pr3853-rtt-harness-adoption.md create mode 100644 docs/superpowers/plans/2026-08-24-rtt-skill.md create mode 100644 docs/superpowers/specs/2026-08-24-rtt-skill-design.md create mode 100644 test/hil/test/test_hil_rtt.py create mode 100644 tools/rtt.py diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md index d1e4bdcd5..d9010d28d 100644 --- a/.claude/skills/hil/SKILL.md +++ b/.claude/skills/hil/SKILL.md @@ -82,6 +82,8 @@ See the `usb-kernel-recover` skill for what a real wedge looks like and how to c Examples must be built for the target board(s) — see CLAUDE.md "Build" → "All examples for a board" (produces `examples/cmake-build-/`). `-B examples` points `hil_test.py` at that parent folder. (This applies to `hil_test.py`; `hil_pool_check.py` builds its own missing firmware.) +A board whose flasher probe has no VCOM (or whose BSP has no UART) uses RTT as its console — "No serial device found for /dev/serial/by-id/…" on every host test is the symptom. Config: `"logger": "rtt"` (jlink flashers only) plus a self-named variant carrying the define — `"variant": [{"name": "", "defines": ["LOGGER=rtt"]}]` — and prebuilt example sets must carry the same `-DLOGGER=rtt`. Caveat: the cdc/msc-fixture host tests don't speak RTT yet, so such a board cannot carry `is_cdc`/`is_msc` fixtures (the config loader rejects it; see the rtt follow-up doc). Details: the `rtt` skill. + ## Arguments - **Board:** `-b BOARD_NAME`, repeatable for a subset (`-b a -b b`); omit to run all boards in the config. Give a whole set to ONE run rather than one run per board: it schedules the boards across host controllers and budgets concurrent flashes and usbtest batteries per controller (`hil_lock.py` `FLASH_PARALLEL`/`USBTEST_PARALLEL`). Those permits are in-process semaphores — a second `hil_test.py` running alongside does not share them, it multiplies the load on the same xHCI cards. diff --git a/.claude/skills/rtt/SKILL.md b/.claude/skills/rtt/SKILL.md new file mode 100644 index 000000000..5e14ae84c --- /dev/null +++ b/.claude/skills/rtt/SKILL.md @@ -0,0 +1,201 @@ +--- +name: rtt +description: Use when you need console or printf I/O, TU_LOG capture, or a raw byte channel over a debug probe on real hardware — the board has no UART wired or its probe no VCOM, a LOGGER=rtt build needs reading or writing, "RTT Control Block not found", an RTT server won't come up or drops output, JLinkRTTLogger/JLinkRTTClient/JLinkGDBServer/openocd rtt misbehave, or another workflow (HIL console, SystemView capture) needs RTT stood up on a J-Link, ST-Link, CMSIS-DAP or WCH-Link probe. +--- + +# rtt — SEGGER RTT transport and console + +RTT is nothing but RAM: a control block `_SEGGER_RTT` (starts with the magic +string `"SEGGER RTT"`) plus per-channel ring buffers +`{sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags}`. The target advances +`WrOff`; the host must **write `RdOff` back** to free space — a reader that +only reads never drains the ring. Channel 0 is the "Terminal" console; +SystemView claims its own `"SysView"` up-buffer on the same control block — +they coexist. The debug probe reads/writes this RAM while the core runs, so +everything here is zero-wiring: no UART, no VCOM. + +Scope: byte transport and console. Timing/profiling → `etm-trace`/`sysview`; +debugging decision flows and the wedged-target drain model → `target-debug`; +Espressif consoles → `esp-target-debug` (USB-Serial-JTAG, no SEGGER RTT). + +## Quick start — console on a J-Link probe + +Use the skill's tool `tools/rtt.py` for every route; do not hand-roll +JLinkExe/JLinkGDBServer/openocd/telnet pipelines (`--help` for all modes): + +```bash +# firmware: TU_LOG + stdio → RTT channel 0 (hw/bsp/board.c routes sys_read too) +cmake -DBOARD= -DLOG=2 -DLOGGER=rtt ... # Make: LOG=2 LOGGER=rtt + +# flash + reset FIRST (the console owns the probe once open), then: +python3 tools/rtt.py --backend jlink --probe --device --seconds 20 +# -i forwards stdin to the target; --seconds 0 streams until Ctrl-C/EOF +``` + +`JLINK_DEVICE` comes from `hw/bsp//boards//board.cmake` (or +`family.cmake`). Always pass the probe serial — rigs and benches run several +probes, and the `ninja -jlink` flash target grabs whichever J-Link +enumerates first: pin it (`-DJLINK_OPTION="-USB "`) or flash with +`JLinkExe -SelectEmuBySN`. The HIL harness uses the same implementation +(`hil_util.JlinkRtt`) via a board's `"logger": "rtt"` (jlink flashers +only) plus a single self-named variant carrying the define — +`"variant": [{"name": "", "defines": ["LOGGER=rtt"]}]`, the roster's +one shape for always-on defines — variant defines feed `hil_test.py +--build` and the CI matrix; a prebuilt `cmake-build-` set must be +configured with the same `-DLOGGER=rtt` itself. Keep harness console builds +quiet (`LOGGER=rtt` WITHOUT `LOG=2`): reset-then-attach only preserves what +fits the up-buffer (stock 1 KB, NO_BLOCK_SKIP), and a chatty boot burst +truncates at the ring boundary before the drain attaches — measured +1022-1023 B captures on ea4088 with `LOG=2`, enumeration lines falling off +the end. `BUFFER_SIZE_UP` is the knob when verbose logs are really needed. +Rig boards need `hil_lock.py` held first — see the `hil` skill. + +To validate bidirectionality end-to-end you need firmware that both polls +the console AND replies via printf. `board_test` polls `board_getchar()` +(RTT-aware via `sys_read`) but echoes through `board_putchar` → +`board_uart_write`, which is NOT LOGGER-aware — on a UART-less board the +echo hits the `-1` stub and vanishes (measured on ea4088). For a validation +run, patch its echo to `printf` locally, or drive a host example's menu +(`msc_file_explorer`, `cdc_msc_hid` — they reply via printf). Sending +keystrokes to `cdc_msc` and expecting an echo proves nothing: it never polls +the console. + +## Transport matrix + +| Transport / tool | Live read | Write | Notes | +| ----------------------------------------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ARM memory-AP (any J-Link/ST-Link/CMSIS-DAP) | yes | yes | zero intrusion; core keeps running | +| RISC-V SBA (where implemented) | yes | yes | autonomous like memory-AP | +| WCH QingKe SDI | **NO** | no | DM abstract-command reads perturb the running core: A/B-proven firmware kill ~1.9 s into USB traffic. Halt→read→resume or post-mortem dump ONLY | +| OpenOCD/jaylink on a genuine SEGGER J-Link | yes | untested | routine in the sysview campaigns (metro_m4_express, dozens of attaches, zero wedges); prefer SEGGER tools where both exist (drain rate) | +| OpenOCD/jaylink on the LPC-Link2 (J-Link OB fw) | forbidden | — | measured on ea4088's LPC-Link2 (2023 OB image): transport fails (`jaylink_swd_io`) and knocks the probe off USB; physical replug to recover — SEGGER tools only THERE. Verdict is for that probe only: other J-Link-OB firmware probes are untested — hardware-test before assuming either way | +| `JLinkRTTLogger` | unreliable | — | searches for the control block once at attach and gives up — on some parts it never finds it ("RTT Control Block not found" even with `-RTTAddress`; measured 0/6 on LPC4088). May work elsewhere, but don't build automation on a single-search tool | + +Validated boards, directions and per-board caveats: [boards.md](boards.md). + +## Capture: J-Link route + +`rtt.py` above is this route packaged. Raw form (what it runs): + +```bash +JLinkExe -USB -device -if swd -speed 4000 -NoGui 1 -AutoConnect 1 \ + -RTTTelnetPort # keep stdin open; 'exit' tears it down +nc localhost # JLinkRTTClient minus the banner; carries input too +``` + +Commander keeps hunting for the control block and delivers the buffered boot +burst once the target's first printf creates it. `JLinkGDBServer +-RTTTelnetPort` also serves the port but on some parts (measured: LPC4088) +never locates the control block **unless a GDB client attaches** — fine +inside a GDB session, a silent failure headless — and it briefly halts the +core on connect (measured), which matters for timing-sensitive repros; +Commander does not. One telnet client per port at a time. + +## Capture: OpenOCD route (native probes: ST-Link, CMSIS-DAP) + +This is the LIVE route — WCH-Link targets are SDI and get only the halt→dump +route (transport matrix). Same script, openocd backend (`--elf` = the +FLASHED elf; the script takes the exact control-block address from `nm` — +a full-RAM scan is slower and can match stale RAM after a soft reset): + +```bash +python3 tools/rtt.py --backend openocd --probe \ + --cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" --elf --seconds 20 +# --channel: up-buffer index (0 = "Terminal" console, 1 = SystemView's "SysView" +# buffer in TinyUSB builds); -i forwards stdin → down-buffer 0 +# --vid-pid "0x2e8a 0x000c": pin the probe by USB IDs (with or instead of --probe; +# also keeps openocd discovery off foreign usbfs nodes) +# --addr 0x2000xxxx: explicit control-block address when the flashed elf is not at hand +# --reset-before-attach: reset the target INSIDE the session (2 s settle, then +# attach — the control block must exist before `rtt start` can find it; the ring's +# NO_BLOCK_SKIP head-retention is what preserves byte 0 across the settle) — +# required for streams that only decode from byte 0 +# (SystemView emits its Init record, carrying the timestamp frequency, once at boot; +# a mid-flight attach yields a stream no decoder can lock onto). Verified on +# stm32h743nucleo: after the ring is drained, a plain attach misses the boot preamble +# entirely and this flag captures it. NOT for SAMD5x (an in-session reset via the DSU +# leaves the core held) or WCH SDI. +``` + +What it runs: `openocd -c "adapter serial " -c init -c "rtt setup + 0x800 \"SEGGER RTT\"" -c "rtt polling_interval 1" -c "rtt start" +-c "rtt server start "`, then a socket on that port. + +Attach WITHOUT reset when the flash step already reset the board (on SAMD5x, +an in-session `reset run` goes through the DSU CPU Reset Extension and leaves +the core held). After any reset the target's offsets restart at zero while +the server holds stale ones, and the tool exposes no console to type into (it +launches openocd with tcl/gdb/telnet ports disabled): stop the capture and +run it again to resync — do not reset mid-capture if you can avoid it. `rtt start` +fails while the block doesn't exist yet: it appears at the firmware's first +RTT write, so reset, settle ~500 ms, then start. Read AND write validated on +the ci rig's 8 native-probe boards (ST-Link + CMSIS-DAP, incl. RP2350), +end-to-end through this script's backend on all 8 — per-board rows in +boards.md. OpenOCD polls, and host-side loss is invisible +to the target's overflow counter: at the default 100 ms interval a busy +stream loses most samples (measured 2066 of 5064 events/s delivered on +stm32f407disco) — `rtt polling_interval 1` is mandatory for quantitative +capture, not a tuning nicety. Prefer SEGGER tools where a J-Link exists. + +## Post-mortem: reading the ring without a live server + +Default log mode is `NO_BLOCK_SKIP`: with no reader draining, the ring holds +the **first KB after boot, not the tail** — interpretation rules in +`target-debug`. To keep the last N bytes instead, the firmware must log via +`SEGGER_RTT_WriteWithOverwriteNoLock` (target drags `RdOff` itself; no host +needed) — but SEGGER's own restriction comes with it: *"Do not use +SEGGER_RTT_WriteWithOverwriteNoLock if a J-Link connection reads RTT data"* +(`lib/SEGGER_RTT/RTT/SEGGER_RTT.c`), because the target moving `RdOff` races +the host reader. So it is for firmware you dump post-mortem, never for a +board that also runs a live console (every HIL rtt board does). Reading a wedged target's ring — debug-AP RAM reads don't halt the +core: + +```bash +python3 tools/rtt.py --backend jlink --dump ring.bin \ + --probe --device --elf # or --addr 0x... +# prints pBuffer/Size/WrOff/RdOff; WrOff/RdOff delimit the valid bytes +``` + +(What it runs, for hand-driving JLinkExe: `nm` the ELF for `_SEGGER_RTT`, +`mem32 , 6` = aUp[0] {sName,pBuffer,Size,WrOff,RdOff,Flags}, +then `savebin `.) + +## Buffer modes and locking (target side) + +- Modes: `NO_BLOCK_SKIP` (default for logs — drops whole writes when full), + `NO_BLOCK_TRIM`, `BLOCK_IF_FIFO_FULL` (target spins — dangerous in ISRs). +- Throughput is drain-limited: measured 24.6 KiB/s over a J-Link console + against a saturating printf loop, with the drops happening at the target. + RTT console output is NOT lossless under load; for high-bandwidth streams + size the buffer up (SystemView needs 2048–8192) and watch for overflow. +- Non-ARM ports must supply `SEGGER_RTT_LOCK/UNLOCK`: the vendored generic + RISC-V lock uses `mstatus` CSRs that trap (mcause=2) on WCH QingKe. Worked + port on branch `claude/add-systemview-debug`: `hw/bsp/ch583/ + sysview_rtt_lock_wch.h` (brace-scoped save/restore of CSR 0x800), and the + shared `hw/bsp/sysview_rtt_conf_wch.h` that ch32v20x/ch32v30x family.cmake + force-include to win the include-guard race against the vendored conf. + +## Common mistakes + +- **Attaching before the first printf** — the control block is zeroed `.bss` + until the firmware's first RTT write; early readers see nothing (and + RTTLogger gives up for good). Commander/`rtt.py` keep hunting. +- **Sending input before the server finds the control block** — the J-Link + telnet route silently DROPS client bytes until then (measured on the rig: + an instant `ping` vanished, a delayed one echoed). `rtt.py -i` + holds stdin until target output flows (or 5 s); when driving the raw + socket yourself, wait for output before writing. +- **Resetting while a console is attached** — flash and reset first; the + console owns the probe until closed. +- **Killing servers with `pkill -f`** — the pattern matches your own shell's + cmdline (and unrelated sessions): a compound command that pkills its + wrapper then re-reads a stale log misdiagnosed a healthy probe for an + hour. Close `rtt.py` with Ctrl-C/`--seconds` (its teardown reaps + the whole process group); if you must pattern-kill, bracket a char: + `pkill -f '[J]LinkExe -USB '`. +- **Unpinned flash with several probes attached** — pin by serial, always. +- **Two probes wired to one SWD header** — wedges the target; rewire. +- **Expecting an echo from firmware that never reads the console** — only + code polling `board_getchar()` consumes down-buffer 0 (`board_test` does). +- **Full-RAM `rtt setup` scans** — can lock onto a stale pre-reset block; + use the `nm` address. diff --git a/.claude/skills/rtt/boards.md b/.claude/skills/rtt/boards.md new file mode 100644 index 000000000..5ddd072d2 --- /dev/null +++ b/.claude/skills/rtt/boards.md @@ -0,0 +1,78 @@ +# rtt — per-board validation matrix + +A row appears here only after the board was exercised on real hardware; a new +validation adds the row AND any caveat it surfaced. "Read" = console/log +capture reached the host; "Write" = the target demonstrably consumed console +input (a printf-echo `board_test` returned the sent bytes — stock +`board_test` cannot, see SKILL.md's echo-validation note). Routes match +SKILL.md's capture sections; `Device/cfg` is the J-Link `--device` string or +the openocd target cfg. Rig rows (ci.lan) were validated 2026-08-24 by a +flash→capture→`ping`-echo sweep under per-board `hil_lock` flocks, and +re-validated 2026-08-25 end-to-end through the skill's own CLI +(`tools/rtt.py`, jlink + openocd backends): 20/20 read+write — +including CONCURRENTLY at 8 parallel consoles (20 boards in 39 s, mixed +routes, no port collisions or cross-board output bleed: one server per +probe on its own ephemeral port). htpc rows on the local bench. The openocd backend's `--reset-before-attach` +is decode-validated: a channel-1 SystemView capture on stm32h743nucleo +(byte-identical boot preamble to the sysview campaign's golden reference, +49765 events decoded, ISR/task timings matching to 0.1 µs, overflow 0). + +| Board | Rig | Probe | Route | Read | Write | Device/cfg | +| ------------------------ | ---- | ---------------------- | ------- | ---- | ----- | --------------------- | +| ea4088_quickstart | htpc | LPC-Link2 J-Link fw | J-Link | yes | yes | `LPC4088` | +| raspberry_pi_pico2 | htpc | J-Trace PRO | J-Link | yes | — | `rp2350_m33_0` | +| frdm_k64f | ci | J-Link | J-Link | yes | yes | `MK64FN1M0xxx12` | +| feather_nrf52840_express | ci | J-Link | J-Link | yes | yes | `nrf52840_xxaa` | +| metro_m4_express | ci | J-Link | J-Link | yes | yes | `ATSAMD51J19` | +| lpcxpresso11u37 | ci | J-Link | J-Link | yes | yes | `LPC11U37/401` | +| lpcxpresso55s28 | ci | J-Link | J-Link | yes | yes | `LPC55S28` | +| ra4m1_ek | ci | J-Link | J-Link | yes | yes | `R7FA4M1AB` | +| stm32f072disco | ci | J-Link | J-Link | yes | yes | `stm32f072rb` | +| stm32f407disco | ci | J-Link | J-Link | yes | yes | `stm32f407vg` | +| stm32f723disco | ci | J-Link | J-Link | yes | yes | `stm32f723ie` | +| stm32l476disco | ci | J-Link | J-Link | yes | yes | `STM32L476VG` | +| mimxrt1064_evk | ci | J-Link | J-Link | yes | yes | `MIMXRT1064xxx6A` | +| nrf54lm20dk | ci | J-Link | J-Link | yes | yes | `NRF54LM20A_M33` | +| max32666fthr | ci | CMSIS-DAP | OpenOCD | yes | yes | `target/max32665.cfg` | +| raspberry_pi_pico | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2040.cfg` | +| raspberry_pi_pico_w | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2040.cfg` | +| raspberry_pi_pico2 | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2350.cfg` | +| adafruit_fruit_jam | ci | debugprobe (CMSIS-DAP) | OpenOCD | yes | yes | `target/rp2350.cfg` | +| stm32h743nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32h7x.cfg` | +| stm32g0b1nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32g0x.cfg` | +| stm32u083nucleo | ci | ST-Link | OpenOCD | yes | yes | `target/stm32u0x.cfg` | + +Probe serials live in the rig configs (`test/hil/tinyusb.json`, bench +`local.json`) — always pass them (`--probe` / `adapter serial`). + +## Caveats + +- **ea4088_quickstart**: probe has no VCOM and the BSP has no UART — RTT is + the ONLY console; measured there: 6/6 JLinkExe attaches, boot burst + delivered, 24.6 KiB/s drain; the HIL suite runs over the RTT console + (device_info-class tests — the cdc/msc-fixture host tests don't speak RTT + yet, see the follow-up doc). + NEVER point OpenOCD at this J-Link-firmware probe (jaylink knocks it off + USB; physical replug). JLinkGDBServer never finds the CB headless on this + part; JLinkRTTLogger 0/6. +- **raspberry_pi_pico2 (htpc, J-Trace)**: pin the probe by serial — that + bench runs two J-Links (`-DJLINK_OPTION="-USB "` for the flash + target). Never set a custom JLinkScript for RP2350 over J-Link. Write path + untested there only because the flashed example doesn't poll the console + (the ci row's debugprobe sweep validated RP2350 writes). +- **ST-Link rows**: flashed by `STM32_Programmer_CLI`; RTT capture is a + separate openocd session (`interface/stlink.cfg` + the target cfg above), + attach without reset. + +## Excluded (recorded so absence is never read as "works") + +- `espressif_s3_devkitm`, `espressif_p4_function_ev` — no SEGGER RTT path in + our builds (console is the chip's USB-Serial-JTAG; see `esp-target-debug`). +- `ek_tm4c123gxl` — flashed by `lm4flash`; no debug-probe path configured on + the rig. +- `nanoch32v203`, `ch32v103r_r1_1v0`, `ch32v307v_r1_1v0`, `ch582m_evt` — a + `LOGGER=rtt` build traps on WCH QingKe (the vendored generic RISC-V + `SEGGER_RTT_LOCK` reads `mstatus` CSRs → mcause=2; the working lock port + `sysview_rtt_lock_wch.h` lives only on branch `claude/add-systemview-debug`), + and SDI permits no live streaming anyway (transport matrix). Revisit after + that branch merges. diff --git a/.claude/skills/target-debug/SKILL.md b/.claude/skills/target-debug/SKILL.md index 050a697b9..7ee96f48e 100644 --- a/.claude/skills/target-debug/SKILL.md +++ b/.claude/skills/target-debug/SKILL.md @@ -217,40 +217,36 @@ dump binary memory /tmp/ring.bin &dbg_ring[0] &dbg_ring[512] ## 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 — no UART wiring. SEGGER's host -tools need a J-Link, but OpenOCD serves the same RTT buffer on ST-Link / -CMSIS-DAP / WCH-Link boards: +`LOGGER=rtt` routes it over the debug probe — no UART wiring. Stand the +channel up per the **rtt** skill (servers per probe, transport matrix, +control-block gotchas live there): ```bash -# RTT: JLinkGDBServer from CLAUDE.md "GDB Debugging" + -RTTTelnetPort, then: -timeout 20s JLinkRTTClient > /tmp/rtt.log # non-interactive capture +# RTT (J-Link probe; flash + reset first — the console owns the probe): +timeout 20s python3 tools/rtt.py --backend jlink --probe --device > /tmp/rtt.log # UART (board's debug serial, if wired): stty -F /dev/ttyACM 115200 raw && timeout 20s cat /dev/ttyACM | tee /tmp/uart.log ``` -```bash -# OpenOCD RTT (any probe OpenOCD drives) — in telnet :4444 (or -c equivalents): -rtt setup 0x20000000 0x8000 "SEGGER RTT" # RAM ORIGIN + LENGTH (from the .ld/map) -rtt start # after firmware booted; rerun after each reflash -rtt server start 19021 0 -# then: timeout 20s nc localhost 19021 > /tmp/rtt.log -``` - -OpenOCD polls — bursty logs can drop lines; prefer J-Link where both -exist. The drain-model warning below applies unchanged. +OpenOCD RTT (native probes: ST-Link/CMSIS-DAP): rtt skill §OpenOCD — exact +CB address from `nm`, attach-only. OpenOCD polls — bursty logs can drop +lines; prefer J-Link where both exist. The drain-model warning below +applies unchanged. 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 above. -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. +holds the first KB after boot, not the wedge tail. The buffer flags have no +overwrite mode (only SKIP/TRIM/BLOCK); keeping the tail instead requires the +firmware-side overwrite write call (rtt skill §post-mortem). So post-mortem +RTT from a default-mode build is evidence only if a live drain was running — +otherwise instrument with the RAM ring above. +Stand up the drain per the **rtt** skill: JLinkExe's `-RTTTelnetPort` (what +`rtt.py` wraps) is the headless-proven route; JLinkGDBServer's needs +a GDB client attached on some parts (LPC4088), and JLinkRTTLogger fails to +find the control block on some parts (measured LPC4088, 0/6). The manual +ring read for a wedged target (`nm`/`mem32`/`savebin` — debug-AP reads don't +halt the core) lives there too. ## GDB — state autopsy and watchpoints @@ -331,7 +327,7 @@ Linux gadget peer): ```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) +timeout 30s python3 tools/rtt.py --backend jlink --probe --device > /tmp/target.rtt & # target (rtt skill; or ring dump after) wait ``` diff --git a/.github/scripts/hil_ci_set_matrix.py b/.github/scripts/hil_ci_set_matrix.py index bf50061dd..b567f347c 100644 --- a/.github/scripts/hil_ci_set_matrix.py +++ b/.github/scripts/hil_ci_set_matrix.py @@ -1,5 +1,6 @@ import argparse import json +import shlex import os import sys @@ -112,14 +113,20 @@ def main(): # Each variant builds into cmake-build- with its own cmake # -D defines and raw CFLAGS. No 'variant' -> a single build named after - # the board. + # the board; an always-on define (MAX3421_HOST=1, LOGGER=rtt) is a single + # self-named variant carrying it. variants = board.get('variant') or [{'name': name, 'flags': ''}] for v in variants: arg = build_board if v['name'] != name: arg += f' --build-name {v["name"]}' + # build_util.yml's Build step splices this string into bash source, + # so the quoting round-trips a spaced value into one argv item like + # build_board's argv path. The SAME string also reaches the get_deps + # env expansion and the artifact-name charset, where spaced/quoted + # values still fail (loudly) -- keep defines space-free for d in v.get('defines', []): - arg += f' -D{d}' + arg += f' -D{shlex.quote(d)}' for tok in v.get('flags', '').split(): arg += f' --cflag={tok}' append_build_arg(toolchain, arg) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c26fe5cf8..70555b111 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,7 @@ jobs: - 'tools/ci_select.py' - 'tools/get_deps.py' - 'tools/metrics.py' + - 'tools/rtt.py' - '.github/actions/**' - '.github/workflows/build.yml' - '.github/workflows/build_util.yml' diff --git a/.gitignore b/.gitignore index d74f12459..14bc22b61 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ hw/mcu/sony/cxd56/spresense-exported-sdk/ hw/mcu/st/ hw/mcu/ti/ hw/mcu/wch/ +test/hil/local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a29dc89a..9ac2de228 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -69,7 +69,7 @@ repos: # and md5-checks the logo header from there as its MTP fixtures. - id: hil-test name: hil-test - files: ^(test/hil/|examples/device/mtp/src/) + files: ^(test/hil/|examples/device/mtp/src/|tools/rtt\.py$) entry: python3 -m unittest discover -s test/hil/test -p 'test_hil*.py' pass_filenames: false language: system @@ -84,7 +84,7 @@ repos: language: system - id: ci-select-test name: ci-select-test - files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(ci_select|build|build_utils|get_deps|metrics)\.py$|\.github/(scripts|workflows)/|\.circleci/) + files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(ci_select|build|build_utils|get_deps|metrics|rtt)\.py$|\.github/(scripts|workflows)/|\.circleci/) entry: sh -c "python3 test/hil/test/test_ci_select.py && python3 test/hil/test/test_ci_metrics.py && cd test/hil/test && python3 -m unittest -q test_hil_util.BottomLayer" pass_filenames: false language: system diff --git a/CLAUDE.md b/CLAUDE.md index c43a4f9f7..d3ab995bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ Terminal 2 — connect (``: 2331 JLink, 3333 OpenOCD): arm-none-eabi-gdb build/your_app.elf (gdb) target remote : # then: monitor reset halt → load → continue ``` -**RTT:** build `LOG=2 LOGGER=rtt`, run JLinkGDBServer with `-RTTTelnetPort 19021`, then `JLinkRTTClient` (`timeout 20s JLinkRTTClient > rtt.log` for non-interactive capture). +**RTT:** build `LOG=2 LOGGER=rtt`; capture/console via the `rtt` skill (`.claude/skills/rtt/SKILL.md`). ## Testing diff --git a/docs/superpowers/followup/pr3853-board-putchar-logger.md b/docs/superpowers/followup/pr3853-board-putchar-logger.md new file mode 100644 index 000000000..46a4417bd --- /dev/null +++ b/docs/superpowers/followup/pr3853-board-putchar-logger.md @@ -0,0 +1,57 @@ +# `board_putchar` is not LOGGER-aware + +**Origin:** surfaced while validating the RTT console in PR #3853 (the `rtt` skill +promotion), which is harness-only scope. This is a src-level fix to `hw/bsp/board.c` +that touches every board/logger combination, so it needs its own build sweep rather +than a drive-by. Delete this file when its own PR lands. + +## Established (with evidence) + +`hw/bsp/board.c` retargets stdio through `sys_write`/`sys_read`, which are compiled +per logger: `SEGGER_RTT_Write`/`SEGGER_RTT_Read` under `LOGGER_RTT`, ITM under +`LOGGER_SWO`, `board_uart_write`/`board_uart_read` by default. The two board-level +character helpers do not agree: + +```c +168: int board_getchar(void) { +169: char c; +170: return (sys_read(0, &c, 1) > 0) ? (int) c : (-1); +171: } +172: +173: int board_putchar(int c) { +174: if (board_uart_write((const char *)&c, 1) > 0) { +``` + +`board_getchar` follows the logger; `board_putchar` always goes to the UART. So with +`LOGGER=rtt` console input arrives over RTT while the echo goes out the UART. + +Measured on ea4088_quickstart (`LOGGER=rtt`, `board_uart_write` is a `-1` stub on +lpc40): the `board_test` echo vanishes entirely while a `printf` echo — same console, +same keystroke — comes back byte-for-byte. `LOGGER=swo` has the same asymmetry by +construction (ITM out of `sys_write`, UART out of `board_putchar`), unverified on +hardware. + +## What remains + +Candidate fix: route `board_putchar` through `sys_write(0, ...)` for symmetry with +`board_getchar`. Two things to settle while doing it: + +- `board_putchar` currently passes `&c` of an `int` to a `const char*` — it writes + the low byte only on little-endian. Narrow to a `char` local as part of the change. +- The default (UART) path must keep its current return contract: `board_uart_write` + returns negative when the UART is a stub, and the default `sys_write` breaks out of + its retry loop on that, returning a short count — so `board_putchar` still has to + map "wrote nothing" to `-1`. + +## Validation + +Build sweep across loggers and families — at minimum one UART board, one +`LOGGER=rtt` board and one `LOGGER=swo` board — plus a hardware check that the +`board_test` echo comes back on an RTT board (ea4088_quickstart reproduces the bug +today) and that a plain UART board's echo is unchanged. + +## Why it was split out + +PR #3853 promotes a debug-tooling skill and touches `test/hil/*.py` and +`tools/rtt.py`. A `hw/bsp/board.c` change lands in every example on every board and +belongs in a review that carries the build evidence for it. diff --git a/docs/superpowers/followup/pr3853-rtt-harness-adoption.md b/docs/superpowers/followup/pr3853-rtt-harness-adoption.md new file mode 100644 index 000000000..8f3eae16b --- /dev/null +++ b/docs/superpowers/followup/pr3853-rtt-harness-adoption.md @@ -0,0 +1,62 @@ +# Follow-up: finish RTT-console adoption in the HIL harness + +Split out of the `rtt` skill-promotion PR #3853. That PR deliberately ships the skill + CLI and leaves the harness's remaining +VCOM assumptions in place — converting them is separate test-infra scope that +deserves its own review and HIL runs. Scope here is `test/hil/*.py` only; the +src-level `board_putchar` asymmetry this work surfaced has its own handoff +(`pr3853-board-putchar-logger.md`). + +## Established (with evidence) + +- `hil_util.JlinkRtt` + `open_board_console()` work end-to-end: + ea4088_quickstart runs its host suite over RTT (16 passed / 0 failed / 3 + skipped, the 'hil: read the host console over RTT when the probe has no VCOM' commit), and the `rtt` skill's boards.md carries the + validated matrix. +- `test_host_device_info` honors `"logger": "rtt"` (hil_test.py, `test_host_device_info`; the eof fail-fast assert sits in its read loop): + in RTT mode it resets via the flasher BEFORE opening the console (which + then owns the probe; Commander delivers the buffered boot burst) and its + read loop fails fast on `JlinkRtt.eof` instead of blaming the board. + +## Remaining gaps + +1. **`test_host_cdc_msc_hid` and `test_host_msc_file_explorer` (hil_test.py) still call `hil_util.get_serial_dev(flasher["uid"], ...)` + directly** — on a `logger: rtt` board with `is_cdc`/`is_msc` fixtures they + would fail with the same "No serial device found" the console work fixed + for device_info (an interim load-time gate in `hil_test.py` now rejects + that combination up front; delete the gate when this lands). Fix: route + both through `open_board_console(board)` — but design the conversion + reset-aware rather than hand-copying device_info's dual branch: hoist a + `reset=` parameter into `open_board_console` that does the per-console + ordering itself (RTT: reset via flasher BEFORE opening — the console owns + the probe; VCOM: reset after open to catch the banner), and REMOVE the + existing post-open `# reset device to catch mount messages` blocks in both + tests (grep the marker — line numbers churn) — kept as-is on an RTT board they reset + while the console holds the probe. `JlinkRtt` carries input for their + menus and implements the `reset_input_buffer()` those tests call. +2. **`hil_pool_check.check_host_serial` carries its own inline RTT branch** + (reset → `JlinkRtt` → poll through `hil_util.strip_banner`) — RTT boards + ARE health-checkable today, but the console-opening logic now lives in + two places (`open_board_console` in hil_test.py and this branch), each + with its own reset-ordering. Fix: hoist `open_board_console()` into + `hil_util.py` with the `reset=` parameter from item 1 and collapse + pool_check's branch onto it; keep the `do_reset` flush semantics for the + VCOM path intact. +3. **OpenOCD console backend in the harness**: the skill's CLI + (`tools/rtt.py --backend openocd`, class + `OpenocdRtt` in the same module) is built, deduplicated behind a shared + base class next to `JlinkRtt` in `tools/rtt.py`, re-exported by + `hil_util`, and hardware-validated (all 20 rig boards through the CLI on + both backends, incl. the 8 native-probe ones). What remains is only the + `open_board_console` plumbing: choosing `OpenocdRtt` for a + `"logger": "rtt"` board with an openocd/stlink flasher needs the per-test + flashed-ELF path (for the control-block address) and, for stlink + flashers, an openocd target-cfg mapping the roster doesn't carry — until + then the config-load gate keeps rejecting non-jlink rtt boards. + +## Validation for this follow-up + +Run the ea4088 local host suite (a board with a `is_cdc`+`is_msc` capable +device attached to J3, or the rig's frdm_k64f/mimxrt1064 with a temporary +`logger: rtt` entry) so cdc_msc_hid and msc_file_explorer actually execute +over RTT; then a `hil_pool_check.py` pass on a no-VCOM board. Delete this doc +when the follow-up PR lands. diff --git a/docs/superpowers/plans/2026-08-24-rtt-skill.md b/docs/superpowers/plans/2026-08-24-rtt-skill.md new file mode 100644 index 000000000..e2a40c448 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-rtt-skill.md @@ -0,0 +1,423 @@ +# `rtt` Skill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Promote SEGGER RTT to a standalone skill `.claude/skills/rtt/` (transport core + console layer) with a versioned CLI, validated first on the local htpc bench, then across the ci.lan rig. + +**Architecture:** Knowledge lives in `.claude/skills/rtt/SKILL.md` + `boards.md`; the single code implementation is `test/hil/helper/hil_util.py::RttConsole` (cherry-picked from branch `hil-add-ea4088qs`) exposed via a thin CLI `test/hil/helper/rtt.py`. Existing docs (target-debug, CLAUDE.md, hil) shrink their RTT recipes to pointers. + +**Tech Stack:** Python 3 (stdlib only, matching hil_util), JLinkExe, OpenOCD, TinyUSB `LOGGER=rtt` builds, TDD-for-skills (superpowers:writing-skills). + +> **Historical record — EXECUTED 2026-08-24/25.** The shipped shape evolved past +> this plan during review rounds: the implementation is `tools/rtt.py` (classes +> `JlinkRtt`/`OpenocdRtt`, `--backend` required), not `test/hil/helper/`. The +> spec's "Tooling home" section is the current truth; do not re-execute this plan. + +**Spec:** `docs/superpowers/specs/2026-08-24-rtt-skill-design.md` — read it first; every content decision below argues from it. + +## Global Constraints + +- Branch: `rttconsole-skill`, worktree `/home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill`. Never touch the primary checkout's branch. +- Commit messages: imperative mood, **no `Co-Authored-By:`/`Claude-Session:` trailers, no footers of any kind** (user's standing authorship rule — overrides harness defaults). +- **Never push.** Commit locally; final report says "ready to push". +- Curated-skills rule: smallest possible diffs to existing skills/agents/CLAUDE.md; anything beyond the pointer edits listed here must be proposed to the user first. +- Iron Law (superpowers:writing-skills): no SKILL.md content and no edit to an existing skill without a failing/baseline test first. +- Hardware rules: **never point OpenOCD at a J-Link-firmware probe** (LPC-Link2 611000000, the J-Trace (nickname `jtrace`; its serial is private — read it with ShowEmuList on the bench) — it drops them off USB; each attempt costs the user a physical replug). J-Trace is wired to raspberry_pi_pico2 (never set a custom JLinkScript for RP2350). Prefix any step needing the user's hands with **[ACTION]**. +- ci.lan rig work: hold per-board locks per `.claude/skills/hil/SKILL.md` §Board locks; the actions-runner keeps running. Use the hil-operator agent for rig sweeps (strictly one instance). +- Scratch files go in the session scratchpad, never `/tmp`, never committed. +- `pre-commit run --all-files` must pass before declaring done. + +--- + +### Task 1: Bring the tooling onto this branch + +**Files:** +- Modify: `test/hil/helper/hil_util.py` (via cherry-pick + docstring fix) +- Modify: `test/hil/hil_test.py` (via cherry-pick) + +**Interfaces:** +- Produces: `hil_util.RttConsole(board: dict, timeout: float = 0.1)` where `board = {'flasher': {'uid': '', 'args': '-device '}}`; methods `read(size)->bytes`, `write(bytes)->int`, `in_waiting->int`, `close()`, attr `timeout`. Also `hil_test.open_board_console(board)`. + +- [ ] **Step 1: Symlink missing deps** (worktree has `lib/SEGGER_RTT` but not the MCU SDKs): + +```bash +cd /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill +python3 - <<'EOF' +import os, sys +sys.path.insert(0, 'tools'); import get_deps +main = os.path.expanduser('~/code/tinyusb') +for dep in get_deps.deps_all: + src, dst = os.path.join(main, dep), dep + if not os.path.exists(dst) and os.path.isdir(src): + os.makedirs(os.path.dirname(dst), exist_ok=True); os.symlink(src, dst); print('link', dep) +EOF +``` + +- [ ] **Step 2: Cherry-pick the console commit** (object store is shared across worktrees): + +```bash +git cherry-pick d98e77bac +``` + +Expected: clean pick of `hil: read the host console over RTT when the probe has no VCOM` (touches only hil_util.py + hil_test.py). If it conflicts, resolve keeping d98e77bac's hunks verbatim — master has not touched these regions. + +- [ ] **Step 3: Fix the stale docstring.** `RttConsole`'s docstring opens with "JLinkGDBServer owns the probe and serves RTT channel 0 over TCP" but the code launches `JLinkExe` (J-Link Commander). Edit the docstring's first paragraph to: + +``` + J-Link Commander (JLinkExe) owns the probe and serves RTT channel 0 on -RTTTelnetPort -- + what JLinkRTTClient talks to, minus its banner. Exposes the slice of pyserial the tests + use (read, in_waiting, write, close, timeout) so a caller does not care which console it got. +``` + +- [ ] **Step 4: Import smoke test:** + +```bash +python3 -c "import sys; sys.path.insert(0,'test/hil/helper'); import hil_util; print(hil_util.RttConsole.__doc__.splitlines()[1].strip()[:20])" +``` + +Expected: `J-Link Commander (JL` + +- [ ] **Step 5: Commit** + +```bash +git add test/hil/helper/hil_util.py +git commit -m "hil: RttConsole docstring names the tool it actually runs (JLinkExe)" +``` + +--- + +### Task 2: RED — baseline scenarios without the skill + +Per superpowers:writing-skills, run the failing test before writing any skill text. These are **plan-only** subagents (they must output the exact commands they would run and MUST NOT execute anything against hardware — a wrong baseline attempt costs a probe replug). The lpc4088 session's real lost hour is the primary RED datapoint; these probes map the gap precisely. + +**Files:** +- Create: `/rtt-baselines.md` (verbatim findings; not committed) + +- [ ] **Step 1: Scenario S1 (console/harness routing + technique).** Dispatch a general-purpose subagent, no mention of RTT: + +> In the TinyUSB repo at /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill: board ea4088_quickstart is flashed via an LPC-Link2 running J-Link firmware (serial 611000000). The probe exposes no VCOM and hw/bsp/lpc40/family.c's board_uart_read/write return -1. PLAN ONLY — do not run any hardware command. First list which repo skill(s) (.claude/skills/) you would load for this task and why. Then produce the exact commands to (a) get the firmware's printf/TU_LOG output on this PC headlessly and (b) send keystrokes to the firmware. State every failure mode you anticipate. + +- [ ] **Step 2: Scenario S2 (capture technique, OpenOCD/ST-Link).** Same rules: + +> PLAN ONLY. TinyUSB repo, board stm32h743nucleo flashed over an ST-Link. The firmware was built with LOG=2 LOGGER=rtt. Produce the exact commands to capture 20 seconds of its RTT log headlessly on Linux, and explain how you locate the RTT control block and what can go wrong right after a reset. + +- [ ] **Step 3: Record baseline verbatim** in `/rtt-baselines.md`: which skills each agent said it would load (expected gap: nothing routes, or target-debug loaded for a non-debugging task), which tool each picked (expected: JLinkRTTLogger or bare JLinkGDBServer for S1; full-RAM `rtt setup` scan for S2), which known gotchas each missed (control-block-after-first-printf, probe-by-serial, exact CB address via nm, attach-only after flash-reset, drain-limited/lossy, probe ownership). Every missed item becomes required SKILL.md content; every wrong routing becomes description-keyword input. + +- [ ] **Step 4: Gate.** If a baseline agent nails everything (no gaps), STOP and tell the user — the skill may not be needed in that area and the plan's GREEN content shrinks. (Do not expect this; the lpc4088 session is an existence proof of the failure.) + +--- + +### Task 3: `rtt.py` CLI (TDD) + +**Files:** +- Create: `test/hil/helper/rtt.py` +- Test: fake-probe harness in `/fakejlink/` (not committed) + +**Interfaces:** +- Consumes: `hil_util.RttConsole` from Task 1. +- Produces: CLI `python3 test/hil/helper/rtt.py --probe --device [--seconds N] [-i]` — streams channel-0 bytes to stdout; `--seconds 0` (default) runs until Ctrl-C/EOF; `-i` forwards stdin to the target. Exit 0 on clean close, 1 on connect failure. + +- [ ] **Step 1: Write the fake probe** `/fakejlink/JLinkExe` (`chmod +x`): + +```python +#!/usr/bin/env python3 +# Stands in for J-Link Commander: serves -RTTTelnetPort, greets, echoes input back +# uppercased, exits when stdin says exit (mirrors RttConsole's close() contract). +import socket, sys, threading +port = int(sys.argv[sys.argv.index('-RTTTelnetPort') + 1]) +srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(('127.0.0.1', port)); srv.listen(1) +def serve(): + conn, _ = srv.accept() + conn.sendall(b'hello from target\r\n') + while True: + d = conn.recv(4096) + if not d: return + conn.sendall(d.upper()) +threading.Thread(target=serve, daemon=True).start() +for line in sys.stdin: + if line.strip() == 'exit': break +``` + +- [ ] **Step 2: Run the failing test:** + +```bash +cd /home/hathach/.herdr/worktrees/tinyusb/rttconsole-skill +PATH=/fakejlink:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2 +``` + +Expected: FAIL — `No such file or directory` (rtt.py does not exist). + +- [ ] **Step 3: Implement** `test/hil/helper/rtt.py`: + +```python +#!/usr/bin/env python3 +"""Stream a board's RTT channel-0 console to stdout over a J-Link probe. + +Thin CLI over hil_util.RttConsole -- the same implementation the HIL harness uses. +The probe is owned for the whole run: flash and reset BEFORE starting this, never +reset the target while it is attached. Select the probe by serial; rigs run several. +""" +import argparse +import sys +import threading +import time + +import hil_util # same directory when run by path + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--probe', required=True, help='J-Link probe serial (JLinkExe -USB value)') + ap.add_argument('--device', required=True, help='JLINK_DEVICE string from the board.cmake/family.cmake') + ap.add_argument('--seconds', type=float, default=0, help='capture duration; 0 = until Ctrl-C/EOF') + ap.add_argument('-i', '--interactive', action='store_true', help='forward stdin to the target') + args = ap.parse_args() + + board = {'flasher': {'uid': args.probe, 'args': f'-device {args.device}'}} + try: + con = hil_util.RttConsole(board, timeout=0.1) + except RuntimeError as e: + print(e, file=sys.stderr) + return 1 + + if args.interactive: + def pump_stdin(): + for line in sys.stdin: + con.write(line.encode()) + threading.Thread(target=pump_stdin, daemon=True).start() + + deadline = time.monotonic() + args.seconds if args.seconds else None + try: + while deadline is None or time.monotonic() < deadline: + chunk = con.read(con.in_waiting or 1) + if chunk: + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + except KeyboardInterrupt: + pass + finally: + con.close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) +``` + +- [ ] **Step 4: Run the tests, verify they pass:** + +```bash +P=/fakejlink +PATH=$P:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2 # expect: hello from target +echo hi | PATH=$P:$PATH timeout 15 python3 test/hil/helper/rtt.py --probe 000 --device FAKE --seconds 2 -i # expect: hello from target + HI +pgrep -f '[J]LinkExe -USB 000' && echo LEAK || echo CLEAN # expect: CLEAN (bracket: else pgrep matches its own shell) +``` + +- [ ] **Step 5: Commit** + +```bash +git add test/hil/helper/rtt.py +git commit -m "hil: add rtt.py, a CLI over RttConsole" +``` + +--- + +### Task 4: GREEN — write `.claude/skills/rtt/SKILL.md` + `boards.md` skeleton + +Write the skill addressing Task 2's recorded failures — nothing more (minimal GREEN). All facts below are established in the spec; the drafting job is assembling them into the sibling-skill shape (structure model: `sysview` SKILL.md; ~150–200 lines). + +**Files:** +- Create: `.claude/skills/rtt/SKILL.md` +- Create: `.claude/skills/rtt/boards.md` + +- [ ] **Step 1: Frontmatter.** Name `rtt`. Description (trigger-only, third person, no workflow — superpowers:writing-skills SDO; extend with keywords from Task 2's routing misses): + +```yaml +--- +name: rtt +description: Use when you need console or printf I/O, TU_LOG capture, or a raw byte channel over a debug probe on real hardware — the board has no UART wired or its probe no VCOM, a LOGGER=rtt build needs reading or writing, "RTT Control Block not found", an RTT server won't come up or drops output, JLinkRTTLogger/JLinkRTTClient/JLinkGDBServer/openocd rtt misbehave, or another workflow (HIL console, SystemView capture) needs RTT stood up on a J-Link, ST-Link, CMSIS-DAP or WCH-Link probe. +--- +``` + +- [ ] **Step 2: Body sections**, each carrying exactly this content (wording final at execution, facts verbatim from the spec): + 1. **Overview** — RTT is nothing but RAM (control block `_SEGGER_RTT`, magic "SEGGER RTT", up/down rings `{sName,pBuffer,SizeOfBuffer,WrOff,RdOff,Flags}`); host must write RdOff back to drain; channel 0 = console, SystemView's "SysView" buffer coexists. + 2. **When to use / when not** — console & capture here; timing/profiling → etm-trace/sysview; debugging decision flows → target-debug; Espressif console → esp-target-debug. + 3. **Transport matrix (quick reference table)** — spec §v1 backend matrix verbatim, per-TRANSPORT rows: ARM memory-AP (live, zero intrusion) / RISC-V SBA (live where implemented) / WCH SDI (**dump only, never live** — DM reads kill USB ~1.9 s in) / OpenOCD-on-J-Link-fw-probe (forbidden, USB drop + physical replug). + 4. **Console (bidirectional)** — `LOGGER=rtt` builds route TU_LOG + `sys_read` to channel 0 (`hw/bsp/board.c`); tooling `test/hil/helper/rtt.py` (CLI) / `hil_util.RttConsole` (harness, `"logger": "rtt"` board switch); flash+reset BEFORE opening, console owns the probe. + 5. **Capture: J-Link route** — `JLinkExe -USB -device -if swd -speed 4000 -NoGui 1 -AutoConnect 1 -RTTTelnetPort ` + socket/`nc`; proven standalone. `JLinkGDBServer -RTTTelnetPort` locates the block on some parts only with a GDB client attached (LPC4088 measured) — per-part variance, use JLinkExe when headless. `JLinkRTTLogger`: never (single search at attach, 0/6 measured). + 6. **Capture: OpenOCD route (native probes)** — exact CB address first (`arm-none-eabi-nm | grep _SEGGER_RTT`), then `-c 'rtt setup 0x1000 "SEGGER RTT"' -c 'rtt polling_interval 1' -c 'rtt start' -c 'rtt server start 0'`; attach without reset when the flash step already reset (SAMD5x DSU `reset run` leaves the core held); read path validated on 13 boards (sysview campaign), write path per boards.md. + 7. **Post-mortem** — undrained NO_BLOCK_SKIP ring holds the FIRST KB after boot, not the wedge tail; overwrite mode (`SEGGER_RTT_WriteWithOverwriteNoLock`) keeps the last N bytes with no live host; manual ring read: `nm` the ELF for `_SEGGER_RTT`, `mem32` the aUp[0] descriptor, `savebin` the buffer — debug-AP reads don't halt the target (moved here from target-debug). + 8. **Buffer modes & locking** — SKIP/TRIM/BLOCK (BLOCK spins the target — dangerous in ISRs); non-ARM ports must supply `SEGGER_RTT_LOCK/UNLOCK` (worked example: `hw/bsp/ch583/sysview_rtt_lock_wch.h` on branch `claude/add-systemview-debug` — generic RISC-V lock traps mcause=2 on QingKe). + 9. **Common mistakes** — attach before first printf (block doesn't exist yet); reset while attached; probe not pinned by serial; two probes on one SWD header; treating RTT as lossless (24.6 KiB/s drain measured, drops at the target); full-RAM scan matching stale RAM after soft reset. + 10. **Per-board notes** → pointer to `boards.md`. + +- [ ] **Step 3: `boards.md` skeleton** — header modeled on sysview's boards.md (row = board, probe/transport, backend+direction validated, JLINK_DEVICE/openocd cfg, caveats), plus the two measured rows seeded from the spec: `ea4088_quickstart` (J-Link/LPC-Link2 611000000, read+write-accepted, `LPC4088`, "probe has no VCOM; BSP has no UART; never OpenOCD on this probe") and a placeholder-free note that all further rows land during Tasks 7–8 validation (no unvalidated rows allowed). + +- [ ] **Step 4: Length check:** `wc -l .claude/skills/rtt/SKILL.md` — expect ≤ ~200 (siblings: hil 168, etm-trace 203). + +- [ ] **Step 5: Commit** + +```bash +git add .claude/skills/rtt/ +git commit -m "skills: add rtt - RTT transport and console reference" +``` + +--- + +### Task 5: GREEN verification + REFACTOR + +- [ ] **Step 1: Re-run S1 and S2** (Task 2 prompts verbatim, still plan-only) with fresh subagents. Success criteria: S1 routes to the `rtt` skill, picks `rtt.py`/JLinkExe route, names probe-by-serial + flash-before-attach; S2 uses exact CB address via `nm`, attach-only, and the openocd command block. +- [ ] **Step 2: REFACTOR.** Any missed item or new wrong turn → tighten the specific SKILL.md section (form per writing-skills "Match the Form to the Failure": these are technique/reference failures → recipes and required table slots, not prohibitions) → re-run that scenario until it passes. +- [ ] **Step 3: Commit** (`git add .claude/skills/rtt/SKILL.md && git commit -m "skills: rtt - close gaps found in scenario verification"`) — only if Step 2 changed anything. + +--- + +### Task 6: Pointer edits in existing docs + +Iron Law for skill edits: the failing test is S3 below, run BEFORE editing. + +**Files:** +- Modify: `.claude/skills/target-debug/SKILL.md:224-253` +- Modify: `CLAUDE.md:77` +- Modify: `.claude/skills/hil/SKILL.md` (one added line) + +- [ ] **Step 1: S3 baseline (failing test).** Plan-only subagent: + +> PLAN ONLY. In this TinyUSB repo, a HIL host test on a board whose flasher probe has no VCOM fails with "No serial device found for /dev/serial/by-id/usb-*_-if*". Which repo skill(s) would you load, and what is the fix path? + +Expected FAIL today: the agent loads `hil` (correct routing) but `hil` says nothing about RTT consoles, so the fix path is rediscovery. Record verbatim. + +- [ ] **Step 2: Edit `hil/SKILL.md`** — add one line under its Prerequisites section (placement judgment at execution; content fixed): + +``` +- A board whose probe has no VCOM (or whose BSP has no UART) uses RTT as its console: `"logger": "rtt"` + `"build": {"args": ["LOGGER=rtt"]}` in its config entry — see the rtt skill. +``` + +- [ ] **Step 3: Edit `target-debug/SKILL.md`.** (a) Replace the two RTT lines of the capture block at 224-226 with: + +```bash +# RTT (probe console; details, servers, gotchas: rtt skill): +timeout 20s python3 test/hil/helper/rtt.py --probe --device > /tmp/rtt.log +``` + +(b) Replace the OpenOCD RTT block (232-237) with the single line: `` OpenOCD RTT (native probes): rtt skill §OpenOCD — exact CB address from `nm`, attach-only. `` Keep the drain-preference sentence that follows. (c) Keep the drain-model paragraph (242-247) unchanged; replace 248-253 (GDBServer/RTTLogger/manual-ring-read) with: + +``` +Stand up the drain per the **rtt** skill: JLinkExe's `-RTTTelnetPort` is the +headless-proven route; GDBServer's needs a GDB client on some parts, and +JLinkRTTLogger never works. The manual ring read for a wedged target +(`nm`/`mem32`/`savebin`) lives there too. +``` + +(d) Line 334's correlation one-liner: swap `JLinkRTTClient` for the `rtt.py` invocation from (a). Keep the capture-channel table rows 64-65 unchanged. + +- [ ] **Step 4: Edit `CLAUDE.md:77`** to: + +``` +**RTT:** build `LOG=2 LOGGER=rtt`; capture/console via the `rtt` skill (`.claude/skills/rtt/SKILL.md`). +``` + +- [ ] **Step 5: GREEN for the edits.** Re-run S3 (expect: hil → rtt route, `logger: rtt` fix path) AND re-run S1 once more (expect: unchanged pass — the removed target-debug text must be reachable through the pointers). Also grep for dangling references: `grep -rn "JLinkRTTClient\|RTTTelnetPort" CLAUDE.md .claude/ | grep -v skills/rtt` — every remaining hit must be a deliberate pointer or the sysview branch's own copy. + +- [ ] **Step 6: Commit** + +```bash +git add .claude/skills/target-debug/SKILL.md .claude/skills/hil/SKILL.md CLAUDE.md +git commit -m "docs: route RTT recipes through the rtt skill" +``` + +--- + +### Task 7: Dogfood on the local htpc bench + +Follow ONLY the SKILL.md text (dogfood discipline: gaps found here are REFACTOR input, fixed in SKILL.md before moving on). **[ACTION]-gate with the user before first hardware touch**: confirm LPC-Link2 (611000000) is back on USB and J-Trace (`jtrace`) is on pico2 with pico2 powered. + +**Files:** +- Modify: `.claude/skills/rtt/boards.md` (validated rows) +- Modify: `.claude/skills/rtt/SKILL.md` (only if dogfood exposes gaps) +- Create: `test/hil/local.json` (untracked — copy from the lpc4088 worktree) + +- [ ] **Step 1: Probe roster check:** `JLinkExe -CommandFile <(echo -e 'ShowEmuList\nexit')` (or `lsusb`) — expect 611000000 and the jtrace probe. Missing probe → **[ACTION]** ask the user, do not improvise. + +- [ ] **Step 2: ea4088 bidirectional echo (board_test).** Build + flash + echo, exactly as SKILL.md describes it: + +```bash +cd examples/device/board_test && mkdir -p build-ea4088 && cd build-ea4088 +cmake -DBOARD=ea4088_quickstart -DLOG=2 -DLOGGER=rtt -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .. && cmake --build . +ninja board_test-jlink # flashes via the LPC-Link2; resets the target +cd ../../../.. +(sleep 1; echo ping) | timeout 15 python3 test/hil/helper/rtt.py --probe 611000000 --device LPC4088 --seconds 8 -i | tee /ea4088-echo.log +``` + +Expected: board_test's periodic print lines AND the echoed `ping` (board_test echoes `board_getchar()`). This is the first true validation of target-side console INPUT consumption (the 8550-byte measurement only proved the socket accepted the bytes). + +- [ ] **Step 3: ea4088 HIL host suite over RTT.** Copy the untracked config: `cp /home/hathach/.herdr/worktrees/tinyusb/hil-add-ea4088qs/test/hil/local.json test/hil/local.json`. Build the full example set (`cd examples && cmake -B cmake-build-ea4088_quickstart -DBOARD=ea4088_quickstart -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-ea4088_quickstart` — LOGGER=rtt comes from local.json's `build.args`; verify the harness applies it, else add `-DLOGGER=rtt -DLOG=2`). Run per `.claude/skills/hil/SKILL.md` §Local execution against `local.json`. Expected: ≥ 16 passed / 0 failed (parity with d98e77bac's measured result). + +- [ ] **Step 4: pico2 second-probe/second-architecture capture.** Two J-Links are attached — the flash target MUST pin the probe: + +```bash +cd examples/device/cdc_msc && mkdir -p build-pico2 && cd build-pico2 +cmake -DBOARD=raspberry_pi_pico2 -DLOG=2 -DLOGGER=rtt -DJLINK_OPTION="-USB " -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .. && cmake --build . +ninja cdc_msc-jlink +cd ../../../.. +timeout 15 python3 test/hil/helper/rtt.py --probe --device rp2350_m33_0 --seconds 8 | tee /pico2-rtt.log +``` + +(Verify `-DJLINK_OPTION` is the pin mechanism in `hw/bsp/rp2040/family.cmake` before flashing; if the variable differs, use the family's actual one — do NOT flash with an unpinned `-jlink` target.) Expected: TinyUSB init/TU_LOG lines. Silence → check SKILL.md's own troubleshooting first (block-after-first-printf, wrong device string); if it doesn't resolve the silence, that's a dogfood gap → REFACTOR. + +- [ ] **Step 5: Record boards.md rows** for ea4088_quickstart (upgrade: write path VALIDATED via echo) and raspberry_pi_pico2 (J-Trace, `rp2350_m33_0`, "pin probe by serial — bench runs two J-Links; never a custom JLinkScript"). Apply any SKILL.md refactors the dogfood forced. + +- [ ] **Step 6: Commit** + +```bash +git add .claude/skills/rtt/ +git commit -m "skills: rtt - htpc dogfood rows (ea4088 bidirectional, pico2 capture)" +``` + +--- + +### Task 8: ci.lan rig sweep — all applicable boards + +Goal: a boards.md row per rig board, per its transport. Drive hardware through the hil-operator agent (one instance), locks per hil skill. Builds: `LOGGER=rtt LOG=2` `board_test` per board (echo validates both directions where the backend supports writes). Firmware left on boards is fine — CI reflashes every run. + +**Files:** +- Modify: `.claude/skills/rtt/boards.md` +- Create: `/rtt_sweep/` (per-board logs; not committed) + +- [ ] **Step 1: Build matrix.** From `test/hil/tinyusb.json` take all boards; groups: jlink×12, openocd×9, stlink×3; excluded with reasons recorded in boards.md: esptool×2 (no SEGGER-RTT path in our builds — USB-Serial-JTAG console), ek_tm4c123gxl (lm4flash only, no probe path configured on the rig). For each included board build `examples/device/board_test` with `-DLOG=2 -DLOGGER=rtt` locally where the toolchain exists (arm-none-eabi covers all but WCH); WCH boards (nanoch32v203, ch32v103, ch32v307, ch582m): build only if the riscv toolchain is present locally or on ci.lan — otherwise record `skipped: no riscv toolchain` rather than silently dropping (no silent caps). + +- [ ] **Step 2: Stage on ci.lan:** `scp` each ELF/bin + `test/hil/helper/{hil_util.py,rtt.py}` to `hathach@ci.lan:~/rtt-sweep/`. + +- [ ] **Step 3: Per-board procedure** (hil-operator executes on ci.lan; lock → flash → capture → echo → release): + - **jlink boards:** flash with the board's rig flasher recipe (uid + `-device` from tinyusb.json `flasher.args`), then `(sleep 1; echo ping) | timeout 15 python3 ~/rtt-sweep/rtt.py --probe --device --seconds 8 -i`. PASS = periodic board_test output + `ping` echoed. + - **stlink + openocd boards (native probes):** CB address from the local ELF (`arm-none-eabi-nm board_test.elf | grep _SEGGER_RTT`, computed before scp, carried in the sweep table). Then on ci.lan, one session per board using the board's existing openocd args from tinyusb.json plus: `-c 'adapter serial ' -c 'rtt setup 0x1000 "SEGGER RTT"' -c 'rtt polling_interval 1' -c 'rtt start' -c 'rtt server start 0'`; attach WITHOUT reset (flash already reset it). Read: `timeout 8 nc localhost `. Write test: `(sleep 1; echo ping; sleep 3) | nc localhost ` — PASS/FAIL per direction recorded separately; a write failure here is a finding, not a blocker (spec: OpenOCD write path is the open question this phase answers). + - **WCH boards (WCH-Link, SDI):** NO live streaming, NO rtt server during USB traffic. Validation = post-mortem-style read only: flash, let it run 5 s, then `halt; read the ring via nm address + mdw/dump_image; resume` in one short openocd/wlink session. PASS = ring contains board_test's boot output. Any anomaly → stop, quiesce the DM (rig standing rule), record. +- [ ] **Step 4: Per-board rows into boards.md** — board, transport, read/write verdicts, device string / cfg, caveat. Every board in tinyusb.json appears: validated, failed (with symptom), or skipped (with reason). If OpenOCD write path validated, update SKILL.md's transport matrix row; if not, matrix row says "read-only validated; write untested/failed on ". +- [ ] **Step 5: Restore rig state:** release all locks; run a normal single-board HIL smoke (`stm32f407disco`) per hil skill to confirm the rig is healthy for CI. +- [ ] **Step 6: Commit** + +```bash +git add .claude/skills/rtt/ +git commit -m "skills: rtt - ci.lan rig validation matrix" +``` + +--- + +### Task 9: Follow-up doc, final validation, report + +**Files:** +- Create: `docs/superpowers/followup/pr-rtt-pool-check.md` (rename to `pr-…` once the PR number exists) + +- [ ] **Step 1: Follow-up handoff doc** (superpowers:writing-plans style, per CLAUDE.md "Deferred work"): adopting `RttConsole` in `hil_pool_check.check_host_serial` (`test/hil/helper/hil_pool_check.py:354` — bidirectional, VCOM-assuming; needs `open_board_console` hoisted from `hil_test.py` into `hil_util.py`), citing the ea4088 validation as established ground. Also note the deferred sysview SKILL.md pointer (that branch owns its file; propose to user when it merges). +- [ ] **Step 2: `pre-commit run --all-files`** — expect pass (~55 s; HIL hooks exercise real timeouts). +- [ ] **Step 3: Commit follow-up doc:** `git add docs/superpowers/followup/ && git commit -m "docs: follow-up - pool-check adoption of RttConsole"` +- [ ] **Step 4: Report** to the user: commit list, validation matrix summary (htpc + rig, per-direction verdicts), open findings (e.g. OpenOCD write path), and **ready to push — not pushed**. + +--- + +## Self-Review (completed at planning time) + +- Spec coverage: scoring→spec only; scope/sections→Task 4; tooling→Tasks 1,3; measured-evidence carriage→Task 4 step 2; doc edits→Task 6; validation strategy→Tasks 7,8; non-goals→Task 4 §2 + exclusions in Task 8. Deferred sysview pointer→Task 9. No gaps. +- Placeholder scan: `` is the session scratchpad path (known at execution); `//` are computed per-board by given commands; Task 4 prose is assembled from enumerated facts (TDD forbids pre-writing final skill text before RED completes). No TBDs. +- Type consistency: `RttConsole(board, timeout)` board-dict shape identical in Tasks 1, 3; CLI flags identical in Tasks 3, 6, 7, 8; skill name `rtt` throughout. diff --git a/docs/superpowers/specs/2026-08-24-rtt-skill-design.md b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md new file mode 100644 index 000000000..7a621726f --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md @@ -0,0 +1,164 @@ +# `rtt` skill — design & decision record + +Date: 2026-08-24. Branch: `rttconsole-skill`. Author sessions: lpc4088 handoff +(measurements), sysview handoff (mechanics + probe matrix), this session +(verification + decision). User approved promotion and the name `rtt` on +2026-08-24. + +## Decision + +Promote SEGGER RTT from an inline technique in `.claude/skills/target-debug/` +to a standalone skill `.claude/skills/rtt/`, scoped as **transport core + +console layer**: getting bytes on/off RTT channels over any debug probe, plus +the bidirectional console tooling the HIL harness ships. Consumer-specific +layers (SystemView encode/decode/licensing, TU_LOG conventions, debugging +methodology) stay in their skills and cross-reference. + +## Scoring against the promotion criteria + +Criteria: `docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md` +§"Skill vs technique — promotion criteria" (exists only on branch +`claude/add-systemview-debug`; read via `git show`). Two or more of four +required. Score: **3/4**. + +1. **Ships tooling — yes.** `hil_util.JlinkRtt` (commit d98e77bac: probe + selection by serial, dynamic port allocation, non-blocking bidirectional + socket, process-group teardown) plus a thin CLI added by this plan. + Precedent: `hil` and `code-size` are skills wrapping repo-versioned tools; + "recipes over already-installed tools" is what RTT was *before* this code + existed (why SWO stayed a technique at 1.5/4 — see `SWO_SKILL_HANDOFF.md`). +2. **Answers its own routed question — yes.** "Give this board a console / + printf I/O with no UART and no VCOM" is asked from harness and bring-up + contexts that never load target-debug (whose trigger is *misbehaving + firmware*). Measured cost of the missing route: the lpc4088 session burned + an hour rediscovering a gotcha already written at target-debug + SKILL.md:249-253. +3. **Carries validation state — yes.** Measured tool matrix (below), 13-board + OpenOCD read-path campaign from the sysview cycle, WCH SDI A/B proof, + SAMD5x DSU gotcha, lock-porting example, per-probe constraints. +4. **Long but conditionally relevant — yes.** The transport knowledge is a + page+ that most target-debug sessions don't need and harness sessions + can't find there. + +## Measured evidence the skill must carry + +From the lpc4088 session (LPC4088 + LPC-Link2 J-Link fw 611000000, SWD 4 MHz; +single board — re-verify on more hardware during validation): + +- `JLinkExe -RTTTelnetPort -AutoConnect 1`: 6/6 reliable; delivers the + buffered boot burst; accepted an 8550-byte write in one call. **The proven + standalone path.** +- Drain rate 24.6 KiB/s (253,127 B / 10.0 s) against a saturating printf + firmware that produced 689,896 lines — 0.6 % delivered. RTT console is + **drain-limited and lossy under saturation; drops happen at the target** + (NO_BLOCK_SKIP, 1 KB default buffer). +- `JLinkRTTLogger`: 0/6 — "RTT Control Block not found" even given + `-RTTAddress`, block plainly readable over SWD. Searches once at attach, + never retries. **Never build on it.** +- `JLinkGDBServer -RTTTelnetPort` with **no GDB client attached**: served the + port, never located the control block (this board). target-debug's + GDBServer+JLinkRTTClient recipe was proven in flows where GDB attaches, and + CLAUDE.md's recipe worked on other parts — treat as per-part variance, + document both; do not "correct" either into a flat contradiction. +- OpenOCD (jaylink) driving this J-Link-firmware probe: transport failure + (`LIBUSB_ERROR_TIMEOUT`, `jaylink_swd_io() failed`), probe drops off USB, + **physical replug needed** — twice, reproducible. Standing rule: never + point OpenOCD at that class of probe (J-Link OB firmware on a debug-probe + board like the LPC-Link2). Genuine SEGGER J-Links work under jaylink — + routine in the sysview campaigns (metro_m4_express). + +From the sysview cycle (branch `claude/add-systemview-debug`, 13-board +campaign 2026-08-12): + +- OpenOCD `rtt setup … ; rtt start; rtt server start + ` **read path validated** on ST-Link, CMSIS-DAP and J-Link probes + (`test/hil/sysview_ci.py`). Exact CB address from + `arm-none-eabi-nm | grep _SEGGER_RTT` beats a full-RAM scan (slower, + can mis-hit stale RAM after soft reset). +- The real transport requirement is **autonomous memory access while the core + runs**: ARM memory-AP (zero intrusion), RISC-V SBA where implemented. + **WCH QingKe SDI has neither** — Debug Module abstract commands perturb the + running core; A/B-proven kill ~1.9 s into USB traffic. Per-transport rule: + SDI = halt→read→resume / post-mortem dump only, never live streaming. +- SAMD5x + OpenOCD: in-session `reset run` via the DSU CPU Reset Extension + leaves the core held — attach without reset when the flash step already + reset the board (general preference: attach-only capture). +- Lock porting example: `hw/bsp/ch583/sysview_rtt_lock_wch.h` (QingKe CSR + 0x800 brace-scoped save/restore; generic RISC-V lock traps mcause=2). +- Drain hierarchy: J-Link native > OpenOCD polling; matters only at + SystemView bandwidths (workable buffers 2048–8192); console logs never + overflow the drain in practice. +- RTT mechanics for the concepts section: control block `_SEGGER_RTT` (magic + "SEGGER RTT") + ring buffers {sName, pBuffer, SizeOfBuffer, WrOff, RdOff, + Flags}; the HOST must write RdOff back to drain; modes NO_BLOCK_SKIP (log + default) / NO_BLOCK_TRIM / BLOCK_IF_FIFO_FULL (target spins — dangerous in + ISRs); post-mortem mode = `SEGGER_RTT_WriteWithOverwriteNoLock` (target + drags RdOff, ring holds last N bytes, no live host needed); channel 0 = + "Terminal" console, SystemView claims its own "SysView" up-buffer — + coexist on one control block. + +## Gotchas the skill centralises + +Control block exists only after the target's first printf (early reader sees +nothing; Logger gives up). The console owns the probe: flash and reset before +opening it; never reset while attached. An undrained NO_BLOCK_SKIP ring holds +the FIRST KB after boot, not the wedge tail. Always select probes by serial +(`-USB ` / `adapter serial`) — rigs run several. Two probes wired to one +SWD header wedge the target. + +## v1 backend matrix + +| Backend | Read (capture) | Write (console input) | +| ----------------------------------------------------- | ---------------------------- | ------------------------------------------ | +| J-Link native (`JLinkExe -RTTTelnetPort`) | validated | validated (8.5 KB writes) | +| OpenOCD on native probes (ST-Link/CMSIS-DAP/WCH-Link) | validated (sysview campaign) | unvalidated — validate in the ci-rig phase | +| OpenOCD on the LPC-Link2 (J-Link OB fw, measured) | forbidden (USB drop) | forbidden | +| WCH SDI (any tool) | halt→dump only | n/a | + +`JlinkRtt`/CLI are J-Link-only in v1; OpenOCD console-write support is +added only if the ci-rig phase validates it. + +## Tooling home + +Single implementation in `tools/rtt.py`: a stdlib-only importable module +(shared socket-console base + `JlinkRtt` + `OpenocdRtt`) that doubles as +the CLI. `hil_util` imports and re-exports the classes (the harness keeps +addressing `hil_util.JlinkRtt`), so the dependency points harness → tools, +never tools → harness. Because `hil_util` loads it at import time, the file +is harness-critical: it is classified with `test/hil/` in `ci_select`'s full +rule and covered by the pre-commit `hil-test` hook (test_hil_rtt.py). +Precedent: `code-size` wrapping `tools/metrics_compare_base.py` — the skill +is md-only and points at the tool. `open_board_console()` stays in +`hil_test.py` for now; pool-check adoption is a follow-up doc, not this PR. + +## Doc edits (curated-skills rule: smallest possible diffs) + +- `target-debug/SKILL.md`: capture-channel rows and the drain-model warning + stay; the two capture recipe blocks and the RTTLogger/GDBServer paragraph + shrink to one-liners pointing at `rtt`; the manual ring-read recipe + (`nm`/`mem32`/`savebin`) moves into `rtt` §post-mortem. +- `CLAUDE.md` GDB section RTT line becomes build flag + pointer. +- `hil/SKILL.md` gains one routing line (the fix that would have prevented + the lost hour). +- `sysview/SKILL.md` pointer is **deferred** until that branch merges, and + proposed to the user first. No edits to `sysview_ci.py` or the sysview + skill now. + +## Validation strategy (user-directed) + +1. **Dogfood on the local htpc bench first**: ea4088_quickstart via LPC-Link2 + (replugged; OpenOCD attempts on it are skipped outright) and + raspberry_pi_pico2 via the J-Trace (nickname `jtrace`, serial private; now wired to pico2; RP2350 = + `rp2350_m33_0`, never a custom JLinkScript). Follow only the SKILL.md + text (dogfood = REFACTOR input). +2. **Then all boards on the ci.lan rig**, per-transport smoke capture, rows + recorded in `.claude/skills/rtt/boards.md`. Exclusions recorded honestly + (esptool boards: no SEGGER-RTT path in our builds — USB-Serial-JTAG + console instead; tm4c: no probe path configured on the rig). + +## Non-goals + +Timing/profiling (etm-trace, sysview, parked swo-trace), SystemView +encode/decode/licensing, TU_LOG conventions, debugging decision flows +(target-debug), Espressif USB-Serial-JTAG console (esp-target-debug), WCH SDI +live streaming (impossible — see matrix). diff --git a/test/hil/helper/hil_pool_check.py b/test/hil/helper/hil_pool_check.py index b92f0aee0..4623ce45f 100644 --- a/test/hil/helper/hil_pool_check.py +++ b/test/hil/helper/hil_pool_check.py @@ -360,7 +360,47 @@ def check_host_serial(board: dict, do_reset: bool = True, want_hello: bool = Fal do_reset=False listens to the firmware as-is: used right after a flash whose own reset already started it — a second openocd/JLink session back-to-back on - the same probe can fail transiently and leave the target halted.""" + the same probe can fail transiently and leave the target halted. + + "logger": "rtt" boards have no VCOM: the same check runs over the probe's RTT + console instead. The reset happens BEFORE the console opens (it owns the probe), + which also zeroes the .bss ring — so pre-reset backlog cannot count as life, and + without a reset Commander delivers the boot burst the preceding flash left.""" + if board.get('logger') == 'rtt': + if do_reset: + # a failed reset leaves the previous run's ring intact: attaching anyway would + # score stale output as life, so bail to host_alive's board_test reflash ladder + rc, err = call_flasher(getattr(hil_flash, f'reset_{board["flasher"]["name"].lower()}'), board) + if rc: + say(f'{board["name"]:26} reset failed: {err}') + return None + try: + ser = hil_util.JlinkRtt(board, timeout=0.3) + except hil_util.RttError as e: + say(f'{board["name"]:26} no RTT console: {e}') + return None + try: + data = b'' + deadline = time.monotonic() + SERIAL_WAIT + while time.monotonic() < deadline: + ser.write(b'U') + data += ser.read(256) + # JLinkExe's banner arrives whether or not the target is alive -- + # judged unfiltered it scores a dead board 'alive'. Same shared filter + # as test_host_device_info; complete_only drops a trailing partial + # line, so a banner FRAGMENT split by this read boundary cannot count + # as target output either. + td = hil_util.strip_banner(data, complete_only=True) + if want_hello: + if b'Hello from TinyUSB' in td: + return td + elif td and not boardtest_output(td): + return td + return hil_util.strip_banner(data) + except hil_util.RttError: + return None # console died mid-poll (server exited, probe dropped) + finally: + ser.close() import serial try: port = hil_util.get_serial_dev(board['flasher']['uid'], None, None, 0) diff --git a/test/hil/helper/hil_util.py b/test/hil/helper/hil_util.py index 03d01270f..f279cfa77 100644 --- a/test/hil/helper/hil_util.py +++ b/test/hil/helper/hil_util.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Bottom layer of the HIL harness: the bounded command runner plus the shared helpers and -# data every other module needs. Stays stdlib-only and imports nothing local -- everything +# data every other module needs. Stays stdlib-only; its one local dependency is +# tools/rtt.py (the RTT console, loaded by path below) -- everything # else imports this, including the unit tests on GitHub's bare runner; never import them # from here. Callers set the module global `verbose`. @@ -499,6 +500,29 @@ def run_alongside(argv: list, work, timeout: int) -> subprocess.CompletedProcess return _reap() +# The RTT console implementation lives in tools/rtt.py (importable classes + CLI, +# stdlib-only, harness-critical — see its module docstring). Loaded by file path so +# no sys.path entry for tools/ can shadow other imports; re-exported here so the +# harness keeps addressing hil_util.JlinkRtt. +import importlib.util as _ilu + +_rtt_path = TINYUSB_ROOT / 'tools' / 'rtt.py' +if not _rtt_path.exists(): + # name the real cause: a bare FileNotFoundError out of an exec_module here reads + # as a harness bug, when the actual problem is an incompletely staged tree + raise ImportError(f'{_rtt_path} is missing — the RTT console lives there and the ' + f'harness depends on it; stage it alongside test/hil (hil_ci.sh does)') +_rtt_spec = _ilu.spec_from_file_location('tinyusb_tools_rtt', _rtt_path) +_rtt = _ilu.module_from_spec(_rtt_spec) +sys.modules[_rtt_spec.name] = _rtt # registered: RttError must be picklable across the fork Pool +_rtt_spec.loader.exec_module(_rtt) +JlinkRtt = _rtt.JlinkRtt +OpenocdRtt = _rtt.OpenocdRtt +RttError = _rtt.RttError +RTT_BANNER_RE = _rtt.RTT_BANNER_RE +strip_banner = _rtt.strip_banner + + def _cmd_label(cmd) -> str: """A one-line name for a banner. An argv whose payload is a `python3 -c` program would otherwise dump the whole body into the CI log, where run_cmd's banners are already the diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh index daa787242..43ede5795 100644 --- a/test/hil/hil_ci.sh +++ b/test/hil/hil_ci.sh @@ -288,6 +288,9 @@ scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \ "$ROOT_DIR/test/hil/helper/hil_lock.py" \ "$ROOT_DIR/test/hil/helper/hil_report.py" \ "$REMOTE:$REMOTE_DIR/test/hil/helper/" +# the rtt console/capture tool (rtt skill), harness-critical: hil_util imports it +ssh "$REMOTE" mkdir -p "$REMOTE_DIR/tools" +scp -q "$ROOT_DIR/tools/rtt.py" "$REMOTE:$REMOTE_DIR/tools/" # Copy only firmware binaries (elf/bin/hex) plus esptool metadata # (config.env + flash_args needed by the esptool flasher), preserving structure diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index e32998420..233627ec7 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -206,6 +206,7 @@ class Board(TypedDict): # needs one carries a single variant named after itself (metro_m4_express / # MAX3421_HOST=1), which is exactly what the `or [...]` default below synthesises variant: NotRequired[list[VariantCfg]] + logger: NotRequired[str] # "rtt": console = the debug probe's RTT channel 0, not a VCOM (rtt skill) toolchain: NotRequired[str] # CI build bucket override, e.g. "riscv-gcc" (consumed by hil_ci_set_matrix.py) @@ -292,6 +293,25 @@ def open_serial_dev(port: str): return ser +def open_board_console(board: Board): + """The board's log console: its probe's VCOM, or RTT when the probe has none. + + Both ends expose the same read/in_waiting/write/close surface, so the tests read one + the same way they read the other.""" + if board.get('logger') == 'rtt': + # JlinkRtt speaks JLinkExe only; an openocd/stlink flasher would yield + # `-device ''` and fail 15 s later with a misleading port error. The OpenOCD + # RTT route is validated manually on native probes but has no harness backend + # yet (rtt skill; followup doc) — and never point it at ea4088's LPC-Link2 + # (measured: knocks that probe off USB; other J-Link-OB probes untested) + assert board['flasher']['name'].lower() == 'jlink', \ + f'{board["name"]}: "logger": "rtt" needs a jlink flasher, not {board["flasher"]["name"]}' + return hil_util.JlinkRtt(board) + ser = open_serial_dev(hil_util.get_serial_dev(board['flasher']["uid"], None, None, 0)) + ser.timeout = 0.1 + return ser + + def serial_write_all(ser: serial.Serial, data: bytes): # write_timeout is a deadline for the whole call. A timeout means the device stopped # draining, and it is fatal: pyserial loses the partial-write count on raise, so @@ -300,7 +320,18 @@ def serial_write_all(ser: serial.Serial, data: bytes): ser.write(data) except serial.SerialTimeoutException: raise AssertionError(f'Serial write timeout after {SERIAL_WRITE_TIMEOUT:.1f}s') + except hil_util.RttError as e: + # the RTT console's failure contract (stall/closed/peer death): same + # drain-stopped meaning as the serial timeout -- a test failure, not a harness + # crash. Deliberately NOT bare RuntimeError: NotImplementedError and CPython's + # own 'dictionary changed size during iteration' are RuntimeErrors too, and a + # harness bug must not be reported as this board misbehaving. + raise AssertionError(f'Console write failed: {e}') + +# J-Link Commander's telnet greeting: never target output (defined with the console +# in tools/rtt.py; hil_pool_check strips it through the same object) +RTT_BANNER_RE = hil_util.RTT_BANNER_RE LP_OPEN_TIMEOUT = 5 # bound on opening the printer lp node; see test_device_printer_to_cdc # Runs under hil_util.run_alongside as `python3 -c`. Inline rather than a file so hil_ci.sh's @@ -508,34 +539,53 @@ def test_host_device_info(board): flasher = board['flasher'] declared_devs = [f'{d["vid_pid"]}_{d["serial"]}' for d in board['tests']['dev_attached']] - port = hil_util.get_serial_dev(flasher["uid"], None, None, 0) - ser = open_serial_dev(port) - ser.timeout = 0.1 - - # reset device since we can miss the first line - ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board) - assert ret.returncode == 0, 'Failed to reset device' - - data = b'' - timeout = enum_timeout() - while timeout > 0: - new_data = ser.read(ser.in_waiting or 1) - if new_data: - data += new_data - enum_dev_sn = [] - for l in data.decode('utf-8', errors='ignore').splitlines(): - vid_pid_sn = re.search(r'ID ([0-9a-fA-F]+):([0-9a-fA-F]+) SN (\w+)', l) - if vid_pid_sn: - enum_dev_sn.append(f'{vid_pid_sn.group(1)}_{vid_pid_sn.group(2)}_{vid_pid_sn.group(3)}') - if set(declared_devs).issubset(set(enum_dev_sn)): - break - time.sleep(0.1) - timeout -= 0.1 - ser.close() + if board.get('logger') == 'rtt': + # The RTT console owns the probe, so reset BEFORE opening it (Commander then + # delivers the buffered boot burst). Unconditional, not only under --skip-flash: + # a previous run's console drained the ring, and the enumeration lines print + # only once — without this a re-run on unchanged firmware reads an empty ring. + ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board) + assert ret.returncode == 0, 'Failed to reset device' + ser = open_board_console(board) + try: + if board.get('logger') != 'rtt': + # reset device since we can miss the first line; on the VCOM the console + # survives the reset, so resetting after open catches the boot banner. + ret = getattr(hil_flash, f'reset_{flasher["name"].lower()}')(board) + assert ret.returncode == 0, 'Failed to reset device' + + data = b'' + timeout = enum_timeout() + while timeout > 0: + # infra death is not a board failure: without this a dead JLinkExe/probe + # would burn the whole timeout and report as 'No data from device' + assert not getattr(ser, 'eof', False), \ + 'RTT console died (its server exited or the probe dropped off USB)' + new_data = ser.read(ser.in_waiting or 1) + if new_data: + data += new_data + enum_dev_sn = [] + for l in data.decode('utf-8', errors='ignore').splitlines(): + vid_pid_sn = re.search(r'ID ([0-9a-fA-F]+):([0-9a-fA-F]+) SN (\w+)', l) + if vid_pid_sn: + enum_dev_sn.append(f'{vid_pid_sn.group(1)}_{vid_pid_sn.group(2)}_{vid_pid_sn.group(3)}') + if set(declared_devs).issubset(set(enum_dev_sn)): + break + time.sleep(0.1) + timeout -= 0.1 + finally: + ser.close() - if len(data) == 0: - assert False, 'No data from device' lines = data.decode('utf-8', errors='ignore').splitlines() + if board.get('logger') == 'rtt': + # JLinkExe's telnet banner is delivered at connect, whether or not it ever + # finds the control block, so len(data) alone cannot tell "board said nothing" + # from "console never attached to the ring" -- drop the banner first + target_lines = hil_util.strip_banner(data).splitlines() + assert target_lines, ('No data from device: the RTT console attached but the target ' + 'produced nothing -- firmware built without LOGGER=rtt, or SWD lost') + elif len(data) == 0: + assert False, 'No data from device' enum_dev_sn = [] for l in lines: @@ -1729,7 +1779,7 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st def build_board(board: Board) -> tuple[str, int]: """Build firmware for this board via tools/build.py. - Honors board config's variant list (name, defines, flags). + Honors board config's variant list. Output goes to cmake-build/cmake-build-/ (tools/build.py layout). Unbounded on purpose: --build is a local convenience (no CI workflow passes it), so @@ -2337,6 +2387,56 @@ def main() -> None: config_boards = [e for e in config['boards'] if e['name'] in boards] config_boards = [e for e in config_boards if e['flasher']['name'] not in args.exclude_flasher and (not args.flasher or e['flasher']['name'] in args.flasher)] + + # fail rtt misconfigurations before the first flash cycle -- but only for boards + # this run actually touches: one bad roster entry must not abort other runs' subsets + def _rtt_config_abort(msg: str): + # loud AND leaving evidence, like the no-boards branch below: exiting with no + # report at all lets the PR comment keep the previous push's stale table + print(f'ERROR: {msg}', flush=True) + rd = Path(os.environ.get('HIL_REPORT_DIR', '.')) + hil_report.mark_report_no_boards(rd, f'config error: {msg}', fresh=not args.accumulate) + sys.exit(1) + + bad_logger = [e['name'] for e in config_boards if e.get('logger') not in (None, 'rtt')] + if bad_logger: + # only the exact string activates RTT handling; anything else would silently + # mean VCOM and reproduce the misleading 'No serial device found' failure + _rtt_config_abort(f'unknown "logger" value (only "rtt" is supported): {", ".join(bad_logger)}') + bad_rtt = [e['name'] for e in config_boards + if e.get('logger') == 'rtt' and e['flasher']['name'].lower() != 'jlink'] + if bad_rtt: + # JlinkRtt speaks JLinkExe only (the OpenOCD RTT route is manual — rtt skill) + _rtt_config_abort(f'"logger": "rtt" needs a jlink flasher: {", ".join(bad_rtt)}') + rtt_no_logger_def = [e['name'] for e in config_boards + if e.get('logger') == 'rtt' + and any('LOGGER=rtt' not in (v.get('defines') or []) + for v in (e.get('variant') or [{}]))] + if rtt_no_logger_def: + # a prebuilt cmake-build- configured with -DLOGGER=rtt is a legitimate + # build path the roster need not describe, so warn there -- but when this run is + # responsible for the firmware (--build, or CI where the hil-build job compiled + # the artifact from these same defines) the flashed image is UART-logger and every + # test times out as 'the target produced nothing'. An always-on define is + # expressed as a single self-named variant (see the Board comment). + msg = (f'"logger": "rtt" board has a variant without LOGGER=rtt in its defines ' + f'({", ".join(rtt_no_logger_def)})') + if args.build or os.environ.get('GITHUB_ACTIONS'): + _rtt_config_abort(f'{msg} -- the firmware built for this run cannot serve the ' + f'configured RTT console') + print(f'warning: {msg} -- fine for prebuilt example sets, wrong for --build/CI ' + f'builds', flush=True) + rtt_fixture = [e['name'] for e in config_boards + if e.get('logger') == 'rtt' + and any(d.get('is_cdc') or d.get('is_msc') + for d in e.get('tests', {}).get('dev_attached', []))] + if rtt_fixture: + # interim guard, removed when the followup lands: cdc_msc_hid/msc_file_explorer + # still open the flasher VCOM directly and would die mid-run on an rtt board + _rtt_config_abort(f'"logger": "rtt" boards cannot carry is_cdc/is_msc fixtures yet ' + f'(host cdc/msc tests bypass the RTT console — see ' + f'the rtt harness-adoption doc in docs/superpowers/followup/): {", ".join(rtt_fixture)}') + if not config_boards: # same reason the unknown -b board exits 1: 'No tests were run.' with rc 0 reads as # a green HIL leg, so a roster edit emptying a leg's filter stops testing silently diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py index ace230246..22fbde17b 100644 --- a/test/hil/test/test_ci_select.py +++ b/test/hil/test/test_ci_select.py @@ -499,6 +499,8 @@ class TestPortAndCoreRoleUseExtras(unittest.TestCase): self.assertFalse(s['full']) for board in boards: tests = s['boards'][board] + if tests == 'all': + continue # a board whose whole allowed set is selected collapses to 'all' self.assertIn('device/hid_composite_freertos', tests) self.assertIn('device/cdc_msc_freertos', tests) self.assertIn('device/audio_test_freertos', tests) @@ -510,6 +512,8 @@ class TestPortAndCoreRoleUseExtras(unittest.TestCase): self.assertFalse(s['full']) for board in boards: tests = s['boards'][board] + if tests == 'all': + continue # a board whose whole allowed set is selected collapses to 'all' self.assertIn('device/hid_composite_freertos', tests) self.assertIn('device/cdc_msc_freertos', tests) self.assertIn('device/audio_test_freertos', tests) @@ -987,6 +991,7 @@ class TestTheHarnessTestsAreNotTheHarness(unittest.TestCase): 'test/hil/test/test_hil_bounded.py', 'test/hil/test/test_hil_health.py', 'test/hil/test/test_hil_report.py', + 'test/hil/test/test_hil_rtt.py', 'test/hil/test/test_hil_util.py', ], 'test/hil/test/ gained or lost a file; it is carved out of rule 2, so confirm ' 'the rig still does not read anything in there before updating this list') diff --git a/test/hil/test/test_hil_rtt.py b/test/hil/test/test_hil_rtt.py new file mode 100644 index 000000000..3a07f13ec --- /dev/null +++ b/test/hil/test/test_hil_rtt.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Unit tests for hil_util.JlinkRtt and the rtt.py CLI against a fake JLinkExe +# on PATH — real subprocesses and sockets, no hardware, stdlib only, so the pre-commit +# hil-test hook can run this on GitHub's bare runner. Run directly: +# python3 test/hil/test/test_hil_rtt.py +import os +import subprocess +import sys +import tempfile +import time +import unittest +from contextlib import suppress as contextlib_suppress +from pathlib import Path + +# the module under test lives in the parent dir's helper/ package +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from helper import hil_util + +CLI = Path(__file__).resolve().parents[3] / 'tools' / 'rtt.py' + +# Serves -RTTTelnetPort like J-Link Commander: greets, echoes input uppercased, exits on +# stdin 'exit' (JlinkRtt.close()'s contract). FAKE_JLINK_MODE=die_after_greet sends the +# greeting then drops the connection and exits — the probe-unplug/crash case; +# FAKE_JLINK_MODE=tick also streams a line every 50 ms — the continuous-capture case. +FAKE_JLINK = '''#!/usr/bin/env python3 +import os, socket, sys, threading, time +port = int(sys.argv[sys.argv.index('-RTTTelnetPort') + 1]) +srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(('127.0.0.1', port)); srv.listen(1) +mode = os.environ.get('FAKE_JLINK_MODE', '') +def serve(): + conn, _ = srv.accept() + # the real server sends its banner AT CONNECT, before the control block is + # found — target data only flows later; the CLI's -i gate must not release + # on the banner + conn.sendall(b'SEGGER J-Link fake - Real time terminal output\\r\\n' + b'J-Link FakeProbe V1.0, SN=000\\r\\nProcess: JLinkExe\\r\\n') + if mode == 'banner_only': + while True: + if not conn.recv(4096): os._exit(0) + if mode == 'rst': + import struct + conn.recv(4096) # wait for the client to speak, then reset the connection + conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + conn.close(); os._exit(0) + if mode == 'late_cb': + # models JLinkExe before it finds the control block: client bytes sent in + # this window are silently dropped, output starts only after the "attach" + end = time.time() + 1.0 + conn.setblocking(False) + while time.time() < end: + try: + conn.recv(4096) # discard early input like the real server + except OSError: + pass + time.sleep(0.05) + conn.setblocking(True) + conn.sendall(b'hello from target\\r\\n') + if mode == 'die_after_greet': + conn.close(); os._exit(0) + if mode == 'tick': + def tick(): + try: + while True: + time.sleep(0.05); conn.sendall(b'tick\\r\\n') + except OSError: + pass + threading.Thread(target=tick, daemon=True).start() + while True: + d = conn.recv(4096) + if not d: return + conn.sendall(d.upper()) +threading.Thread(target=serve, daemon=True).start() +for line in sys.stdin: + if line.strip() == 'exit': break +''' + +BOARD = {'flasher': {'uid': '000', 'args': '-device FAKE'}} + + +@unittest.skipIf(os.name == 'nt', 'POSIX PATH/exec semantics') +class JlinkRttFakeProbe(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._dir = tempfile.TemporaryDirectory() + fake = Path(cls._dir.name) / 'JLinkExe' + fake.write_text(FAKE_JLINK) + fake.chmod(0o755) + cls._path = f'{cls._dir.name}{os.pathsep}{os.environ["PATH"]}' + + @classmethod + def tearDownClass(cls): + cls._dir.cleanup() + + def _fake_path(self): + # register the restore BEFORE mutating, then prepend the fake tool dir + self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH']) + os.environ['PATH'] = self._path + + def _console(self, mode=''): + self._fake_path() + if mode: + os.environ['FAKE_JLINK_MODE'] = mode + self.addCleanup(os.environ.pop, 'FAKE_JLINK_MODE', None) + con = hil_util.JlinkRtt(BOARD, timeout=0.1) + self.addCleanup(con.close) + return con + + def _read_until(self, con, want, timeout=3): + out = b'' + end = time.monotonic() + timeout + while want not in out and time.monotonic() < end: + out += con.read(con.in_waiting or 1) + return out + + def test_read_and_echo_write(self): + con = self._console() + self.assertIn(b'hello from target', self._read_until(con, b'hello from target')) + self.assertEqual(con.write(b'ping'), 4) + self.assertIn(b'PING', self._read_until(con, b'PING')) + + def test_eof_latched_when_server_dies(self): + con = self._console(mode='die_after_greet') + self._read_until(con, b'hello from target') + end = time.monotonic() + 3 + while not con.eof and time.monotonic() < end: + time.sleep(0.05) + self.assertTrue(con.eof) # dead server is detected, not spun on + t0 = time.monotonic() + self.assertEqual(con.read(64), b'') # empty, paced like a serial timeout + elapsed = time.monotonic() - t0 + self.assertLess(elapsed, 0.5) # bounded by the 0.1 s timeout, not hung + self.assertGreater(elapsed, 0.02) # ...but not a busy-spin fast return + con.timeout = None # pyserial's block-forever mode must + t0 = time.monotonic() # ALSO pace (0.1 s default), not spin + self.assertEqual(con.read(64), b'') + elapsed = time.monotonic() - t0 + self.assertLess(elapsed, 0.5) + self.assertGreater(elapsed, 0.02) + con.timeout = 0.1 + + def test_reset_input_buffer(self): + con = self._console() + self._read_until(con, b'hello from target') + con.write(b'x') + time.sleep(0.3) + con.reset_input_buffer() + self.assertEqual(con.in_waiting, 0) + + def test_write_after_close_raises_runtimeerror(self): + con = self._console() + con.close() + with self.assertRaises(RuntimeError): + con.write(b'x') + + def test_write_after_server_death_raises(self): + # TCP accepts one send after peer death — write() must refuse instead of + # "succeeding" into the void + con = self._console(mode='die_after_greet') + self._read_until(con, b'hello from target') + end = time.monotonic() + 3 + while not con.eof and time.monotonic() < end: + time.sleep(0.05) + with self.assertRaises(RuntimeError): + con.write(b'ping') + + def test_read_after_close_raises_runtimeerror(self): + con = self._console() + self._read_until(con, b'hello from target') + con.close() + with self.assertRaises(RuntimeError): + con.read(1) + + def test_missing_jlinkexe_raises_runtimeerror(self): + self._fake_path() + os.environ['PATH'] = self._dir.name # no python3 either, but JLinkExe fails first + os.rename(f'{self._dir.name}/JLinkExe', f'{self._dir.name}/JLinkExe.off') + self.addCleanup(os.rename, f'{self._dir.name}/JLinkExe.off', f'{self._dir.name}/JLinkExe') + with self.assertRaises(RuntimeError): + hil_util.JlinkRtt(BOARD, timeout=0.1) + + def test_close_reaps_the_server(self): + con = self._console() + proc = con._proc + con.close() + self.assertIsNotNone(proc.poll()) # no zombie, no probe held + + def test_cli_exits_when_server_dies(self): + # --seconds 0 must end on server EOF (rc 1), not hang forever + env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='die_after_greet') + r = subprocess.run([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '0'], + env=env, capture_output=True, timeout=20) + self.assertEqual(r.returncode, 1) + self.assertIn(b'hello from target', r.stdout) + self.assertIn(b'server closed', r.stderr) + + def test_peer_reset_latches_eof(self): + # a killed server closes with RST when bytes are unread; the read side must + # LATCH eof (so the harness's `assert not ser.eof` triage fires) and never + # leak ConnectionResetError/ValueError to in_waiting/eof callers + con = self._console(mode='rst') + # rst mode sends only the banner (it RSTs on first input) -- wait for the + # banner tail, not target output that never comes + self._read_until(con, b'Process: JLinkExe') + con.write(b'x') # fake resets the connection on input + end = time.monotonic() + 3 + try: + while not con.eof and time.monotonic() < end: + con.in_waiting # must not raise across the RST + time.sleep(0.05) + except Exception as e: # noqa: BLE001 - the regression this guards + self.fail(f'{type(e).__name__} escaped the latch-only contract: {e}') + self.assertTrue(con.eof) + with self.assertRaises(hil_util.RttError): + con.write(b'y') # dead server refuses writes + + def test_write_timeout_env_rejects_inf(self): + # hil_util's twin rejects inf for the same reason: an unbounded write is what + # this knob exists to bound + import importlib.util as ilu + from pathlib import Path as _P + spec = ilu.spec_from_file_location('rtt_env_probe', _P(CLI)) + mod = ilu.module_from_spec(spec) + old = os.environ.get('HIL_SERIAL_WRITE_TIMEOUT') + os.environ['HIL_SERIAL_WRITE_TIMEOUT'] = 'inf' + self.addCleanup(lambda: os.environ.__setitem__('HIL_SERIAL_WRITE_TIMEOUT', old) + if old is not None else os.environ.pop('HIL_SERIAL_WRITE_TIMEOUT', None)) + spec.loader.exec_module(mod) + self.assertEqual(mod.RTT_WRITE_TIMEOUT, 10) + + def test_cli_rejects_bad_seconds_and_jlink_channel(self): + def run(*a): + return subprocess.run([sys.executable, str(CLI), *a], capture_output=True, timeout=15) + for bad in ('-5', 'nan'): + r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', bad) + self.assertEqual(r.returncode, 2, f'--seconds {bad} was accepted') + # the jlink telnet route serves channel 0 only; asking for another is an error, + # not silence (--dump can read any ring, so it stays allowed there) + r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--channel', '1') + self.assertEqual(r.returncode, 2) + self.assertIn(b'channel 0 only', r.stderr) + # a negative index would walk backwards off aUp[] (dump route included) + r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--channel', '-1') + self.assertEqual(r.returncode, 2) + self.assertIn(b'>= 0', r.stderr) + + def test_pyserial_surface_contracts(self): + con = self._console() + self._read_until(con, b'hello from target') + con.write(b'abcdef') + self._read_until(con, b'ABC') # echo queued + before = con.in_waiting + self.assertEqual(con.read(0), b'') # pyserial: consumes nothing + self.assertEqual(con.read(-1), b'') # never hand over/destroy bytes + self.assertEqual(con.in_waiting, before) + con.timeout = None # pyserial: block until satisfied + con.write(b'xy') # fresh echo guarantees the read returns + self.assertEqual(len(con.read(2)), 2) + con.timeout = 0.1 + con.close() + with self.assertRaises(hil_util.RttError): + con.in_waiting # closed console reports closed, not healthy + self.assertTrue(con.eof) + + def test_context_manager_closes(self): + self._fake_path() + with hil_util.JlinkRtt(BOARD, timeout=0.1) as con: + proc = con._proc + self.assertIsNotNone(proc.poll()) # __exit__ released the probe + + def test_staging_and_banner_coupling(self): + # tripwires for couplings no import-walk can see: + # (a) hil_ci.sh must stage tools/rtt.py -- hil_util exec_module's it, so an + # unstaged rig tree kills every harness import + hil_ci = (Path(__file__).resolve().parents[1] / 'hil_ci.sh').read_text() + self.assertIn('tools/rtt.py', hil_ci) + # (b) the shared RTT banner filter must drop ALL THREE J-Link banner lines, + # including the middle one, which is the PROBE MODEL string and in + # libjlinkarm carries no 'SEGGER ' prefix (J-Link OH3, J-Trace H9...) + banner_re = hil_util.RTT_BANNER_RE + for line in ('SEGGER J-Link V9.66 - Real time terminal output', + 'SEGGER J-Link LPC-Link 2 V1.0, SN=611000000', + 'J-Link OH3 V1.0, SN=123456789', + 'J-Trace H9 V2.0, SN=123456789002', + 'Process: JLinkExe'): + self.assertTrue(banner_re.match(line), f'banner line not filtered: {line!r}') + for line in ('Hello from TinyUSB', 'USBD init on controller 0', + 'ID 1a86:8010 SN 7FD88F0604B5', 'echo:p'): + self.assertFalse(banner_re.match(line), f'target line wrongly filtered: {line!r}') + + def test_pool_check_dead_rtt_board_is_not_alive(self): + # JLinkExe's banner alone must not score a dead board 'alive': pool_check's + # rtt aliveness judges only target bytes (the bug: unfiltered, the banner + # made `not boardtest_output(data)` true on the first poll) + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from helper import hil_pool_check + # a dead board burns the whole poll window; the verdict is the same at 0.5 s + self.addCleanup(setattr, hil_pool_check, 'SERIAL_WAIT', hil_pool_check.SERIAL_WAIT) + hil_pool_check.SERIAL_WAIT = 0.5 + self._fake_path() + os.environ['FAKE_JLINK_MODE'] = 'banner_only' + self.addCleanup(os.environ.pop, 'FAKE_JLINK_MODE', None) + board = dict(BOARD, name='deadboard', logger='rtt') + got = hil_pool_check.check_host_serial(board, do_reset=False, want_hello=True) + self.assertEqual(got, b'') # dead, not "alive on banner" + + def test_cli_arg_contract(self): + # --backend is explicit (no default); vid-pid is openocd-only; the openocd + # backend accepts --addr instead of --elf and --vid-pid instead of --probe + def run(*a, inp=b''): + return subprocess.run([sys.executable, str(CLI), *a], + input=inp, capture_output=True, timeout=15) + r = run('--probe', '000', '--device', 'FAKE') # no --backend + self.assertEqual(r.returncode, 2) + self.assertIn(b'--backend', r.stderr) + r = run('--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--vid-pid', '0x1 0x2') + self.assertEqual(r.returncode, 2) # vid-pid is openocd-only + r = run('--backend', 'openocd', '--cfg', '-f x.cfg', '--addr', '0x20000000') + self.assertEqual(r.returncode, 2) # needs --probe or --vid-pid + self.assertIn(b'vid-pid', r.stderr) + r = run('--backend', 'openocd', '--probe', '000', '--cfg', '-f x.cfg', '--addr', 'nothex') + self.assertEqual(r.returncode, 2) + self.assertIn(b'hex', r.stderr) + + def test_cli_interactive_echo(self): + env = dict(os.environ, PATH=self._path) + r = subprocess.run([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '2', '-i'], + env=env, input=b'hi', capture_output=True, timeout=20) + self.assertEqual(r.returncode, 0) + self.assertIn(b'HI', r.stdout) # bytes forwarded without needing a newline + self.assertNotIn(b'never forwarded', r.stderr) # forwarding happened: no false alarm + + def test_cli_interactive_input_held_until_output(self): + # input piped at process start must survive the server's control-block hunt + # (the real JLinkExe drops client bytes until the block is found — measured + # on the rig: instant 'ping' lost, delayed 'ping' echoed) + env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='late_cb') + r = subprocess.run([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '3', '-i'], + env=env, input=b'hi', capture_output=True, timeout=25) + self.assertEqual(r.returncode, 0) + self.assertIn(b'HI', r.stdout) + + def test_cli_interactive_no_input_diagnostic(self): + # -i with stdin closed immediately: the diagnostic must say stdin was never + # forwarded (true), keyed on actual forwarding -- not on the attach gate, + # which releases after 5 s and forwards anyway on longer runs + env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='banner_only') + r = subprocess.run([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '1', '-i'], + env=env, input=b'', capture_output=True, timeout=20) + self.assertEqual(r.returncode, 0) + self.assertIn(b'never forwarded', r.stderr) + self.assertIn(b'no target output', r.stderr) + + def test_cli_downstream_pipe_close(self): + # a real `rtt.py | head`-style consumer: close the read end mid-stream + # and the CLI must exit 0 via its BrokenPipe path, not traceback (this test + # fails if the handler is removed — subprocess.run capture can't cover it) + env = dict(os.environ, PATH=self._path, FAKE_JLINK_MODE='tick') + p = subprocess.Popen([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '8'], + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p.stdout.read(10) # let it stream a little + p.stdout.close() # downstream hangs up + rc = p.wait(timeout=20) + err = p.stderr.read() + p.stderr.close() + self.assertEqual(rc, 0, err) + self.assertNotIn(b'Traceback', err) + + def test_cli_feeder_races_shutdown(self): + # a feeder still writing when --seconds expires must not crash the CLI + # (pump thread vs close() race: historically tracebacks and SIGABRT rc 134) + env = dict(os.environ, PATH=self._path) + for _ in range(3): + p = subprocess.Popen([sys.executable, str(CLI), + '--backend', 'jlink', '--probe', '000', '--device', 'FAKE', '--seconds', '1', '-i'], + env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + try: + while True: + p.stdin.write(b'hi\n') + p.stdin.flush() + time.sleep(0.01) + except (BrokenPipeError, OSError): + pass + rc = p.wait(timeout=20) + err = p.stderr.read() + p.stderr.close() + with contextlib_suppress(OSError, ValueError): + p.stdin.close() + self.assertEqual(rc, 0, err) + self.assertNotIn(b'Exception in thread', err) + + + +class StripBanner(unittest.TestCase): + # both harness consumers (device_info verdict, pool_check aliveness) judge + # target-aliveness through this ONE filter -- pin its shape here + def test_drops_banner_keeps_target(self): + raw = (b'SEGGER J-Link V9.66 - Real time terminal output\r\n' + b'J-Link OH3 V1.0, SN=123456789\r\nProcess: JLinkExe\r\n' + b'Hello from TinyUSB\r\n') + self.assertEqual(hil_util.strip_banner(raw), b'Hello from TinyUSB') + + def test_complete_only_drops_split_banner_fragment(self): + # a poll loop can catch the banner mid-line at a read boundary; the + # fragment must not defeat the prefix regex and score as target output + frag = b'SEGGER J-Link V9.66 - Real time terminal output\r\nProce' + self.assertEqual(hil_util.strip_banner(frag, complete_only=True), b'') + # the final verdict keeps a genuine unterminated target tail + self.assertEqual(hil_util.strip_banner(b'tud_task\r\nrunn'), b'tud_task\nrunn') + self.assertEqual(hil_util.strip_banner(b'', complete_only=True), b'') + + +# Serves like `openocd ... -c "rtt server start PORT CH"`: parses the port from its +# single shell-quoted command line, greets, echoes uppercased. No banner (matches the +# real openocd rtt server, which sends target data only). +FAKE_OPENOCD = '''#!/usr/bin/env python3 +import os, re, socket, sys, threading, time +if os.environ.get('FAKE_OPENOCD_ARGV'): + open(os.environ['FAKE_OPENOCD_ARGV'], 'w').write(' '.join(sys.argv)) +port = int(re.search(r'rtt server start (\\d+)', ' '.join(sys.argv)).group(1)) +srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +srv.bind(('127.0.0.1', port)); srv.listen(1) +conn, _ = srv.accept() +conn.sendall(b'hello from target\\r\\n') +while True: + d = conn.recv(4096) + if not d: break + conn.sendall(d.upper()) +''' + + +@unittest.skipIf(os.name == 'nt', 'POSIX PATH/exec semantics') +class OpenocdRttFakeProbe(unittest.TestCase): + """The openocd-backend class shares its whole read/write/eof contract with + JlinkRtt via the base class (covered above); this exercises the parts it owns: + spawn/connect, echo round-trip, teardown.""" + + @classmethod + def setUpClass(cls): + cls._dir = tempfile.TemporaryDirectory() + fake = Path(cls._dir.name) / 'openocd' + fake.write_text(FAKE_OPENOCD) + fake.chmod(0o755) + cls._path = f'{cls._dir.name}{os.pathsep}{os.environ["PATH"]}' + + @classmethod + def tearDownClass(cls): + cls._dir.cleanup() + + def _fake_path(self): + self.addCleanup(os.environ.__setitem__, 'PATH', os.environ['PATH']) + os.environ['PATH'] = self._path + + def test_reset_before_attach_shapes_the_command(self): + # SystemView-style consumers need the server draining WHEN the target boots + # (its Init record is emitted once); the opt-in flag must put `reset run` + # between init and rtt setup, and must not appear otherwise + self._fake_path() + argv_file = os.path.join(self._dir.name, 'argv.txt') + os.environ['FAKE_OPENOCD_ARGV'] = argv_file + self.addCleanup(os.environ.pop, 'FAKE_OPENOCD_ARGV', None) + for flag, want in ((True, True), (False, False)): + con = hil_util.OpenocdRtt('-f fake.cfg', 0x20000000, 1, serial_no='000', + reset_before_attach=flag) + try: + argv = Path(argv_file).read_text() + finally: + con.close() + self.assertEqual('reset run' in argv, want, argv) + if want: # ordering is the whole point: reset, settle, THEN attach + self.assertLess(argv.index('reset run'), argv.index('rtt setup'), argv) + self.assertIn('sleep 2000', argv) + self.assertIn('rtt server start', argv) + self.assertTrue(argv.rstrip().endswith('1'), argv) # channel threaded through + + def test_openocd_route_echo_and_teardown(self): + self._fake_path() + con = hil_util.OpenocdRtt('-f fake.cfg', 0x20000000, 0, + serial_no='000', vid_pid='0x1234 0x5678') + self.addCleanup(con.close) + out = b'' + end = time.monotonic() + 3 + while b'hello from target' not in out and time.monotonic() < end: + out += con.read(con.in_waiting or 1) + self.assertIn(b'hello from target', out) + con.write(b'ping') + end = time.monotonic() + 3 + while b'PING' not in out and time.monotonic() < end: + out += con.read(con.in_waiting or 1) + self.assertIn(b'PING', out) + proc = con._proc + con.close() + self.assertIsNotNone(proc.poll()) # no zombie, no probe held + with self.assertRaises(RuntimeError): + con.write(b'x') # same post-close contract as JlinkRtt + + +if __name__ == '__main__': + unittest.main() diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py index e06d2ba8b..1a283bda7 100644 --- a/test/hil/test/test_hil_util.py +++ b/test/hil/test/test_hil_util.py @@ -145,9 +145,13 @@ class BottomLayer(unittest.TestCase): # hil_pool_check included: test_hil_util_is_a_single_module_instance imports it # on the bare runner, and its `import serial` is function-local for exactly # this reason -- hoisting it must fail HERE, not on every PR's pre-commit CI + # ../../tools/rtt: hil_util exec_module's it at import (helper/hil_util.py's + # loader block), so a non-stdlib import THERE kills ci_select on the bare + # runner just as surely -- and the spec_from_file_location call is invisible to + # the ast.Import walk below, which is why it must be listed explicitly for mod in ('helper/hil_util', 'hil_flash', '../../tools/ci_select', 'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check', - '../../tools/build', '../../tools/build_utils'): + '../../tools/build', '../../tools/build_utils', '../../tools/rtt'): tree = ast.parse((hil_dir / f'{mod}.py').read_text()) # module level only: a deferred import inside a function cannot break # importability (hil_pool_check keeps `import serial` function-local diff --git a/tools/ci_select.py b/tools/ci_select.py index ca9d54c27..1526f2064 100755 --- a/tools/ci_select.py +++ b/tools/ci_select.py @@ -132,7 +132,11 @@ _METRICS_RE = re.compile( r'^(tools/metrics[^/]*\.py$|\.github/scripts/metrics_[^/]*\.py$)') _FULL_RE = re.compile( r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|' - r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|' + # tools/rtt.py is part of the harness, not a standalone tool: hil_util imports it + # at module load, so a break in it breaks every rig run the same way a test/hil/ + # edit can (the pre-commit hil-test hook runs its unit tests for the same reason) + r'test/hil/|tools/rtt\.py$|' + r'\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|' # generates the whole CircleCI matrix, same authority as .github/** r'\.circleci/|' # rule 16 says `tools/build*.py`; name the two siblings the glob implies. Both @@ -764,6 +768,16 @@ def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel, # only the tests whose example builds the lib, and only those the rig runs tests = {e for e in lib_examples(lib, repo_root) if any(e in pool for pool in ALL_TESTS.values()) or e in extras} + if lib == 'SEGGER_RTT': + # no example names this lib, but a board whose roster entry says + # "logger": "rtt" (variant defines LOGGER=rtt) reads EVERY test's console + # through it -- a break here silently breaks all of that board's rows + rtt_boards = [b['name'] for b in roster_boards if b.get('logger') == 'rtt'] + if rtt_boards: + s.roles.update(('device', 'host')) + s.add(rtt_boards, 'all', + f'{path}: SEGGER_RTT is the rtt console on {rtt_boards} -> all tests') + return if not tests: s.reasons.append(f'{path}: lib {lib} used by no HIL test, no contribution') return @@ -1139,9 +1153,13 @@ def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None): lib = m.group(1) exs = lib_examples(lib, repo_root) if not exs: - # empty means empty: no example's build pulls this lib in, so no build - # compiles it (lib/SEGGER_RTT is only reached through LOGGER=rtt, which - # no CI build sets) + # empty means empty: no example's build pulls this lib in, so no MAIN- + # matrix build compiles it. (lib/SEGGER_RTT is reached through LOGGER=rtt, + # which the main matrix never sets; the hil-build legs set it only for + # roster boards whose variant defines carry it, via the HIL SEGGER_RTT rule. + # No committed CI roster has such a board yet, so a SEGGER_RTT edit is + # currently neither built nor HIL-tested by CI -- verify vendor bumps + # manually until a rig board adopts "logger": "rtt".) s.reasons.append(f'{path}: lib {lib} built by no example, no contribution') return s.add(all_bsp_families(repo_root), exs, f'{path}: lib {lib} -> {sorted(exs)}') diff --git a/tools/rtt.py b/tools/rtt.py new file mode 100644 index 000000000..e3aef36f2 --- /dev/null +++ b/tools/rtt.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""RTT console/capture over a debug probe — importable classes + CLI (the rtt +skill's SKILL.md is the manual). + +Three routes (see the skill's transport matrix for which route a probe gets). +--backend is always explicit: + + J-Link route (console/capture, channel 0 only) + rtt.py --backend jlink --probe --device [--seconds N] [-i] + OpenOCD route (native probes: ST-Link/CMSIS-DAP; console/capture, any channel) + rtt.py --backend openocd [--probe ] [--vid-pid "0xVVVV 0xPPPP"] \\ + --cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" \\ + (--elf | --addr 0x2000xxxx) [--channel N] [--seconds N] [-i] + [--reset-before-attach] # capture from the target's boot (SystemView) + Post-mortem ring dump (J-Link, no halt — debug-AP reads) + rtt.py --backend jlink --dump --probe --device \\ + (--elf | --addr 0x...) + +The probe is owned for the whole run: flash and reset BEFORE starting this, never +reset the target while it is attached. Pin the probe: rigs and benches run +several (jlink: --probe serial; openocd: --probe and/or --vid-pid). + +The classes (JlinkRtt for J-Link, OpenocdRtt for openocd-driven probes) expose +the slice of pyserial the HIL harness uses — read/in_waiting/write/close/timeout, +reset_input_buffer, context-manager use, plus an `eof` latch — and are imported +by test/hil/helper/hil_util.py, so this file is HARNESS-CRITICAL: a change here +is classified like a test/hil/ harness change (tools/ci_select.py) and runs the +console unit tests (pre-commit hil-test hook, test/hil/test/test_hil_rtt.py). +Stdlib only — hil_util imports this file, never the other way around. +""" +import argparse +import contextlib +import os +import re +import select +import shlex +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time + + +class RttError(RuntimeError): + """Every way a console can break: stall, closed, dead or reset server. + + A RuntimeError subclass so existing `except RuntimeError` callers keep working, + but named so the harness can tell a console failure from an unrelated + NotImplementedError / 'dictionary changed size during iteration' and stop + reporting harness bugs as board failures.""" + + +def _pos_float_env(name: str, default: float) -> float: + # mirrors hil_util.pos_float_env, including its rejection of inf/nan: an infinite + # write timeout is an unbounded write, the very thing this knob exists to bound + raw = os.environ.get(name) + if raw is None: + return default + try: + v = float(raw) + except ValueError: + print(f'warning: {name} is not a number; using {default}', file=sys.stderr, flush=True) + return default + if not (v > 0 and v < float('inf')): + print(f'warning: {name}={v} is not usable; using {default}', file=sys.stderr, flush=True) + return default + return v + + +# whole-call deadline for write() — same env knob as the harness's serial twin +RTT_WRITE_TIMEOUT = _pos_float_env('HIL_SERIAL_WRITE_TIMEOUT', 10) + +# J-Link Commander's telnet greeting, sent at connect BEFORE (or without) the control +# block being found: never target output. Three lines; the middle one is the PROBE +# MODEL string, which in libjlinkarm carries no 'SEGGER ' prefix (J-Link OH3, +# J-Trace H9, ...) though some builds do prefix it — match both shapes. Consumers +# judging "did the target speak" must strip these lines first. +RTT_BANNER_RE = re.compile(r'^(SEGGER J-|J-Link[ 0-9]|J-Trace[ 0-9]|Process:\s)') + + +def strip_banner(data: bytes, complete_only: bool = False) -> bytes: + """Target bytes only: drop the J-Link server banner lines and blanks. + + Both harness consumers (hil_test's device_info verdict, hil_pool_check's + aliveness score) must judge "did the target speak" through this one filter, + or the same byte stream scores differently per consumer. complete_only=True + additionally drops a trailing unterminated line — for poll loops judging a + growing buffer, where a banner FRAGMENT at a read boundary (b'SEGG', b'Proce') + would defeat the prefix regex and count as target output; the final verdict + after the window should pass complete_only=False to keep a genuine + unterminated tail.""" + lines = data.splitlines(keepends=False) + if complete_only and data and not data.endswith((b'\n', b'\r')) and lines: + lines = lines[:-1] + return b'\n'.join(l for l in lines + if l.strip() and not RTT_BANNER_RE.match(l.decode('utf-8', errors='ignore'))) + + +def free_ports(count: int) -> list: + """Bind ephemeral ports and hand back the numbers. Boards run in parallel, so the + RTT/GDB ports cannot be the SEGGER defaults or two boards collide. + + Known TOCTOU: the port is free when released here, but another process can claim + it before the server binds it. Accepted — the server binds the port itself, so + there is no fd to hand over. The post-connect re-poll catches the common outcome + (our server lost the bind and died); a foreign listener that stays alive is not + detectable here and would need the connected peer to be validated.""" + socks = [] + try: + for _ in range(count): + s = socket.socket() + s.bind(('127.0.0.1', 0)) + socks.append(s) + return [s.getsockname()[1] for s in socks] + finally: + for s in socks: + s.close() + + +def nm_rtt_addr(elf: str, nm: str = None) -> int: + """Control-block address from the FLASHED elf's symbol table. --addr is the way + out when nm cannot read the file (another architecture, no toolchain).""" + nm = nm or os.environ.get('RTT_NM', 'arm-none-eabi-nm') + try: + r = subprocess.run([nm, elf], capture_output=True, text=True, timeout=30) + except FileNotFoundError: + raise SystemExit(f'{nm} not on PATH — set RTT_NM=, or pass --addr') + except subprocess.TimeoutExpired: + raise SystemExit(f'{nm} did not finish reading {elf} in 30 s — pass --addr instead') + if r.returncode != 0: + raise SystemExit(f'{nm} could not read {elf}: {r.stderr.strip()[:200]}\n' + f'(wrong architecture? set RTT_NM=, or pass --addr)') + for line in r.stdout.splitlines(): + # " _SEGGER_RTT": a defined data symbol only — an undefined one + # (" U _SEGGER_RTT") has no address and would int('U', 16) + m = re.match(r'^([0-9a-fA-F]+)\s+[bBdD]\s+_SEGGER_RTT$', line.strip()) + if m: + return int(m.group(1), 16) + raise SystemExit(f'no defined _SEGGER_RTT symbol in {elf} — was it built with LOGGER=rtt?') + + +class _SocketRtt: + """Shared console core: a TCP socket onto an RTT server owned by self._proc. + + Subclasses build their server argv and call _spawn() + _connect() in __init__. + One failure contract: RttError for every way the console can break (stall, + closed, dead server) — callers are written for exactly it. A dead or resetting + server LATCHES `eof` rather than raising from the read side, so read loops and + the harness's `assert not ser.eof` triage see it without an exception racing + them to a generic handler.""" + + server = 'RTT server' # for error messages + + def __init__(self, timeout: float = 0.1): + self.timeout = timeout + self._buf = b'' + self._eof = False + self._sock = None + self._proc = None + self._log = None + self._lock = threading.Lock() # _buf is touched by the CLI pump thread too + + def _spawn(self, cmd: list, stdin=None) -> None: + # server output spools to a temp file: a PIPE nobody drains blocks a + # single-threaded server once 64 KiB of log accumulates (openocd at + # polling_interval 1 against a resetting target fills that in minutes) and + # the console goes silent with no error; the file also feeds _server_tail + self._log = tempfile.NamedTemporaryFile(prefix='rtt-server-', suffix='.log') + try: + self._proc = subprocess.Popen(cmd, stdin=stdin, stdout=self._log, + stderr=subprocess.STDOUT, start_new_session=True) + except FileNotFoundError as e: + self.close() + raise RttError(f'RTT console: {e.filename or cmd[0]} not on PATH') from e + except BaseException: + # any other spawn failure (PermissionError...) must not leak the log fd + self.close() + raise + + def _connect(self, port: int) -> None: + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + try: + self._sock = socket.create_connection(('127.0.0.1', port), timeout=2) + break + except OSError: + if self._proc.poll() is not None: + break + time.sleep(0.2) + if self._sock is None: + tail = self._server_tail() + self.close() + raise RttError(f'RTT console: {self.server} did not serve port {port}{tail}') + if self._proc.poll() is not None: + # the connect succeeded but our server is dead: a foreign process claimed + # the port in the free_ports window — refuse a console wired to a stranger + self.close() + raise RttError(f'RTT console: {self.server} died after connect (port {port} hijacked?)') + self._sock.setblocking(False) + except (KeyboardInterrupt, SystemExit): + # a signal mid-construction must not orphan the server we just spawned + self.close() + raise + + def _server_tail(self) -> str: + log = getattr(self, '_log', None) + if log: + with contextlib.suppress(OSError, ValueError): + with open(log.name, 'rb') as fh: + tail = fh.read()[-400:].decode(errors='replace') + if tail: + return ' — ' + tail + return '' + + def _drain(self) -> None: + # LATCH, never raise: a peer reset or a socket closed under us ends the + # stream exactly like an orderly EOF. Raising here raced the harness's + # `assert not ser.eof` triage into a generic handler that re-flashes the + # board, and leaked ConnectionResetError/ValueError to in_waiting callers. + # the WHOLE body under the lock, not just the append: the CLI's -i pump thread + # and the read loop drain the same socket concurrently, and recv->append being + # non-atomic let chunks land out of order (measured: transposed 64-byte + # segments in 3/6 stress trials) + try: + with self._lock: + while self._sock and select.select([self._sock], [], [], 0)[0]: + try: + chunk = self._sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + if not chunk: + self._eof = True + return + self._buf += chunk + except (OSError, ValueError, TypeError, AttributeError): + self._eof = True + + @property + def eof(self) -> bool: + """True once the server hung up AND everything it sent has been read out.""" + if self._sock is None: + return True + self._drain() + return self._eof and not self._buf + + @property + def in_waiting(self) -> int: + if self._sock is None: + # pyserial raises on a closed port; answering "N bytes waiting" from a + # closed dead console would let a caller bug look like a healthy board + raise RttError('RTT console is closed') + self._drain() + return len(self._buf) + + def read(self, size: int = 1) -> bytes: + if size is None or size <= 0: + # pyserial's read(0) returns b'' and consumes nothing; a negative size + # must not silently hand over (or destroy) buffered bytes + return b'' + if self._sock is None: + raise RttError('RTT console is closed') + self._drain() + deadline = None if self.timeout is None else time.monotonic() + self.timeout + while (len(self._buf) < size and not self._eof + and (deadline is None or time.monotonic() < deadline)): + time.sleep(0.005) + self._drain() + if self._eof and len(self._buf) < size: + # dead server: pace the empty returns like a serial timeout would, so a + # caller's read loop cannot busy-spin at 100% CPU (416k empty reads/s + # measured unpaced). timeout=None deliberately diverges from pyserial's + # block-forever: the eof latch makes "server is gone" knowable, and an + # eternal block on it helps nobody -- paced empties + .eof is the contract. + pace = self.timeout if self.timeout is not None else 0.1 + remaining = (deadline - time.monotonic()) if deadline is not None else pace + time.sleep(max(0.0, min(remaining, pace))) + with self._lock: + out, self._buf = self._buf[:size], self._buf[size:] + return out + + def reset_input_buffer(self) -> None: + # pyserial surface: the host tests flush pre-reset backlog through this + if self._sock is None: + raise RttError('RTT console is closed') + self._drain() + with self._lock: + self._buf = b'' + + def write(self, data: bytes) -> int: + # select+send, not sendall(): the socket is non-blocking for reads, and sendall() + # on a non-blocking socket raises BlockingIOError as soon as the send buffer is + # full, with no count of what already went out -- a caller cannot resume without + # duplicating bytes. Same reason serial_write_all treats a short write as fatal. + sock = self._sock # snapshot: close() from another thread nulls the attribute + if sock is None: + raise RttError('RTT console is closed') + self._drain() + if self._eof: + # TCP accepts exactly one send after peer death — without this the bytes + # would "succeed" into the void and the read timeout gets blamed on the target + raise RttError(f'RTT console write to a dead server ({self.server} gone)') + sent = 0 + deadline = time.monotonic() + RTT_WRITE_TIMEOUT + while sent < len(data): + if time.monotonic() > deadline: + raise RttError(f'RTT console write stalled after {sent}/{len(data)} bytes') + try: + if not select.select([], [sock], [], 0.1)[1]: + continue + sent += sock.send(data[sent:]) + except (BlockingIOError, InterruptedError): + continue + except (OSError, ValueError, TypeError, AttributeError) as e: + # peer death (BrokenPipe/ConnectionReset) or the socket closed under us + # mid-call: keep the class's one failure contract + raise RttError(f'RTT console write failed after {sent}/{len(data)} bytes: {e}') from e + return sent + + def _gentle_stop(self, proc) -> None: + """Subclass hook: ask the server to exit before the group takedown.""" + + def close(self) -> None: + self._eof = True # latch: post-close eof reads True, like a hung-up server + if getattr(self, '_sock', None): + self._sock.close() + self._sock = None + with self._lock: + self._buf = b'' # pyserial contract: nothing is readable after close + proc = getattr(self, '_proc', None) + if proc: + if proc.poll() is None: + self._gentle_stop(proc) + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=5) + if proc and proc.poll() is None: + # own session (start_new_session), so the group takedown gets the server and + # anything it spawned; leaving one alive would hold the probe for the next test + try: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + except (ProcessLookupError, PermissionError): + pass + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(proc.pid, signal.SIGKILL) + # reap, or the server stays a zombie for the caller's lifetime + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=2) + if proc: + for pipe in (proc.stdin, proc.stdout): + if pipe: + with contextlib.suppress(OSError, ValueError): + pipe.close() + # the server spool file: one fd plus a /tmp file per console, and the server + # grows it while alive -- GC is not a release policy on a rig + log = getattr(self, '_log', None) + if log: + with contextlib.suppress(OSError, ValueError): + log.close() + self._log = None + + # a console dropped without close() must not hold the probe for the process's life + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def __del__(self): + with contextlib.suppress(Exception): + self.close() + + +class JlinkRtt(_SocketRtt): + """Bidirectional console over SEGGER RTT channel 0, for J-Link probes (the only + console on boards whose probe has no VCOM or whose BSP has no UART). + + J-Link Commander (JLinkExe) owns the probe and serves RTT channel 0 on + -RTTTelnetPort -- what JLinkRTTClient talks to, minus its banner. It keeps + hunting for the control block and streams whatever the buffer already holds, + where JLinkRTTLogger searches once when it attaches and gives up. It also + carries input, which the host tests that drive a menu need. + + The probe is held for as long as this is open, so flashing and resetting the + board must happen before it is created or after close(). Select the probe by + serial: rigs run more than one.""" + + server = 'JLinkExe' + + def __init__(self, board: dict, timeout: float = 0.1): + super().__init__(timeout) + flasher = board['flasher'] + args = shlex.split(flasher.get('args', '')) + if '-device' not in args: + # fail with the real cause now: JLinkExe without a device blocks prompting + # and would surface 15 s later as a misleading port error + raise RttError(f'RTT console: no -device in flasher args: {flasher.get("args")!r}') + port = free_ports(1)[0] + # defaults first, the roster's args after so they can override (-if jtag, + # -JLinkScriptFile, an explicit -speed). NOTE: hil_flash orders it the other + # way (roster args first, its own -if/-speed last, so ITS defaults win) -- + # a roster override honored here is ignored by flash/reset; align them if a + # roster ever carries such args. -ExitOnError makes a failed target connect + # EXIT Commander + # (a clean error with the log tail) instead of leaving a banner-only console + cmd = ['JLinkExe', '-USB', str(flasher['uid']), '-if', 'swd', + '-JTAGConf', '-1,-1', '-speed', 'auto', '-NoGui', '1', + '-ExitOnError', '1', '-AutoConnect', '1', + *args, '-RTTTelnetPort', str(port)] + # stdin stays open: Commander exits when it runs out of input; close() writes + # 'exit' there. + self._spawn(cmd, stdin=subprocess.PIPE) + self._connect(port) + + def _gentle_stop(self, proc) -> None: + with contextlib.suppress(OSError, ValueError): + proc.stdin.write(b'exit\n') + proc.stdin.flush() + # close our pipe end in its own suppress: a BrokenPipe on the write above must + # not skip it (the base close also closes it for the server-already-dead path) + with contextlib.suppress(OSError, ValueError): + proc.stdin.close() + + +class OpenocdRtt(_SocketRtt): + """The console surface over an openocd `rtt server` (native probes: + ST-Link/CMSIS-DAP — never point openocd at ea4088's LPC-Link2, measured to + knock that probe off USB; other J-Link-OB probes untested). + + Exact control-block address (never a full-RAM scan), polling_interval 1 + (default 100 ms polling loses most of a busy stream), attach WITHOUT reset — + flash and reset before starting; `rtt start` needs the block to exist. + reset_before_attach opts into an in-session reset for streams that only + decode from byte 0 (SystemView).""" + + server = 'openocd' + + def __init__(self, cfg: str, addr: int, channel: int, serial_no: str = None, + vid_pid: str = None, timeout: float = 0.1, reset_before_attach: bool = False): + super().__init__(timeout) + port = free_ports(1)[0] + # argv, never a shell string: cfg/serial/vid_pid come from roster JSON and the + # command line, and a '$', backtick or quote in any of them would otherwise be + # substituted by the shell or break out of it + cmd = ['openocd', '-c', 'tcl_port disabled', '-c', 'gdb_port disabled', + '-c', 'telnet_port disabled'] + # probe pin: vid_pid keeps discovery from opening foreign usbfs nodes (a + # wedged one hangs the open), serial disambiguates same-model probes — + # both before the -f scripts, like hil_flash does + if vid_pid: + if not re.fullmatch(r'0x[0-9a-fA-F]{1,4} 0x[0-9a-fA-F]{1,4}', vid_pid.strip()): + # openocd only WARNS and exits 0 on a malformed value, so the pin + # silently does not apply and discovery reopens every usbfs node -- + # the convoy hil_flash.valid_vid_pid exists to stop + raise RttError(f'--vid-pid must be "0xVVVV 0xPPPP", got {vid_pid!r}') + cmd += ['-c', f'adapter usb vid_pid {vid_pid.strip()}'] + if serial_no: + cmd += ['-c', f'adapter serial {serial_no}'] + cmd += shlex.split(cfg) + cmd += ['-c', 'init'] + # opt-in: reset the target INSIDE this session, give it 2 s to boot, THEN + # attach and drain. The order is forced: `rtt start` needs the control block + # to already exist in RAM (the firmware creates it at init), and attaching + # ahead of the reset would latch the PREVIOUS run's stale block. Byte 0 still + # reaches the consumer because NO_BLOCK_SKIP retains the ring's HEAD: a boot + # burst bigger than the ring loses its tail until the drain catches up, never + # its first bytes -- which is the part a boot-anchored decoder needs + # (SystemView's Init record, carrying the timestamp frequency, is emitted once + # at boot; a mid-flight attach yields a stream no decoder can lock onto; size + # BUFFER_SIZE_UP to the boot burst if the tail matters too). Costs the tool's + # usual no-reset invariant, and is unsafe on parts where an in-session reset + # leaves the core held (SAMD5x DSU) or perturbs the target (WCH SDI). + if reset_before_attach: + cmd += ['-c', 'reset run', '-c', 'sleep 2000'] + cmd += ['-c', f'rtt setup 0x{addr:x} 0x800 "SEGGER RTT"', + '-c', 'rtt polling_interval 1', '-c', 'rtt start', + '-c', f'rtt server start {port} {channel}'] + self._spawn(cmd) + self._connect(port) + + def _gentle_stop(self, proc) -> None: + # no stdin channel to ask openocd to exit, and it keeps its listener up after + # the client disconnects: go straight to the group takedown instead of blocking + # the base class's 5 s wait on a process that has no reason to leave + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(proc.pid, signal.SIGTERM) + + +def dump_ring(probe: str, device: str, addr: int, out_path: str, channel: int = 0) -> int: + """Post-mortem: read aUp[channel]'s ring over the debug AP (no halt) via JLinkExe. + NO_BLOCK_SKIP means an undrained ring holds the FIRST KB after boot, not the + tail — interpretation rules in the target-debug skill.""" + if re.search(r'[\s"\']', out_path): + raise SystemExit(f'--dump path must not contain whitespace or quotes: {out_path!r} ' + f'(it is spliced into a JLinkExe script line)') + # a stale file from an earlier run must not satisfy the success check below + with contextlib.suppress(OSError): + os.remove(out_path) + # SEGGER_RTT_CB: acID[16], MaxNumUpBuffers, MaxNumDownBuffers, then aUp[] at 0x18, + # each ring 6 words {sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags}. Read the + # counts with the descriptor so an out-of-range channel is rejected instead of + # reading whatever RAM follows the array. + jlink = ['JLinkExe', '-USB', probe, '-device', device, '-if', 'swd', + '-speed', '4000', '-NoGui', '1', '-AutoConnect', '1'] + + def _jlink_run(script: str): + # same clean-exit contract as nm_rtt_addr/_spawn: a missing binary or a wedged + # probe must not reach the CLI as a traceback + try: + return subprocess.run(jlink, input=script, capture_output=True, text=True, timeout=60) + except FileNotFoundError: + raise SystemExit('JLinkExe not on PATH — the --dump route needs J-Link Commander') + except subprocess.TimeoutExpired: + raise SystemExit('JLinkExe did not finish in 60 s — probe wedged or target unreachable?') + + script = f'mem32 {addr + 0x10:#x}, 2\nmem32 {addr + 0x18 + channel * 24:#x}, 6\nexit\n' + r = _jlink_run(script) + words = [] + for line in r.stdout.splitlines(): + # UNANCHORED: when the script arrives on stdin, some JLinkExe versions glue + # the 'J-Link>' prompt onto the result line with no newline between + m = re.search(r'([0-9A-Fa-f]{8}) = ((?:[0-9A-Fa-f]{8} ?)+)$', line.strip()) + if m: + words += [int(w, 16) for w in m.group(2).split()] + if len(words) < 8: + print(r.stdout[-500:], file=sys.stderr) + raise SystemExit(f'could not read the aUp[{channel}] descriptor — wrong control block address?') + max_up = words[0] + if not 0 < max_up <= 32: + raise SystemExit(f'control block at {addr:#x} looks uninitialized ' + f'(MaxNumUpBuffers={max_up}) — the target has not written to RTT yet, ' + f'or the address is wrong') + if channel >= max_up: + raise SystemExit(f'--channel {channel}: this firmware has {max_up} up-buffer(s) (0..{max_up - 1})') + _, pbuf, size, wroff, rdoff, _ = words[2:8] + if not pbuf or not size: + raise SystemExit(f'up-buffer {channel} is not initialized (pBuffer={pbuf:#x} size={size}) — ' + f'the target has not written to it yet') + script = f'savebin {out_path}, {pbuf:#x}, {size:#x}\nexit\n' + _jlink_run(script) + # JLinkExe exits 0 even when a command inside its script fails, so the only proof + # savebin worked is the file itself: it must hold the WHOLE ring, since a read that + # dies partway (probe disconnect, unreadable address) still leaves a short file that + # would otherwise be reported as a complete dump. Removing it also keeps the + # invariant above -- no stale file can satisfy a later run's check. + got = os.path.getsize(out_path) if os.path.exists(out_path) else 0 + if got < size: + with contextlib.suppress(OSError): + os.remove(out_path) + if got == 0: + raise SystemExit(f'savebin produced no data at {out_path} — probe or address problem') + raise SystemExit(f'savebin wrote {got}/{size} B to {out_path} (truncated dump removed) ' + f'— probe or address problem') + print(f'ring: {size} B at {pbuf:#x}, WrOff={wroff:#x} RdOff={rdoff:#x} -> {out_path}\n' + f'valid bytes wrap at WrOff; default NO_BLOCK_SKIP holds the FIRST data after ' + f'boot, not the tail', file=sys.stderr) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--backend', choices=['jlink', 'openocd'], required=True, + help='transport route — explicit, no default (skill transport matrix)') + ap.add_argument('--probe', help='probe serial (JLinkExe -USB / openocd "adapter serial")') + ap.add_argument('--vid-pid', help='openocd probe pin by USB IDs, e.g. "0x2e8a 0x000c" ' + '(with or instead of --probe)') + ap.add_argument('--device', help='JLINK_DEVICE from board.cmake/family.cmake (jlink backend)') + ap.add_argument('--cfg', help='openocd -f/-c args, e.g. "-f interface/stlink.cfg -f target/stm32h7x.cfg"') + ap.add_argument('--elf', help='the FLASHED elf: exact _SEGGER_RTT address via nm (openocd/--dump)') + ap.add_argument('--addr', help='SEGGER RTT control block address (hex), instead of --elf') + ap.add_argument('--channel', type=int, default=0, help='up-buffer index (0 console, 1 SysView)') + ap.add_argument('--seconds', type=float, default=0, help='capture duration; 0 = until Ctrl-C/EOF') + ap.add_argument('-i', '--interactive', action='store_true', help='forward stdin to the target') + ap.add_argument('--reset-before-attach', action='store_true', + help='openocd: reset the target inside the capture session so the ' + 'server is draining when it boots (needed for streams that must ' + 'include the boot preamble, e.g. SystemView); unsafe on SAMD5x/WCH') + ap.add_argument('--dump', metavar='OUT.bin', + help='post-mortem ring dump (jlink backend; needs --elf or --addr)') + args = ap.parse_args() + + if args.seconds < 0 or args.seconds != args.seconds: # negative or nan + ap.error(f'--seconds must be >= 0 (0 = until Ctrl-C/EOF), got {args.seconds}') + if args.channel < 0: + # a negative index would walk backwards off aUp[] into the control-block + # header and read garbage as a descriptor + ap.error(f'--channel must be >= 0, got {args.channel}') + + def rtt_addr(): + if args.addr: + try: + return int(args.addr, 16) + except ValueError: + ap.error(f'--addr must be hex, got {args.addr!r}') + if args.elf: + return nm_rtt_addr(args.elf) + ap.error('need --elf (flashed elf, address via nm) or --addr') + + if args.backend == 'jlink': + if args.reset_before_attach: + ap.error('--reset-before-attach is openocd-only (the J-Link route attaches ' + 'to a running target; flash and reset before starting it)') + if args.channel and not args.dump: + # -RTTTelnetPort serves the Terminal buffer only; --dump can read any ring + ap.error('the jlink backend streams channel 0 only (use --backend openocd ' + 'for another channel, or --dump to read one)') + if args.vid_pid: + ap.error('--vid-pid is openocd-only; J-Link probes are selected by serial (--probe)') + if not (args.probe and args.device): + ap.error('the jlink backend needs --probe and --device') + elif not (args.probe or args.vid_pid): + ap.error('the openocd backend needs --probe and/or --vid-pid') + + if args.dump: + if args.backend != 'jlink': + ap.error('--dump uses the jlink backend (debug-AP reads via JLinkExe)') + return dump_ring(args.probe, args.device, rtt_addr(), args.dump, args.channel) + + # install BEFORE the console exists: an external `timeout`/kill during the + # up-to-15 s connect window must still reach the cleanup below, or the openocd + # route leaves a server holding the probe and the port (JLinkExe would exit on + # stdin EOF; openocd has no such channel and its own session shields it) + def _terminate(signum, _frame): + raise KeyboardInterrupt + for _sig in (signal.SIGTERM, signal.SIGHUP): + with contextlib.suppress(ValueError, OSError): + signal.signal(_sig, _terminate) + + try: + if args.backend == 'openocd': + if not args.cfg: + ap.error('--backend openocd needs --cfg') + con = OpenocdRtt(args.cfg, rtt_addr(), args.channel, + serial_no=args.probe, vid_pid=args.vid_pid, + reset_before_attach=args.reset_before_attach) + else: + con = JlinkRtt({'flasher': {'uid': args.probe, 'args': f'-device {args.device}'}}, + timeout=0.1) + except RttError as e: + print(e, file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 # constructors clean up after themselves on the way out + + saw_output = threading.Event() + forwarded = threading.Event() + if args.interactive: + def pump_stdin(): + # Hold input until the capture side has seen TARGET output (or 5 s for a + # quiet firmware): the J-Link telnet route silently DROPS client bytes + # until Commander locates the control block, so input forwarded at attach + # vanishes (measured on the rig: instant 'ping' lost, delayed 'ping' + # echoed). The gate must ignore the server's own banner — it arrives at + # connect, BEFORE the block is found. Raw os.read, not sys.stdin.buffer: + # bytes with no newline wait, and no BufferedReader lock — a daemon + # thread blocked holding that lock at interpreter shutdown aborts + # CPython (_enter_buffered_busy). + saw_output.wait(5) + try: + while True: + data = os.read(0, 4096) + if not data: + return + con.write(data) + forwarded.set() + except (RttError, OSError, ValueError): + return # console closed/stalled/dead; capture side reports the state + threading.Thread(target=pump_stdin, daemon=True).start() + + deadline = time.monotonic() + args.seconds if args.seconds else None + rc = 0 + seen = b'' # pre-release accumulator for the banner check only + try: + while deadline is None or time.monotonic() < deadline: + try: + chunk = con.read(con.in_waiting or 1) + except RttError as e: + print(f'rtt: {e}', file=sys.stderr) + rc = 1 + break + if chunk: + if args.interactive and not saw_output.is_set(): + # target data = anything past the J-Link banner's final line + # ('Process: '); the openocd server has no banner + seen = (seen + chunk)[-65536:] + if args.backend != 'jlink': + saw_output.set() + else: + i = seen.find(b'Process: ') + j = seen.find(b'\n', i) if i >= 0 else -1 + if j >= 0 and len(seen) > j + 1: + saw_output.set() + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + elif con.eof: + print('rtt: server closed the connection', file=sys.stderr) + rc = 1 + break + except KeyboardInterrupt: + pass + except BrokenPipeError: + # downstream consumer (head/grep -m) closed the pipe: a normal way to end a + # capture, not an error. Point stdout at devnull so interpreter shutdown does + # not raise on the final implicit flush. + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + finally: + if args.interactive and not forwarded.is_set(): + # only claim what is true: the gate releases after 5 s and forwards anyway, + # so "never forwarded" must come from the forwarded flag, not the gate + print('rtt: -i stdin was never forwarded to the target (no input arrived, ' + 'or the console closed first)', file=sys.stderr) + if args.interactive and not saw_output.is_set(): + print('rtt: no target output within the window', file=sys.stderr) + # a late TERM landing during the up-to-12 s teardown must not skip the kill + # escalation and orphan the server -- cleanup is committed at this point + for _sig in (signal.SIGTERM, signal.SIGHUP): + with contextlib.suppress(ValueError, OSError): + signal.signal(_sig, signal.SIG_IGN) + con.close() + return rc + + +if __name__ == '__main__': + sys.exit(main()) -- cgit v1.3.1