diff options
| author | hathach <[email protected]> | 2026-09-04 06:06:25 +0700 |
|---|---|---|
| committer | hathach <[email protected]> | 2026-09-04 06:06:25 +0700 |
| commit | 633e074a0b9effbfbb84c93695e52fb75009564c (patch) | |
| tree | 10b47ad5246540921ae40fa0db0a71b2bbcad0ee | |
| parent | f9c0c1239250399bc41347b9160bd95721f86dca (diff) | |
sysview: fix cycle-3 review findings
- kill_traffic(): stop the recording at --duration-ms exactly, then reap
the workload, instead of waiting on it (up to 60s) before -stop
- sysview_report.py: key open_calls by (func id, context) so interleaved
callers of the same function don't cross-pair CALL/RET
- build.yml: intersect the sysview dep-fetch board list with the PR's own
HIL selection (tinyusb.json vs hfp.json), and skip entirely when HIL is
not running for this PR
- nrf54h20dk: shrink SYSVIEW_BUFFER_SIZE_DEFAULT to 4096 -- .data/.bss sit
in the 32 KiB primary RAM, so the family's 65536 default fails to link
- tusb_sysview.c: copy pcTaskName into a local buffer at snapshot time so
a task deleted mid-lap can't leave SendTaskInfo() dereferencing a freed TCB
- sysview_ci.py: drop a comparison against a baseline whose capture config
(example/workload/duration_s) changed, and fail cdc_burst when the link
stays enumerated but echoes nothing back
- SKILL.md: document the WCH post-mortem dump path (OpenOCD, no J-Link)
| -rw-r--r-- | .claude/skills/sysview/SKILL.md | 62 | ||||
| -rw-r--r-- | .claude/skills/sysview/boards.md | 7 | ||||
| -rw-r--r-- | .claude/skills/sysview/scripts/sysview_record.py | 34 | ||||
| -rw-r--r-- | .claude/skills/sysview/scripts/sysview_report.py | 12 | ||||
| -rw-r--r-- | .github/workflows/build.yml | 34 | ||||
| -rw-r--r-- | hw/bsp/nrf/boards/nrf54h20dk/board.cmake | 4 | ||||
| -rw-r--r-- | src/common/tusb_sysview.c | 15 | ||||
| -rw-r--r-- | test/hil/sysview_ci.py | 33 |
8 files changed, 163 insertions, 38 deletions
diff --git a/.claude/skills/sysview/SKILL.md b/.claude/skills/sysview/SKILL.md index 5add70844..a3581b668 100644 --- a/.claude/skills/sysview/SKILL.md +++ b/.claude/skills/sysview/SKILL.md @@ -46,7 +46,9 @@ timing, FreeRTOS stack high-water, heap totals, app markers. 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. + exclusive there. `sysview_dump.py` scripts that dump over J-Link only; WCH has + no `JLINK_DEVICE`, so its dump is hand-driven over OpenOCD (post-mortem + section). - **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 @@ -101,8 +103,9 @@ python3 test/hil/helper/hil_lock.py release <board> `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`. + iteration and never delivers the traffic you meant to record (the recorder + `-stop`s at `--duration-ms` and kills it there, so it costs a wasted capture, + not a hung run) — 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. @@ -176,6 +179,59 @@ bulk rates, never minutes. `--resume` lets the core carry on; by default it stay halted (you're mid-autopsy). When done, step 5 of the live recipe applies unchanged: restore pristine firmware, then release the lock. +### WCH: the same dump by hand (no J-Link) + +`sysview_dump.py` is **J-Link-only** (`JLinkExe`, SWD) and every WCH row in +`boards.md` has `JLINK_DEVICE` `—`, so on `ch32v20x`/`ch32v30x` the dump is +hand-driven over OpenOCD: one attach reads the same channel-1 ring descriptor +and dumps the same bytes, and the WrOff split below is the script's +linearization verbatim. `ch32v10x` and `ch583` reset on every WCH-Link attach, +which wipes the ring — no dump route there either (`boards.md`). Build with the +same `cmake` lines as above (there is no `-jlink` flash target here); `<uid>` is +the board's `flasher.uid` in `test/hil/tinyusb.json`. + +```bash +ELF=build-pm/cdc_msc_freertos.elf +OUT=/tmp/sysview-pm; mkdir -p $OUT +OOCD=(openocd -c "tcl_port disabled" -c "gdb_port disabled" -c "telnet_port disabled" + -c "adapter serial <uid>" -c "adapter usb vid_pid 0x1a86 0x8010" + -f target/wch-riscv.cfg) + +# flash — no `verify`: read-back over SDI returns a repeated word, so it always mismatches +"${OOCD[@]}" -c "program $ELF reset exit" +# ... reproduce the hang ... + +CB=$(python3 -c "import sys; sys.path.insert(0,'.claude/skills/sysview/scripts'); \ + from sysview_record import rtt_cb_from_elf as f; print(f('$ELF'))") # _SEGGER_RTT +DESC=$(printf '0x%x' $((CB + 0x30))) # aUp[1] "SysView": sName,pBuffer,SizeOfBuffer,WrOff,RdOff,Flags + +# one attach, halt only — NEVER reset: the halt IS the autopsy point, and under +# SDI a reset target does not come back +"${OOCD[@]}" -c "init; halt; dump_image $OUT/header.bin $CB 0x48; \ + set d [read_memory $DESC 32 6]; \ + dump_image $OUT/ring.bin [lindex \$d 1] [lindex \$d 2]; shutdown" + +python3 - "$OUT" <<'EOF' +import struct, sys, pathlib +out = pathlib.Path(sys.argv[1]) +hdr = (out / "header.bin").read_bytes() +assert hdr[:10] == b"SEGGER RTT", "no control block there — wrong ELF, or RAM not initialized" +_, pbuf, size, wroff, rdoff, _fl = struct.unpack_from("<6I", hdr, 0x30) # aUp[1] = "SysView" +ring = (out / "ring.bin").read_bytes() +assert len(ring) == size, f"read {len(ring)} ring bytes, expected {size}" +(out / "capture.SVDat").write_bytes(ring[wroff:] + ring[:wroff]) # oldest byte first +print(f"pBuffer=0x{pbuf:x} SizeOfBuffer={size} WrOff={wroff} RdOff={rdoff}") +EOF +python3 .claude/skills/sysview/scripts/sysview_record.py \ + --from-raw $OUT/capture.SVDat --out $OUT-decoded +``` + +The core is left halted; recover the board by reflashing pristine firmware — +`reset run` under SDI never comes back. **Not bench-run yet**: the OpenOCD/Tcl +and the split are syntax-checked against openocd 0.12.0+dev and against +`sysview_dump.py`'s linearization, but no WCH post-mortem has been captured on +the rig. + ## Build options `-DSYSVIEW=` accepts `1..4` or `ON` (= 4); anything else is a configure error. diff --git a/.claude/skills/sysview/boards.md b/.claude/skills/sysview/boards.md index d17ae05aa..690e880ca 100644 --- a/.claude/skills/sysview/boards.md +++ b/.claude/skills/sysview/boards.md @@ -91,6 +91,13 @@ window is the attach_only mid-stream join (2.4–5.6 s across runs). `SYSVIEW=4`. `boards/stm32f401blackpill/board.cmake` sets `SYSVIEW_BUFFER_SIZE_DEFAULT 4096`; the rest of the family (>=128 KiB) keeps 65536. Unmeasured — expect the small-buffer event loss the other 4096 rows show. +- **nrf54h20dk overrides its family default down to 4096** — its linker script puts + `.data`/`.bss` in the 32 KiB primary `RAM` (0x22000000), so nrf's family-wide 65536 + fails to link at `SYSVIEW=4` ("`.bss` is too large to fit in RAM memory segment"). + `boards/nrf54h20dk/board.cmake` sets `SYSVIEW_BUFFER_SIZE_DEFAULT 4096`; the rest of the + family keeps 65536. Unmeasured — expect the small-buffer event loss the other 4096 rows + show. Routing static `.bss` to RAM00 (512 KiB, already a TODO in that board.cmake) would + free the buffer to grow. - **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 diff --git a/.claude/skills/sysview/scripts/sysview_record.py b/.claude/skills/sysview/scripts/sysview_record.py index 2653e60d3..425a30990 100644 --- a/.claude/skills/sysview/scripts/sysview_record.py +++ b/.claude/skills/sysview/scripts/sysview_record.py @@ -139,6 +139,19 @@ def dismiss_dialogs(display, rounds=6): time.sleep(1) +def kill_traffic(proc): + """Kill a --traffic-cmd and reap it. The command runs under `sh -c`, which can spawn + children of its own (a pipeline), so the whole process group goes -- start_new_session + at Popen is what makes that group ours to kill.""" + if not proc or proc.poll() is not None: + return + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + + def free_display(display): run(["pkill", "-9", "-f", f"Xvfb {display} "]) time.sleep(1) @@ -334,19 +347,16 @@ def record(args, serial, rtt): 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. + # start_new_session=True: its own process group, so kill_traffic() can take the + # whole workload down, 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 - + # The window is --duration-ms, nothing else: stop the moment it expires, THEN deal + # with the workload. Waiting on the process first (as this did, up to 60 s) let a + # --traffic-cmd that outlives the interval stretch the recording by that much. sv_cmd("-stop") + kill_traffic(traffic) 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. @@ -368,11 +378,7 @@ def record(args, serial, rtt): 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 + kill_traffic(traffic) if sv and sv.poll() is None: sv.kill() xvfb.kill() diff --git a/.claude/skills/sysview/scripts/sysview_report.py b/.claude/skills/sysview/scripts/sysview_report.py index 55213b39d..c4090ccc6 100644 --- a/.claude/skills/sysview/scripts/sysview_report.py +++ b/.claude/skills/sysview/scripts/sysview_report.py @@ -175,9 +175,12 @@ def main(): 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 + open_calls = {} # (func id, context) -> [call_ts, ...] stack, one 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) + # a preempted task -- must pair LIFO, innermost CALL to innermost RET. + # Keyed by context too: two contexts calling the same function interleave + # (A-CALL, B-CALL, A-RET, B-RET), and a single per-id stack would pair + # A's return with B's call, quietly corrupting the duration percentiles) 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 @@ -343,10 +346,11 @@ def main(): # 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. + key = (fid, row.get("context", "")) if "Returns" not in detail: # a CALL - open_calls.setdefault(fid, []).append(_ts) + open_calls.setdefault(key, []).append(_ts) else: # a RET - stack = open_calls.get(fid) + stack = open_calls.get(key) if not stack: dropped_pairs += 1 # RET without its CALL: spliced else: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fdfa1fa38..f9d13aa3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -631,23 +631,43 @@ jobs: # sysview_ci.py capture builds device/cdc_msc for the sysview-flagged boards on # this runner. "Clean workspace" above wipes the checkout every run, so this always # re-clones (get_deps.py's own already-at-pinned-commit skip never gets a chance to - # apply here); scoping to just the sysview-flagged boards keeps it to a handful of - # shallow (depth=1) clones rather than the full job matrix's dependency set. + # apply here); scoping to just the boards the capture step below actually selects + # keeps it to a handful of shallow (depth=1) clones rather than the full job + # matrix's dependency set. # N8: board list derived from $HIL_JSON itself -- the same "sysview" key sysview_ci.py's # own select_boards() checks -- instead of a hardcoded pair that silently drifts out of # sync with test/hil/tinyusb.json (a third sysview board added the documented way would # otherwise just build failed here, inside a continue-on-error step, with nothing to say - # why). + # why). That roster list is then intersected with the PR's own board selection, so a PR + # whose selection holds no sysview board clones nothing. if: ${{ !cancelled() && github.run_attempt == '1' }} continue-on-error: true + env: + SEL_ARGS_TINYUSB: ${{ needs.set-matrix.outputs.hil_args_tinyusb }} + SEL_RUN_TINYUSB: ${{ needs.set-matrix.outputs.hil_run_tinyusb }} + SEL_ARGS_HFP: ${{ needs.set-matrix.outputs.hil_args_hfp }} + SEL_RUN_HFP: ${{ needs.set-matrix.outputs.hil_run_hfp }} run: | - BOARDS=$(python3 -c " - import json + case "$HIL_JSON" in + *tinyusb.json) SEL_ARGS="$SEL_ARGS_TINYUSB"; SEL_RUN="$SEL_RUN_TINYUSB" ;; + *hfp.json) SEL_ARGS="$SEL_ARGS_HFP"; SEL_RUN="$SEL_RUN_HFP" ;; + esac + if [ "$SEL_RUN" = "false" ]; then + echo "HIL skipped by PR selection, no SystemView deps to fetch" + exit 0 + fi + BOARDS=$(SEL_ARGS="$SEL_ARGS" python3 -c " + import json, os + # -b names only, mirroring the capture step's own selection parse: a -bt board is + # never captured, and no -b at all (full matrix) still means every sysview board + argv = os.environ['SEL_ARGS'].split() + sel = {b for a, b in zip(argv, argv[1:]) if a == '-b'} cfg = json.load(open('$HIL_JSON')) - print(' '.join(b['name'] for b in cfg['boards'] if 'sysview' in b)) + print(' '.join(b['name'] for b in cfg['boards'] + if 'sysview' in b and (not sel or b['name'] in sel))) ") if [ -z "$BOARDS" ]; then - echo "no sysview-flagged boards in $HIL_JSON, nothing to fetch deps for" + echo "no selected sysview-flagged board in $HIL_JSON, nothing to fetch deps for" exit 0 fi ARGS="" diff --git a/hw/bsp/nrf/boards/nrf54h20dk/board.cmake b/hw/bsp/nrf/boards/nrf54h20dk/board.cmake index 8095b59fe..3a2c4e9d9 100644 --- a/hw/bsp/nrf/boards/nrf54h20dk/board.cmake +++ b/hw/bsp/nrf/boards/nrf54h20dk/board.cmake @@ -1,3 +1,7 @@ +# .data/.bss live in the 32 KiB primary RAM: the family's 65536 SystemView default +# cannot link here. +set(SYSVIEW_BUFFER_SIZE_DEFAULT 4096) + set(MCU_VARIANT nrf54h20) function(update_board TARGET) diff --git a/src/common/tusb_sysview.c b/src/common/tusb_sysview.c index 40ca81573..a507a73d5 100644 --- a/src/common/tusb_sysview.c +++ b/src/common/tusb_sysview.c @@ -305,6 +305,9 @@ void tusb_sysview_stack_report(void) { * Single writer (usbd's periodic report, or usbh's in a host-only build -- * never both, never reentered), so no locking is needed. */ static TaskStatus_t status[SYSVIEW_FREERTOS_MAX_NOF_TASKS]; + /* pcTaskName points into the live TCB, which a task deleted later in the lap frees; copy the + * name at snapshot time so SendTaskInfo() below never dereferences a dead TCB. */ + static char names[SYSVIEW_FREERTOS_MAX_NOF_TASKS][configMAX_TASK_NAME_LEN]; /* Report one task per call instead of looping over all of them: even with * the array off the stack, up to SYSVIEW_FREERTOS_MAX_NOF_TASKS back-to-back * SEGGER_SYSVIEW_SendTaskInfo() calls (each locking + writing the RTT ring buffer) in a @@ -321,12 +324,16 @@ void tusb_sysview_stack_report(void) { // real time, even though this function only ever publishes ONE task per call. Cache its // result and refresh only when the rotation wraps back to index 0 (once per full lap over the // task list, not every call): same set of Stack Info events published, over the same rotation, - // at roughly 1/SYSVIEW_FREERTOS_MAX_NOF_TASKS the scheduler-suspended time. Entries point into - // live TCBs (pcTaskName), so a lap-old snapshot assumes no task is deleted meanwhile -- true of - // every instrumented example, and the same lifetime SEGGER's own FreeRTOS task list relies on. + // at roughly 1/SYSVIEW_FREERTOS_MAX_NOF_TASKS the scheduler-suspended time. Everything published + // from a cached entry is a value copy (the name into names[] here, the rest plain integers), so a + // task deleted mid-lap only makes its own entry stale, never a dangling dereference. static UBaseType_t n = 0; if (next_idx == 0) { n = uxTaskGetSystemState(status, TU_ARRAY_SIZE(status), NULL); + for (UBaseType_t t = 0; t < n; t++) { + strncpy(names[t], status[t].pcTaskName, configMAX_TASK_NAME_LEN - 1); + names[t][configMAX_TASK_NAME_LEN - 1] = '\0'; + } } if (n == 0) { return; } UBaseType_t const i = next_idx; @@ -334,7 +341,7 @@ void tusb_sysview_stack_report(void) { SEGGER_SYSVIEW_TASKINFO info = {0}; info.TaskID = (U32)(uintptr_t) status[i].xHandle; - info.sName = status[i].pcTaskName; + info.sName = names[i]; info.Prio = status[i].uxCurrentPriority; info.StackBase = (U32)(uintptr_t) status[i].pxStackBase; uint32_t const free_bytes = status[i].usStackHighWaterMark * sizeof(StackType_t); diff --git a/test/hil/sysview_ci.py b/test/hil/sysview_ci.py index fb534a2e9..c34adcb1f 100644 --- a/test/hil/sysview_ci.py +++ b/test/hil/sysview_ci.py @@ -164,9 +164,13 @@ def gated_pair(base_j, pr_j, b, p, fmt, delta_fn): delta = delta_fn(b, p) return bcell, pcell, delta -def board_section(name, base_j, pr_j): +def board_section(name, base_j, pr_j, base_dropped=None): live_window_s = (pr_j.get("capture") or {}).get("live_window_s") heading = f"### {name}" + (f" — live {live_window_s:.1f} s" if live_window_s is not None else "") + if base_dropped: + # The report header states the PR-side config only, so a dropped baseline has to say + # here why every row reads "new" -- otherwise it looks like base never captured. + heading += f" — base dropped, capture config changed ({', '.join(base_dropped)})" lines = [heading, ""] if pr_j.get("error"): return "\n".join(lines + [f"capture failed: {pr_j['error']}", ""]) @@ -279,6 +283,17 @@ def board_section(name, base_j, pr_j): f' y-axis "µs" 0 --> {ymax:.1f}'] + bars + ["```"] return "\n".join(lines) + "\n" +CAPTURE_CFG_KEYS = ("example", "workload", "duration_s") + +def capture_cfg_changes(base_j, pr_j): + """Config differences that make a baseline incomparable: a PR that changes a board's + sysview example, workload or duration_s measures different work, so base-vs-PR deltas + would be noise dressed up as a regression.""" + if not base_j: + return [] + return [f"{k} `{base_j.get(k)}`→`{pr_j.get(k)}`" + for k in CAPTURE_CFG_KEYS if base_j.get(k) != pr_j.get(k)] + def report(base_dir, pr_dir): pr = load_set(pr_dir) if not pr: @@ -296,7 +311,8 @@ def report(base_dir, pr_dir): f"base `{base_commit}` → PR `{any_pr.get('commit', '?')}`*") parts = [HEADER, "", head, ""] for b in boards: - parts.append(board_section(b, base.get(b), pr[b])) + changed = capture_cfg_changes(base.get(b), pr[b]) + parts.append(board_section(b, None if changed else base.get(b), pr[b], changed)) parts.append(LEGEND) return "\n".join(parts) + "\n" @@ -340,8 +356,9 @@ def board_result(board, commit, metrics=None, capture_info=None, error=None): "capture": capture_info or {}, "metrics": metrics, "error": error} def _workload_cdc_burst(node, duration_s): - """Returns True if traffic ran for the full window, False if the serial - link died early (device dropped off the bus) -- never raises.""" + """Returns True if traffic ran for the full window, False if the serial link died early + (device dropped off the bus) or the device stayed enumerated but echoed nothing back -- + never raises.""" import serial, time # write_timeout: without it, a device that stops draining its OUT endpoint blocks s.write() # forever, wedging PHASE 2 while the board flock is held -- the same failure mode hil_test.py @@ -351,17 +368,21 @@ def _workload_cdc_burst(node, duration_s): # wedge faster, and importing hil_test would pull in its pymtp dependency for one constant. s = serial.Serial(node, 115200, timeout=0.02, write_timeout=2) end = time.monotonic() + duration_s + echoed = 0 while time.monotonic() < end: t = time.monotonic() while time.monotonic() - t < 0.30 and time.monotonic() < end: try: - s.write(b"x" * 64); s.read(64) + s.write(b"x" * 64); echoed += len(s.read(64)) except Exception: s.close() return False time.sleep(min(1.0, max(0, end - time.monotonic()))) s.close() - return True + # A device that keeps its CDC endpoints enumerated but never echoes a byte produces a + # capture of an idle bus; only writes throwing (the link-died path above) used to fail + # this workload, so such a run was published as a clean loaded capture. + return echoed > 0 WORKLOADS = {"cdc_burst": _workload_cdc_burst, "idle": lambda node, duration_s: __import__("time").sleep(duration_s) or True} |
