Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions GOTCHAS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# Gotchas

## 2026-08-12 — Vendor symbols referenced only from `configure.cpp` do not link (archive ordering)

- **Symptom:** `undefined reference to stm32::initialize::enable_serial_wire_output(core::units::Hertz, unsigned long)`
at link time for **all** F4 apps AND all M7 apps that use jarnax `configure.cpp`, even though
`nm` shows the symbol IS defined (`T`) in `libmodule-stm32-basic-<chip>.a` and the mangled name
matches (`Em` = unsigned long = both `std::uint32_t` and `std::size_t` on ARM32).
- **Root cause:** module static-library archives are placed **once**, *before* the jarnax archive,
on the link line (`libmodule-stm32-...a libarch-cortex-...a libmodule-jarnax-...a libarch-cortex-...a libmodule-jarnax-...a`).
When ld scans the stm32 archive nothing has referenced the symbol yet, so the member is never
extracted; by the time jarnax's reference appears the archive has passed. `-Wl,--no-undefined`
then fails the link. (cortex+jarnax are repeated twice on the link line for exactly this reason.)
- **Pre-existing example:** the `@FIXME` at `modules/stm32/source/stm32h7xx/clocks.cpp:38` documents
the same problem for `early_power()`: it has to be called from inside the vendor's `clocks()`
rather than from `configure.cpp`.
- **Fix / rule:** any vendor symbol referenced only from `configure.cpp` must be *pulled in* by
an already-extracted member — call it from the vendor `clocks()` (guarded by
`if constexpr (cortex::swo::enable)`), not from `configure.cpp`. Do not rely on the board
archive happening to reference it.
- **Gotcha:** a misleading "M7 passes" check — building only a module or a configure stage and
grepping for `FAILED` can miss that the app ELFs never linked. Verify app `.elf` artifacts
actually exist / contain the symbol.

## 2026-08-12 — J-Link rewrites DBGMCU_CR on connect, unclocking the D3 debug domain

- **Symptom:** after the firmware programs the ST SWO/SWTF blocks (0x5C003000/0x5C004000), a
fresh J-Link session reads `DBGMCU_CR` = `0x00000007` (bits 0-2 = J-Link's own
DBG_SLEEP/DBG_STOP/DBG_STANDBY) and the SWO/SWTF registers are unreadable
(`Could not start CPU core. (ErrorCode: -1)`), making it look like the firmware never
programmed them.
- **Root cause:** each pylink-square-mcp tool call is a new J-Link connection; on connect/reset
the J-Link writes `DBGMCU_CR` with its low-power debug bits (0x07), clobbering
TRACECLKEN/D1DBGCKEN/D3DBGCKEN (bits 20/21/22). With those clear, the D3 debug domain is
unclocked, so SWO/SWTF reads hang.
- **Fix / verification pattern:** verify in a **single session** — set a hardware breakpoint at a
post-configure symbol (e.g. `cortex::system::main()`) via `pylink-square-mcp test_breakpoint`
and dump the registers when it hits, before any reconnect. Verified values for issue-41:
`DBGMCU_CR`=0x00700007, `SWO_CODR`=0x2b (43 @100MHz/2.24MBaud), `SWO_SPPR`=2 (AsyncNRZ),
`SWTF_CTRL`=0x301 (ENSO).

## 2026-08-11 — J-Link Remote Server has no `-if/-device/-speed` options

- **Symptom:** Launching the backend with `JLinkRemoteServer -if SWD -device STM32H753ZI -speed auto`
Expand Down
164 changes: 118 additions & 46 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -1,59 +1,131 @@
# PLAN: Route pylink-square-mcp through a persistent J-Link Remote Server
# PLAN: Enable Serial Wire Output (SWO) for debug tracing

Issue: #52 — branch `issue-52` (tracks `github/develop`).
Issue: #41 — branch `issue-41` (tracks `github/develop`).

## Summary

The `tools/pylink-square-mcp` J-Link MCP server crashes periodically. Every
tool call created a fresh `pylink.JLink()`, opened the USB link, connected,
and discarded it — repeated J-Link DLL load/unload + USB connect/disconnect
churn inside a long-lived process. Fix: connect through a persistent local
**J-Link Remote Server** (default `127.0.0.1:19020`), and have
`mcp_server.py` launch that daemon on startup if it isn't already listening.
The daemon intentionally outlives the MCP process so USB stays stable across
MCP crashes/restarts.
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:

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).

