summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhathach <[email protected]>2026-09-03 17:14:39 +0700
committerhathach <[email protected]>2026-09-04 04:21:57 +0700
commit463ff1f3d2d97b53659364d7c4cde99693688100 (patch)
treefe241055450ca900d947af7cd32ae6b2ac6a3802
parentd2de029fd9def460d66e87fd1088cc49ed0f4574 (diff)
sysview: add the skill - headless SystemView capture and reporting
SKILL.md leads with the capture routes ranked by evidence: OpenOCD RTT streaming through the rtt skill's tools/rtt.py (--channel 1 --reset-before-attach, since the Init record that carries the timestamp frequency and sync preamble is emitted once at boot; 238 KB captured without it decodes to zero events), the live GUI recorder under Xvfb for depth, and --from-raw post-mortem decode last. boards.md holds the per-board reference: probe routes, RAM bases, measured buffer sizes, the full-pool campaign table and the honest capture status of every board tried, including the gaps: nrf5340dk HardFaults in vTaskStartScheduler before any task runs; PIO-USB host ISRs are invisible to level-1 coverage (fruit_jam); metro_m4_express is attach-only (the SAMD5x DSU CPU Reset Extension holds the core after an in-session reset run); WCH is dump-only - the QingKe SDI has no autonomous memory access, so a persistent debug session corrupts the running core ~1.9 s into USB traffic, ruled out against lock, post-mortem mode and firmware; a stray TU_ASSERT still halts for inspection rather than being silenced. sysview_record.py drives the capture end to end and refuses to back up its own ini stub as the user's SystemView config (a SIGKILLed run had consumed the real backup that way). sysview_report.py turns the exported CSV into the JSON report: ISR load, function pairing and durations, workload-window CPU load from the scheduling events. Returns are classified on the bare 'Returns' detail - SystemView annotates a duration on only ~0.5% of them (measured on stm32f407disco and raspberry_pi_pico), and when the annotation is absent the duration comes from the recorded timestamps, verified the same quantity at 167.998 vs a declared 168.000 ticks/us over 264 annotated returns. Both loss markers count as overflow: explicit '*** Overflow ***' rows and 'Returns to *** OVERFLOW ***' exit contexts - only counting the first understated real loss by two orders of magnitude.
-rw-r--r--.claude/skills/sysview/SKILL.md334
-rw-r--r--.claude/skills/sysview/boards.md369
-rw-r--r--.claude/skills/sysview/scripts/sysview_dump.py153
-rw-r--r--.claude/skills/sysview/scripts/sysview_record.py499
-rw-r--r--.claude/skills/sysview/scripts/sysview_report.py503
-rw-r--r--tools/codespell/ignore-words.txt1
6 files changed, 1859 insertions, 0 deletions
diff --git a/.claude/skills/sysview/SKILL.md b/.claude/skills/sysview/SKILL.md
new file mode 100644
index 000000000..9ea0d852b
--- /dev/null
+++ b/.claude/skills/sysview/SKILL.md
@@ -0,0 +1,334 @@
+---
+name: sysview
+description: Use when you need RTOS/scheduler-level timing from real hardware — per-task and per-ISR CPU load, task-switch and ISR enter/exit timeline, ready→run scheduling latency, per-function hot-path timing, FreeRTOS stack high-water or heap tracking, or a post-mortem trace of what ran right before a crash or hang — on a FreeRTOS or bare-metal TinyUSB target, when TU_LOG, GDB or DWT PC-sampling do not answer the question. Covers SEGGER SystemView over a J-Link probe or over any OpenOCD-supported probe via RTT.
+---
+
+# sysview — SEGGER SystemView profiling for TinyUSB
+
+Per-context CPU load, ISR durations, ready→run latency, per-function hot-path
+timing, FreeRTOS stack high-water, heap totals, app markers.
+
+| Skill | Answers |
+|----------------|---------------------------------------------------------------------|
+| `target-debug` | what the target did (logs, driver state, sampled PCs) |
+| `etm-trace` | exactly which instructions executed (profile, coverage, history) |
+| **`sysview`** | **where CPU time goes: task/ISR schedule, load, switch/ISR timing** |
+
+## Requirements
+
+- **Board row in `boards.md`** (same directory) — read it before capturing:
+ `JLINK_DEVICE`, any required buffer override, and board caveats live there.
+- **A timestamp source.** DWT cores (M3/M4/M7/M33) work as-is; M0/M0+ and RISC-V
+ need a ported BSP timer. A family without one refuses the build — at configure
+ time where that check exists (`ch32v10x`, TIM2-less `stm32f0`), at link
+ otherwise. Both are the intended "port it first" signal, not a regression.
+ Ported families and the porting contract: `boards.md`.
+- **`lib/SystemView`** — a mandatory `tools/get_deps.py` dependency, so any
+ `get_deps.py <family>`/`-b <board>` (or no args) fetches it.
+- **A probe.** The transport is RTT — a ring buffer in target RAM — so anything
+ that reads memory can carry a trace. Pick by fidelity:
+
+| Route | Needs | Fidelity |
+|-----------------------------|--------------------------------------|-------------------------------------------------------------|
+| Live recorder | J-Link + SystemView GUI | full — counts, CPU%, rare events |
+| `rtt server` → `--from-raw` | OpenOCD with `rtt setup`/`rtt start` | matches the recorder **only with `rtt polling_interval 1`** |
+| `dump_image` → `--from-raw` | any probe that reads memory | last ring-full only, no streaming |
+
+ Preference: **OpenOCD streaming for routine/CI work** — headless, no J-Link,
+ one command shape for every probe type, the only route `sysview_ci.py` drives
+ (the capture is GUI-free; decoding the raw file still runs the GUI, see Host
+ setup);
+ its cost is drain rate (SEGGER's native stack drains faster, so fast parts
+ overflow sooner at the same buffer — and drain depends on the probe's USB
+ path, so re-baseline after bus topology changes). **Live recorder for depth**:
+ a deep single-board session, a part with no OpenOCD target cfg, or when
+ overflow at 65536 says the drain is the bottleneck. **Dump when nothing else
+ can work**: post-mortem autopsy of a hang (the halt IS the capture), and all
+ WCH parts — their SDI attach is destructive (kills USB on ch32v2/v3, resets
+ ch583-class, boards.md), so a live session and a USB workload are mutually
+ exclusive there.
+
+- **Hold the board lock** for any route (see the recipe below). Every command
+ here runs on the host the probe is attached to — for the ci.lan rig, reaching
+ it and picking the right config is the `hil` skill's job.
+
+## Capture: live J-Link route
+
+Complete sequence. `<DEV>` is the board's `JLINK_DEVICE` from `boards.md`.
+
+```bash
+# 1. deps + lock (the lock holder runs until you release it)
+python3 tools/get_deps.py -b <board>
+python3 test/hil/helper/hil_lock.py hold <board> --reason "sysview" &
+
+# 2. build instrumented + flash (-DJLINK_OPTION pins THIS probe by nickname;
+# without it the -jlink target grabs whichever J-Link enumerates first)
+cd examples/device/cdc_msc_freertos
+cmake -B build-sv -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel \
+ -DSYSVIEW=4 -DJLINK_OPTION="-USB jtrace" .
+cmake --build build-sv
+ninja -C build-sv cdc_msc_freertos-jlink
+cd -
+
+# 3. record (all flags: sysview_record.py --help), driving the load you care about
+python3 .claude/skills/sysview/scripts/sysview_record.py \
+ --device <DEV> --probe jtrace \
+ --elf examples/device/cdc_msc_freertos/build-sv/cdc_msc_freertos.elf \
+ --duration-ms 8000 --out /tmp/sysview-out \
+ --traffic-cmd "…drive the failing/loaded case…"
+
+# 4. report (add --json for one machine-readable object instead of tables)
+python3 .claude/skills/sysview/scripts/sysview_report.py /tmp/sysview-out
+
+# 5. ALWAYS: restore pristine firmware, then release
+cmake -B build-clean -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel \
+ -DJLINK_OPTION="-USB jtrace" examples/device/cdc_msc_freertos
+cmake --build build-clean && ninja -C build-clean cdc_msc_freertos-jlink
+python3 test/hil/helper/hil_lock.py release <board>
+```
+
+- **Nothing may live under `/tmp/sv-*`** — SystemView deletes that pattern at
+ every startup. The recorder rejects such an `--out`, but the guard does **not**
+ cover build dirs: a `cmake -B /tmp/sv-hw-<board>` tree was erased mid-session.
+- `--duration-ms` is how long the recorder *waits*, **not how much trace you
+ get** (6000 measured 12-14 s wide). If you need an exact window, read
+ `live_window_s` back out of `--json` and adjust.
+- **Class-driver functions only fire under load** — an idle capture leaves
+ `mscd_xfer_cb`/`tud_cdc_read` empty. Drive `--traffic-cmd` at the DUT's stable
+ node (`/dev/serial/by-id/usb-TinyUSB_*-if00`), never a volatile
+ `/dev/ttyACM<n>`. A ready-made CDC load is `_workload_cdc_burst()` in
+ `test/hil/sysview_ci.py`; the equivalent one-liner:
+ `python3 -c "import serial,time; p=serial.Serial('<node>',115200,timeout=0.2,write_timeout=2); [ (p.write(b'x'*64), p.read(64), time.sleep(0.002)) for _ in range(4000) ]"`
+ **Only read back if the example echoes.** `cdc_msc` echoes; the dual examples do
+ not call `tud_cdc_read()`, so a read-based workload blocks its full timeout per
+ iteration, outlives the recording window, and can hang the wrapper — drive
+ write-only there, and always set `write_timeout`.
+- `--no-events` skips the large `events.txt` (needed only for the percentile
+ tables); `--export-terminal` adds `SEGGER_SYSVIEW_PrintfHost` output.
+ `recording.SVDat` opens in a desktop SystemView for the visual timeline.
+
+## Capture: OpenOCD route (no J-Link)
+
+Same build, minus `-DJLINK_OPTION` — but pin the flash by probe serial the same
+way (`-DOPENOCD_SERIAL=<uid>`; `<uid>` is `flasher.uid` in
+`test/hil/tinyusb.json`). Standing up the RTT transport is the **`rtt`** skill's
+job — `tools/rtt.py` wraps the whole session (control-block address from the
+ELF, `rtt polling_interval 1`, teardown) so none of it is hand-assembled here:
+
+```bash
+# flash first; the capture session then reboots the target itself
+openocd <board's -f/-c args> -c 'adapter serial <uid>' \
+ -c "init; halt; program build-sv/cdc_msc_freertos.elf verify; reset; exit"
+
+python3 tools/rtt.py --backend openocd --probe <uid> \
+ --cfg "<board's -f interface/... -f target/... args>" \
+ --elf build-sv/cdc_msc_freertos.elf --channel 1 --seconds 25 \
+ --reset-before-attach > capture.SVDat &
+# ...drive the workload while it records, then decode:
+python3 .claude/skills/sysview/scripts/sysview_record.py \
+ --from-raw capture.SVDat --out /tmp/sysview-openocd
+python3 .claude/skills/sysview/scripts/sysview_report.py /tmp/sysview-openocd
+```
+
+- **`--channel 1` selects the "SysView" up-buffer; `--reset-before-attach` is
+ required, not tidiness**: the Init record carrying the timestamp frequency is
+ emitted once at boot, so a mid-flight attach can capture a stream with no sync
+ preamble that decodes to zero events (measured: 238 KB of undecodable bytes
+ without the flag; with it, h743 metrics identical to the J-Link golden capture
+ — ISR p50 4.3 µs on both routes). **Exception: never reset WCH parts — under
+ SDI the target does not come back** (dump route only, `boards.md`); on SAMD5x
+ the in-session reset holds the core, so skip the flag there and rely on the
+ flash's own reset still holding the boot preamble in the ring (short window —
+ metro caveat, `boards.md`).
+- Durations are transport-independent (p50/p99 match within ~1%); only sample
+ count changes. OpenOCD's drain is slower than SEGGER's stack (polling-loss
+ numbers: `rtt` skill), so fast parts overflow sooner at the same buffer — use
+ J-Link when you need counts, CPU%, or statistics on rare events.
+- Server won't come up, drops output, "control block not found", probe/transport
+ quirks → the `rtt` skill's transport matrix and common mistakes.
+ `test/hil/sysview_ci.py` runs this same `rtt server` route for the rig
+ campaign with its own openocd invocation (same setup, polling interval and
+ channel; it predates `tools/rtt.py`).
+
+## Capture: post-mortem (crashed/wedged target)
+
+Answers "what ran right before this hung". Build with post-mortem mode **in
+addition to** a level, flash it, then reproduce the failure under it — it only
+captures what happens after flashing, so a board already halted under a
+different image cannot be autopsied this way.
+
+```bash
+cmake -B build-pm -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel \
+ -DSYSVIEW=4 -DSYSVIEW_POST_MORTEM=1 .
+cmake --build build-pm && ninja -C build-pm cdc_msc_freertos-jlink
+# ... reproduce the hang, then dump WITHOUT resetting (reset destroys evidence) ...
+python3 .claude/skills/sysview/scripts/sysview_dump.py \
+ --device <DEV> --probe jtrace --elf build-pm/cdc_msc_freertos.elf \
+ --out /tmp/sysview-pm
+python3 .claude/skills/sysview/scripts/sysview_record.py \
+ --from-raw /tmp/sysview-pm/capture.SVDat --out /tmp/sysview-pm-decoded
+```
+
+The ring is an overwrite buffer holding only the most recent events — a 16 KB
+ring under steady CDC bulk traffic covered a ~20-25 ms tail. Size
+`SYSVIEW_BUFFER_SIZE` for the window you need — you get the last tens of ms at
+bulk rates, never minutes. `--resume` lets the core carry on; by default it stays
+halted (you're mid-autopsy). When done, step 5 of the live recipe applies
+unchanged: restore pristine firmware, then release the lock.
+
+## Build options
+
+`-DSYSVIEW=` accepts `1..4` or `ON` (= 4); anything else is a configure error.
+Recording starts itself at boot — no source edits, no `Conf()` call, no ISR wrap.
+**CMake only**: `make BOARD=<x> SYSVIEW=4` stops with an error rather than
+building an uninstrumented ELF.
+
+| Level | Adds |
+|-------|-------------------------------------------------------------------------|
+| 1 | USB interrupt enter/exit |
+| 2 | + `usbd`/`usbh` core (`tud_task`, `tuh_task`, `usbd_edpt_xfer`) |
+| 3 | + `dcd`/`hcd` API (`dcd_edpt_xfer`, `hcd_edpt_xfer`) |
+| 4 | + class drivers (`tud_cdc_read`, `tud_cdc_write_flush`, `mscd_xfer_cb`) |
+
+- **Level 1 records the ISR from whichever entry the port actually uses**, so ISR
+ coverage is per-port, not universal: the `tud_int_handler`/`tuh_int_handler`
+ macros on BSPs that call them, and the driver's own handler on ports that
+ install one directly (`dcd_rp2040.c`/`hcd_rp2040.c` — rp2040 never reaches the
+ macros). A port doing neither records no ISR spans at all: `ch32v20x`'s FSDEV
+ port 0 tail-calls `dcd_int_handler` from naked asm, so its ISR table stays empty
+ at every level. Check `boards.md` before concluding a board has no interrupts.
+- `SYSVIEW_BUFFER_SIZE`: `-D` override wins; else RAM-rich families default to
+ 65536 via `SYSVIEW_BUFFER_SIZE_DEFAULT` in their `family.cmake` (imxrt, stm32h7,
+ stm32f4/f7, rp2040, samd5x_e5x — the dual-role dogfood's measured-safe value);
+ everything else falls back to 4096 and overrides per board (`boards.md`). `SYSVIEW_RAM_BASE` (default
+ `0x20000000`) is set per **family** and only matters for named-object ids; the
+ six lpc families override it themselves (see `boards.md`).
+- Off by default and **verified byte-identical** to a build with no SYSVIEW code:
+ every call site compiles away below its level.
+- **Not everything is instrumented**: `usbd_edpt_xfer_fifo`/`dcd_edpt_xfer_fifo`
+ (audio/UAC2 streaming) and the two control-transfer `hcd_edpt_xfer` sites in
+ enumeration are unwrapped, so function timing under-reports those paths.
+
+## Reading results
+
+- **CPU load** is share of the capture window (`live_window_s` in `--json` — first
+ event to last, nothing trimmed). `Idle` ≈ headroom.
+- **ISR duration** is exact per enter→exit. USB IRQ time ≫ its instruction count
+ = stalled on the peripheral (slave-mode FIFO at wire pace).
+- **ready→run** is scheduling latency — the number behind throughput jitter. Big
+ p99 vs p50 = a higher-priority task or long ISR preempting.
+- **function** (level ≥ 2) is per-invocation wall time for the 8 built-in call
+ sites; SystemView computes CALL→RET itself. The table is sorted by name and has
+ no total-time column — **to rank hot functions, sort by `n × p50_us`**
+ (CPU occupancy), which is what the CI report does:
+ `sysview_report.py <dir> --json | jq -r '.functions|sort_by(-(.n*.p50_us))[]|"\(.n*.p50_us/1000|floor)ms \(.name)"'`
+- **marker** (`SEGGER_SYSVIEW_MarkStart/MarkStop`, app-inserted) times a span you
+ bracket yourself — a temporary source touch, revert when done.
+- **stack** high-water round-robins **one task per call**, so a short capture
+ surfaces only 1-2 of ~6 tasks. **heap** needs `configSUPPORT_DYNAMIC_ALLOCATION=1`
+ in the app's FreeRTOSConfig.h — none of the families wired for the CPU-load
+ table (`nrf`, `samd5x_e5x`, `stm32f4`, `stm32f7`, `stm32h7`) enable that today,
+ so `heap` reads `null` on every stock TinyUSB example (static-allocation-first);
+ the plumbing is proven and ready for an app that opts in.
+- **`max` is never trustworthy when overflow is nonzero** — a dropped record makes
+ the decoder pair one invocation's CALL with a later RET, producing impossible
+ outliers (134 ms on a 10 µs function). Quote p50; treat `max` as a lead.
+- **`p99` needs `n > 100` to mean anything.** The percentile index lands on the
+ last sample at or below n=100, so p99 *is* the max there — including any spliced
+ outlier. The PR report withholds it below that threshold (`– ⚠︎ n=<n>`) while
+ still showing p50, which only needs n ≥ 50. A withheld p99 on a clean capture
+ means "drive more traffic or capture longer", not "something is wrong".
+- **overflow** climbing into double digits means events were dropped and load
+ stats undercount: raise `SYSVIEW_BUFFER_SIZE` or shorten the window. `overflow`
+ in `--json` is the real count over the capture — one number, and what gates a
+ report row.
+
+### `--json` schema
+
+```
+{
+ "contexts": [{name, activations, cpu_pct, total_ms, blocked_ms, min_us, avg_us, max_us}],
+ "isr" | "ready_run" | "functions" | "markers": [{name, n, p50_us, p99_us, max_us}],
+ "stack": [{name, bytes_used}],
+ "heap": {allocs, frees, net_bytes} | null,
+ "overflow": N, "dropped_pairs": N,
+ "live_window_s": F | null, "warnings": [str, ...],
+ "workload_window_s": F | null, "workload_anchor": "cli"|"markers"|"cdc-read-span"|null,
+ "contexts_workload": [{name, busy_ms, cpu_pct_workload}]
+}
+```
+`live_window_s` is the capture itself, first event to last -- nothing is trimmed, so an idle
+stretch stays in it. It is `null` (and `contexts[].cpu_pct` too) when events.txt has no `Init`
+record: without a timestamp frequency the decode is untrustworthy, so the figure is withheld
+rather than derived from a guess; see `warnings`. `cpu_pct` is SystemView's own CPU Load column
+as recorded -- the reporter no longer recomputes or clamps it.
+`cpu_pct_workload` is busy time summed from the export's scheduling events over the workload
+window (anchor priority: `--window T0:T1`, an app's Start/Stop Marker pair, the span of
+`tud_cdc_read` events). On a bare-metal build only ISR shares appear -- there are no Task
+Run/System Idle events without an RTOS, so main-loop time is unattributable and the shares do
+not sum to 100. `null`/empty when no anchor exists (e.g. the workload never ran).
+
+## Warnings
+
+- **Never register a `SEGGER_SYSVIEW_MODULE`.** SystemView 4.10b (Linux) greys
+ out Save Recording / Export Data as soon as any module is registered —
+ bench-proven with 5 configurations. TinyUSB uses a fixed event base
+ (`TU_SV_EVENT_BASE` = 512) and maps ids to names host-side instead.
+- **`SEGGER_SYSVIEW_LOCK` masks interrupts.** A burst of SystemView calls inside
+ the USB path dropped a device off the bus on real hardware — this is why the
+ stack reporter is round-robin. Keep any new periodic call site cheap.
+- **Default FreeRTOS stacks are marginal at `SYSVIEW=4`** — enough to overflow
+ `cdc_msc_freertos`'s 1024-byte task stacks during enumeration. The example
+ already bumps them under `CFG_TUD_SYSVIEW`; check stack sizes if you add a new
+ instrumented example.
+- **Reported CPU% is only as good as the window.** The export mixes the boot
+ window you actually drove. The reporter does not trim: it reports the capture
+ first event to last, idle included, so check `live_window_s` and the workload
+ before quoting a percentage.
+- **Restore pristine firmware before releasing the lock** so the next CI/HIL run
+ doesn't inherit an instrumented image, and revert marker/Printf source touches.
+- Never commit recordings — `events.txt` can exceed 30 MB.
+
+## Host setup and licensing
+
+```bash
+sudo apt-get install -y xvfb xdotool imagemagick
+curl -sL -o /tmp/sv.deb https://www.segger.com/downloads/systemview/systemview_linux_deb64
+sudo apt-get install -y /tmp/sv.deb # provides /usr/bin/systemview
+```
+`JLinkExe` is a separate install (segger.com/jlink); the OpenOCD route needs no
+`JLinkExe`, but decoding its raw capture (`--from-raw`) launches the GUI under
+Xvfb, so the packages above apply to every route.
+
+`sysview_record.py` drives the GUI under a private Xvfb over its single-instance
+socket (`localhost:19050`): it starts from a fresh ini, kills stale Xvfb servers,
+and dismisses modals before every export — all three were real sources of flaky
+runs. On failure it writes `debug.png` into `--out`.
+
+**Licensing affects only the GUI recorder** — the target sources in
+`lib/SystemView` are 1-clause BSD, so nothing shipped in a built image carries a
+condition. Without a registered key the GUI shows a dialog on every launch and
+the script clicks "Continue under SFL", which **asserts non-commercial or
+educational use on the operator's behalf**. Commercial use needs a CUL. The
+OpenOCD capture itself never launches the GUI, but `--from-raw` decoding does, so
+the condition applies wherever a capture is decoded. Registering a free key stops
+the dialog appearing (ci.lan has one, node-locked, expiring 2027-01-29).
+
+## Per-board notes
+
+Every validated board has a row in `boards.md` (same directory) — `JLINK_DEVICE`,
+timestamp source and measured rate accuracy, capture route, buffer override —
+plus a caveat entry where it has one. **Read a board's row and caveat before
+capturing on it**; a new validation adds both, following that file's "Adding a
+board" ladder.
+
+## References
+
+- SystemView User Guide (UM08027; command line §3.14, target integration ch.4,
+ FreeRTOS §4.7.5, module registration §6.2/§7.5):
+ <https://www.segger.com/downloads/systemview> — local copy in the calibre
+ library (`read-doc` skill).
+- Target sources: <https://github.com/SEGGERMicro/SystemView> (pinned in
+ `tools/get_deps.py` as `lib/SystemView`).
+- Build wiring: `hw/bsp/family_support.cmake` (`if (SYSVIEW)`); leveled macros:
+ `src/common/tusb_sysview.h`/`.c`.
diff --git a/.claude/skills/sysview/boards.md b/.claude/skills/sysview/boards.md
new file mode 100644
index 000000000..1cfdee325
--- /dev/null
+++ b/.claude/skills/sysview/boards.md
@@ -0,0 +1,369 @@
+# sysview — per-board reference
+
+Every board validated with `-DSYSVIEW` has a row below plus, where it has one,
+a caveat entry. **Read a board's row AND its caveat before capturing on it**;
+a new validation adds both.
+
+`JLINK_DEVICE` is the `--device` value `sysview_record.py` and `sysview_dump.py`
+need — the same string `hw/bsp/<family>/boards/<board>/board.cmake` (or the
+family's `family.cmake`) sets. `—` means the board has no J-Link device string
+because it was captured over OpenOCD or a post-mortem dump instead.
+
+"1.000 s reads as" is timestamp-rate accuracy: ten host-timed 1.000 s silences
+were driven and the median gap read back out of the trace, so a rate error shows
+up directly as a ratio. `stm32f407disco` is the control — its DWT scale is
+unarguably cycles/`SystemCoreClock`. The 1-3% on OpenOCD rows is that route's
+record loss inflating measured gaps, not the timers (same TIM2 code as the
+J-Link `stm32g0` row). WCH parts are measured differently — `system_ticks` read
+twice a known interval apart over `openocd`, run twice (once with no sleep) and
+differenced, because the probe halts the core to read memory and that stops the
+tick.
+
+"Route" is the route this board was validated on: `J-Link` = live recorder,
+`OpenOCD` = `rtt server` streaming, `dump` = post-mortem `dump_image` (last
+ring-full only). Commands for each are in `SKILL.md`.
+
+"Buffer" is the effective `SYSVIEW_BUFFER_SIZE` this board is known to need or
+get: RAM-rich families default to 65536 in their `family.cmake`
+(`SYSVIEW_BUFFER_SIZE_DEFAULT`), small parts override down per board, and
+`-DSYSVIEW_BUFFER_SIZE` beats both. Blank means unmeasured on the 4096
+fallback — not proven sufficient. If `overflow` climbs during a capture, raise
+it regardless of the column.
+
+**WCH boards additionally inherit the family-wide `All WCH parts` caveat below**
+— check it as well as the board's own bullet.
+
+| Board | Family | JLINK_DEVICE | Timestamp source | 1.000 s reads as | Route | Buffer | Window | Ovfl | USB ISR (p50 µs) | tud_task p50/p99 µs |
+|--------------------------|------------|-------------------|------------------|------------------------|---------|--------|--------|-------|------------------|---------------------|
+| stm32f407disco | stm32f4 | stm32f407vg | DWT (control) | 1.0003 s (+0.03%) | OpenOCD | 65536 | 16.6 s | 0 | 83, 6.9 | 7.4 / 7.9 |
+| stm32l476disco | stm32l4 | stm32l476vg | DWT | 1.0003 s (+0.03%) | OpenOCD | 4096 | 8.9 s | 2339 | 83, 16.5 | 17.3 / 45.2 |
+| stm32f723disco | stm32f7 | stm32f723ie | DWT | 1.0016 s (+0.16%) | OpenOCD | 65536 | 16.6 s | 33331 | 83, 7.6 | 7.7 / 8.5 |
+| stm32f072disco | stm32f0 | stm32f072rb | TIM2 @ 1 MHz | 1.0022 s (+0.22%) | OpenOCD | 2048 | 17.2 s | 9495 | 47, 29.0 | 33.0 / 72.0 |
+| lpcxpresso11u37 | lpc11 | LPC11U37 | CT32B0 @ 1 MHz | 1.0038 s (+0.38%) | OpenOCD | 2048 | 16.9 s | 225 | 38, 33.0 | 43.0 / 143.0 |
+| ra4m1_ek | ra | R7FA4M1AB | DWT | 1.0097 s (+0.97%) | J-Link | 2048 | — | — | — | — (see caveat) |
+| stm32u083nucleo | stm32u0 | stm32u083rc | TIM2 @ 1 MHz | 1.0118 s (+1.18%) | OpenOCD | 4096 | 16.8 s | 3074 | 24, 21.0 | 23.0 / 69.0 |
+| adafruit_fruit_jam | rp2040 | rp2350_m33_0 | DWT | 1.0185 s (+1.85%) | OpenOCD | 65536 | 16.7 s | 24913 | 30, 9.0 | 5.8 / 36.9 |
+| stm32g0b1nucleo | stm32g0 | stm32g0b1re | TIM2 @ 1 MHz | 1.0222 s (+2.2%) | OpenOCD | 4096 | 17.3 s | 16978 | 24, 17.0 | 19.0 / 22.0 |
+| stm32h743nucleo | stm32h7 | stm32h743xi | DWT | 1.0259 s (+2.6%) | OpenOCD | 65536 | 17.4 s | 0 | 117, 4.3 | 4.5 / 4.7 |
+| raspberry_pi_pico | rp2040 | rp2040_m0_0 | `time_us_32` | 1.0304 s (+3.0%) | OpenOCD | 65536 | 17.1 s | 0 | 21, 11.0 | 6.0 / 40.0 |
+| feather_nrf52840_express | nrf | nrf52840_xxaa_app | DWT | 1.0536 s (+5.4%) | OpenOCD | 65536 | 16.8 s | 29693 | 55, 23.5 | 16.5 / 90.8 |
+| metro_m4_express | samd5x_e5x | ATSAMD51J19 | DWT | 1.0100 s (+1.00%) | OpenOCD | 65536 | 2.4 s | 0 | 99, 12.2 | 15.8 / 62.6 |
+| max32666fthr | maxim | (family.cmake) | TMR0 @ 48 MHz | 1.0005 s (+0.05%) | OpenOCD | 65536 | 11.6 s | 746 | 18, 8.5 | 5.8 / 61.1 |
+| raspberry_pi_pico2 | rp2040 | rp2350_m33_0 | DWT | validated — see caveat | OpenOCD | | — | — | — | — |
+| same54_xplained | samd5x_e5x | ATSAME54P20 | DWT | validated end-to-end | J-Link | | — | — | — | — (prose below) |
+| mimxrt1064_evk | imxrt | MIMXRT1064xxx6A | DWT | validated (dual) | J-Link | 65536 | — | — | — | — |
+| ch32v307v_r1_1v0 | ch32v30x | — | QingKe SysTick | tick 996.1 Hz (−0.39%) | dump | | — | — | — | — |
+| ch582m_evt | ch583 | — | QingKe SysTick | tick 997.2 Hz (−0.28%) | dump | 2048 | — | — | — | — |
+| nanoch32v203 | ch32v20x | — | QingKe SysTick | tick 838.6 Hz (−16.1%) | dump | 2048 | — | — | — | — |
+
+`cdc_msc` at `SYSVIEW=4` builds on all 23 arm-gcc/riscv boards in the ci.lan
+pool; the rows above are the ones whose timestamp source was measured. Route,
+Buffer and the capture columns (Window/Ovfl/ISR/`tud_task`) show the latest
+validated capture — the 2026-08-12 full-pool campaign for the OpenOCD rows
+(`cdc_msc` `SYSVIEW=4`, `cdc_burst` 15 s, artifacts
+`ci.lan:~/sysview-v2/out/campaign-final/`); the rate column keeps each board's
+original measurement, some of which predate the route switch. `—` capture
+cells: the board was not in the campaign (J-Link-only validations, host-role
+pico2, dump-route WCH). Reading the overflow column: zero on 65536 with a fast
+probe path; small-buffer boards (2048/4096) drop by design; f723/fruit_jam/
+feather lose on drain-path throughput (drain-rate caveat below). metro's short
+window is the attach_only mid-stream join (2.4–5.6 s across runs).
+
+- **Killing the SystemView GUI can cost you its registry.** SIGKILLing `systemview` (which the
+ capture path does on teardown) has been seen to rewrite `SEGGER_REG_HKEY_CURRENT_USER.xml`
+ without its `License` element — a registered key silently disappears and every later launch
+ falls back to the SFL dialog. Re-add it via License Manager if headless captures start
+ failing. Observed on ci.lan after a long unattended run.
+
+## Per-board caveats
+
+- **Local-board evidence (htpc, OpenOCD route)**: stm32u5 lost 13912 events (~19% of the
+ stream) at the 4096 fallback under a 12 s cdc_msc workload — now defaults to 65536. lpc55 lost
+ 34 on the same route, an order of magnitude lighter, so it keeps the fallback until something
+ measures otherwise. `lpcxpresso55s69` has no `lpc55*.cfg` in either OpenOCD build here; a
+ hand-rolled attach-only Cortex-M33 target works, but `transport select swd` must be an explicit
+ `-c` BEFORE `-f interface/jlink.cfg` or OpenOCD errors "Can't change session's transport".
+- **ra4m1_ek no longer links at `SYSVIEW=4`** — the 2026-08-11 build sweep hit
+ `region RAM overflowed by 960 bytes` even at `-DSYSVIEW_BUFFER_SIZE=2048`; its
+ rate row predates that. Re-validating needs a lower level or freed RAM.
+- **nrf5340dk cannot be captured at present**: it HardFaults inside `vTaskStartScheduler()`
+ before any task runs — reproduced on a plain non-instrumented build and after a full
+ `nrfjprog --recover`, so it is a board/boot issue, not a SystemView one. The RTT control block
+ is never written (that happens once `usb_device_task` runs), so no capture route can reach it.
+- **Buffer evidence from the dual-role dogfood**: at the default 4096, mimxrt1064_evk lost 96.7%
+ of ISR exit contexts at only ~109 Hz, metro_m4_express 99.1% on its EIC vector, and
+ adafruit_fruit_jam's captures came back sparse for the same reason. 65536 is the measured-safe
+ value on RAM-rich parts; treat a blank Buffer cell as "unmeasured", not "default suffices".
+- **Overflow depends on the probe's drain rate, not just the buffer** (post-PCIe-rework
+ campaign, 2026-08-11, 15 s cdc_burst): at the same 65536, raspberry_pi_pico and
+ stm32h743nucleo captured with zero loss while feather_nrf52840_express lost 73 bursts
+ (median ~29k events) over its openocd-jlink path and adafruit_fruit_jam 14 bursts —
+ and stm32f407disco went from zero loss to 3-4 small bursts (~830 events, 0.45%) after
+ the rig's USB controllers were re-arranged. Re-baseline per board after any bus
+ topology change before reading overflow as a firmware regression.
+- **Dual-role ISR bracket status**: the outer bracket in the ten shared-vector BSPs remains
+ hardware-unexercised — metro_m4_express's only real dual config (MAX3421) compiles the
+ shared-vector `tuh_int_handler` call out (`#if CFG_TUH_ENABLED && !CFG_TUH_MAX3421`), routing
+ host interrupts via a separate EIC vector, and the other rig dual boards are not
+ bracket-family. Doubling is structurally impossible in every rig-buildable config; the bracket
+ is covered by the ceedling behavioral test and nm-verified wiring only.
+
+- **ch32v307v_r1_1v0** (also read `All WCH parts` below) — at `SYSVIEW=4` it
+ drops off the USB bus under sustained CDC traffic and needs a reset to recover
+ (the `SEGGER_SYSVIEW_LOCK` interrupt-masking cost, not a capture-route
+ problem). Profile it with light load or a lower `-DSYSVIEW` level.
+- **ch32v10x (`ch32v103r_r1_1v0`)** — `-DSYSVIEW` is a configure-time
+ `FATAL_ERROR`. The part runs in U-mode where `csrr mstatus` traps, and writing
+ the alternate CSR 0x800 directly corrupts the QingKe V3 interrupt-mode config
+ (hung the board), so no safe RTT lock exists and unserialized RTT writes would
+ produce plausible but corrupt traces. Its WCH-Link also resets the target on
+ every attach, wiping the ring, so the post-mortem route is out too.
+- **ch582m_evt** — links with essentially zero free RAM at `SYSVIEW=4` even at
+ `-DSYSVIEW_BUFFER_SIZE=2048`. It builds; treat any added instrumentation as
+ likely to push it over.
+- **nanoch32v203** — the −16% is a **board clock bug, not a SystemView one**: a
+ build with no SYSVIEW at all measures the same 838.5 Hz, so `board_millis()`
+ and every timeout derived from it are 16% slow. `board.cmake` asks for
+ `SYSCLK_FREQ_144MHz_HSE`; the tick implies an actual HCLK near 120.7 MHz.
+- **All WCH parts** (`ch32v20x`, `ch32v30x`, `ch583`) — the dump route is the
+ only capture route TODAY, but the blocker is the OpenOCD driver, not the
+ silicon:
+ - *Mechanism* (isolated on nanoch32v203, 2026-08-11, unified OpenOCD
+ 0.12.0+dev-02620): the SDI **attach/examine is harmless** — the CDC stayed
+ on the bus through `init` + examination in two controlled runs. Death comes
+ when **RTT polling starts**: the fork's custom `wch_riscv` target reads
+ memory by halting the hart (`curstate: halted` afterwards; it ignores
+ `riscv set_mem_access`, and `sysbus` is unimplemented in the DM), and at
+ `polling_interval 1` a halted core cannot service USB — host drops the
+ device within a second. ch582m additionally wipes its ring on the
+ driver's attach-reset (like ch32v10x).
+ - *The silicon CAN stream*: `wlink dump` reads RAM from the RUNNING target
+ without disturbing it — stress-proven: 194 consecutive 64-byte reads
+ during 12 s of saturated CDC traffic, **42130/42130 echoes**, zero read
+ failures, device still enumerated (no x9 corruption manifested either —
+ that hazard may be CH569- or hart-mediated-read-specific).
+ - *Fork fix implemented* (`hathach/openocd` tinyusb branch, `584faee80`,
+ unpushed): `wch_riscv` reads a RUNNING hart via the probe's native bulk
+ read (0x03/0x02-0x0c + data EP), preceded by a one-shot AttachChip
+ (0x0d/0x02) session refresh — without it running-hart reads return a
+ repeated junk word (`0x4003b0c3`/`0xbeef0080` fills). Stable session:
+ `init; poll off; reset halt` (examine on the halted core — examine on a
+ RUNNING core is the near-always killer, dcsr.cause=haltreq observed);
+ `reset run`. Verified: control block read live off the running chip,
+ 417 read-polls/s with USB enumerated. Running-hart writes: no DIRECT
+ primitive is safe (DM abstract word write and probe bulk write both kill
+ the firmware, the latter without landing data — and note the probe
+ 0x01+0x05 sequence is flash-loader staging, not a generic RAM write), but
+ **brief halt-write-resume is** (fork `5e33b27c4`): 8 ms per cycle, 10
+ cycles under saturated CDC traffic, 49530/49530 echoes — the USB
+ peripheral NAKs in hardware while the core is halted.
+ - *Streaming status — RULED OUT; root cause is the debug transport, not
+ firmware/lock/PM* (fork `60efbf3b3`, root-caused 2026-08-12). The
+ decisive A/B: normal and POST_MORTEM firmware, identical stable attach +
+ sustained persistent-session reads under CDC load, **both die at t+1.9 s**
+ — same lock, same death, so the SystemView lock and the PM overwrite path
+ are BOTH exonerated (an earlier "PM wedges under USB load" note was wrong;
+ PM firmware alone is flawless: 37361/37361 echoes, WrOff climbing to 3797,
+ no debugger). The real mechanism: ARM probes read RAM through the
+ **memory-AP**, an autonomous DAP bus master that touches RAM without
+ engaging the CPU, so the core runs untouched while the host drains RTT —
+ that is why every ARM board streams. WCH's QingKe **SDI single-wire link
+ has no memory-AP**; every read goes through the Debug Module's abstract
+ commands, and a **persistently active DM corrupts the running core within
+ ~2 s during USB traffic** — the same SDI DM-active register corruption
+ documented on CH569 (zeroes x9 during USB2-HS). `wlink` survives only by
+ attaching/detaching per call (DM never persistently active). Since RTT
+ streaming needs a persistent draining session, it cannot work on WCH,
+ full stop. **Dump route remains the only WCH capture.** Banked regardless:
+ brief non-destructive RAM inspection of a running WCH hart (fork reads,
+ good for seconds — enough for a live peek, not a stream). NOTE on asserts:
+ `TU_ASSERT` under an attached debugger halts the core via the stock riscv
+ `ebreak` (ebreakm set during examine) — that is intended, so you can trap
+ and inspect the fault; it is NOT the streaming killer (that is the DM
+ corruption above), so do not silence it.
+ - *Unified-OpenOCD note kept for the dump/flash paths*: `hathach/openocd`,
+ `tinyusb` branch, is the rig's `/usr/local/bin/openocd`; it registers
+ `rtt setup`/`rtt start` for WCH targets where stock WCH/MounRiver forks
+ only register `rtt server`.
+ - *Any route*: never `reset run` inside an RTT session — under SDI the target
+ does not come back and USB never re-enumerates.
+- **max32666fthr** — a Cortex-M4 with NO DWT cycle counter: read live 2026-08-11
+ with the sysview build running, `DEMCR=0x01000000` (TRCENA set by our init) but
+ `DWT_CTRL=0x4F000000` (`NOCYCCNT`=1 — Maxim implemented only the 4 watchpoint
+ comparators) and `CYCCNT` frozen at 0; UG6971 documents no DWT/trace at all.
+ This was the mechanism behind the historical "no usable rate number" (every
+ duration silently zero — a CM4-with-NOCYCCNT *links fine*, so the "fails to
+ link = not ported" signal never fires). **Ported** the same day:
+ `hw/bsp/maxim/sysview_max32_tmr.h` — TMR0 free-running at 48 MHz
+ (f_PCLK = f_SYS_CLK/2, Continuous mode, `CMP=0xFFFFFFFF`, prescaler 1;
+ `TMRn_CNT` is documented always-readable while counting; the hardware wrap
+ reloads CNT to 1, a ~0.23 ppb slip — ignore). The family builds SystemView
+ with `SEGGER_SYSVIEW_CORE_OTHER` and sets `CFG_TUSB_SYSVIEW_TIMESTAMP_BSP`,
+ the tusb_sysview.c opt-in for ARMv7-M parts without CYCCNT where the BSP also
+ reports the rate (the 1 MHz microsecond contract is unreachable: MAX32
+ prescalers are powers-of-two only). Timer instance is `-DSYSVIEW_MAX32_TMR=n`
+ (default 0; TinyUSB examples use no TMR and FreeRTOS ticks on SysTick, so
+ TMR0 is free). MAX3266x only — MAX32650/32690 use different GCR clock-gate
+ register names and stay unported. Validated on the rig: window 21 s, USB
+ ISR 18 p50 8.5 µs / p99 13.1 µs, and the rate test measured seven clean
+ 1.000 s gaps at 1.00044–1.00091 (median +0.05%).
+- **raspberry_pi_pico2** — host-role on the rig (device port not PC-wired), so
+ its old "no usable rate number" is a wiring limit, not a chip one. Validated
+ 2026-08-11 with a `host/device_info` SYSVIEW capture over the standard
+ OpenOCD route: ISR 30 (same RP2350 USBCTRL exception as adafruit_fruit_jam)
+ n=173 p50 7.2 µs, `tuh_task` p50 9.6 µs / p99 44.3 µs, overflow 0 — M33 DWT
+ timestamps are sane. A precise rate number needs a host-timed stimulus this
+ wiring cannot provide; the example idles after its single enumeration
+ (0.82 s of activity), so window-vs-wall-clock is no substitute. For an
+ RP2350 rate reference use fruit_jam's measured +1.85% — but crystals differ
+ per board, so treat it as indicative only.
+- **metro_m4_express** — set `"attach_only": true` in its `sysview` roster block:
+ after openocd's own `reset run` (jlink interface + `atsame5x.cfg`) the core never
+ comes back — the RTT control block never appears and the CDC never re-enumerates
+ (SAMD5x DSU CPU Reset Extension is the prime suspect). Measured 2026-08-11: two
+ reset-run captures died with `rtt: No control block found` while attach-without-
+ reset streamed immediately with the CDC still on the bus. The J-Link flash is
+ NOT the problem (erase+program+verify all real, enumeration 1.0 s after flash) —
+ and its own post-flash reset is what makes attach-only sound: the capture still
+ sees a boot that is only seconds old. Rate measured over this route 2026-08-11:
+ five independent host-timed gaps at 1.00992–1.01002 (spread ±0.005%), with the
+ traffic-landed check explicit this time (11/11 bursts echoed) — replacing the
+ old "no usable rate number", which was a no-traffic measurement artifact, not
+ a clock fault. When rate-testing here, ignore the device's own ~2 s periodic
+ event cluster (~28 events): it interleaves with host bursts and, unfiltered,
+ produces alternating ±10% gaps whose pairs sum correctly — the giveaway.
+ Attach-only quality caveat (validated
+ end-to-end 2026-08-11, `metro_m4_express: ok`): joining mid-stream shortens the
+ clean window (2.4–5.6 s decoded of a 15 s workload across runs) and leaves a few nonsense head
+ rows (`ISR 3`/`ISR 512`, n≈3) before the decoder syncs — real rows (ISR 96/98/99,
+ `tud_task` n=6048) follow.
+- **stm32u083nucleo** — its OpenOCD build ships `stm32u5x.cfg` but no
+ `stm32u0x.cfg`, and its ST-Link runs stock firmware (so the J-Link route is out
+ too). Capture with a hand-written generic Cortex-M target; flash separately
+ with `STM32_Programmer_CLI`. `source [find target/swj-dp.tcl]` explicitly —
+ it defines `swj_newdap`, and without it the tap declaration fails with a
+ confusing `invalid command name`:
+ ```tcl
+ source [find target/swj-dp.tcl]
+ transport select hla_swd
+ set _CHIPNAME stm32u0
+ swj_newdap $_CHIPNAME cpu -irlen 4 -expected-id 0x6ba02477
+ dap create $_CHIPNAME.dap -chain-position $_CHIPNAME.cpu
+ target create $_CHIPNAME.cpu cortex_m -endian little -dap $_CHIPNAME.dap
+ ```
+- **stm32h743nucleo** — OpenOCD RAM_D1 is `0x24000000`, length `0x80000`
+ (`hw/bsp/stm32h7/linker/stm32h743xx_flash.ld`); those are the `rtt setup`
+ arguments. Plain `interface/stlink.cfg` works — no `stlink-dap.cfg` fallback
+ needed.
+
+## Reference numbers from validated captures
+
+Use these to sanity-check a new capture on the same board.
+
+- **same54_xplained** (`cdc_msc_freertos`, `SYSVIEW=4`, live J-Link, dogfooded
+ end-to-end):
+ - Contexts/ISR: `cdc` 3.9-5.3% CPU, `usbd` avg 181-186 µs, ISR 96/98/99
+ (USB IRQ, exception# = NVIC IRQn+16) p50 27-33 µs, `Idle` ~89%, overflow 0
+ at a 16 KB buffer.
+ - Functions (level 4, CDC+MSC load): `tud_task` p50 23 µs, `usbd_edpt_xfer`
+ 13 µs, `dcd_edpt_xfer` 5 µs, `tud_cdc_write_flush` 4.7 µs, `tud_cdc_read`
+ 118-135 µs (includes FreeRTOS scheduling overhead — tight polling loop),
+ `mscd_xfer_cb` 24-34 µs; all n>100. Markers n=320, p50 ~770 µs.
+ - Stack (one capture, round-robin): `usbd` 684/1024 B (67%), `cdc` 308/1024 B
+ (30%), `IDLE` 40/512 B, `blinky` 148/512 B, `Tmr Svc` 204 B, `io` 228 B.
+ - Heap: plumbing proven with a temporary dynamic-alloc probe (`allocs=1
+ frees=1 net_bytes=0`); dormant on the stock static-allocation example.
+ - Post-mortem: mid-load halt-and-dump decoded with `cdc`/`usbd`/ISR
+ 98/99/`Scheduler`/`Idle` all present and sane, overflow 0.
+- **stm32h743nucleo** — OpenOCD route validated end-to-end: contexts, ISR 117
+ (= OTG_FS IRQn 101 + 16, independently verified) and the function table all
+ populated and correctly mapped from a raw `capture.SVDat`. Live-J-Link build
+ validates green but is build-only (no J-Link wired to this board on the rig).
+
+## Not reachable on the ci.lan pool
+
+`frdm_k64f` and `raspberry_pi_pico_w` (device port does not enumerate — **stock
+firmware behaves identically**, so rig cabling), `ch32v103r_r1_1v0` (see caveat
+above), `nrf5340dk` (probe USB port), `ek_tm4c123gxl` (lm4flash only), and both
+Espressif boards (ESP-IDF build).
+
+## Family support status
+
+- **Timestamp source.** Cores with DWT (ARMv7-M, ARMv8-M mainline: M3/M4/M7/M33)
+ need nothing. Cores without it (ARMv6-M M0/M0+, RISC-V) need
+ `SEGGER_SYSVIEW_X_GetTimestamp()` in `hw/bsp/<family>/family.c` returning
+ **free-running microseconds**. Ported: `stm32f0`/`g0`/`u0` (TIM2), `lpc11`
+ (CT32B0), `rp2040` (`time_us_32`), `ch32v20x`/`v30x`/`ch583` (QingKe SysTick).
+ An ARMv7-M part whose DWT lacks CYCCNT (MAX3266x) instead sets
+ `CFG_TUSB_SYSVIEW_TIMESTAMP_BSP` + `SEGGER_SYSVIEW_CORE_OTHER` in its
+ family.cmake and additionally provides `SEGGER_SYSVIEW_X_GetTimestampFreq()`
+ — the fixed-1MHz contract is waived where the prescaler can't reach it
+ (see the max32666fthr caveat).
+ A family with neither fails to link — the intended "not ported yet" signal.
+ Two families refuse `-DSYSVIEW` at **configure** time instead, with a
+ FATAL_ERROR naming the reason: `ch32v10x` (no safe RTT lock, see its caveat)
+ and any `stm32f0` variant without TIM2 (e.g. `stm32f070rbnucleo` — the hook is
+ gated on `defined(TIM2)`, so it would otherwise fail at link with six undefined
+ references). The refusal is level-independent — it is the timestamp hook that is
+ missing, not the instrumentation, so `-DSYSVIEW=1` hits the same wall as 4. A
+ clean configure error is the signal; treat it as "port the timestamp first",
+ not as a build regression.
+- **ISR coverage is per-port** (mechanism and cases: SKILL.md's Build options).
+ Ports with NO coverage: `ch32v20x`'s FSDEV port 0 (naked-asm tail-call into
+ `dcd_int_handler` reaches neither the macros nor an instrumented handler) and
+ the PIO-USB host driver (`hcd_pio_usb.c`, `CFG_TUH_RPI_PIO_USB=1` — a
+ different path from the instrumented `hcd_rp2040.c`; measured on
+ adafruit_fruit_jam: full function tables, zero ISR rows). Coverage gaps, not
+ faults — function-level timing still works on both.
+- **Known limitation: the ISR depth counter collapses genuine nesting.**
+ `tusb_sysview_isr_enter()`/`_exit()`'s depth counter
+ (`src/common/tusb_sysview.c`) exists to fold each dual-role BSP's
+ back-to-back self-wrapped `tud_int_handler()`+`tuh_int_handler()` calls into
+ one ENTER/EXIT pair — but it collapses *real* nesting the same way: a USB
+ IRQ that genuinely preempts another (e.g. `ch32v20x`'s documented
+ HP-preempting-LP) is folded into the span of the ISR it preempted instead
+ of recorded as its own nested entry. A real tradeoff of the outer-bracket
+ design (now hardware-verified), not a bug to re-architect away.
+- **FreeRTOS per-task CPU-load table** needs the hook block in the family's
+ `FreeRTOSConfig.h`. Wired today: `nrf`, `samd5x_e5x`, `stm32f4`, `stm32f7`,
+ `stm32h7`. To add a family, append `#include "sysview_freertos_hooks.h"` at the
+ tail of `hw/bsp/<family>/FreeRTOSConfig/FreeRTOSConfig.h` — both build systems
+ already reach that header. Any other FreeRTOS family still builds, links and
+ emits every other table; it just prints a CMake `message(WARNING)` naming the
+ file to patch and leaves the per-task table empty.
+- **RAM base for named-object ids.** `SEGGER_SYSVIEW_SetRAMBase()` shrinks
+ RAM-resident pointers into `base+offset` ids; `family_support.cmake`'s SYSVIEW
+ block defaults `SYSVIEW_RAM_BASE` to the Cortex-M-canonical `0x20000000`, which
+ underflows the shrink (garbage mutex/task names) on any family whose SRAM
+ starts elsewhere. `lpc11`/`lpc13`/`lpc17`/`lpc40`/`lpc43` set
+ `SYSVIEW_RAM_BASE_DEFAULT` to `0x10000000` and `lpc15` to `0x02000000`, each
+ in its own `family.cmake`, which the SYSVIEW block above then adopts unless
+ `SYSVIEW_RAM_BASE` is already defined. Every other validated family's SRAM starts at the default
+ `0x20000000`, so this list is complete — no override needed elsewhere in the
+ table above. A family not yet covered can override with
+ `-DSYSVIEW_RAM_BASE=<addr>` (SRAM origin from its linker script).
+- **Heap alloc/free table** needs `configSUPPORT_DYNAMIC_ALLOCATION=1` in the
+ app's own `FreeRTOSConfig.h` — none of the five families wired for the
+ CPU-load table above enable it, so `heap` reads `null` in every stock capture
+ regardless of `-DSYSVIEW` level. The plumbing
+ (`tusb_sysview_heap_alloc`/`_free`, `sysview_freertos_hooks.h`'s
+ `traceMALLOC`/`traceFREE`) is proven and costs nothing when unused; an app
+ that turns dynamic allocation on gets the table for free.
+
+## Adding a board
+
+1. Build `cdc_msc` (or `cdc_msc_freertos`) at `-DSYSVIEW=4`. A link failure on
+ the timestamp symbol means the family needs a `SEGGER_SYSVIEW_X_GetTimestamp()`
+ — port it before going further. A `.bss` overflow means the board needs a
+ `-DSYSVIEW_BUFFER_SIZE` override.
+2. Capture on whichever route the board's probe supports — OpenOCD streaming
+ first (routine/CI default), J-Link live for depth or missing target cfgs,
+ dump only when live attach cannot work (SKILL.md's route table has the
+ preference rationale; WCH is dump-only, see the caveat).
+3. Verify the timestamp rate before trusting any duration: drive ten host-timed
+ 1.000 s silences and read the median gap. **Confirm traffic actually landed
+ first** — an idle enumerated TinyUSB CDC device emits a USB event about every
+ 2.016 s, so with no traffic the median reads as almost exactly 2× the intended
+ 1.000 s. `metro_m4_express` (a DWT board, where a 2× error is impossible)
+ showed exactly this, as did `ch32v307v_r1_1v0`.
+4. Add the row here, plus a caveat bullet for anything hard-won.
diff --git a/.claude/skills/sysview/scripts/sysview_dump.py b/.claude/skills/sysview/scripts/sysview_dump.py
new file mode 100644
index 000000000..3e36c5611
--- /dev/null
+++ b/.claude/skills/sysview/scripts/sysview_dump.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Post-mortem SystemView dump: recover the last seconds of scheduling
+history from a halted target, no live capture running.
+
+Post-mortem mode (SEGGER_SYSVIEW_POST_MORTEM_MODE=1, see hw/bsp/
+family_support.cmake's SYSVIEW_POST_MORTEM option, set via
+-DSYSVIEW=<level> -DSYSVIEW_POST_MORTEM=1) makes the target write
+its RTT "SysView" up-buffer as a plain overwrite ring: no host draining it,
+the buffer always holds whatever fits of the most recent events. This
+script attaches to an ALREADY-RUNNING (or already-crashed/wedged) target
+over J-Link, halts it (that halt IS the autopsy point — do not reset first,
+resetting or reflashing destroys the evidence), reads the RTT control
+block's channel-1 ("SysView") ring descriptor, dumps the raw ring bytes,
+and linearizes them oldest-to-newest into DIR/capture.SVDat — decodable
+with `sysview_record.py --from-raw DIR/capture.SVDat --out DIR2`.
+
+Linearization (WrOff-split, hardware-validated on same54_xplained — see
+the SKILL for the A/B rationale): WrOff is the next byte the target will
+write, so it also marks the OLDEST byte still valid in the ring —
+ring[WrOff:] + ring[:WrOff] reorders the whole buffer oldest-to-newest.
+The candidate built from RdOff instead (ring[WrOff:] + ring[:RdOff]) was
+tried and rejected: SEGGER_RTT_WriteWithOverwriteNoLock's own bookkeeping
+keeps RdOff == WrOff+1 once the ring has wrapped (so that candidate is
+just this one plus one harmless duplicate byte) but leaves RdOff at 0
+forever before the first wrap (RdOff is a host read cursor — nothing on
+the target ever advances it otherwise) — which would discard all the
+real just-written data in exactly the case a fast crash most needs it.
+
+Leaves the core halted by default (you are mid-autopsy); --resume sends a
+plain "g" so the target carries on.
+"""
+import argparse
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+# Same-dir helpers, not re-implemented: resolve_probe() and rtt_cb_from_elf() (the latter reads
+# the ELF symbol table directly, no arm-none-eabi-nm shell-out -- see its docstring there).
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from sysview_record import resolve_probe, rtt_cb_from_elf
+
+RTT_MAGIC = b"SEGGER RTT"
+# SEGGER_RTT_CB layout (see lib/SEGGER_RTT/RTT/SEGGER_RTT.h):
+# char acID[16]; int MaxNumUpBuffers; int MaxNumDownBuffers; SEGGER_RTT_BUFFER_UP aUp[...];
+# aUp[] starts at +0x18; each SEGGER_RTT_BUFFER_UP is 6 words (24 bytes):
+# sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags
+# Channel 0 is always "Terminal" (SEGGER_RTT's own default up-buffer);
+# SystemView's Init allocates the next free channel for "SysView", which is
+# channel 1 as long as nothing else grabs an up-buffer first — confirmed on
+# same54_xplained hardware (both a live SYSVIEW=4 and a POST_MORTEM=1 build).
+AUP_OFFSET = 0x18
+AUP_STRIDE = 0x18
+SYSVIEW_CHANNEL = 1
+HEADER_LEN = AUP_OFFSET + (SYSVIEW_CHANNEL + 1) * AUP_STRIDE # magic..aUp[1] end
+
+
+def run(cmd, **kw):
+ return subprocess.run(cmd, capture_output=True, text=True, **kw)
+
+
+def jlink(args, serial, cmds):
+ """Run one attach-only JLinkExe session (no reset — never disturb a
+ halted/wedged target). cmds is the command-file body, without -CommandFile's
+ trailing 'q' (added here)."""
+ with tempfile.NamedTemporaryFile("w", suffix=".jlink", delete=False) as f:
+ f.write("\n".join([*cmds, "q\n"]))
+ cmd_file = f.name
+ try:
+ r = run(["JLinkExe", "-USB", serial, "-device", args.device, "-if", "SWD",
+ "-speed", str(args.speed), "-autoconnect", "1", "-nogui", "1",
+ "-CommandFile", cmd_file], timeout=60)
+ if r.returncode != 0:
+ sys.exit(f"error: JLinkExe failed:\n{r.stdout}\n{r.stderr}")
+ return r.stdout
+ finally:
+ Path(cmd_file).unlink(missing_ok=True)
+
+
+def dump(args, serial, cb):
+ tmp = Path(tempfile.mkdtemp(prefix="sysview_dump_"))
+ try:
+ # 1. Halt (no reset) and read the RTT control block header, far enough
+ # to cover the magic + channel-1 ("SysView") ring descriptor.
+ header_bin = tmp / "header.bin"
+ jlink(args, serial, ["h", f"savebin {header_bin}, {cb}, 0x{HEADER_LEN:X}"])
+ header = header_bin.read_bytes()
+ if len(header) != HEADER_LEN:
+ sys.exit(f"error: read {len(header)} bytes, expected {HEADER_LEN} "
+ f"— probe/target read failed")
+ if header[:len(RTT_MAGIC)] != RTT_MAGIC:
+ sys.exit(f"error: no 'SEGGER RTT' magic at {cb} — wrong ELF, or "
+ f"target RAM not yet initialized. Core left halted.")
+ up1_off = AUP_OFFSET + SYSVIEW_CHANNEL * AUP_STRIDE
+ _sname, p_buffer, size_of_buffer, wr_off, rd_off, _flags = \
+ struct.unpack_from("<6I", header, up1_off)
+ if size_of_buffer == 0 or wr_off >= size_of_buffer or rd_off >= size_of_buffer:
+ sys.exit(f"error: implausible channel-{SYSVIEW_CHANNEL} descriptor "
+ f"(pBuffer=0x{p_buffer:X} size={size_of_buffer} "
+ f"WrOff={wr_off} RdOff={rd_off}) — not a SystemView build? "
+ f"Core left halted.")
+
+ # 2. Halt (no-op, already halted) and dump the ring bytes.
+ ring_bin = tmp / "ring.bin"
+ cmds = ["h", f"savebin {ring_bin}, 0x{p_buffer:X}, 0x{size_of_buffer:X}"]
+ if args.resume:
+ cmds.append("g")
+ jlink(args, serial, cmds)
+ ring = ring_bin.read_bytes()
+ if len(ring) != size_of_buffer:
+ sys.exit(f"error: read {len(ring)} ring bytes, expected {size_of_buffer}")
+
+ # 3. Linearize (WrOff-split — see module docstring) and write out.
+ out = Path(args.out)
+ out.mkdir(parents=True, exist_ok=True)
+ linearized = ring[wr_off:] + ring[:wr_off]
+ (out / "capture.SVDat").write_bytes(linearized)
+
+ print(f"SizeOfBuffer={size_of_buffer} WrOff={wr_off} RdOff={rd_off}")
+ print(f"wrote: {out / 'capture.SVDat'}")
+ print(f"core left {'running (--resume)' if args.resume else 'halted'}")
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--device", required=True, help="J-Link device name, e.g. ATSAME54P20")
+ ap.add_argument("--probe", required=True, help="J-Link serial or USB nickname")
+ ap.add_argument("--elf", help="post-mortem-build ELF — _SEGGER_RTT address read from it")
+ ap.add_argument("--rttcbaddr", help="RTT control block address (overrides --elf)")
+ ap.add_argument("--out", required=True, help="output directory for capture.SVDat")
+ ap.add_argument("--speed", type=int, default=4000, help="SWD speed kHz")
+ ap.add_argument("--resume", action="store_true",
+ help="resume the core (g) after dumping; default leaves it halted")
+ args = ap.parse_args()
+
+ if not args.rttcbaddr and not args.elf:
+ ap.error("need --elf or --rttcbaddr")
+ for tool in ("JLinkExe",):
+ if not shutil.which(tool):
+ sys.exit(f"error: '{tool}' not on PATH")
+
+ serial = resolve_probe(args.probe)
+ cb = args.rttcbaddr or rtt_cb_from_elf(args.elf)
+ dump(args, serial, cb)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/sysview/scripts/sysview_record.py b/.claude/skills/sysview/scripts/sysview_record.py
new file mode 100644
index 000000000..2653e60d3
--- /dev/null
+++ b/.claude/skills/sysview/scripts/sysview_record.py
@@ -0,0 +1,499 @@
+#!/usr/bin/env python3
+"""Unattended SEGGER SystemView profiling on a headless host.
+
+Drives the SystemView GUI's live J-Link recorder under a private Xvfb: it
+connects to the target over the debug probe, records SystemView events for a
+window while an optional workload runs, then exports the analysis to CSV — no
+desktop, no human clicks. What makes it work headlessly:
+
+ * the per-launch SFL license dialog is dismissed by clicking "Continue under
+ SFL" (the second button from the right — the corner is "Decline", which
+ quits the app);
+ * a fresh ini (the old one is removed, never merged — configparser corrupts
+ its binary window-state blobs and SystemView then crashes on startup) so
+ -start pops the recorder config dialog PREFILLED from the CLI args, whose
+ Finish button is what actually connects J-Link;
+ * a stale Xvfb on the target display is killed first (a leftover one makes
+ our Xvfb fail to bind, leaving SystemView with no display).
+
+The target must run a SystemView-instrumented firmware (build with
+-DSYSVIEW=<level>, see hw/bsp/family_support.cmake) — tusb_sysview_init()
+calls SEGGER_SYSVIEW_Start() automatically, no source edits needed. Outputs
+in --out:
+contexts.csv (per-task/ISR CPU load + run-time stats), recording.SVDat (raw,
+openable in a desktop GUI), optionally events.txt / terminal.csv, and
+debug.png on failure. Run on the host that owns the probe; one probe, one
+client — no other J-Link tool may use it during recording.
+"""
+import argparse
+import os
+import re
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+SV_PORT = 19050
+INI = Path.home() / ".config/SEGGER/SEGGER SystemView.ini"
+INI_BAK = INI.with_name(INI.name + ".tinyusb-bak")
+
+
+def run(cmd, **kw):
+ return subprocess.run(cmd, capture_output=True, text=True, **kw)
+
+
+def resolve_probe(probe):
+ """Map a J-Link USB nickname to its serial via the JLinkExe banner
+ (SystemView's -usb wants a serial when several probes are attached)."""
+ if not probe or probe.isdigit():
+ return probe
+ r = run(["JLinkExe", "-USB", probe, "-nogui", "1"], input="qc\n", timeout=30)
+ m = re.search(r"S/N:\s*(\d+)", r.stdout)
+ if not m:
+ sys.exit(f"error: cannot resolve probe '{probe}' to a serial")
+ return m.group(1)
+
+
+def rtt_cb_from_elf(elf):
+ """Address of _SEGGER_RTT, read straight out of the ELF symbol table.
+
+ Deliberately does not shell out to <prefix>nm: the prefix differs per target
+ (arm-none-eabi- vs riscv-none-elf-) and neither is on PATH by default on the
+ rig, which turned a working capture into a FileNotFoundError traceback.
+ Every TinyUSB target is 32-bit little-endian, so only ELF32 LSB is handled.
+ """
+ with open(elf, "rb") as f:
+ b = f.read()
+ if b[:4] != b"\x7fELF" or b[4] != 1 or b[5] != 1:
+ sys.exit(f"error: {elf} is not a 32-bit little-endian ELF")
+ u16 = lambda o: int.from_bytes(b[o:o + 2], "little")
+ u32 = lambda o: int.from_bytes(b[o:o + 4], "little")
+ shoff, shentsize, shnum = u32(0x20), u16(0x2E), u16(0x30)
+ for i in range(shnum):
+ sh = shoff + i * shentsize
+ if u32(sh + 4) != 2: # SHT_SYMTAB
+ continue
+ symoff, symsize, entsize = u32(sh + 0x10), u32(sh + 0x14), u32(sh + 0x24)
+ strtab = shoff + u32(sh + 0x18) * shentsize # sh_link -> .strtab
+ stroff = u32(strtab + 0x10)
+ for s in range(symoff, symoff + symsize, entsize):
+ name_off = stroff + u32(s)
+ end = b.index(b"\0", name_off)
+ if b[name_off:end] == b"_SEGGER_RTT":
+ return hex(u32(s + 4))
+ sys.exit(f"error: no _SEGGER_RTT symbol in {elf} — not a SystemView build?")
+
+
+# ---------------------------------------------------------------- X helpers
+
+def xdo(display, *args):
+ return run(["xdotool", *args], env={**os.environ, "DISPLAY": display})
+
+
+def visible_windows(display):
+ wins = []
+ for wid in xdo(display, "search", "--onlyvisible", "--name", ".").stdout.split():
+ name = xdo(display, "getwindowname", wid).stdout.strip()
+ g = xdo(display, "getwindowgeometry", "--shell", wid).stdout
+ geo = {k: int(v) for k, v in re.findall(r"(\w+)=(-?\d+)", g)}
+ wins.append((wid, name, geo))
+ return wins
+
+
+def click(display, x, y):
+ xdo(display, "mousemove", str(x), str(y), "click", "1")
+ time.sleep(1.5)
+
+
+def click_dialog(display, name_match, dx=45, timeout=20):
+ """Click a bottom-right button of the first dialog whose title matches.
+ dx = pixels from the right edge: 45 is the corner OK; 130 is the config
+ dialog's Finish (corner is Cancel); 177 is the license 'Continue under
+ SFL' (corner is Decline)."""
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ for wid, name, g in visible_windows(display):
+ if name_match(name):
+ click(display, g["X"] + g["WIDTH"] - dx, g["Y"] + g["HEIGHT"] - 25)
+ return True
+ time.sleep(1)
+ return False
+
+
+def dismiss_dialogs(display, rounds=6):
+ """Click the bottom-right button of every small (dialog-sized) window,
+ a few rounds. Used after -stop where the modal titles vary ('SystemView
+ overflow events recorded' with a Close button, info popups) — matching by
+ size, not title, is what reliably clears them so -save is not blocked."""
+ for _ in range(rounds):
+ hit = False
+ for wid, name, g in visible_windows(display):
+ if g["WIDTH"] < 900 and g["HEIGHT"] < 720: # a dialog, not the main window
+ click(display, g["X"] + g["WIDTH"] - 45, g["Y"] + g["HEIGHT"] - 25)
+ hit = True
+ if not hit:
+ return
+ time.sleep(1)
+
+
+def free_display(display):
+ run(["pkill", "-9", "-f", f"Xvfb {display} "])
+ time.sleep(1)
+
+
+# ---------------------------------------------------------------- socket
+
+def sv_cmd(cmd, timeout=10):
+ with socket.create_connection(("127.0.0.1", SV_PORT), timeout=timeout) as s:
+ s.sendall((cmd + "\n").encode())
+ time.sleep(0.3)
+
+
+def wait_port(port, timeout):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ try:
+ socket.create_connection(("127.0.0.1", port), timeout=1).close()
+ return True
+ except OSError:
+ time.sleep(1)
+ return False
+
+
+def wait_file(path, timeout):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ if path.exists() and path.stat().st_size > 0:
+ return True
+ time.sleep(1)
+ return False
+
+
+def _is_our_stub(path):
+ """True if the file is the minimal ini this script writes, not a real SystemView config."""
+ try:
+ t = path.read_text()
+ except OSError:
+ return False
+ return t.startswith("[Preferences]") and len(t) < 200 and "LoadProjectOnStart=false" in t
+
+
+def stash_ini():
+ """Overwrite the SystemView ini with our minimal stub, first backing up
+ whatever was there (a developer's window layout, recent-projects list,
+ recorder presets) so restore_ini()'s finally-block call gives it back --
+ without this, record()/decode_raw() previously clobbered it permanently.
+ Skips taking a new backup if INI_BAK already exists: that means an earlier
+ run crashed before restoring, and INI_BAK still holds the real original
+ (copying again here would overwrite it with our own stub instead).
+ License acknowledgement lives in a separate file
+ (SEGGER_REG_HKEY_CURRENT_USER.xml) -- never touched by this script."""
+ INI.parent.mkdir(parents=True, exist_ok=True)
+ # Never back up our OWN stub as if it were the user's config. If a run is SIGKILLed the
+ # finally-block restore never happens, leaving the stub in place with its backup already
+ # consumed; the next run would then "back up" the stub and the real config is gone for good.
+ # Observed on ci.lan: a 19 KB SystemView config replaced by the 60-byte stub, unrecoverable.
+ if INI.exists() and not INI_BAK.exists() and not _is_our_stub(INI):
+ shutil.copy2(INI, INI_BAK)
+ INI.write_text("[Preferences]\n"
+ "LoadProjectOnStart=false\n"
+ "SaveProperties=false\n")
+
+
+def check_not_sample(out):
+ """Fail loudly if the export is SEGGER's bundled demo rather than our target.
+
+ A wrong-data export decodes cleanly and reads plausibly, so nothing downstream
+ can tell it apart -- the only cheap discriminator is the context names, which
+ on the shipped LPC4367/embOS demos are nothing like a TinyUSB build's. Checked
+ by name rather than by count so an idle-but-real capture still passes."""
+ ctx = out / "contexts.csv"
+ if not ctx.exists():
+ return
+ names = ctx.read_text(errors="replace")
+ demo = [m for m in ("Job Runner", "Compass", "Acceleration", "M4CORE", "M0APP")
+ if m in names]
+ if demo:
+ sys.exit(f"error: export contains SEGGER's bundled demo recording "
+ f"(saw {', '.join(demo)}), not this target's trace. SystemView "
+ f"auto-loaded its last data file; ensure LoadDataOnStart=false "
+ f"reached the ini, and re-record.")
+
+
+def restore_ini():
+ """Undo stash_ini(): restore the backed-up ini, or remove our stub if
+ there was nothing to restore (no ini existed before this run)."""
+ if INI_BAK.exists():
+ shutil.move(str(INI_BAK), str(INI))
+ else:
+ INI.unlink(missing_ok=True)
+
+
+def clear_stale_exports(paths):
+ """Delete any pre-existing files at these paths before a fresh export run.
+ wait_file() above only checks exists()+size>0 -- a stale file left over from a
+ previous invocation (e.g. a prior run that got this far and no further) would
+ let a silently-failed -export/-save this run pass as if it had produced fresh
+ output."""
+ for p in paths:
+ try:
+ p.unlink()
+ except FileNotFoundError:
+ pass
+
+
+# ---------------------------------------------------------------- main flow
+
+def record(args, serial, rtt):
+ out = Path(args.out)
+ disp = args.display
+ exports = [("-save", out / "recording.SVDat"),
+ ("-export-contexts", out / "contexts.csv")]
+ if not args.no_events:
+ exports.append(("-export", out / "events.txt"))
+ if args.export_terminal:
+ exports.append(("-export-terminal", out / "terminal.csv"))
+ # Before launching SystemView: a stale export left over from a previous invocation
+ # would let wait_file() below pass a silently-failed -save/-export this run as fresh.
+ clear_stale_exports(p for _, p in exports)
+ # Fresh MINIMAL ini: overwrite (never merge — configparser corrupts the
+ # binary @ByteArray window-state blobs and SystemView crashes on startup).
+ # SaveProperties=false stops the Recording-Properties dialog blocking -save.
+ # Do NOT set LoadDataOnStart=false: the auto-loaded startup recording is
+ # what makes -start pop the recorder config dialog (whose Finish connects
+ # J-Link) — suppress it and -start silently records nothing. Its
+ # "Events loaded" modal is dismissed below before the socket wait.
+ # stash_ini()/restore_ini() (below, in the finally block) back this up and
+ # give it back so a developer's window layout etc. survives the run.
+ stash_ini()
+ free_display(disp)
+ xvfb = subprocess.Popen(["Xvfb", disp, "-screen", "0", "1600x1000x24"],
+ stderr=subprocess.DEVNULL)
+ sv = None
+ traffic = None
+ try:
+ time.sleep(1)
+ sv = subprocess.Popen(
+ ["systemview", "-single", "-recorder", "J-Link", "-device", args.device,
+ "-usb", serial, "-if", "SWD", "-speed", str(args.speed), "-rttcbaddr", rtt],
+ env={**os.environ, "DISPLAY": disp},
+ stdout=open(out / "systemview.log", "w"), stderr=subprocess.STDOUT)
+
+ click_dialog(disp, lambda n: "License" in n or "Commercial" in n, dx=177, timeout=30)
+ # a sample recording auto-loads on a fresh ini — dismiss its info modal
+ click_dialog(disp, lambda n: "Events loaded" in n or "System Information" in n, timeout=8)
+ if not wait_port(SV_PORT, 30):
+ raise RuntimeError("SystemView command server (:19050) never came up "
+ "— license dialog not dismissed? see debug.png")
+
+ sv_cmd("-start")
+ # -start pops the recorder config dialogs (prefilled from CLI args):
+ # a small recorder-type picker ("SystemView Recorder:" dropdown,
+ # OK/Cancel) then the large "Recorder Configuration" J-Link config
+ # dialog (Finish/Cancel). On BOTH, the confirm button (OK / Finish)
+ # sits at dx=130 from the right edge — Cancel is the corner button
+ # (dx=45) on both, not OK, despite the smaller dialog's title being
+ # just "Recorder Configuration" with no size cue otherwise.
+ #
+ # A probe running ST-Link-compatible J-Link firmware (e.g. an onboard
+ # ST-Link reflashed with J-Link firmware, as used on some Discovery
+ # boards) additionally pops a one-time-per-day "Terms of use" dialog
+ # AFTER Finish, asynchronously (a few seconds later, once the J-Link
+ # DLL actually opens the probe) — Accept is the corner button here
+ # (dx=45), Decline is second-from-right (dx=134): the opposite
+ # corner convention from the license dialog. Left unhandled, the
+ # connect silently stalls and the export step below re-exports
+ # whatever was already loaded (e.g. SystemView's bundled sample
+ # recording on a fresh ini) instead of erroring — verified on ci.lan.
+ #
+ # Poll the WHOLE window — a dialog can appear a few seconds after
+ # -start (or after the previous one is dismissed), so require two
+ # consecutive empty polls (not just one) before concluding no more
+ # dialogs are coming.
+ seen_config = False
+ misses = 0
+ t0 = time.time()
+ while time.time() - t0 < 30:
+ hit = False
+ for wid, name, g in visible_windows(disp):
+ if "Terms of use" in name:
+ click(disp, g["X"] + g["WIDTH"] - 45, g["Y"] + g["HEIGHT"] - 25)
+ hit = seen_config = True
+ elif "Recorder" in name or "Connection" in name or "Configuration" in name:
+ click(disp, g["X"] + g["WIDTH"] - 130, g["Y"] + g["HEIGHT"] - 25)
+ hit = seen_config = True
+ if seen_config and not hit:
+ misses += 1
+ if misses >= 2:
+ break
+ else:
+ misses = 0
+ time.sleep(1)
+ time.sleep(3) # J-Link connect + first events
+
+ # start_new_session=True: traffic_cmd runs under `sh -c`, which can itself spawn
+ # children (e.g. a pipeline) -- putting it in its own process group lets the
+ # finally block below kill the whole group, not just the shell.
+ traffic = subprocess.Popen(args.traffic_cmd, shell=True, start_new_session=True) \
+ if args.traffic_cmd else None
+ time.sleep(args.duration_ms / 1000)
+ if traffic:
+ try:
+ traffic.wait(timeout=60)
+ except subprocess.TimeoutExpired:
+ pass # killed in the finally block below, whole process group
+
+ sv_cmd("-stop")
+ time.sleep(2)
+ # After stop an "overflow events recorded" / info modal (with a Close
+ # button) can block -save. Its title varies, so clear by size.
+ dismiss_dialogs(disp)
+
+ for cmd, path in exports:
+ sv_cmd(f"{cmd} {path}")
+ time.sleep(1)
+ dismiss_dialogs(disp, rounds=3) # clear any save/confirm modal
+ if not wait_file(path, 60):
+ raise RuntimeError(f"{cmd} produced no file — see debug.png")
+ sv_cmd("-quit")
+ try:
+ sv.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ pass
+ except Exception:
+ run(["import", "-window", "root", str(out / "debug.png")],
+ env={**os.environ, "DISPLAY": disp})
+ raise
+ finally:
+ if traffic and traffic.poll() is None:
+ try:
+ os.killpg(os.getpgid(traffic.pid), signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ if sv and sv.poll() is None:
+ sv.kill()
+ xvfb.kill()
+ restore_ini()
+
+
+def decode_raw(args):
+ """Load a raw capture (e.g. sysview_dump.py's post-mortem capture.SVDat)
+ and export it — no probe, no J-Link, no --device/--probe needed. Same
+ Xvfb/dialog choreography as record(), minus the live-recorder connect."""
+ out = Path(args.out)
+ disp = args.display
+ exports = [("-export-contexts", out / "contexts.csv")]
+ if not args.no_events:
+ exports.append(("-export", out / "events.txt"))
+ if args.export_terminal:
+ exports.append(("-export-terminal", out / "terminal.csv"))
+ # Before launching SystemView: a stale export left over from a previous invocation
+ # would let wait_file() below pass a silently-failed -export this run as fresh. Does
+ # not touch args.from_raw (the source capture being decoded, a different path).
+ clear_stale_exports(p for _, p in exports)
+ stash_ini()
+ free_display(disp)
+ xvfb = subprocess.Popen(["Xvfb", disp, "-screen", "0", "1600x1000x24"],
+ stderr=subprocess.DEVNULL)
+ sv = None
+ try:
+ time.sleep(1)
+ sv = subprocess.Popen(
+ ["systemview", "-single", "-port", str(SV_PORT), "-wait"],
+ env={**os.environ, "DISPLAY": disp},
+ stdout=open(out / "systemview.log", "w"), stderr=subprocess.STDOUT)
+
+ click_dialog(disp, lambda n: "License" in n or "Commercial" in n, dx=177, timeout=30)
+ # Same fresh-ini auto-load info modal record() dismisses (symmetry — untested
+ # whether this launch mode, no -recorder args, can actually trigger it, but
+ # dismiss_dialogs() below is a no-op if nothing is there).
+ click_dialog(disp, lambda n: "Events loaded" in n or "System Information" in n, timeout=8)
+ if not wait_port(SV_PORT, 30):
+ raise RuntimeError("SystemView command server (:19050) never came up "
+ "— license dialog not dismissed? see debug.png")
+
+ sv_cmd(f"-load {Path(args.from_raw).resolve()}")
+ time.sleep(2)
+ dismiss_dialogs(disp) # "System Information" popup(s) after a raw load
+
+ for cmd, path in exports:
+ sv_cmd(f"{cmd} {path}")
+ time.sleep(1)
+ dismiss_dialogs(disp, rounds=3)
+ if not wait_file(path, 60):
+ raise RuntimeError(f"{cmd} produced no file — see debug.png")
+ sv_cmd("-quit")
+ try:
+ sv.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ pass
+ except Exception:
+ run(["import", "-window", "root", str(out / "debug.png")],
+ env={**os.environ, "DISPLAY": disp})
+ raise
+ finally:
+ if sv and sv.poll() is None:
+ sv.kill()
+ xvfb.kill()
+ restore_ini()
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--device", help="J-Link device name, e.g. ATSAME54P20 (live capture)")
+ ap.add_argument("--probe", help="J-Link serial or USB nickname (live capture)")
+ ap.add_argument("--elf", help="instrumented ELF — _SEGGER_RTT address read from it")
+ ap.add_argument("--rttcbaddr", help="RTT control block address (overrides --elf)")
+ ap.add_argument("--duration-ms", type=int, default=10000)
+ ap.add_argument("--out", required=True, help="output directory")
+ ap.add_argument("--speed", type=int, default=4000, help="SWD speed kHz")
+ ap.add_argument("--display", default=":96", help="private Xvfb display")
+ ap.add_argument("--traffic-cmd", help="shell command run during the recording window")
+ ap.add_argument("--from-raw", help="decode a raw capture (e.g. sysview_dump.py's "
+ "capture.SVDat) instead of recording live — no --device/--probe needed")
+ ap.add_argument("--no-events", action="store_true",
+ help="skip events.txt export (large: ~3 MB per second recorded)")
+ ap.add_argument("--export-terminal", action="store_true",
+ help="export terminal.csv (SEGGER_SYSVIEW_Print* output)")
+ args = ap.parse_args()
+
+ if args.from_raw:
+ if not Path(args.from_raw).is_file():
+ ap.error(f"--from-raw {args.from_raw} not found")
+ else:
+ if not args.rttcbaddr and not args.elf:
+ ap.error("need --elf or --rttcbaddr")
+ if not (args.device and args.probe):
+ ap.error("need --device and --probe for live capture")
+ # SystemView deletes stale /tmp/sv-* dirs on startup (its own temp-dir
+ # pattern) — an --out matching it gets wiped mid-run and -save has nowhere
+ # to write. Reject it.
+ op = Path(args.out).resolve()
+ if op.parent == Path("/tmp") and op.name.startswith("sv-"):
+ ap.error(f"--out {args.out} collides with SystemView's /tmp/sv-* temp dirs "
+ f"(it deletes them on startup) — use e.g. /tmp/sysview-<board>")
+ tools = ["systemview", "Xvfb", "xdotool", "import"]
+ if not args.from_raw:
+ tools.append("JLinkExe")
+ for tool in tools:
+ if not shutil.which(tool):
+ sys.exit(f"error: '{tool}' not on PATH (apt: systemview deb, xvfb, "
+ f"xdotool, imagemagick; SEGGER J-Link package)")
+
+ Path(args.out).mkdir(parents=True, exist_ok=True)
+ if args.from_raw:
+ decode_raw(args)
+ else:
+ serial = resolve_probe(args.probe)
+ rtt = args.rttcbaddr or rtt_cb_from_elf(args.elf)
+ record(args, serial, rtt)
+ check_not_sample(Path(args.out))
+ print(f"recorded -> {Path(args.out) / 'contexts.csv'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/sysview/scripts/sysview_report.py b/.claude/skills/sysview/scripts/sysview_report.py
new file mode 100644
index 000000000..55213b39d
--- /dev/null
+++ b/.claude/skills/sysview/scripts/sysview_report.py
@@ -0,0 +1,503 @@
+#!/usr/bin/env python3
+"""Summarize a sysview_record.py output directory into profiling numbers.
+
+From contexts.csv: per-context (task/ISR) CPU load, activation count,
+min/avg/max run time, and total blocked time. From events.txt (if
+present): overflow count (nonzero = data loss — raise SYSVIEW_BUFFER_SIZE),
+per-ISR duration percentiles, per-task ready->run latency percentiles,
+FreeRTOS task stack high-water bytes (from periodic "Stack Info" events,
+CFG_TUD_SYSVIEW's tusb_sysview_stack_report()), heap alloc/free totals
+(from "Allocate Memory"/"Free Memory" events, traceMALLOC/traceFREE ->
+tusb_sysview_heap_alloc/free — only present on dynamic-allocation builds),
+per-function timing percentiles (TUD/TUH_SYSVIEW_CALL/RET call sites, see
+src/common/tusb_sysview.h) and marker timing percentiles (SEGGER_SYSVIEW_
+MarkStart/MarkStop pairs, app-inserted).
+
+--json prints one JSON object to stdout instead of the text tables (every
+numeric field a real number, not a formatted string) — for CI/PR-comment
+consumption, e.g. `sysview_report.py <dir> --json | jq .contexts`.
+"""
+import argparse
+import csv
+import json
+import re
+import sys
+from pathlib import Path
+
+# Function-timing event ids are recorded at TU_SV_EVENT_BASE (512) + id
+# (src/common/tusb_sysview.h) instead of a registered SEGGER_SYSVIEW_MODULE
+# (module registration greys out Save/Export on the SystemView 4.10b Linux
+# host, bench-confirmed with 5 module configurations). SystemView therefore renders both the CALL
+# and its matching RecordEndCall as event name "Function #<512+id>" (verified
+# on real hardware: build/record/inspect events.txt, not guessed) — this
+# table maps the numeric id back to the C function name for the report.
+TU_SV_EVENT_BASE = 512
+TU_SV_FUNC_NAMES = {
+ 0: "tud_task",
+ 1: "usbd_edpt_xfer",
+ 2: "dcd_edpt_xfer",
+ 3: "tud_cdc_write_flush",
+ 4: "tud_cdc_read",
+ 5: "mscd_xfer_cb",
+ 6: "tuh_task",
+ 7: "hcd_edpt_xfer",
+}
+# W13: the workload-window anchor below keys off this table instead of a hardcoded "Function
+# #516" literal, so test_sysview_report.py's check that TU_SV_FUNC_NAMES matches the header's
+# enum order also protects this derivation -- inserting/reordering an id shifts both together.
+_WORKLOAD_ANCHOR_EVENT = "Function #" + str(
+ TU_SV_EVENT_BASE + next(fid for fid, name in TU_SV_FUNC_NAMES.items() if name == "tud_cdc_read"))
+
+
+def parse_time_s(s):
+ """SystemView CSV writes '0.008 165 075 s' / '0.034 725 ms'."""
+ m = re.match(r"([\d. ]+)\s*(s|ms|us)", s.strip())
+ if not m:
+ return 0.0
+ v = float(m.group(1).replace(" ", ""))
+ return v * {"s": 1.0, "ms": 1e-3, "us": 1e-6}[m.group(2)]
+
+
+def parse_ts_int(v):
+ """events.txt's timestampint column, defensively -- some rows carry an
+ unparsable/empty value; treat those as unknown rather than crashing."""
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def pct(sorted_vals, p):
+ """Conventional nearest-rank percentile: index (n-1)*p/100, not n*p/100 -- the latter lands
+ one slot too high (e.g. pct([10, 100], 50) would report the MAX as the p50 median) and
+ biases every low-n p50 upward in general."""
+ if not sorted_vals:
+ return 0.0
+ n = len(sorted_vals)
+ return sorted_vals[min(n - 1, int((n - 1) * p / 100))]
+
+
+def fmt_us(sec):
+ return f"{sec * 1e6:.1f}"
+
+
+def num_us(sec):
+ """Same value fmt_us() prints, as a real float for --json."""
+ return round(sec * 1e6, 1)
+
+
+def duration_table(durs_by_key, name_of=lambda k: str(k)):
+ """durs_by_key: key -> [duration_s, ...]. Returns rows sorted by key, each
+ {name, n, p50_us, p99_us, max_us} — shared by ISR/ready-run/function/marker
+ tables, which are all "sorted duration list per named thing"."""
+ rows = []
+ for key, vals in sorted(durs_by_key.items()):
+ vals.sort()
+ rows.append({
+ "name": name_of(key),
+ "n": len(vals),
+ "p50_us": num_us(pct(vals, 50)),
+ "p99_us": num_us(pct(vals, 99)),
+ "max_us": num_us(vals[-1]),
+ })
+ return rows
+
+
+def print_duration_table(label, rows, width=12):
+ print(f"\n{label:<{width}} {'n':>7} {'p50_us':>8} {'p99_us':>8} {'max_us':>8}")
+ for r in rows:
+ print(f"{r['name']:<{width}} {r['n']:>7} {r['p50_us']:>8.1f} "
+ f"{r['p99_us']:>8.1f} {r['max_us']:>8.1f}")
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("outdir", help="directory produced by sysview_record.py")
+ ap.add_argument("--json", action="store_true",
+ help="print one JSON object instead of text tables")
+ ap.add_argument("--window", metavar="T0:T1",
+ help="explicit workload window, seconds from the first event -- overrides "
+ "the marker/CDC-span anchors for cpu_pct_workload")
+ args = ap.parse_args()
+ out = Path(args.outdir)
+
+ ctx_csv = out / "contexts.csv"
+ if not ctx_csv.exists():
+ sys.exit(f"error: {ctx_csv} not found")
+
+ if not args.json:
+ print(f"{'context':<12} {'act':>7} {'cpu%':>7} {'total_ms':>10} {'blk_ms':>8} "
+ f"{'min_us':>8} {'avg_us':>8} {'max_us':>8}")
+ contexts = []
+ for row in csv.DictReader(open(ctx_csv)):
+ act = int(row["Activations"] or 0)
+ total = parse_time_s(row["Total Run Time"])
+ if act == 0 and total == 0:
+ continue
+ avg = total / act if act else 0.0
+ blk = parse_time_s(row["Total Blocked Time"])
+ cpu_str = row["CPU Load"].strip()
+ if not args.json:
+ print(f"{row['Name']:<12} {act:>7} {cpu_str:>7} {total * 1e3:>10.3f} "
+ f"{blk * 1e3:>8.3f} "
+ f"{fmt_us(parse_time_s(row['Min Run Time'])):>8} {fmt_us(avg):>8} "
+ f"{fmt_us(parse_time_s(row['Max Run Time'])):>8}")
+ contexts.append({
+ "name": row["Name"],
+ "activations": act,
+ "cpu_pct": float(cpu_str.rstrip("%").strip() or 0.0),
+ "total_ms": round(total * 1e3, 3),
+ "blocked_ms": round(blk * 1e3, 3),
+ "min_us": num_us(parse_time_s(row["Min Run Time"])),
+ "avg_us": num_us(avg),
+ "max_us": num_us(parse_time_s(row["Max Run Time"])),
+ })
+
+ ev = out / "events.txt"
+ if not ev.exists():
+ if args.json:
+ print(json.dumps({
+ "contexts": contexts, "isr": [], "ready_run": [], "functions": [],
+ "markers": [], "stack": [], "heap": None, "overflow": 0,
+ "dropped_pairs": 0, "live_window_s": None,
+ "workload_window_s": None, "workload_anchor": None, "contexts_workload": [],
+ "warnings": [],
+ }, indent=1))
+ else:
+ print("\n(no events.txt — re-run without --no-events for percentiles/latency)")
+ return
+
+ overflow = 0 # real count over the capture -- what gate() consumes
+ isr_runs = {} # context -> [duration_s] from ISR Enter "Runs for X us"
+ ready_lat = {} # task -> [latency_s] from Task Ready "name, runs after X us"
+ stack_used = {} # task -> bytes_used, last "Stack Info" wins (high-water only grows)
+ heap_last = None # last "Allocate Memory" / "Free Memory" match (running totals)
+ func_durs = {} # func id (0-based, see TU_SV_FUNC_NAMES) -> [duration_s]
+ marker_durs = {} # marker id -> [duration_s]
+ open_calls = {} # func id -> [True, ...] stack, one entry per CALL awaiting its RET
+ # (a list, not a bool: nesting -- CALL,CALL,RET,RET on the same id, e.g.
+ # a preempted task -- must pair LIFO, innermost CALL to innermost RET)
+ dropped_pairs = 0 # count of spliced CALL/RET pairs discarded due to data loss
+ # Scheduling reconstruction for cpu_pct_workload: the export's transition events (Task Run /
+ # System Idle switch the running task; ISR Enter/Exit nest on top) let per-context busy time
+ # be SUMMED from raw events over any window -- no rescaling of SystemView's whole-recording
+ # totals. Segments: (t_start, t_end, context). Runner state: ISR stack over current task.
+ sched_segments = []
+ sched_task = None # current running task/idle context, from Task Run / System Idle
+ sched_isrs = [] # nested ISR contexts, innermost last
+ sched_last_ts = None # timestamp of the previous transition
+ wl_first = None # workload window anchors: span of tud_cdc_read (Function #516) events
+ wl_last = None
+ mark_first = None # marker-pair anchors (Start/Stop Marker), preferred over the CDC span
+ mark_last = None
+ runs_re = re.compile(r"Runs for ([\d.]+) (us|ms)")
+ ready_re = re.compile(r"runs after ([\d.]+) (us|ms)")
+ # "<task> (0x<id>): <size> @ 0x<base>, <used> Bytes used" — tusb_sysview_stack_report()'s
+ # periodic reports; the one-shot report at task-create time has no "Bytes used" suffix
+ # (StackUsage not yet meaningful) and is intentionally not matched here.
+ stack_re = re.compile(r"^(.+) \(0x[0-9A-Fa-f]+\): \d+ @ 0x[0-9A-Fa-f]+, (\d+) Bytes used$")
+ # SystemView's own running totals, common to "Allocate Memory" and "Free Memory" details:
+ # "... -- <used> used, <free> free, <pct>% full -- <N> allocations, <M> frees, difference <D>"
+ heap_re = re.compile(r"(\d+) used, \d+ free, [\d.]+% full -- (\d+) allocations, (\d+) frees, difference")
+ # Un-registered module CALL/RET pair (tusb_sysview.h _TU_SV_RECORD/_TU_SV_END): SystemView
+ # renders BOTH the call and its matching RecordEndCall under the same event name
+ # "Function #<512+id>" (verified on hardware, see TU_SV_FUNC_NAMES above) — the RecordEndCall
+ # row's detail already carries the computed duration ("Returns after X us"), so no manual
+ # timestamp pairing is needed the way ISR/ready-latency above do it.
+ func_re = re.compile(r"^Function #(\d+)$")
+ returns_re = re.compile(r"Returns after ([\d.]+) (us|ms)")
+ # SEGGER_SYSVIEW_MarkStart(id)/MarkStop(id) (app-inserted, see Step 5): SystemView decodes
+ # these natively (no module registration needed, unlike the Function-id events above) as
+ # "Start Marker 0x<id>" / "Stop Marker 0x<id>", and — verified on hardware — the Stop row's
+ # own detail text already carries the computed duration ("Ran for X us, pass #N"), the same
+ # shape as the Function RecordEndCall rows, so no manual pairing is needed here either.
+ mark_stop_re = re.compile(r"^Stop Marker 0x([0-9A-Fa-f]+)$")
+ ran_re = re.compile(r"Ran for ([\d.]+) (us|ms)")
+ freq_re = re.compile(r"Cycle Freq\.: (\d+)")
+
+ # The export contains boot-time records still sitting in the RTT ring (a quiet capture can
+ # be mostly dead air -- measured: 111 s span, 98 s idle). live_window_s below is just the
+ # capture's own first-event-to-last span -- no gap heuristic trims it, and every percentile
+ # sample (ISR/ready-run/function/marker) is drawn from the whole stream, not "the window";
+ # see the "No gap heuristic" comment further down for why.
+ rows = list(csv.DictReader(open(ev, errors="replace")))
+ warnings = []
+ freq = None
+ init_row = next((r for r in rows if r.get("event") == "Init"), None)
+ if init_row:
+ fm = freq_re.search(init_row.get("detail", ""))
+ if fm:
+ freq = int(fm.group(1))
+ if freq is None:
+ # Don't silently guess: on DWT cores the real frequency is CPU cycles, not
+ # microseconds (tusb_sysview_init() passes tusb_sysview_cpu_freq()), so a wrong
+ # guess collapses live_window_s by orders of magnitude while still looking
+ # plausible. Every existing capture has an Init row; a missing one means an
+ # older/malformed export -- withhold rather than trust a made-up time base.
+ warnings.append("no Init record in events.txt -- missing timestamp frequency, "
+ "live_window_s/cpu_pct withheld")
+ ts_all = [parse_ts_int(r.get("timestampint")) for r in rows]
+ valid_ts = [t for t in ts_all if t is not None]
+ # No gap heuristic: the reported window is the capture, first event to last. An idle
+ # stretch inside a capture is a real observation about the target, not something to trim --
+ # trimming it silently changed which data the percentiles and CPU% described. The only thing
+ # ever worth excluding is pre-attach ring content, and the capture path already avoids that
+ # by draining from a reset target rather than reading whatever accumulated earlier.
+ live_start = valid_ts[0] if valid_ts else 0
+ live_window_s = ((valid_ts[-1] - live_start) / freq) if (valid_ts and freq) else None
+ backwards = next((i for i in range(len(valid_ts) - 1) if valid_ts[i + 1] < valid_ts[i]), None)
+ # W5: also gates workload_window_s/workload_anchor/contexts_workload further below -- those
+ # are computed from this same valid_ts/freq, so a corrupt clock taints them exactly as much
+ # as it taints live_window_s, not just the metric it happens to be computed next to.
+ clock_corrupt = live_window_s is not None and (live_window_s <= 0 or backwards is not None)
+ if clock_corrupt:
+ # Timestamps went non-monotonic -- e.g. a 32-bit DWT CYCCNT wraps every ~25.6 s at
+ # 168 MHz, comfortably inside a 15 s capture plus boot dead-air. This monotonicity check
+ # is the only defense against that: the window itself is just first-event-to-last (no
+ # gap heuristic, see above), so nothing else here would ever notice a wrapped counter on
+ # its own. A negative/zero span is not a smaller-but-valid window, it is "the clock
+ # lied" -- treat it exactly like no time base at all (gate() already withholds every
+ # metric once live_window_s is None) rather than publish a negative "live -N s" heading
+ # with whole-recording CPU% silently relabelled as live-window share.
+ warnings.append(f"timestamps not monotonic (counter wrap?) or zero span "
+ f"({live_window_s:.3f}s) -- treating as no time base")
+ live_window_s = None
+
+ for idx, row in enumerate(rows):
+ event = row.get("event", "")
+ detail = row.get("detail", "")
+ _ts = ts_all[idx]
+ if _ts is not None:
+ _trans = None
+ if event == "Task Run" or event == "System Idle":
+ _trans = ("task", row.get("context", ""))
+ elif event == "ISR Enter":
+ _trans = ("isr_in", row.get("context", ""))
+ elif event == "ISR Exit":
+ _trans = ("isr_out", None)
+ if _trans is not None:
+ _runner = sched_isrs[-1] if sched_isrs else sched_task
+ if _runner is not None and sched_last_ts is not None and _ts > sched_last_ts:
+ sched_segments.append((sched_last_ts, _ts, _runner))
+ kind, ctx = _trans
+ if kind == "task":
+ sched_task = ctx
+ elif kind == "isr_in":
+ sched_isrs.append(ctx)
+ elif kind == "isr_out" and sched_isrs:
+ sched_isrs.pop()
+ sched_last_ts = _ts
+ if event == _WORKLOAD_ANCHOR_EVENT: # tud_cdc_read CALL or RET: the throughput test
+ wl_first = _ts if wl_first is None else wl_first
+ wl_last = _ts
+ elif event.startswith("Start Marker ") or event.startswith("Stop Marker "):
+ mark_first = _ts if mark_first is None else mark_first
+ mark_last = _ts
+ if "*** OVERFLOW ***" in detail:
+ # SystemView's second loss marker: an exit/switch whose return context was lost to
+ # ring overflow renders as "Returns to *** OVERFLOW ***" in the detail text, WITHOUT
+ # a companion "*** Overflow ***" event row. A dual-role dogfood measured 96-99% of
+ # ISR exits carrying this form while the explicit rows numbered 0-1 -- counting only
+ # the rows understated real loss by two orders of magnitude.
+ overflow += 1
+ if event == "*** Overflow ***":
+ overflow += 1
+ # any in-flight CALL may have lost its RET (or vice versa) across the
+ # gap -- the exporter would splice it with a later invocation
+ dropped_pairs += sum(len(v) for v in open_calls.values())
+ open_calls.clear()
+ elif event == "ISR Enter":
+ m = runs_re.search(detail)
+ if m:
+ isr_runs.setdefault(row["context"], []).append(
+ float(m.group(1)) * (1e-6 if m.group(2) == "us" else 1e-3))
+ elif event == "Task Ready":
+ m = ready_re.search(detail)
+ if m:
+ task = detail.split(",")[0]
+ ready_lat.setdefault(task, []).append(
+ float(m.group(1)) * (1e-6 if m.group(2) == "us" else 1e-3))
+ elif event == "Stack Info":
+ m = stack_re.match(detail)
+ if m:
+ stack_used[m.group(1)] = int(m.group(2))
+ elif event in ("Allocate Memory", "Free Memory"):
+ m = heap_re.search(detail)
+ if m:
+ heap_last = m
+ elif event.startswith("Function #"):
+ # Pairing bookkeeping runs for EVERY row: a CALL must land in open_calls, or its RET
+ # finds nothing to pop and gets wrongly counted as a spliced orphan -- systematically
+ # losing the first invocation of every instrumented function. There is no live-window
+ # filter applied here or anywhere below: every completed CALL/RET pair becomes a
+ # duration sample regardless of where in the capture it falls (see the "No gap
+ # heuristic" comment above -- the window is the capture).
+ fm = func_re.match(event)
+ if fm:
+ fid = int(fm.group(1)) - TU_SV_EVENT_BASE
+ dm = returns_re.search(detail)
+ # A return is "Returns after <N> us" only when SystemView could annotate the
+ # duration; overwhelmingly it emits a bare "Returns" (measured: 99.5% on
+ # stm32f407disco, 99.2% on raspberry_pi_pico). Keying CALL-vs-RET off the
+ # duration regex therefore misread almost every return as a call, which both
+ # inflated dropped_pairs to nonsense (108923 against 706 paired) and left the
+ # p50/p99 columns computed from the surviving ~0.5% subsample.
+ if "Returns" not in detail: # a CALL
+ open_calls.setdefault(fid, []).append(_ts)
+ else: # a RET
+ stack = open_calls.get(fid)
+ if not stack:
+ dropped_pairs += 1 # RET without its CALL: spliced
+ else:
+ call_ts = stack.pop() # LIFO: pairs with the innermost open CALL
+ if dm: # SystemView's own number, when given
+ func_durs.setdefault(fid, []).append(
+ float(dm.group(1)) * (1e-6 if dm.group(2) == "us" else 1e-3))
+ elif freq and call_ts is not None and _ts is not None:
+ # Same quantity from the same capture: the recorded timestamps
+ # reproduce SystemView's annotated durations to 167.998 vs 168.000
+ # ticks/us on stm32f407disco (264 annotated returns cross-checked).
+ func_durs.setdefault(fid, []).append((_ts - call_ts) / freq)
+ elif event.startswith("Stop Marker "):
+ m = mark_stop_re.match(event)
+ dm = ran_re.search(detail)
+ if m and dm:
+ marker_id = int(m.group(1), 16)
+ marker_durs.setdefault(marker_id, []).append(
+ float(dm.group(1)) * (1e-6 if dm.group(2) == "us" else 1e-3))
+
+ # Any CALL still open at end of stream never got a matching RET (recording simply ended
+ # mid-call) -- count it as a lost pair rather than silently dropping it uncounted.
+ dropped_pairs += sum(len(v) for v in open_calls.values())
+
+ # Close the final scheduling segment: whoever was running at the last transition kept the
+ # CPU until at least the last observed event -- beyond that is unobserved, so accounting
+ # stops there rather than extrapolating.
+ if sched_last_ts is not None and valid_ts and valid_ts[-1] > sched_last_ts:
+ _runner = sched_isrs[-1] if sched_isrs else sched_task
+ if _runner is not None:
+ sched_segments.append((sched_last_ts, valid_ts[-1], _runner))
+
+ # cpu_pct_workload: busy share per context over the workload window. Anchor priority:
+ # explicit --window, then an app's own Start/Stop Marker pair (the target declaring "the
+ # test runs here"), then the span of tud_cdc_read events (the CI throughput workload's
+ # definitional footprint). Busy time is summed from the scheduling segments above and
+ # clipped to the window -- by construction it cannot exceed the window.
+ workload_anchor = None
+ w0 = w1 = None
+ if args.window and valid_ts and freq:
+ try:
+ t0_s, t1_s = (float(x) for x in args.window.split(":", 1))
+ except ValueError:
+ sys.exit(f"error: --window expects T0:T1 in seconds, got {args.window!r}")
+ # W6: the two fallback anchors below both carry their own last>first check
+ # (mark_last > mark_first / wl_last > wl_first) -- this explicit path had none, so
+ # e.g. --window 5:2 silently produced a negative workload_window_s.
+ if t1_s <= t0_s:
+ sys.exit("error: --window end must be after start")
+ w0 = valid_ts[0] + int(t0_s * freq)
+ w1 = valid_ts[0] + int(t1_s * freq)
+ workload_anchor = "cli"
+ elif mark_first is not None and mark_last is not None and mark_last > mark_first:
+ w0, w1, workload_anchor = mark_first, mark_last, "markers"
+ elif wl_first is not None and wl_last is not None and wl_last > wl_first:
+ w0, w1, workload_anchor = wl_first, wl_last, "cdc-read-span"
+ workload_window_s = None
+ contexts_workload = []
+ if w0 is not None and freq:
+ workload_window_s = (w1 - w0) / freq
+ busy = {}
+ for seg0, seg1, ctx in sched_segments:
+ lo, hi = max(seg0, w0), min(seg1, w1)
+ if hi > lo:
+ busy[ctx] = busy.get(ctx, 0) + (hi - lo)
+ span = w1 - w0
+ contexts_workload = [
+ {"name": ctx, "busy_ms": round(t / freq * 1e3, 3),
+ "cpu_pct_workload": round(t / span * 100, 2)}
+ for ctx, t in sorted(busy.items(), key=lambda kv: -kv[1])
+ ]
+
+ # W5: a corrupt clock (clock_corrupt, set above) taints these the same way it taints
+ # live_window_s -- they are computed from the very same valid_ts/freq. Withhold outright
+ # rather than publish numbers derived from timestamps already known to be wrong (repro: a
+ # wrapped-timestamp capture showed cpu_pct_workload 100.0 right alongside the "no time base"
+ # warning that should have been the tell).
+ if clock_corrupt:
+ workload_window_s = None
+ workload_anchor = None
+ contexts_workload = []
+
+ # cpu_pct is SystemView's own CPU Load column, reported as recorded. It used to be
+ # recomputed against the window and clamped to 100 -- a derived number wearing the same
+ # name as the measured one. If no Init record fixed the timestamp frequency, the decode
+ # itself is untrustworthy, so withhold rather than publish a figure derived from a guess.
+ if freq is None:
+ for c in contexts:
+ c["cpu_pct"] = None
+
+ isr_rows = duration_table(isr_runs)
+ ready_rows = duration_table(ready_lat)
+ func_rows = duration_table(func_durs, lambda fid: TU_SV_FUNC_NAMES.get(fid, f"id{fid}"))
+ marker_rows = duration_table(marker_durs, lambda mid: f"marker{mid}")
+ stack_rows = [{"name": task, "bytes_used": used} for task, used in stack_used.items()]
+ if heap_last:
+ net_bytes, allocs, frees = (int(x) for x in heap_last.groups())
+ heap = {"allocs": allocs, "frees": frees, "net_bytes": net_bytes}
+ else:
+ heap = None
+
+ if args.json:
+ print(json.dumps({
+ "contexts": contexts,
+ "isr": isr_rows,
+ "ready_run": ready_rows,
+ "functions": func_rows,
+ "markers": marker_rows,
+ "stack": stack_rows,
+ "heap": heap,
+ "overflow": overflow,
+ "dropped_pairs": dropped_pairs,
+ "workload_window_s": round(workload_window_s, 2) if workload_window_s is not None else None,
+ "workload_anchor": workload_anchor,
+ "contexts_workload": contexts_workload,
+ "live_window_s": round(live_window_s, 2) if live_window_s is not None else None,
+ "warnings": warnings,
+ }, indent=1))
+ return
+
+ print(f"\nlive_window_s: {live_window_s:.2f}" if live_window_s is not None
+ else "\nlive_window_s: n/a (no time base)")
+ if workload_window_s is not None:
+ print(f"workload window ({workload_anchor}): {workload_window_s:.2f} s")
+ for c in contexts_workload:
+ print(f" {c['name']:<14} {c['cpu_pct_workload']:>6.2f} % busy {c['busy_ms']:.3f} ms")
+ print(f"overflow events: {overflow}"
+ + (" — DATA LOST: raise SYSVIEW_BUFFER_SIZE or lighten tracing" if overflow else ""))
+ print(f"dropped_pairs: {dropped_pairs}")
+ for w in warnings:
+ print(f"warning: {w}")
+ if isr_rows:
+ print_duration_table("ISR duration", isr_rows)
+ if ready_rows:
+ print_duration_table("ready->run", ready_rows)
+
+ if stack_rows:
+ print(f"\n{'stack high-water':<20} {'bytes_used':>10}")
+ for r in stack_rows:
+ print(f"{r['name']:<20} {r['bytes_used']:>10}")
+
+ if heap:
+ print(f"\nheap: allocs={heap['allocs']} frees={heap['frees']} net_bytes={heap['net_bytes']}")
+ else:
+ print("\nheap: no events (static allocation build)")
+
+ if func_rows:
+ print_duration_table("function", func_rows, width=20)
+ if marker_rows:
+ print_duration_table("marker", marker_rows, width=20)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/codespell/ignore-words.txt b/tools/codespell/ignore-words.txt
index 6b301d27b..e8abea0b0 100644
--- a/tools/codespell/ignore-words.txt
+++ b/tools/codespell/ignore-words.txt
@@ -3,6 +3,7 @@ busses
dout
endianess
fro
+hart
hsi
inout
linke