From a9853a36b6b243ed42266873d23880d75acd792f Mon Sep 17 00:00:00 2001 From: Erik Rainey Date: Tue, 11 Aug 2026 20:53:00 -0500 Subject: [PATCH] jalo: add SWO trace console viewer (issue-54) AI-authored work (opencode/big-pickle): - JLinkController SWO wrappers over pylink-square (enable/start/stop/flush/ read/read_stimulus/num_bytes/enabled), guarded by connected state. - SWO Console tab in the TUI: CPU/SWO speed, port mask/port, bytes, continuous switch, Enable/Start/Read/Stop/Clear buttons, RichLog output. - Shared decode_trace_chunk line-buffering helper (RTT refactored to use it). - Auto-start after connect, stop on disconnect/unmount, restart after reset. - CLI args --swo-cpu-speed/--swo-speed/--swo-port-mask/--swo-port/ --swo-auto-start with parse_args validation. - 54 unit tests in tools/test_jalo_swo.py (pylink mocked), all passing. - Live-probe smoke test of the SWO wrappers against the J-Link remote server. Human review: pending. closes issue-54 (tool-side SWO viewer; target-side ITM init tracked in #41) --- PLAN.md | 176 +++++++------------ tools/jalo.py | 390 ++++++++++++++++++++++++++++++++++++++++- tools/test_jalo_swo.py | 386 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 830 insertions(+), 122 deletions(-) create mode 100644 tools/test_jalo_swo.py diff --git a/PLAN.md b/PLAN.md index cd8fbe5..29b49c6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,131 +1,77 @@ -# PLAN: Enable Serial Wire Output (SWO) for debug tracing +# PLAN: Add SWO (Serial Wire Output) trace viewer to tools/jalo.py -Issue: #41 — branch `issue-41` (tracks `github/develop`). +Issue: #54 — branch `issue-54` (tracks `github/develop`). **STATUS: DONE, +awaiting human review before commit.** ## Summary -On the H7 the Cortex-M7 TPIU (`0xE0040000`) does NOT drive the SWO pin. The trace -path is ITM → SWTF (`0x5C004000`) → SWO (`0x5C003000`) → PB3 (`TRACESWO`, AF0), -and the ST SWO block is driven from TRACECK. This change programs those ST blocks -and un-gates the debug clocks: +`tools/jalo.py` is the Textual TUI SVD/J-Link debugger. It has an RTT console +but no SWO (Serial Wire Output / ITM trace) viewer. This issue adds a SWO +Console tab mirroring the RTT Console UX, backed by the pylink-square SWO API, +plus CLI preconfiguration and host-run unit tests. -1. `DBGMCU_CR`: `TRACECLKEN` (bit 20), `D1DBGCKEN` (bit 21), `D3DBGCKEN` (bit 22). -2. `SWO` (`0x5C003000`): unlock via LAR, Async-NRZ mode (`SWO_SPPR`=2), baud - prescaler (`SWO_CODR`, zero-based: `prescaler = trace_clock / baud - 1`). -3. `SWTF` (`0x5C004000`): unlock via LAR, `ENSO=1` to forward the ITM trace bus. -4. TRACECK follows `RCC_CFGR[SW]`; with SYSCLK = PLL1P, TRACECK = PLL1R. Board - config gives VCO 800 MHz, `pll_r=8-1` → TRACECK = **100 MHz** → CODR = 43 for - the 2.24 MBaud `basic` config. - -The F4 has no ST SWO block (standard TPIU drives the pin), so its -`enable_serial_wire_output` is a documented no-op and `ClockTree.trace = sysclk`. - -## Verified facts - -- SWO/SWTF register layout per RM0433 Rev 8 §35.4; LAR unlock key `0xC5ACCE55` - (`SWO_LAR`@0x5C003FB0, `SWTF_LAR`@0x5C004FB0). Reset values: SWTF_CTRL 0x300 - (bit 0 `ENSO` off), SWO_SWTF_PRIORITY 0x8. -- `DBGMCU_CR` field names confirmed from SVD: `trace_clock_enable` (bit 20), - `domain1_debug_clock_enable` (bit 21), `domain3_debug_clock_enable` (bit 22). - The D3 domain holds the SWO/SWTF blocks, so D3DBGCKEN must be set. -- PB3 = `JTDO/TRACESWO`, AF0 (datasheet stm32h753zi.txt line 6873), unused by any - board pin. -- The SVD has no RCC trace-clock mux field on the H7 — TRACECK is derived from - SYSCLK selection; the STM32H753.svd has no SWO/SWTF description, so the headers - were hand-written in the peripheralyzer style (per-register struct with - `Fields bits` + `uint32_t whole`, `static_assert` on size/offsets, - `sizeof(Swo)==sizeof(SwoTraceFunnel)==0xFB4`). -- **Link ordering**: module archives are scanned once, before the jarnax archive. - Vendor symbols referenced only from `configure.cpp` do not link (same problem - documented at `clocks.cpp:38` for `early_power`). The SWO call therefore lives - in the vendor `clocks()`; `configure.cpp` keeps only `cortex::initialize::swo()` - (TPIU/ITM, which is in the repeated cortex archive). +SWO target-side init (pin mux / DBGMCU / trace registers) is tracked +separately in #41 and is out of scope here. This issue is tool-side only: +capture and display SWO trace through the J-Link. ## Changes -1. **`modules/stm32/include/stm32/h7xx/Swo.hpp`** (new) — `Swo` peripheral at - 0x5C003000: `current_output_divisor` (`CODR`@0x010, 13-bit `prescaler`), - `selected_pin_protocol` (`SWO_SPPR`@0x0F0), `lock_access` (`SWO_LAR`@0xFB0). -2. **`modules/stm32/include/stm32/h7xx/SwoTraceFunnel.hpp`** (new) — `SwoTraceFunnel` - at 0x5C004000: `control` (`SWTF_CTRL`@0x000, bit 0 `enable_swo`), - `priority` (`SWTF_PRIORITY`@0x004), `lock_access` (`SWTF_LAR`@0xFB0). -3. **`stm32h7xx.hpp`** — include both new headers; add externs - `serial_wire_output` / `swo_trace_funnel` beside `debug`. -4. **`source/stm32h7xx/peripherals.cpp`** — UNITTEST RAM globals for the two new - peripherals (same pattern as `debug`). -5. **`modules/stm32/linkerscripts/stm32h753zi-sections.ld`** — PROVIDE - `_stm32_swo = 0x5C003000`, `_stm32_swo_trace_funnel = 0x5C004000` (mangled - extern names also PROVIDEd). -6. **`modules/stm32/include/stm32/Initialize.hpp`** — declare - `enable_serial_wire_output(core::units::Hertz trace_clock, std::uint32_t baud)`. -7. **`source/stm32h7xx/debug.cpp`** (new) — `enable_trace_port_clock()` (sets bits - 20/21/22, read-modify-write, idempotent) and `enable_serial_wire_output()`: - unlock LARs, `transmit_mode = AsyncNRZ`, masked CODR write (whole register to - dodge GCC `-Wconversion` on the 13-bit field), `SWTF_CTRL.ENSO = 1`. -8. **`source/stm32f4xx/debug.cpp`** (new) — no-op implementations of both - functions (added to the F4 CMakeLists sources). -9. **`source/stm32h7xx/clocks.cpp`** — `clock_tree.trace = pll_vco / (pll_r + 1)`; - at the end, `if constexpr (cortex::swo::enable)` - `enable_serial_wire_output(trace, baud)` (cast to `std::uint32_t`). Called here, - not from configure.cpp, for the link-ordering reason above. -10. **`source/stm32f4xx/clocks.cpp`** + **`stm32f4xx.hpp`** — `ClockTree.trace = - sysclk`; same guarded no-op call. -11. **`modules/jarnax/source/configure.cpp`** — SWO vendor call removed; comment - explains why it lives in the vendor `clocks()`. `cortex::initialize::swo()` kept. -12. **`modules/cortex/source/initialize.cpp`** — fix stale black-magic SWO URL - comment. -13. **`boards/nucleo_h753zi/**`** — PB3 `swo_pin_` configured AF0 in `Initialize()` - (matches existing AF pin pattern). -14. **`applications/nucleo-demo/source/Demo.cpp`** — TEMP trace-capture - instrumentation removed; SWO marker emit + `cortex/swo.hpp` include retained. +1. **`JLinkController` SWO wrappers** (`tools/jalo.py`) — thin, guarded methods + over the pylink-square SWO API: + - `swo_enabled()`, `swo_enable(cpu_speed, swo_speed, port_mask)`, + - `swo_start(swo_speed)`, `swo_stop()`, `swo_flush()`, + - `swo_num_bytes()`, `swo_read(offset, num_bytes, remove=False)`, + - `swo_read_stimulus(port, num_bytes)`. + All return `False`/`None` (with `last_error`) instead of raising when not + connected or on JLinkException. + +2. **SWO Console tab** in `SVDDebuggerApp` (mirrors RTT tab): + - Controls: CPU speed (Hz, default 480000000), SWO speed (Hz, default + 2000000), port mask (default 0x1), stimulus port (default 0), bytes to + read, continuous-capture switch. + - Buttons: Enable SWO, Start, Read, Stop, Clear. + - `RichLog` output; stimulus-port data decoded as text with the same + line-buffering approach as the RTT console. + - SWO is stopped cleanly on disconnect and on unmount. -## Tests +3. **CLI args** (`build_argument_parser`): + - `--swo-cpu-speed`, `--swo-speed`, `--swo-port-mask`, `--swo-port`, + - `--swo-auto-start` (enable SWO + begin capture after connecting). + - Validation added in `parse_args`. -- **`modules/stm32/tests/gtest-stm32-debug.cpp`** (new, registered in - `modules/stm32/tests/CMakeLists.txt` as `host_unit_test(NAME stm32-debug ... - BOARDS nucleo_h753zi CONFIGURATIONS basic)`): 10 tests over the UNITTEST - globals — Empty setup/teardown check; D3DBGCKEN set (bit 22); idempotence; - unrelated CR bits preserved by RMW; LAR unlock values; AsyncNRZ protocol; - CODR = 24 for 100 MHz/4 MBaud and 43 for 100 MHz/2.24 MBaud; ENSO enabled. - All pass on host (llvm + clang). -- The register writes are hardware init; host coverage is register-level via the - UNITTEST globals. Future emulator-based testing will exercise the end-to-end - trace path (ITM → SWTF → SWO → pin). +4. **Tests** (`tools/test_jalo_swo.py`, run via `.venv/bin/python -m pytest`): + - `JLinkController` SWO methods against a mocked `pylink.JLink` (connected + and disconnected paths, error paths, last_error capture). + - `build_argument_parser`/`parse_args` SWO arg parsing and validation. + - Pure decode/line-buffering helper tests (no hardware required). ## Verification -- Host: `cmake --workflow --preset on-host-native-llvm` and `-clang` — all pass. -- Cross: `cmake --workflow --preset on-target-cortex-m4-gcc-arm-none-eabi` and - `-cortex-m7-gcc-arm-none-eabi` — all link and build clean. -- Live hardware (DONE): flashed `firmware-nucleo-demo-basic-nucleo_h753zi.elf`, - reset+ran to `cortex::system::main()`. Single-session breakpoint dump at main - entry (avoiding the J-Link's DBGMCU_CR clobber on connect) showed: - - `DBGMCU_CR` = 0x00700007 (TRACECLKEN/D1DBGCKEN/D3DBGCKEN set; low bits are - the J-Link's own DBG_SLEEP/STOP/STANDBY). - - `SWO_CODR` = 0x2b (43) → 100 MHz / 2.24 MBaud. - - `SWO_SPPR` = 2 → AsyncNRZ (UART). - - `SWTF_CTRL` = 0x301 → reset 0x300 + ENSO. - - A J-Link reconnect between sessions rewrites DBGMCU_CR to 0x07 (unclocks the - D3 debug domain, making SWO/SWTF reads fail) — the firmware writes are only - visible in a single session, which is why the in-session breakpoint dump was - needed. -- PB3 waveform capture deferred: the tooling to read SWO bytes from the J-Link - probe does not exist yet (issue #54). +- `.venv/bin/python -m pytest tools/test_jalo_swo.py -v` — **54 passed**. +- `python3 -m py_compile tools/jalo.py tools/test_jalo_swo.py` — OK. +- Live probe smoke test (`JLinkController` against the J-Link remote server at + 127.0.0.1:19020, STM32H753ZI): connect, `swo_enable(480M, 2M, 0x1)`, + `swo_start(2M)`, `swo_num_bytes()`, `swo_read_stimulus`, `swo_read`, + `swo_stop`, disconnect all succeeded against real pylink. No trace bytes were + returned because the target is not yet emitting ITM/SWO (target-side init is + #41) — the empty-payload "no new data" path was exercised. +- No C++ source touched; firmware build presets unaffected. ## Gotchas -- Do NOT enable the H7 DBGMCU/SWO in a shared TU: F4 must not link H7 - `debug.cpp` (references `stm32::h7xx::debug`). The `add_module` CHIPS split - handles this; F4 has its own no-op `debug.cpp`. -- **Archive ordering**: vendor symbols referenced only from `configure.cpp` are - never extracted (single scan before jarnax). Keep vendor-init calls inside the - vendor module (see the `early_power` FIXME at `clocks.cpp:38`). -- `std::size_t` → `std::uint32_t` for the baud needs an explicit cast on host - builds (`-Wconversion -Werror`); identical types on ARM32. -- CODR is a 13-bit field; write the whole register with `& 0x1FFFU` to avoid - `-Wconversion` on the truncated bitfield. -- `clocks(ClockConfiguration const&)` early-returns when SWS already = PLL (warm - boot), so the SWO programming only runs on cold boots; DBGMCU/SWO/SWTF - registers survive system reset, so this is acceptable. -- Deferred to issue #55: cold-POR boot hard-fault (CFSR IMPREISERR, HFSR - 0xC0000000) observed in `on_startup()` before `configure()`. +- pylink `swo_enable(cpu_speed, swo_speed, port_mask)` also programs the + target ITM/DWT/TPIU registers (via `JLINKARM_SWO_EnableTarget`), so the tool + only needs to provide CPU/SWO speeds; the GPIO/pin init is target firmware + side (#41). +- `swo_read(offset, num_bytes, remove=False)` does NOT remove data unless + `remove=True`; the console must either pass `remove=True` or call + `swo_flush()` to avoid re-reading the same bytes. +- `swo_read_stimulus(port, num_bytes)` only returns printable data for the + given stimulus port and is the right primitive for a console. +- With a target not emitting ITM trace, `swo_read_stimulus` returns an empty + list (not `None`) — the console treats that as "no new data", so guard on + `data is None` for errors, not on empty payloads. +- Textual app tests need `run_test(size=(200, 50))` (SWO controls overflow the + 80x24 default), `pilot.pause()` after tab switches, and + `active_effect_duration = 0` on re-clicked buttons (textual drops clicks + while the `-active` animation class is set). diff --git a/tools/jalo.py b/tools/jalo.py index 7d6b6d4..3b908d6 100644 --- a/tools/jalo.py +++ b/tools/jalo.py @@ -511,6 +511,103 @@ def reset(self) -> bool: self.last_error = str(exc) return False + def swo_enabled(self) -> bool: + if not self.connected or not self.link: + return False + try: + return bool(self.link.swo_enabled()) + except Exception: + return False + + def swo_enable(self, cpu_speed: int, swo_speed: int, port_mask: int) -> bool: + if not self.connected or not self.link: + self.last_error = "probe not connected" + return False + try: + self.link.swo_enable(cpu_speed, swo_speed, port_mask) + self.last_error = "" + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def swo_start(self, swo_speed: int) -> bool: + if not self.connected or not self.link: + self.last_error = "probe not connected" + return False + try: + self.link.swo_start(swo_speed) + self.last_error = "" + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def swo_stop(self) -> bool: + if not self.connected or not self.link: + self.last_error = "probe not connected" + return False + try: + self.link.swo_stop() + self.last_error = "" + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def swo_flush(self) -> bool: + if not self.connected or not self.link: + self.last_error = "probe not connected" + return False + try: + self.link.swo_flush() + self.last_error = "" + return True + except Exception as exc: + self.last_error = str(exc) + return False + + def swo_num_bytes(self) -> Optional[int]: + if not self.connected or not self.link: + return None + try: + return int(self.link.swo_num_bytes()) + except Exception: + return None + + def swo_read(self, offset: int, num_bytes: int, remove: bool = False) -> Optional[List[int]]: + if not self.connected or not self.link or num_bytes <= 0: + return None + try: + return list(self.link.swo_read(offset, num_bytes, remove=remove)) + except Exception: + return None + + def swo_read_stimulus(self, port: int, num_bytes: int) -> Optional[List[int]]: + if not self.connected or not self.link or num_bytes <= 0: + return None + try: + return list(self.link.swo_read_stimulus(port, num_bytes)) + except Exception: + return None + + +def decode_trace_chunk(payload: bytes, pending: str) -> tuple[str, List[str]]: + """Decode a chunk of trace text into complete lines, buffering a trailing partial line. + + Args: + payload: raw bytes read from the trace channel + pending: previously buffered partial line + + Returns: + ``(new_pending, complete_lines)`` where ``new_pending`` holds any trailing + partial line and ``complete_lines`` are ready for display. + """ + text = payload.decode("utf-8", errors="replace") + parts = (pending + text).replace("\r\n", "\n").split("\n") + new_pending = parts.pop() + return new_pending, parts + class SVDDebuggerApp(App): """Textual TUI Application dashboard wrapper.""" @@ -575,11 +672,14 @@ class SVDDebuggerApp(App): #tab-rtt-view { height: 1fr; } + #tab-swo-view { + height: 1fr; + } #stack-view-root, #memory-view-root { height: 1fr; padding: 1; } - #rtt-view-root { + #rtt-view-root, #swo-view-root { height: 1fr; padding: 1; } @@ -588,7 +688,7 @@ class SVDDebuggerApp(App): margin-bottom: 1; align: left middle; } - #rtt-controls { + #rtt-controls, #swo-controls { height: auto; margin-bottom: 1; align: left middle; @@ -614,6 +714,21 @@ class SVDDebuggerApp(App): #input-rtt-bytes { width: 8; } + #input-swo-cpu { + width: 12; + } + #input-swo-speed { + width: 12; + } + #input-swo-mask { + width: 8; + } + #input-swo-port { + width: 5; + } + #input-swo-bytes { + width: 8; + } #left-pane { width: 30fr; border-right: solid $primary; @@ -680,6 +795,11 @@ def __init__( auto_load_svd: bool = False, auto_connect: bool = False, auto_rtt_capture: bool = False, + auto_swo_capture: bool = False, + swo_cpu_speed: int = 480000000, + swo_speed: int = 2000000, + swo_port_mask: int = 0x1, + swo_port: int = 0, poll_interval_s: float = 0.5, ): super().__init__() @@ -695,6 +815,7 @@ def __init__( self.stack_rows: List[tuple] = [] self.memory_rows: List[tuple] = [] self.rtt_pending_text: dict[int, str] = {} + self.swo_pending_text: dict[int, str] = {} self.initial_svd_path = svd_path self.initial_core_svd_path = core_svd_path self.initial_elf_path = elf_path @@ -703,6 +824,11 @@ def __init__( self.auto_load_svd = auto_load_svd self.auto_connect = auto_connect self.auto_rtt_capture = auto_rtt_capture + self.auto_swo_capture = auto_swo_capture + self.initial_swo_cpu_speed = swo_cpu_speed + self.initial_swo_speed = swo_speed + self.initial_swo_port_mask = swo_port_mask + self.initial_swo_port = swo_port self.poll_interval_s = poll_interval_s # Grid column mapping identifiers @@ -720,6 +846,7 @@ def __init__( self.col_mem_desc_key = None self.poll_timer = None self.rtt_poll_timer = None + self.swo_poll_timer = None def compose(self) -> ComposeResult: yield Header() @@ -798,6 +925,28 @@ def compose(self) -> ComposeResult: yield Button("Clear", id="btn-rtt-clear") yield RichLog(id="rtt-log", max_lines=400, markup=True) + with TabPane("SWO Console", id="tab-swo-view"): + with Vertical(id="swo-view-root"): + with Horizontal(id="swo-controls"): + yield Label("CPU Hz:", classes="label-mgr") + yield Input(value=str(self.initial_swo_cpu_speed), id="input-swo-cpu") + yield Label("SWO Hz:", classes="label-mgr") + yield Input(value=str(self.initial_swo_speed), id="input-swo-speed") + yield Label("Mask:", classes="label-mgr") + yield Input(value=f"0x{self.initial_swo_port_mask:X}", id="input-swo-mask") + yield Label("Port:", classes="label-mgr") + yield Input(value=str(self.initial_swo_port), id="input-swo-port") + yield Label("Bytes:", classes="label-mgr") + yield Input(value="256", id="input-swo-bytes") + yield Label("Continuous:", classes="label-mgr") + yield Switch(value=self.auto_swo_capture, id="switch-swo-continuous") + yield Button("Enable SWO", id="btn-swo-enable", variant="primary") + yield Button("Start", id="btn-swo-start", variant="success") + yield Button("Read", id="btn-swo-read") + yield Button("Stop", id="btn-swo-stop", variant="warning") + yield Button("Clear", id="btn-swo-clear") + yield RichLog(id="swo-log", max_lines=400, markup=True) + yield RichLog(id="log-output", max_lines=100, markup=True) yield Footer() @@ -836,6 +985,9 @@ def on_mount(self) -> None: self.query_one("#switch-rtt-continuous", Switch).value = self.auto_rtt_capture self._sync_rtt_read_controls() + self.query_one("#switch-swo-continuous", Switch).value = self.auto_swo_capture + self._sync_swo_read_controls() + self.populate_peripheral_tree() if self.auto_load_svd and self.initial_svd_path: @@ -857,6 +1009,9 @@ def on_unmount(self) -> None: if self.rtt_poll_timer is not None: self.rtt_poll_timer.stop() self.rtt_poll_timer = None + if self.swo_poll_timer is not None: + self.swo_poll_timer.stop() + self.swo_poll_timer = None self.jlink.disconnect() def on_button_pressed(self, event: Button.Pressed) -> None: @@ -905,7 +1060,33 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "btn-rtt-clear": self.query_one("#rtt-log", RichLog).clear() + elif event.button.id == "btn-swo-enable": + self._enable_swo() + + elif event.button.id == "btn-swo-start": + self._start_swo_capture() + + elif event.button.id == "btn-swo-read": + self._read_swo_console() + + elif event.button.id == "btn-swo-stop": + self._stop_swo_capture() + + elif event.button.id == "btn-swo-clear": + self.query_one("#swo-log", RichLog).clear() + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "switch-swo-continuous": + self.auto_swo_capture = bool(event.value) + self._sync_swo_read_controls() + + if self.auto_swo_capture and self.jlink.connected: + self._start_swo_capture() + elif not self.auto_swo_capture and self.swo_poll_timer is not None: + self.swo_poll_timer.stop() + self.swo_poll_timer = None + return + if event.switch.id != "switch-rtt-continuous": return @@ -925,6 +1106,13 @@ def _sync_rtt_read_controls(self) -> None: switch.value = self.auto_rtt_capture read_button.disabled = self.auto_rtt_capture + def _sync_swo_read_controls(self) -> None: + switch = self.query_one("#switch-swo-continuous", Switch) + read_button = self.query_one("#btn-swo-read", Button) + if switch.value != self.auto_swo_capture: + switch.value = self.auto_swo_capture + read_button.disabled = self.auto_swo_capture + @staticmethod def _parse_int_input(value: str, default: int) -> int: try: @@ -1124,6 +1312,15 @@ def _restart_rtt_after_reset(self) -> None: self._read_rtt_console(quiet= self.auto_rtt_capture) + def _restart_swo_after_reset(self) -> None: + """Restart SWO capture after a reset so the console follows the new session.""" + if not self.jlink.connected or not self.auto_swo_capture: + return + if self.swo_poll_timer is not None: + self.swo_poll_timer.stop() + self.swo_poll_timer = None + self._start_swo_capture() + def _read_rtt_console(self, quiet: bool = False) -> None: logger = self.query_one("#rtt-log", RichLog) if not self.jlink.connected or not self.jlink.link: @@ -1147,17 +1344,134 @@ def _read_rtt_console(self, quiet: bool = False) -> None: logger.write("[dim]RTT: no new data.[/dim]") return - text = payload.decode("utf-8", errors="replace") - pending = self.rtt_pending_text.get(buffer_index, "") + text - normalized = pending.replace("\r\n", "\n") - lines = normalized.split("\n") - self.rtt_pending_text[buffer_index] = lines.pop() if lines else "" + self.rtt_pending_text[buffer_index], lines = decode_trace_chunk( + payload, self.rtt_pending_text.get(buffer_index, "") + ) for line in lines: logger.write(line) except Exception as exc: logger.write(f"[red]RTT read failed: {exc}[/red]") + def _enable_swo(self, quiet: bool = False) -> bool: + logger = self.query_one("#swo-log", RichLog) + if not self.jlink.connected: + if not quiet: + logger.write("[yellow]SWO enable ignored: probe is not connected.[/yellow]") + return False + + cpu_speed = self._parse_int_input(self.query_one("#input-swo-cpu", Input).value, 0) + swo_speed = self._parse_int_input(self.query_one("#input-swo-speed", Input).value, 0) + port_mask = self._parse_int_input(self.query_one("#input-swo-mask", Input).value, 0) + + if cpu_speed <= 0: + logger.write("[red]SWO enable: CPU speed must be a positive Hz value.[/red]") + return False + if swo_speed <= 0: + logger.write("[red]SWO enable: SWO speed must be a positive Hz value.[/red]") + return False + if port_mask <= 0: + logger.write("[red]SWO enable: port mask must be a positive value.[/red]") + return False + + if self.jlink.swo_enable(cpu_speed, swo_speed, port_mask): + if not quiet: + logger.write( + f"[green]SWO enabled: CPU={cpu_speed} Hz, SWO={swo_speed} Hz, " + f"mask=0x{port_mask:X}.[/green]" + ) + self.swo_pending_text.clear() + return True + + detail = self.jlink.last_error or "unknown error" + logger.write(f"[red]SWO enable failed: {detail}[/red]") + return False + + def _start_swo_capture(self) -> None: + logger = self.query_one("#swo-log", RichLog) + if not self.jlink.connected: + logger.write("[yellow]SWO start ignored: probe is not connected.[/yellow]") + return + + if self.swo_poll_timer is not None: + self.swo_poll_timer.stop() + self.swo_poll_timer = None + + swo_speed = self._parse_int_input(self.query_one("#input-swo-speed", Input).value, 0) + if swo_speed <= 0: + logger.write("[red]SWO start: SWO speed must be a positive Hz value.[/red]") + return + + if self.jlink.swo_start(swo_speed): + logger.write(f"[green]SWO collection started at {swo_speed} Hz.[/green]") + elif self._enable_swo(quiet=True): + logger.write("[green]SWO enabled and collection started (target trace was not configured).[/green]") + else: + detail = self.jlink.last_error or "unknown error" + logger.write(f"[red]SWO start failed: {detail}[/red]") + return + + self.swo_pending_text.clear() + + if self.auto_swo_capture: + self.swo_poll_timer = self.set_interval(self.poll_interval_s, self._read_swo_console_silent) + self._read_swo_console(quiet=self.auto_swo_capture) + + def _stop_swo_capture(self) -> None: + logger = self.query_one("#swo-log", RichLog) + if self.swo_poll_timer is not None: + self.swo_poll_timer.stop() + self.swo_poll_timer = None + if not self.jlink.connected: + logger.write("[yellow]SWO stop ignored: probe is not connected.[/yellow]") + return + + if self.jlink.swo_stop(): + logger.write("[green]SWO stopped.[/green]") + else: + detail = self.jlink.last_error or "unknown error" + logger.write(f"[red]SWO stop failed: {detail}[/red]") + self.swo_pending_text.clear() + self.auto_swo_capture = False + self._sync_swo_read_controls() + + def _read_swo_console_silent(self) -> None: + self._read_swo_console(quiet=True) + + def _read_swo_console(self, quiet: bool = False) -> None: + logger = self.query_one("#swo-log", RichLog) + if not self.jlink.connected: + logger.write("[yellow]SWO read ignored: probe is not connected.[/yellow]") + return + + port = self._parse_int_input(self.query_one("#input-swo-port", Input).value, -1) + num_bytes = self._parse_int_input(self.query_one("#input-swo-bytes", Input).value, -1) + if port < 0 or port > 31: + logger.write("[red]SWO read: port must be between 0 and 31.[/red]") + return + if num_bytes <= 0 or num_bytes > 4096: + logger.write("[red]SWO read: bytes must be between 1 and 4096.[/red]") + return + + data = self.jlink.swo_read_stimulus(port, num_bytes) + if data is None: + detail = self.jlink.last_error or "read failed" + logger.write(f"[red]SWO read failed: {detail}[/red]") + return + + payload = bytes(data) + if not payload: + if not quiet: + logger.write("[dim]SWO: no new data.[/dim]") + return + + self.swo_pending_text[port], lines = decode_trace_chunk( + payload, self.swo_pending_text.get(port, "") + ) + + for line in lines: + logger.write(line) + def _load_svd(self, svd_path: str) -> None: logger = self.query_one("#log-output", RichLog) if not svd_path: @@ -1218,6 +1532,8 @@ def _connect_probe(self) -> None: self.poll_timer = self.set_interval(self.poll_interval_s, self.poll_active_registers) if self.auto_rtt_capture: self._start_rtt_console() + if self.auto_swo_capture: + self._start_swo_capture() else: detail = self.jlink.last_error or "unknown error" logger.write(f"[red]Hardware Connection Failure: {detail}[/red]") @@ -1231,12 +1547,23 @@ def _disconnect_probe(self) -> None: if self.rtt_poll_timer: self.rtt_poll_timer.stop() self.rtt_poll_timer = None + if self.swo_poll_timer: + self.swo_poll_timer.stop() + self.swo_poll_timer = None try: if self.jlink.connected and self.jlink.link: self.jlink.link.rtt_stop() except Exception: pass + try: + if self.jlink.connected and self.jlink.link: + self.jlink.link.swo_stop() + except Exception: + pass self.jlink.disconnect() + self.swo_pending_text.clear() + self.auto_swo_capture = False + self._sync_swo_read_controls() connect_button.label = "Connect Probe" connect_button.variant = "success" logger.write("J-Link hardware abstraction pipeline session teardown completed successfully.") @@ -1263,6 +1590,7 @@ def _control_target(self, action: str) -> None: logger.write(f"[green]Target action completed: {action}[/green]") if action == "reset": self._restart_rtt_after_reset() + self._restart_swo_after_reset() self.poll_active_registers() else: detail = self.jlink.last_error or "unknown error" @@ -1647,6 +1975,37 @@ def build_argument_parser() -> argparse.ArgumentParser: action="store_true", help="Automatically start continuous RTT capture after connecting", ) + parser.add_argument( + "--swo-cpu-speed", + type=int, + default=480000000, + help="Target CPU speed in Hz used to configure SWO (default: 480000000)", + ) + parser.add_argument( + "--swo-speed", + type=int, + default=2000000, + help="SWO output baud rate in Hz (default: 2000000)", + ) + parser.add_argument( + "--swo-port-mask", + type=lambda s: int(s, 0), + default=0x1, + help="ITM stimulus port enable mask (default: 0x1)", + ) + parser.add_argument( + "--swo-port", + type=int, + default=0, + help="Stimulus port shown in the SWO console, 0-31 (default: 0)", + ) + parser.add_argument( + "--swo-auto-start", + "--swo-continuous", + dest="swo_auto_start", + action="store_true", + help="Automatically enable SWO and start continuous capture after connecting", + ) parser.add_argument( "--poll-interval", type=float, @@ -1666,6 +2025,18 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace: if args.remote_port <= 0 or args.remote_port > 65535: parser.error("--remote-port must be in the range 1..65535") + if args.swo_cpu_speed <= 0: + parser.error("--swo-cpu-speed must be greater than 0") + + if args.swo_speed <= 0: + parser.error("--swo-speed must be greater than 0") + + if args.swo_port_mask <= 0: + parser.error("--swo-port-mask must be greater than 0") + + if args.swo_port < 0 or args.swo_port > 31: + parser.error("--swo-port must be in the range 0..31") + if args.usb: try: int(args.usb, 0) @@ -1711,6 +2082,11 @@ def main(argv: Optional[List[str]] = None) -> int: auto_load_svd=args.auto_load, auto_connect=args.auto_connect, auto_rtt_capture=args.rtt_continuous, + auto_swo_capture=args.swo_auto_start, + swo_cpu_speed=args.swo_cpu_speed, + swo_speed=args.swo_speed, + swo_port_mask=args.swo_port_mask, + swo_port=args.swo_port, poll_interval_s=args.poll_interval, ) app.run() diff --git a/tools/test_jalo_swo.py b/tools/test_jalo_swo.py new file mode 100644 index 0000000..d6afc78 --- /dev/null +++ b/tools/test_jalo_swo.py @@ -0,0 +1,386 @@ +"""Host-run unit tests for the SWO (Serial Wire Output) support in tools/jalo.py. + +These tests mock pylink-square and run without any J-Link hardware. Run with: + + .venv/bin/python -m pytest tools/test_jalo_swo.py -v +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from unittest import mock + +import pytest + +import jalo +from jalo import JLinkController, decode_trace_chunk + +TEST_SIZE = (200, 50) + + +@pytest.fixture +def connected_controller(): + controller = JLinkController() + controller.connected = True + controller.link = mock.MagicMock() + return controller + + +@pytest.fixture +def disconnected_controller(): + return JLinkController() + + +# --------------------------------------------------------------------------- +# decode_trace_chunk +# --------------------------------------------------------------------------- + + +class TestDecodeTraceChunk: + def test_complete_lines_are_emitted(self): + pending, lines = decode_trace_chunk(b"hello\nworld\n", "") + assert pending == "" + assert lines == ["hello", "world"] + + def test_partial_line_is_buffered(self): + pending, lines = decode_trace_chunk(b"hel", "") + assert pending == "hel" + assert lines == [] + + def test_partial_line_completes_across_chunks(self): + pending, lines = decode_trace_chunk(b"wor", "hel") + assert pending == "helwor" + assert lines == [] + pending, lines = decode_trace_chunk(b"\n", pending) + assert pending == "" + assert lines == ["helwor"] + + def test_crlf_is_normalized(self): + pending, lines = decode_trace_chunk(b"a\r\nb\r\n", "") + assert pending == "" + assert lines == ["a", "b"] + + def test_mixed_newlines_are_split(self): + pending, lines = decode_trace_chunk(b"a\r\nb\nc\n", "") + assert pending == "" + assert lines == ["a", "b", "c"] + + def test_empty_payload_keeps_pending(self): + pending, lines = decode_trace_chunk(b"", "abc") + assert pending == "abc" + assert lines == [] + + def test_binary_garbage_is_replaced_not_crashing(self): + pending, lines = decode_trace_chunk(b"\xff\xfe\n", "") + assert pending == "" + assert lines == ["\ufffd\ufffd"] + + +# --------------------------------------------------------------------------- +# JLinkController.swo_* wrappers +# --------------------------------------------------------------------------- + + +class TestSwoEnable: + def test_connected(self, connected_controller): + assert connected_controller.swo_enable(480_000_000, 2_000_000, 0x3) is True + connected_controller.link.swo_enable.assert_called_once_with( + 480_000_000, 2_000_000, 0x3 + ) + assert connected_controller.last_error == "" + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_enable(1, 2, 3) is False + assert disconnected_controller.last_error == "probe not connected" + + def test_raises_sets_last_error(self, connected_controller): + connected_controller.link.swo_enable.side_effect = RuntimeError("boom") + assert connected_controller.swo_enable(1, 2, 3) is False + assert connected_controller.last_error == "boom" + + +class TestSwoStart: + def test_connected(self, connected_controller): + assert connected_controller.swo_start(2_000_000) is True + connected_controller.link.swo_start.assert_called_once_with(2_000_000) + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_start(2_000_000) is False + assert disconnected_controller.last_error == "probe not connected" + + def test_raises_sets_last_error(self, connected_controller): + connected_controller.link.swo_start.side_effect = RuntimeError("boom") + assert connected_controller.swo_start(2_000_000) is False + assert connected_controller.last_error == "boom" + + +class TestSwoStop: + def test_connected(self, connected_controller): + assert connected_controller.swo_stop() is True + connected_controller.link.swo_stop.assert_called_once_with() + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_stop() is False + assert disconnected_controller.last_error == "probe not connected" + + def test_raises_sets_last_error(self, connected_controller): + connected_controller.link.swo_stop.side_effect = RuntimeError("boom") + assert connected_controller.swo_stop() is False + assert connected_controller.last_error == "boom" + + +class TestSwoFlush: + def test_connected(self, connected_controller): + assert connected_controller.swo_flush() is True + connected_controller.link.swo_flush.assert_called_once_with() + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_flush() is False + assert disconnected_controller.last_error == "probe not connected" + + +class TestSwoEnabled: + def test_connected(self, connected_controller): + connected_controller.link.swo_enabled.return_value = True + assert connected_controller.swo_enabled() is True + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_enabled() is False + + def test_raises_returns_false(self, connected_controller): + connected_controller.link.swo_enabled.side_effect = RuntimeError("boom") + assert connected_controller.swo_enabled() is False + + +class TestSwoNumBytes: + def test_connected(self, connected_controller): + connected_controller.link.swo_num_bytes.return_value = 42 + assert connected_controller.swo_num_bytes() == 42 + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_num_bytes() is None + + def test_raises_returns_none(self, connected_controller): + connected_controller.link.swo_num_bytes.side_effect = RuntimeError("boom") + assert connected_controller.swo_num_bytes() is None + + +class TestSwoRead: + def test_connected_passes_through_remove(self, connected_controller): + connected_controller.link.swo_read.return_value = [1, 2, 3] + assert connected_controller.swo_read(0, 16, remove=True) == [1, 2, 3] + connected_controller.link.swo_read.assert_called_once_with(0, 16, remove=True) + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_read(0, 16) is None + + def test_invalid_count(self, connected_controller): + assert connected_controller.swo_read(0, 0) is None + assert connected_controller.swo_read(0, -1) is None + + def test_raises_returns_none(self, connected_controller): + connected_controller.link.swo_read.side_effect = RuntimeError("boom") + assert connected_controller.swo_read(0, 16) is None + + +class TestSwoReadStimulus: + def test_connected(self, connected_controller): + connected_controller.link.swo_read_stimulus.return_value = [ord("h"), ord("i")] + assert connected_controller.swo_read_stimulus(0, 64) == [ord("h"), ord("i")] + connected_controller.link.swo_read_stimulus.assert_called_once_with(0, 64) + + def test_disconnected(self, disconnected_controller): + assert disconnected_controller.swo_read_stimulus(0, 64) is None + + def test_invalid_count(self, connected_controller): + assert connected_controller.swo_read_stimulus(0, 0) is None + + def test_raises_returns_none(self, connected_controller): + connected_controller.link.swo_read_stimulus.side_effect = RuntimeError("boom") + assert connected_controller.swo_read_stimulus(0, 64) is None + + +# --------------------------------------------------------------------------- +# parse_args SWO validation +# --------------------------------------------------------------------------- + + +class TestArgumentParsing: + def test_swo_defaults(self): + args = jalo.parse_args([]) + assert args.swo_cpu_speed == 480_000_000 + assert args.swo_speed == 2_000_000 + assert args.swo_port_mask == 0x1 + assert args.swo_port == 0 + assert args.swo_auto_start is False + + def test_swo_custom_values(self): + args = jalo.parse_args( + [ + "--swo-cpu-speed", + "400000000", + "--swo-speed", + "1000000", + "--swo-port-mask", + "0x7", + "--swo-port", + "3", + "--swo-auto-start", + ] + ) + assert args.swo_cpu_speed == 400_000_000 + assert args.swo_speed == 1_000_000 + assert args.swo_port_mask == 0x7 + assert args.swo_port == 3 + assert args.swo_auto_start is True + + def test_swo_port_mask_decimal(self): + args = jalo.parse_args(["--swo-port-mask", "3"]) + assert args.swo_port_mask == 3 + + def test_swo_continuous_alias(self): + args = jalo.parse_args(["--swo-continuous"]) + assert args.swo_auto_start is True + + @pytest.mark.parametrize( + "argv", + [ + ["--swo-cpu-speed", "0"], + ["--swo-cpu-speed", "-1"], + ["--swo-speed", "0"], + ["--swo-speed", "-5"], + ["--swo-port-mask", "0"], + ["--swo-port-mask", "-2"], + ["--swo-port", "-1"], + ["--swo-port", "32"], + ["--swo-port", "100"], + ], + ) + def test_swo_invalid_values_rejected(self, argv): + with pytest.raises(SystemExit) as exc_info: + jalo.parse_args(argv) + assert exc_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# TUI app behaviour (headless textual harness, pylink mocked away) +# --------------------------------------------------------------------------- + + +def _log_text(app): + log = app.query_one("#swo-log") + return "".join(strip.text for strip in log.lines) + + +def _make_connected_app(): + app = jalo.SVDDebuggerApp(swo_cpu_speed=400_000_000, swo_speed=1_000_000, swo_port_mask=0x3) + app.jlink.connected = True + app.jlink.link = mock.MagicMock() + return app + + +@pytest.mark.anyio +async def test_app_mounts_swo_controls_with_configured_values(): + app = _make_connected_app() + async with app.run_test(size=TEST_SIZE): + assert app.query_one("#input-swo-cpu").value == "400000000" + assert app.query_one("#input-swo-speed").value == "1000000" + assert app.query_one("#input-swo-mask").value == "0x3" + assert app.query_one("#input-swo-port").value == "0" + assert app.query_one("#btn-swo-enable") is not None + assert app.query_one("#btn-swo-read").disabled is False + + +@pytest.mark.anyio +async def test_enable_swo_button_configures_target_and_logs(): + app = _make_connected_app() + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-enable") + app.jlink.link.swo_enable.assert_called_once_with(400_000_000, 1_000_000, 0x3) + assert "SWO enabled" in _log_text(app) + + +@pytest.mark.anyio +async def test_start_button_starts_collection(): + app = _make_connected_app() + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-start") + app.jlink.link.swo_start.assert_called_once_with(1_000_000) + assert "collection started" in _log_text(app) + + +@pytest.mark.anyio +async def test_start_button_falls_back_to_enable_when_start_fails(): + app = _make_connected_app() + app.jlink.link.swo_start.side_effect = RuntimeError("not enabled") + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-start") + app.jlink.link.swo_enable.assert_called_once_with(400_000_000, 1_000_000, 0x3) + + +@pytest.mark.anyio +async def test_read_button_decodes_stimulus_port_lines(): + app = _make_connected_app() + app.jlink.link.swo_read_stimulus.return_value = list(b"hello from SWO\r\nworld\n") + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-read") + text = _log_text(app) + assert "hello from SWO" in text + assert "world" in text + + +@pytest.mark.anyio +async def test_read_button_buffers_partial_lines(): + app = _make_connected_app() + app.jlink.link.swo_read_stimulus.side_effect = [list(b"partial"), list(b" line\n")] + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + app.query_one("#btn-swo-read").active_effect_duration = 0 + await pilot.pause() + await pilot.click("#btn-swo-read") + assert "partial" not in _log_text(app) + await pilot.pause() + await pilot.click("#btn-swo-read") + assert "partial line" in _log_text(app) + + +@pytest.mark.anyio +async def test_stop_button_stops_capture(): + app = _make_connected_app() + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-stop") + app.jlink.link.swo_stop.assert_called_once_with() + assert app.query_one("#btn-swo-read").disabled is False + + +@pytest.mark.anyio +async def test_enable_ignored_when_disconnected(): + app = jalo.SVDDebuggerApp() + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#btn-swo-enable") + assert "not connected" in _log_text(app) + + +@pytest.mark.anyio +async def test_continuous_switch_disables_read_button(): + app = _make_connected_app() + async with app.run_test(size=TEST_SIZE) as pilot: + app.query_one("#main-tabs").active = "tab-swo-view" + await pilot.pause() + await pilot.click("#switch-swo-continuous") + assert app.query_one("#btn-swo-read").disabled is True + assert app.auto_swo_capture is True