## Changes

1. **`jlink_connection.py` (new)** — shared helper:
- `add_connection_args(parser)`: adds `--remote-host` (default
`127.0.0.1`), `--remote-port` (default `19020`), `--direct`.
- `connect(device, speed, remote_host, remote_port, interface=SWD)`: opens
via `jlink.open(ip_addr="host:port")`; falls back to a direct USB
connection (with a stderr warning) if the Remote Server is unreachable.
- Device/speed stay client-side in `jlink.connect()`; the Remote Server
only needs `-Port` (and optionally `-USB <S/N or nickname>`) per
https://kb.segger.com/J-Link_Remote_Server.
2. **All 13 J-Link tool scripts** — import the shared helper, register the
connection args, and replace the inline open/connect block with
`connect(...)` (backtrace, clock_tree, debug_target, dump_ethernet,
dump_memory, flash_target, flash_verify, live_dump, rtt_read, run_for,
run_to_main, step_target, test_breakpoint). `svd_query` is SVD-only, no
J-Link.
3. **`mcp_server.py`** — on startup:
- parse `--device/--speed/--remote-port/--usb-serial` (env fallbacks
`JLINK_MCP_*`);
- `ensure_remote_server()`: if nothing is listening on the port, locate
`JLinkRemoteServer` and spawn `-Port <port> [-USB <serial>]` with output
to `jlink-remote-server.log`; poll for the listener; never kill on exit;
- every J-Link tool handler appends `--remote-host/--remote-port` so all
calls route through the daemon.
4. **`.gitignore`** — ignore the Remote Server log artifacts.
5. **README.md** — document the backend, options/env vars, config example.
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.

## Tests

- **`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).

## Verification

- `python3 -m py_compile` on all touched scripts.
- Live hardware test through the daemon: `debug_target`, `run_for --seconds 2`,
`rtt_read --continuous 4` all connected/ran/read correctly via
`127.0.0.1:19020`.
- MCP startup twice: first run launched the daemon; second run detected the
existing listener ("already listening").
- No C++ source touched; firmware build presets unaffected (CI still validates).
- 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).

## Gotchas

- `JLinkRemoteServer` on macOS is a symlink to `JLinkRemoteServerCLExe`; its
accepted options are `-Port`, `-USB`, `-IP` — there is no `-if/-device/-speed`
(those are client-side).
- Do NOT put the probe's real serial number in commits/examples/code.
- 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()`.
3 changes: 3 additions & 0 deletions applications/nucleo-demo/source/Demo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "BoardContext.hpp"
#include "board.hpp"
#include "core/Conversions.hpp"
#include "cortex/swo.hpp"
#include "jarnax/Assertion.hpp"
#include "memory.h"

Expand Down Expand Up @@ -119,6 +120,8 @@ void Demo::OnCycle(DemoState state) {
}

if (countdown_.IsExpired()) {
// TEMP: issue-41 SWO verification - emit a marker every 2 seconds
cortex::swo::emit(cortex::swo::Port::System, "SWO-OK\r\n");
jarnax::Ticks ticks = ticker_.GetTicksSinceBoot();
jarnax::Time time = ticker_.GetTimeSinceBoot();
uint32_t random = rng_.GetNextRandom();
Expand Down
1 change: 1 addition & 0 deletions boards/nucleo_h753zi/include/BoardContext.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class BoardContext {
stm32::gpio::Pin status_pin_; ///< The Status Pin
stm32::gpio::Pin performance_pin_; ///< The Performance Pin
stm32::gpio::Pin timing_pin_; ///< The Timing Pin (for debugging)
stm32::gpio::Pin swo_pin_; ///< The Serial Wire Output (SWO) Pin (PB3)
/// The Error Indicator
stm32::Indicator error_indicator_;
/// The Status Indicator
Expand Down
5 changes: 5 additions & 0 deletions boards/nucleo_h753zi/source/BoardContext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ BoardContext::BoardContext()
, status_pin_{stm32::gpio::Port::B, 0} // LD1 Green LED
, performance_pin_{stm32::gpio::Port::E, 1} // LED2 Yellow LED
, timing_pin_{stm32::gpio::Port::A, 3} // GPIO
, swo_pin_{stm32::gpio::Port::B, 3} // SWO (JTDO/TRACESWO)
, error_indicator_{error_pin_, stm32::Level::High}
, status_indicator_{status_pin_, stm32::Level::High}
, performance_indicator_{performance_pin_, stm32::Level::High}
Expand Down Expand Up @@ -184,6 +185,10 @@ core::Status BoardContext::Initialize(void) {
.SetOutputSpeed(stm32::gpio::Speed::Medium)
.SetOutputType(stm32::gpio::OutputType::PushPull)
.SetResistor(stm32::gpio::Resistor::PullDown);
swo_pin_.SetMode(stm32::gpio::Mode::AlternateFunction)
.SetAlternative(0) // Alt 0 is TRACESWO (JTDO)
.SetOutputSpeed(stm32::gpio::Speed::VeryHigh)
.SetOutputType(stm32::gpio::OutputType::PushPull);
error_indicator_.Inactive();
status_indicator_.Inactive();
performance_indicator_.Inactive();
Expand Down
2 changes: 1 addition & 1 deletion modules/cortex/source/initialize.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ void swo(std::uint32_t desired_baud, Hertz clock_frequency) {
if constexpr (not cortex::swo::enable) {
return;
}
// @see https://black-magic.org/usage/swo.html
// @see https://black-magic.org/docs_v1/usage/swo/

// compute the clock divider as a zero based value
std::uint32_t clock_divider = (clock_frequency.value() / desired_baud) - 1U;
Expand Down
4 changes: 4 additions & 0 deletions modules/jarnax/source/configure.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ void ATTRIBUTE((used)) configure(void) {
"Initializing Tick @ %" PRIu32 "/sec from %" PRIu32 "\r\n", cortex::GetTickRate().value(), cortex::GetSystemClockFrequency().value()
);
cortex::initialize::tick(cortex::GetTickRate(), cortex::GetSystemClockFrequency());
// The H7 SWO block is driven from TRACECK (PLL1R), not the core clock, so the vendor
// programs the ST SWO/SWTF blocks from its own clocks() (like early_power, because the
// module archive is only scanned once before jarnax, so vendor symbols referenced only
// here would not link).
cortex::initialize::swo(cortex::swo::baudrate, cortex::GetClockFrequency());
// configure the system control block configuration register
cortex::initialize::configuration();
Expand Down
2 changes: 2 additions & 0 deletions modules/stm32/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ add_module(NAME stm32
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32f4xx/vectors.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32f4xx/power.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32f4xx/clocks.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32f4xx/debug.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/gpio/Pin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/Conversions.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/Indicator.cpp
Expand Down Expand Up @@ -33,6 +34,7 @@ add_module(NAME stm32
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32h7xx/vectors.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32h7xx/power.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32h7xx/clocks.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/stm32h7xx/debug.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/gpio/Pin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/Conversions.cpp
${CMAKE_CURRENT_SOURCE_DIR}/source/Indicator.cpp
Expand Down
16 changes: 16 additions & 0 deletions modules/stm32/include/stm32/Initialize.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
/// @file
/// STM32 Chip Initialization Functions

#include <cstdint>

#include "core/Units.hpp"

/// The ST Micro 32 Namespace
namespace stm32 {

Expand All @@ -16,6 +20,18 @@ void early_power(void);
/// @brief The required Clock initialization for all STM32 chips
void clocks(void);

/// @brief Enables the DBGMCU trace port clocks (TRACECLKEN/D1DBGCKEN/D3DBGCKEN) so the trace
/// blocks can be clocked.
/// @note On the H7 this also un-gates the D3 domain which contains the SWO/SWTF blocks.
void enable_trace_port_clock(void);

/// @brief Programs the ST Serial Wire Output path (SWO + SWTF) for UART trace on the SWO pin.
/// @param trace_clock The frequency of the trace clock (TRACECK) driving the SWO block.
/// @param baud The desired SWO UART baud rate.
/// @note H7 only: the Cortex-M7 TPIU (0xE0040000) does not drive the SWO pin, the ST SWO block
/// (0x5C003000) does. The F4 implementation is a no-op as it uses the standard TPIU instead.
void enable_serial_wire_output(core::units::Hertz trace_clock, std::uint32_t baud);

/// @brief The required GPIO initialization
/// @note Implemented in the Board
extern void gpio(void);
Expand Down
Loading
Loading