From eca6caf673452c8ec940e2acf5e46d0631fb72bf Mon Sep 17 00:00:00 2001 From: Ha Thach Date: Fri, 28 Aug 2026 14:16:02 +0700 Subject: Add RTT console/capture tooling (tools/rtt.py), rtt skill, and HIL harness support (#3853) Promote SEGGER RTT from an inline debugging technique to a standalone skill backed by one stdlib-only implementation in tools/rtt.py: a CLI and importable module for console/capture over J-Link (RTTTelnetPort) and OpenOCD (rtt server) probes, with probe selection by serial or VID:PID, control-block address via --elf or --addr, bidirectional console, post-mortem ring dump, and --reset-before-attach for boot-time capture. The HIL harness reads a board's console over RTT when its probe has no VCOM ("logger": "rtt" plus a LOGGER=rtt variant define), covering device_info, pool-check aliveness, and CI wiring. Validated on 22 boards across both backends; 26 unit tests run in pre-commit. --- tools/ci_select.py | 26 +- tools/rtt.py | 727 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 749 insertions(+), 4 deletions(-) create mode 100644 tools/rtt.py (limited to 'tools') diff --git a/tools/ci_select.py b/tools/ci_select.py index ca9d54c27..1526f2064 100755 --- a/tools/ci_select.py +++ b/tools/ci_select.py @@ -132,7 +132,11 @@ _METRICS_RE = re.compile( r'^(tools/metrics[^/]*\.py$|\.github/scripts/metrics_[^/]*\.py$)') _FULL_RE = re.compile( r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|' - r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|' + # tools/rtt.py is part of the harness, not a standalone tool: hil_util imports it + # at module load, so a break in it breaks every rig run the same way a test/hil/ + # edit can (the pre-commit hil-test hook runs its unit tests for the same reason) + r'test/hil/|tools/rtt\.py$|' + r'\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|' # generates the whole CircleCI matrix, same authority as .github/** r'\.circleci/|' # rule 16 says `tools/build*.py`; name the two siblings the glob implies. Both @@ -764,6 +768,16 @@ def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel, # only the tests whose example builds the lib, and only those the rig runs tests = {e for e in lib_examples(lib, repo_root) if any(e in pool for pool in ALL_TESTS.values()) or e in extras} + if lib == 'SEGGER_RTT': + # no example names this lib, but a board whose roster entry says + # "logger": "rtt" (variant defines LOGGER=rtt) reads EVERY test's console + # through it -- a break here silently breaks all of that board's rows + rtt_boards = [b['name'] for b in roster_boards if b.get('logger') == 'rtt'] + if rtt_boards: + s.roles.update(('device', 'host')) + s.add(rtt_boards, 'all', + f'{path}: SEGGER_RTT is the rtt console on {rtt_boards} -> all tests') + return if not tests: s.reasons.append(f'{path}: lib {lib} used by no HIL test, no contribution') return @@ -1139,9 +1153,13 @@ def _classify_build_one(path, repo_root, s: _BSel, get_deps_families=None): lib = m.group(1) exs = lib_examples(lib, repo_root) if not exs: - # empty means empty: no example's build pulls this lib in, so no build - # compiles it (lib/SEGGER_RTT is only reached through LOGGER=rtt, which - # no CI build sets) + # empty means empty: no example's build pulls this lib in, so no MAIN- + # matrix build compiles it. (lib/SEGGER_RTT is reached through LOGGER=rtt, + # which the main matrix never sets; the hil-build legs set it only for + # roster boards whose variant defines carry it, via the HIL SEGGER_RTT rule. + # No committed CI roster has such a board yet, so a SEGGER_RTT edit is + # currently neither built nor HIL-tested by CI -- verify vendor bumps + # manually until a rig board adopts "logger": "rtt".) s.reasons.append(f'{path}: lib {lib} built by no example, no contribution') return s.add(all_bsp_families(repo_root), exs, f'{path}: lib {lib} -> {sorted(exs)}') diff --git a/tools/rtt.py b/tools/rtt.py new file mode 100644 index 000000000..e3aef36f2 --- /dev/null +++ b/tools/rtt.py @@ -0,0 +1,727 @@ +#!/usr/bin/env python3 +"""RTT console/capture over a debug probe — importable classes + CLI (the rtt +skill's SKILL.md is the manual). + +Three routes (see the skill's transport matrix for which route a probe gets). +--backend is always explicit: + + J-Link route (console/capture, channel 0 only) + rtt.py --backend jlink --probe --device [--seconds N] [-i] + OpenOCD route (native probes: ST-Link/CMSIS-DAP; console/capture, any channel) + rtt.py --backend openocd [--probe ] [--vid-pid "0xVVVV 0xPPPP"] \\ + --cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" \\ + (--elf | --addr 0x2000xxxx) [--channel N] [--seconds N] [-i] + [--reset-before-attach] # capture from the target's boot (SystemView) + Post-mortem ring dump (J-Link, no halt — debug-AP reads) + rtt.py --backend jlink --dump --probe --device \\ + (--elf | --addr 0x...) + +The probe is owned for the whole run: flash and reset BEFORE starting this, never +reset the target while it is attached. Pin the probe: rigs and benches run +several (jlink: --probe serial; openocd: --probe and/or --vid-pid). + +The classes (JlinkRtt for J-Link, OpenocdRtt for openocd-driven probes) expose +the slice of pyserial the HIL harness uses — read/in_waiting/write/close/timeout, +reset_input_buffer, context-manager use, plus an `eof` latch — and are imported +by test/hil/helper/hil_util.py, so this file is HARNESS-CRITICAL: a change here +is classified like a test/hil/ harness change (tools/ci_select.py) and runs the +console unit tests (pre-commit hil-test hook, test/hil/test/test_hil_rtt.py). +Stdlib only — hil_util imports this file, never the other way around. +""" +import argparse +import contextlib +import os +import re +import select +import shlex +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time + + +class RttError(RuntimeError): + """Every way a console can break: stall, closed, dead or reset server. + + A RuntimeError subclass so existing `except RuntimeError` callers keep working, + but named so the harness can tell a console failure from an unrelated + NotImplementedError / 'dictionary changed size during iteration' and stop + reporting harness bugs as board failures.""" + + +def _pos_float_env(name: str, default: float) -> float: + # mirrors hil_util.pos_float_env, including its rejection of inf/nan: an infinite + # write timeout is an unbounded write, the very thing this knob exists to bound + raw = os.environ.get(name) + if raw is None: + return default + try: + v = float(raw) + except ValueError: + print(f'warning: {name} is not a number; using {default}', file=sys.stderr, flush=True) + return default + if not (v > 0 and v < float('inf')): + print(f'warning: {name}={v} is not usable; using {default}', file=sys.stderr, flush=True) + return default + return v + + +# whole-call deadline for write() — same env knob as the harness's serial twin +RTT_WRITE_TIMEOUT = _pos_float_env('HIL_SERIAL_WRITE_TIMEOUT', 10) + +# J-Link Commander's telnet greeting, sent at connect BEFORE (or without) the control +# block being found: never target output. Three lines; the middle one is the PROBE +# MODEL string, which in libjlinkarm carries no 'SEGGER ' prefix (J-Link OH3, +# J-Trace H9, ...) though some builds do prefix it — match both shapes. Consumers +# judging "did the target speak" must strip these lines first. +RTT_BANNER_RE = re.compile(r'^(SEGGER J-|J-Link[ 0-9]|J-Trace[ 0-9]|Process:\s)') + + +def strip_banner(data: bytes, complete_only: bool = False) -> bytes: + """Target bytes only: drop the J-Link server banner lines and blanks. + + Both harness consumers (hil_test's device_info verdict, hil_pool_check's + aliveness score) must judge "did the target speak" through this one filter, + or the same byte stream scores differently per consumer. complete_only=True + additionally drops a trailing unterminated line — for poll loops judging a + growing buffer, where a banner FRAGMENT at a read boundary (b'SEGG', b'Proce') + would defeat the prefix regex and count as target output; the final verdict + after the window should pass complete_only=False to keep a genuine + unterminated tail.""" + lines = data.splitlines(keepends=False) + if complete_only and data and not data.endswith((b'\n', b'\r')) and lines: + lines = lines[:-1] + return b'\n'.join(l for l in lines + if l.strip() and not RTT_BANNER_RE.match(l.decode('utf-8', errors='ignore'))) + + +def free_ports(count: int) -> list: + """Bind ephemeral ports and hand back the numbers. Boards run in parallel, so the + RTT/GDB ports cannot be the SEGGER defaults or two boards collide. + + Known TOCTOU: the port is free when released here, but another process can claim + it before the server binds it. Accepted — the server binds the port itself, so + there is no fd to hand over. The post-connect re-poll catches the common outcome + (our server lost the bind and died); a foreign listener that stays alive is not + detectable here and would need the connected peer to be validated.""" + socks = [] + try: + for _ in range(count): + s = socket.socket() + s.bind(('127.0.0.1', 0)) + socks.append(s) + return [s.getsockname()[1] for s in socks] + finally: + for s in socks: + s.close() + + +def nm_rtt_addr(elf: str, nm: str = None) -> int: + """Control-block address from the FLASHED elf's symbol table. --addr is the way + out when nm cannot read the file (another architecture, no toolchain).""" + nm = nm or os.environ.get('RTT_NM', 'arm-none-eabi-nm') + try: + r = subprocess.run([nm, elf], capture_output=True, text=True, timeout=30) + except FileNotFoundError: + raise SystemExit(f'{nm} not on PATH — set RTT_NM=, or pass --addr') + except subprocess.TimeoutExpired: + raise SystemExit(f'{nm} did not finish reading {elf} in 30 s — pass --addr instead') + if r.returncode != 0: + raise SystemExit(f'{nm} could not read {elf}: {r.stderr.strip()[:200]}\n' + f'(wrong architecture? set RTT_NM=, or pass --addr)') + for line in r.stdout.splitlines(): + # " _SEGGER_RTT": a defined data symbol only — an undefined one + # (" U _SEGGER_RTT") has no address and would int('U', 16) + m = re.match(r'^([0-9a-fA-F]+)\s+[bBdD]\s+_SEGGER_RTT$', line.strip()) + if m: + return int(m.group(1), 16) + raise SystemExit(f'no defined _SEGGER_RTT symbol in {elf} — was it built with LOGGER=rtt?') + + +class _SocketRtt: + """Shared console core: a TCP socket onto an RTT server owned by self._proc. + + Subclasses build their server argv and call _spawn() + _connect() in __init__. + One failure contract: RttError for every way the console can break (stall, + closed, dead server) — callers are written for exactly it. A dead or resetting + server LATCHES `eof` rather than raising from the read side, so read loops and + the harness's `assert not ser.eof` triage see it without an exception racing + them to a generic handler.""" + + server = 'RTT server' # for error messages + + def __init__(self, timeout: float = 0.1): + self.timeout = timeout + self._buf = b'' + self._eof = False + self._sock = None + self._proc = None + self._log = None + self._lock = threading.Lock() # _buf is touched by the CLI pump thread too + + def _spawn(self, cmd: list, stdin=None) -> None: + # server output spools to a temp file: a PIPE nobody drains blocks a + # single-threaded server once 64 KiB of log accumulates (openocd at + # polling_interval 1 against a resetting target fills that in minutes) and + # the console goes silent with no error; the file also feeds _server_tail + self._log = tempfile.NamedTemporaryFile(prefix='rtt-server-', suffix='.log') + try: + self._proc = subprocess.Popen(cmd, stdin=stdin, stdout=self._log, + stderr=subprocess.STDOUT, start_new_session=True) + except FileNotFoundError as e: + self.close() + raise RttError(f'RTT console: {e.filename or cmd[0]} not on PATH') from e + except BaseException: + # any other spawn failure (PermissionError...) must not leak the log fd + self.close() + raise + + def _connect(self, port: int) -> None: + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + try: + self._sock = socket.create_connection(('127.0.0.1', port), timeout=2) + break + except OSError: + if self._proc.poll() is not None: + break + time.sleep(0.2) + if self._sock is None: + tail = self._server_tail() + self.close() + raise RttError(f'RTT console: {self.server} did not serve port {port}{tail}') + if self._proc.poll() is not None: + # the connect succeeded but our server is dead: a foreign process claimed + # the port in the free_ports window — refuse a console wired to a stranger + self.close() + raise RttError(f'RTT console: {self.server} died after connect (port {port} hijacked?)') + self._sock.setblocking(False) + except (KeyboardInterrupt, SystemExit): + # a signal mid-construction must not orphan the server we just spawned + self.close() + raise + + def _server_tail(self) -> str: + log = getattr(self, '_log', None) + if log: + with contextlib.suppress(OSError, ValueError): + with open(log.name, 'rb') as fh: + tail = fh.read()[-400:].decode(errors='replace') + if tail: + return ' — ' + tail + return '' + + def _drain(self) -> None: + # LATCH, never raise: a peer reset or a socket closed under us ends the + # stream exactly like an orderly EOF. Raising here raced the harness's + # `assert not ser.eof` triage into a generic handler that re-flashes the + # board, and leaked ConnectionResetError/ValueError to in_waiting callers. + # the WHOLE body under the lock, not just the append: the CLI's -i pump thread + # and the read loop drain the same socket concurrently, and recv->append being + # non-atomic let chunks land out of order (measured: transposed 64-byte + # segments in 3/6 stress trials) + try: + with self._lock: + while self._sock and select.select([self._sock], [], [], 0)[0]: + try: + chunk = self._sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + if not chunk: + self._eof = True + return + self._buf += chunk + except (OSError, ValueError, TypeError, AttributeError): + self._eof = True + + @property + def eof(self) -> bool: + """True once the server hung up AND everything it sent has been read out.""" + if self._sock is None: + return True + self._drain() + return self._eof and not self._buf + + @property + def in_waiting(self) -> int: + if self._sock is None: + # pyserial raises on a closed port; answering "N bytes waiting" from a + # closed dead console would let a caller bug look like a healthy board + raise RttError('RTT console is closed') + self._drain() + return len(self._buf) + + def read(self, size: int = 1) -> bytes: + if size is None or size <= 0: + # pyserial's read(0) returns b'' and consumes nothing; a negative size + # must not silently hand over (or destroy) buffered bytes + return b'' + if self._sock is None: + raise RttError('RTT console is closed') + self._drain() + deadline = None if self.timeout is None else time.monotonic() + self.timeout + while (len(self._buf) < size and not self._eof + and (deadline is None or time.monotonic() < deadline)): + time.sleep(0.005) + self._drain() + if self._eof and len(self._buf) < size: + # dead server: pace the empty returns like a serial timeout would, so a + # caller's read loop cannot busy-spin at 100% CPU (416k empty reads/s + # measured unpaced). timeout=None deliberately diverges from pyserial's + # block-forever: the eof latch makes "server is gone" knowable, and an + # eternal block on it helps nobody -- paced empties + .eof is the contract. + pace = self.timeout if self.timeout is not None else 0.1 + remaining = (deadline - time.monotonic()) if deadline is not None else pace + time.sleep(max(0.0, min(remaining, pace))) + with self._lock: + out, self._buf = self._buf[:size], self._buf[size:] + return out + + def reset_input_buffer(self) -> None: + # pyserial surface: the host tests flush pre-reset backlog through this + if self._sock is None: + raise RttError('RTT console is closed') + self._drain() + with self._lock: + self._buf = b'' + + def write(self, data: bytes) -> int: + # select+send, not sendall(): the socket is non-blocking for reads, and sendall() + # on a non-blocking socket raises BlockingIOError as soon as the send buffer is + # full, with no count of what already went out -- a caller cannot resume without + # duplicating bytes. Same reason serial_write_all treats a short write as fatal. + sock = self._sock # snapshot: close() from another thread nulls the attribute + if sock is None: + raise RttError('RTT console is closed') + self._drain() + if self._eof: + # TCP accepts exactly one send after peer death — without this the bytes + # would "succeed" into the void and the read timeout gets blamed on the target + raise RttError(f'RTT console write to a dead server ({self.server} gone)') + sent = 0 + deadline = time.monotonic() + RTT_WRITE_TIMEOUT + while sent < len(data): + if time.monotonic() > deadline: + raise RttError(f'RTT console write stalled after {sent}/{len(data)} bytes') + try: + if not select.select([], [sock], [], 0.1)[1]: + continue + sent += sock.send(data[sent:]) + except (BlockingIOError, InterruptedError): + continue + except (OSError, ValueError, TypeError, AttributeError) as e: + # peer death (BrokenPipe/ConnectionReset) or the socket closed under us + # mid-call: keep the class's one failure contract + raise RttError(f'RTT console write failed after {sent}/{len(data)} bytes: {e}') from e + return sent + + def _gentle_stop(self, proc) -> None: + """Subclass hook: ask the server to exit before the group takedown.""" + + def close(self) -> None: + self._eof = True # latch: post-close eof reads True, like a hung-up server + if getattr(self, '_sock', None): + self._sock.close() + self._sock = None + with self._lock: + self._buf = b'' # pyserial contract: nothing is readable after close + proc = getattr(self, '_proc', None) + if proc: + if proc.poll() is None: + self._gentle_stop(proc) + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=5) + if proc and proc.poll() is None: + # own session (start_new_session), so the group takedown gets the server and + # anything it spawned; leaving one alive would hold the probe for the next test + try: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + except (ProcessLookupError, PermissionError): + pass + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(proc.pid, signal.SIGKILL) + # reap, or the server stays a zombie for the caller's lifetime + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=2) + if proc: + for pipe in (proc.stdin, proc.stdout): + if pipe: + with contextlib.suppress(OSError, ValueError): + pipe.close() + # the server spool file: one fd plus a /tmp file per console, and the server + # grows it while alive -- GC is not a release policy on a rig + log = getattr(self, '_log', None) + if log: + with contextlib.suppress(OSError, ValueError): + log.close() + self._log = None + + # a console dropped without close() must not hold the probe for the process's life + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def __del__(self): + with contextlib.suppress(Exception): + self.close() + + +class JlinkRtt(_SocketRtt): + """Bidirectional console over SEGGER RTT channel 0, for J-Link probes (the only + console on boards whose probe has no VCOM or whose BSP has no UART). + + J-Link Commander (JLinkExe) owns the probe and serves RTT channel 0 on + -RTTTelnetPort -- what JLinkRTTClient talks to, minus its banner. It keeps + hunting for the control block and streams whatever the buffer already holds, + where JLinkRTTLogger searches once when it attaches and gives up. It also + carries input, which the host tests that drive a menu need. + + The probe is held for as long as this is open, so flashing and resetting the + board must happen before it is created or after close(). Select the probe by + serial: rigs run more than one.""" + + server = 'JLinkExe' + + def __init__(self, board: dict, timeout: float = 0.1): + super().__init__(timeout) + flasher = board['flasher'] + args = shlex.split(flasher.get('args', '')) + if '-device' not in args: + # fail with the real cause now: JLinkExe without a device blocks prompting + # and would surface 15 s later as a misleading port error + raise RttError(f'RTT console: no -device in flasher args: {flasher.get("args")!r}') + port = free_ports(1)[0] + # defaults first, the roster's args after so they can override (-if jtag, + # -JLinkScriptFile, an explicit -speed). NOTE: hil_flash orders it the other + # way (roster args first, its own -if/-speed last, so ITS defaults win) -- + # a roster override honored here is ignored by flash/reset; align them if a + # roster ever carries such args. -ExitOnError makes a failed target connect + # EXIT Commander + # (a clean error with the log tail) instead of leaving a banner-only console + cmd = ['JLinkExe', '-USB', str(flasher['uid']), '-if', 'swd', + '-JTAGConf', '-1,-1', '-speed', 'auto', '-NoGui', '1', + '-ExitOnError', '1', '-AutoConnect', '1', + *args, '-RTTTelnetPort', str(port)] + # stdin stays open: Commander exits when it runs out of input; close() writes + # 'exit' there. + self._spawn(cmd, stdin=subprocess.PIPE) + self._connect(port) + + def _gentle_stop(self, proc) -> None: + with contextlib.suppress(OSError, ValueError): + proc.stdin.write(b'exit\n') + proc.stdin.flush() + # close our pipe end in its own suppress: a BrokenPipe on the write above must + # not skip it (the base close also closes it for the server-already-dead path) + with contextlib.suppress(OSError, ValueError): + proc.stdin.close() + + +class OpenocdRtt(_SocketRtt): + """The console surface over an openocd `rtt server` (native probes: + ST-Link/CMSIS-DAP — never point openocd at ea4088's LPC-Link2, measured to + knock that probe off USB; other J-Link-OB probes untested). + + Exact control-block address (never a full-RAM scan), polling_interval 1 + (default 100 ms polling loses most of a busy stream), attach WITHOUT reset — + flash and reset before starting; `rtt start` needs the block to exist. + reset_before_attach opts into an in-session reset for streams that only + decode from byte 0 (SystemView).""" + + server = 'openocd' + + def __init__(self, cfg: str, addr: int, channel: int, serial_no: str = None, + vid_pid: str = None, timeout: float = 0.1, reset_before_attach: bool = False): + super().__init__(timeout) + port = free_ports(1)[0] + # argv, never a shell string: cfg/serial/vid_pid come from roster JSON and the + # command line, and a '$', backtick or quote in any of them would otherwise be + # substituted by the shell or break out of it + cmd = ['openocd', '-c', 'tcl_port disabled', '-c', 'gdb_port disabled', + '-c', 'telnet_port disabled'] + # probe pin: vid_pid keeps discovery from opening foreign usbfs nodes (a + # wedged one hangs the open), serial disambiguates same-model probes — + # both before the -f scripts, like hil_flash does + if vid_pid: + if not re.fullmatch(r'0x[0-9a-fA-F]{1,4} 0x[0-9a-fA-F]{1,4}', vid_pid.strip()): + # openocd only WARNS and exits 0 on a malformed value, so the pin + # silently does not apply and discovery reopens every usbfs node -- + # the convoy hil_flash.valid_vid_pid exists to stop + raise RttError(f'--vid-pid must be "0xVVVV 0xPPPP", got {vid_pid!r}') + cmd += ['-c', f'adapter usb vid_pid {vid_pid.strip()}'] + if serial_no: + cmd += ['-c', f'adapter serial {serial_no}'] + cmd += shlex.split(cfg) + cmd += ['-c', 'init'] + # opt-in: reset the target INSIDE this session, give it 2 s to boot, THEN + # attach and drain. The order is forced: `rtt start` needs the control block + # to already exist in RAM (the firmware creates it at init), and attaching + # ahead of the reset would latch the PREVIOUS run's stale block. Byte 0 still + # reaches the consumer because NO_BLOCK_SKIP retains the ring's HEAD: a boot + # burst bigger than the ring loses its tail until the drain catches up, never + # its first bytes -- which is the part a boot-anchored decoder needs + # (SystemView's Init record, carrying the timestamp frequency, is emitted once + # at boot; a mid-flight attach yields a stream no decoder can lock onto; size + # BUFFER_SIZE_UP to the boot burst if the tail matters too). Costs the tool's + # usual no-reset invariant, and is unsafe on parts where an in-session reset + # leaves the core held (SAMD5x DSU) or perturbs the target (WCH SDI). + if reset_before_attach: + cmd += ['-c', 'reset run', '-c', 'sleep 2000'] + cmd += ['-c', f'rtt setup 0x{addr:x} 0x800 "SEGGER RTT"', + '-c', 'rtt polling_interval 1', '-c', 'rtt start', + '-c', f'rtt server start {port} {channel}'] + self._spawn(cmd) + self._connect(port) + + def _gentle_stop(self, proc) -> None: + # no stdin channel to ask openocd to exit, and it keeps its listener up after + # the client disconnects: go straight to the group takedown instead of blocking + # the base class's 5 s wait on a process that has no reason to leave + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(proc.pid, signal.SIGTERM) + + +def dump_ring(probe: str, device: str, addr: int, out_path: str, channel: int = 0) -> int: + """Post-mortem: read aUp[channel]'s ring over the debug AP (no halt) via JLinkExe. + NO_BLOCK_SKIP means an undrained ring holds the FIRST KB after boot, not the + tail — interpretation rules in the target-debug skill.""" + if re.search(r'[\s"\']', out_path): + raise SystemExit(f'--dump path must not contain whitespace or quotes: {out_path!r} ' + f'(it is spliced into a JLinkExe script line)') + # a stale file from an earlier run must not satisfy the success check below + with contextlib.suppress(OSError): + os.remove(out_path) + # SEGGER_RTT_CB: acID[16], MaxNumUpBuffers, MaxNumDownBuffers, then aUp[] at 0x18, + # each ring 6 words {sName, pBuffer, SizeOfBuffer, WrOff, RdOff, Flags}. Read the + # counts with the descriptor so an out-of-range channel is rejected instead of + # reading whatever RAM follows the array. + jlink = ['JLinkExe', '-USB', probe, '-device', device, '-if', 'swd', + '-speed', '4000', '-NoGui', '1', '-AutoConnect', '1'] + + def _jlink_run(script: str): + # same clean-exit contract as nm_rtt_addr/_spawn: a missing binary or a wedged + # probe must not reach the CLI as a traceback + try: + return subprocess.run(jlink, input=script, capture_output=True, text=True, timeout=60) + except FileNotFoundError: + raise SystemExit('JLinkExe not on PATH — the --dump route needs J-Link Commander') + except subprocess.TimeoutExpired: + raise SystemExit('JLinkExe did not finish in 60 s — probe wedged or target unreachable?') + + script = f'mem32 {addr + 0x10:#x}, 2\nmem32 {addr + 0x18 + channel * 24:#x}, 6\nexit\n' + r = _jlink_run(script) + words = [] + for line in r.stdout.splitlines(): + # UNANCHORED: when the script arrives on stdin, some JLinkExe versions glue + # the 'J-Link>' prompt onto the result line with no newline between + m = re.search(r'([0-9A-Fa-f]{8}) = ((?:[0-9A-Fa-f]{8} ?)+)$', line.strip()) + if m: + words += [int(w, 16) for w in m.group(2).split()] + if len(words) < 8: + print(r.stdout[-500:], file=sys.stderr) + raise SystemExit(f'could not read the aUp[{channel}] descriptor — wrong control block address?') + max_up = words[0] + if not 0 < max_up <= 32: + raise SystemExit(f'control block at {addr:#x} looks uninitialized ' + f'(MaxNumUpBuffers={max_up}) — the target has not written to RTT yet, ' + f'or the address is wrong') + if channel >= max_up: + raise SystemExit(f'--channel {channel}: this firmware has {max_up} up-buffer(s) (0..{max_up - 1})') + _, pbuf, size, wroff, rdoff, _ = words[2:8] + if not pbuf or not size: + raise SystemExit(f'up-buffer {channel} is not initialized (pBuffer={pbuf:#x} size={size}) — ' + f'the target has not written to it yet') + script = f'savebin {out_path}, {pbuf:#x}, {size:#x}\nexit\n' + _jlink_run(script) + # JLinkExe exits 0 even when a command inside its script fails, so the only proof + # savebin worked is the file itself: it must hold the WHOLE ring, since a read that + # dies partway (probe disconnect, unreadable address) still leaves a short file that + # would otherwise be reported as a complete dump. Removing it also keeps the + # invariant above -- no stale file can satisfy a later run's check. + got = os.path.getsize(out_path) if os.path.exists(out_path) else 0 + if got < size: + with contextlib.suppress(OSError): + os.remove(out_path) + if got == 0: + raise SystemExit(f'savebin produced no data at {out_path} — probe or address problem') + raise SystemExit(f'savebin wrote {got}/{size} B to {out_path} (truncated dump removed) ' + f'— probe or address problem') + print(f'ring: {size} B at {pbuf:#x}, WrOff={wroff:#x} RdOff={rdoff:#x} -> {out_path}\n' + f'valid bytes wrap at WrOff; default NO_BLOCK_SKIP holds the FIRST data after ' + f'boot, not the tail', file=sys.stderr) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--backend', choices=['jlink', 'openocd'], required=True, + help='transport route — explicit, no default (skill transport matrix)') + ap.add_argument('--probe', help='probe serial (JLinkExe -USB / openocd "adapter serial")') + ap.add_argument('--vid-pid', help='openocd probe pin by USB IDs, e.g. "0x2e8a 0x000c" ' + '(with or instead of --probe)') + ap.add_argument('--device', help='JLINK_DEVICE from board.cmake/family.cmake (jlink backend)') + ap.add_argument('--cfg', help='openocd -f/-c args, e.g. "-f interface/stlink.cfg -f target/stm32h7x.cfg"') + ap.add_argument('--elf', help='the FLASHED elf: exact _SEGGER_RTT address via nm (openocd/--dump)') + ap.add_argument('--addr', help='SEGGER RTT control block address (hex), instead of --elf') + ap.add_argument('--channel', type=int, default=0, help='up-buffer index (0 console, 1 SysView)') + ap.add_argument('--seconds', type=float, default=0, help='capture duration; 0 = until Ctrl-C/EOF') + ap.add_argument('-i', '--interactive', action='store_true', help='forward stdin to the target') + ap.add_argument('--reset-before-attach', action='store_true', + help='openocd: reset the target inside the capture session so the ' + 'server is draining when it boots (needed for streams that must ' + 'include the boot preamble, e.g. SystemView); unsafe on SAMD5x/WCH') + ap.add_argument('--dump', metavar='OUT.bin', + help='post-mortem ring dump (jlink backend; needs --elf or --addr)') + args = ap.parse_args() + + if args.seconds < 0 or args.seconds != args.seconds: # negative or nan + ap.error(f'--seconds must be >= 0 (0 = until Ctrl-C/EOF), got {args.seconds}') + if args.channel < 0: + # a negative index would walk backwards off aUp[] into the control-block + # header and read garbage as a descriptor + ap.error(f'--channel must be >= 0, got {args.channel}') + + def rtt_addr(): + if args.addr: + try: + return int(args.addr, 16) + except ValueError: + ap.error(f'--addr must be hex, got {args.addr!r}') + if args.elf: + return nm_rtt_addr(args.elf) + ap.error('need --elf (flashed elf, address via nm) or --addr') + + if args.backend == 'jlink': + if args.reset_before_attach: + ap.error('--reset-before-attach is openocd-only (the J-Link route attaches ' + 'to a running target; flash and reset before starting it)') + if args.channel and not args.dump: + # -RTTTelnetPort serves the Terminal buffer only; --dump can read any ring + ap.error('the jlink backend streams channel 0 only (use --backend openocd ' + 'for another channel, or --dump to read one)') + if args.vid_pid: + ap.error('--vid-pid is openocd-only; J-Link probes are selected by serial (--probe)') + if not (args.probe and args.device): + ap.error('the jlink backend needs --probe and --device') + elif not (args.probe or args.vid_pid): + ap.error('the openocd backend needs --probe and/or --vid-pid') + + if args.dump: + if args.backend != 'jlink': + ap.error('--dump uses the jlink backend (debug-AP reads via JLinkExe)') + return dump_ring(args.probe, args.device, rtt_addr(), args.dump, args.channel) + + # install BEFORE the console exists: an external `timeout`/kill during the + # up-to-15 s connect window must still reach the cleanup below, or the openocd + # route leaves a server holding the probe and the port (JLinkExe would exit on + # stdin EOF; openocd has no such channel and its own session shields it) + def _terminate(signum, _frame): + raise KeyboardInterrupt + for _sig in (signal.SIGTERM, signal.SIGHUP): + with contextlib.suppress(ValueError, OSError): + signal.signal(_sig, _terminate) + + try: + if args.backend == 'openocd': + if not args.cfg: + ap.error('--backend openocd needs --cfg') + con = OpenocdRtt(args.cfg, rtt_addr(), args.channel, + serial_no=args.probe, vid_pid=args.vid_pid, + reset_before_attach=args.reset_before_attach) + else: + con = JlinkRtt({'flasher': {'uid': args.probe, 'args': f'-device {args.device}'}}, + timeout=0.1) + except RttError as e: + print(e, file=sys.stderr) + return 1 + except KeyboardInterrupt: + return 130 # constructors clean up after themselves on the way out + + saw_output = threading.Event() + forwarded = threading.Event() + if args.interactive: + def pump_stdin(): + # Hold input until the capture side has seen TARGET output (or 5 s for a + # quiet firmware): the J-Link telnet route silently DROPS client bytes + # until Commander locates the control block, so input forwarded at attach + # vanishes (measured on the rig: instant 'ping' lost, delayed 'ping' + # echoed). The gate must ignore the server's own banner — it arrives at + # connect, BEFORE the block is found. Raw os.read, not sys.stdin.buffer: + # bytes with no newline wait, and no BufferedReader lock — a daemon + # thread blocked holding that lock at interpreter shutdown aborts + # CPython (_enter_buffered_busy). + saw_output.wait(5) + try: + while True: + data = os.read(0, 4096) + if not data: + return + con.write(data) + forwarded.set() + except (RttError, OSError, ValueError): + return # console closed/stalled/dead; capture side reports the state + threading.Thread(target=pump_stdin, daemon=True).start() + + deadline = time.monotonic() + args.seconds if args.seconds else None + rc = 0 + seen = b'' # pre-release accumulator for the banner check only + try: + while deadline is None or time.monotonic() < deadline: + try: + chunk = con.read(con.in_waiting or 1) + except RttError as e: + print(f'rtt: {e}', file=sys.stderr) + rc = 1 + break + if chunk: + if args.interactive and not saw_output.is_set(): + # target data = anything past the J-Link banner's final line + # ('Process: '); the openocd server has no banner + seen = (seen + chunk)[-65536:] + if args.backend != 'jlink': + saw_output.set() + else: + i = seen.find(b'Process: ') + j = seen.find(b'\n', i) if i >= 0 else -1 + if j >= 0 and len(seen) > j + 1: + saw_output.set() + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + elif con.eof: + print('rtt: server closed the connection', file=sys.stderr) + rc = 1 + break + except KeyboardInterrupt: + pass + except BrokenPipeError: + # downstream consumer (head/grep -m) closed the pipe: a normal way to end a + # capture, not an error. Point stdout at devnull so interpreter shutdown does + # not raise on the final implicit flush. + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + finally: + if args.interactive and not forwarded.is_set(): + # only claim what is true: the gate releases after 5 s and forwards anyway, + # so "never forwarded" must come from the forwarded flag, not the gate + print('rtt: -i stdin was never forwarded to the target (no input arrived, ' + 'or the console closed first)', file=sys.stderr) + if args.interactive and not saw_output.is_set(): + print('rtt: no target output within the window', file=sys.stderr) + # a late TERM landing during the up-to-12 s teardown must not skip the kill + # escalation and orphan the server -- cleanup is committed at this point + for _sig in (signal.SIGTERM, signal.SIGHUP): + with contextlib.suppress(ValueError, OSError): + signal.signal(_sig, signal.SIG_IGN) + con.close() + return rc + + +if __name__ == '__main__': + sys.exit(main()) -- cgit v1.3.1 From 6e8e2caf7fc2dc5575f5e0c71e69c1b19b3f5699 Mon Sep 17 00:00:00 2001 From: hathach Date: Fri, 28 Aug 2026 16:39:58 +0700 Subject: pico2_etm_trace: RP2350 board on the MIPI-20 ETM trace carrier Board files for the trace carrier (console GP12/13, LED GP10, I2C GP8/9, PIO-USB host on GP20, all retargeted in board.cmake so the SDK defaults cannot mux a trace pin), compile-time trace pin-conflict checks, the measured DBGPAUSE rationale, Ozone project, and skill/docs updates. Trace validated at the stock 150 MHz (75 MHz TRACECLK, +1 ns sampling): zero overflow through a 15 s throughput soak; V2 probe ceiling 120 MHz. --- .claude/skills/etm-trace/boards.md | 100 +++++++++++++-------- .idea/cmake.xml | 2 + docs/reference/boards.rst | 9 +- hw/bsp/BoardPresets.json | 22 +++++ hw/bsp/rp2040/boards/pico2_etm_trace/board.cmake | 35 ++++++++ hw/bsp/rp2040/boards/pico2_etm_trace/board.h | 80 +++++++++++++++++ .../boards/pico2_etm_trace/ozone/rp2350.jdebug | 79 ++++++++++++++++ .../rp2040/boards/raspberry_pi_pico2/board.cmake | 14 --- .../boards/raspberry_pi_pico2/ozone/rp2350.jdebug | 70 --------------- hw/bsp/rp2040/family.c | 45 ++++++---- tools/build.py | 1 + 11 files changed, 316 insertions(+), 141 deletions(-) create mode 100644 hw/bsp/rp2040/boards/pico2_etm_trace/board.cmake create mode 100644 hw/bsp/rp2040/boards/pico2_etm_trace/board.h create mode 100644 hw/bsp/rp2040/boards/pico2_etm_trace/ozone/rp2350.jdebug delete mode 100644 hw/bsp/rp2040/boards/raspberry_pi_pico2/ozone/rp2350.jdebug (limited to 'tools') diff --git a/.claude/skills/etm-trace/boards.md b/.claude/skills/etm-trace/boards.md index 044d4e0ee..f54e1d7d6 100644 --- a/.claude/skills/etm-trace/boards.md +++ b/.claude/skills/etm-trace/boards.md @@ -26,7 +26,7 @@ reference. | mimxrt1170_evkb | 996 MHz | 50 MHz (root/2) | 1 | 0 | weld 0 Ω R1881-R1886; JP4 shorted; J58 (populated) | re-weld R1884 (D3 open; D1/D2 meter-verified good) → width 4 | | ra6m5_ek (M33) | 200 MHz | 25 MHz (TRCLK/4 /2) | 4 | 0 (unset) | J9 closed; native J20 trace | — | | ra8m1_ek (M85) | 480 MHz | 60 MHz (TRCLK/4 /2) | 4 | 0 (unset) | J9 closed + Table 7 jumpers | — | -| raspberry_pi_pico2 (RP2350 M33) | 48 MHz | 24 MHz (clk_sys/2) | 4 | 0 (unset) | fly-wire GPIO1-5 → MIPI20 (map in jdebug) | 72-80 MHz per seating (re-qualify); >80 needs V3 probe + trace board | +| pico2_etm_trace (RP2350 M33) | 150 MHz | 75 MHz (clk_sys/2) | 4 | +1 ns | Pico 2 on the trace-carrier PCB (MIPI-20) | — | | same54_xplained (E54 M4F) | 120 MHz | 60 MHz (CPU/2) | 4 | 0 (unset) | none — populated 20-pin ETM header | — | | same70_xplained (E70 M7) | 300 MHz | 37.5 MHz (PCK3/2) | 1 | 0 (unset) | solder 20-pin header on J403 (bottom) | width 4 blocked: D1 (J403.16) dead at speed — probe-channel crosscheck pending | | SEGGER H7/F407 ref | demo defaults | demo | 4 | demo | probe-powered: add `--power` | — | @@ -105,42 +105,68 @@ Board caveats (beyond the table): the decoder at t≈0.05 s every run. Runs both chip maxima (120 MHz TRCLK, 60 MHz pin) clean. `ReadIntoTraceCache 0x0 0x10000` in the download hook covers runtime chip-ROM execution. ISR entry: `tusb_int_handler`. -- **raspberry_pi_pico2** (RP2350): TRACECLK is a fixed clk_sys/2, no divider - (DDR data, like every ARM TPIU pin port). **Measured cliff on this rig:** - 80 MHz core (40 MHz TRACECLK) traces idle code but dies under dense data; - 88 MHz+ dies instantly at any width/global-timing/TIF/pad setting. Cause - not pinned down: the same V2 probe samples 66 MHz TRACECLK (132 Msample/s) - on metro_m7_1011, so it is NOT a plain probe sample-rate ceiling. The - cliff at >40 MHz TRACECLK (84+ MHz core) survived a full sweep - global - AND per-pin `--trace-timing`, pad drive 2/4/8/12 mA + slew, width 4/2/1, - TIF 1-25 MHz, newer J-Link library - all flat, so it is V3-probe / real- - trace-board territory (SEGGER's Pico 2 KB requires J-Trace PRO **V3.0+** - and recommends a proper trace board; community reports fly-wires fail at - 75 MHz for everyone, PCBs work). Separately, fly-wire seating quality - sets the width-4 DENSE-data ceiling (48-72 MHz observed across seatings): - after ANY rewiring re-qualify with idle blinky at the target clock, then - cdc_msc x3. Random unknown-packet deaths KB into a clean stream = one - marginal wire; `--trace-width` 1 vs 2 vs 4 bisects which (width 1 = - CLK+D0 only; D1 = GPIO3->MIPI20 pin 16 has gone marginal twice on this - rig). Width-1 is a full-quality fallback: complete cdc_msc profiles at up - to 80 MHz core even when width 4 is broken. - **Never set a custom JLinkScript** — it - replaces J-Link's built-in RP2350 device script, which both declares the - trace component map (funnel/TPIU/ETM are not in the ROM table → "Required - trace components for pin trace not found", 0 fetches) and re-arms the whole - chip-side path via `OnTraceStart` at every resume. Firmware therefore does - no trace setup; TRACE_ETM builds only (a) pin clk_sys to 48 MHz from crt0 - (board.cmake) — the fly-wire ceiling: 96/150 MHz kill the stream in the - startup burst at any sample timing (and at 150 MHz the saturated probe - stops answering halts, "CPU could not be halted"); any post-arm clock - change steps TRACECLK mid-stream and kills the decoder — and (b) - clear TIMER0/1 DBGPAUSE (family.c): debug sessions leave cores - halted-at-reset and the default DBGPAUSE freezes the µs timer, so every - `sleep_ms()` spins forever (looks like a dead board; watchdog-scratch - breadcrumbs survive warm resets but not POR when diagnosing). UART console - is TX-only (GPIO1 = TRACECLK). Empty reset/download hooks: the bootrom - must run the IMAGE_DEF. If the chip ends up wedged/un-attachable: - J-Link `erase` + reset drops it into BOOTSEL (2e8a:000f) for picotool. +- **pico2_etm_trace** (Pico 2 / RP2350 on the carrier; board `raspberry_pi_pico2` + is the bare module and has no trace wiring): rig = **pico2 trace motherboard PCB** + (~/code/pcb/pico2_trace_motherboard: MIPI-20, 27 Ohm source-terminated, + GND-guarded). TRACECLK is a fixed clk_sys/2 (DDR), so the board traces at + the rp2350 pico-sdk default 150 MHz -> 75 MHz TRACECLK width 4, validated + 2026-08-26: cdc_msc enumeration burst 3/3, zero overflow, **data sampling + +1 ns** (committed in the reference; idle eye -1000..+2000 ps, +3000 dead; + TD aliases modulo the 6.67 ns UI); soak: cdc_msc_throughput under a live + host CDC+MSC bulk pump, 3/3 x 15 s, zero overflow, 53.7M fetches (DCD hot + path at 9% load). `TRACE_ETM` is set by the board's own board.cmake - no + build flag needed. + **Other rates need a hand-built clock**: pass `SYS_CLK_KHZ` *together with* + `PLL_SYS_VCO_FREQ_HZ`/`POSTDIV1`/`POSTDIV2` from the SDK's + `scripts/vcocalc.py` as compile definitions (a bare `-DSYS_CLK_KHZ=` only + sets a CMake cache var and is silently ignored - the BSP no longer carries + a PLL table). Measured: 180000 = 90 MHz TRACECLK, loaded eye + +4000..+5000 ps (3/3); 240000 = **the J-Trace PRO V2 ceiling** (120 MHz + TRACECLK, TD +3500) — **⚠ 240 MHz was measured with the core regulator + raised to 1.15 V, which nothing does automatically any more: add + `SYS_CLK_VREG_VOLTAGE_AUTO_ADJUST=1` and + `SYS_CLK_VREG_VOLTAGE_MIN=VREG_VOLTAGE_1_15` yourself, or the chip runs + 60% over its 150 MHz rating at stock 1.10 V.** >=125 MHz + TRACECLK is a hard probe wall at every sample delay/width (the V2 AT its + documented limit: Arm spec 100 MHz in-spec, SEGGER's tuned-V2 best is + 120; the 150 MHz on current product pages is V3/V4). **Firmware needs + almost no trace code**: J-Link's built-in RP2350 device script declares + the off-ROM-table trace components (funnel/TPIU/ETM) and re-arms the + whole chip-side path via OnTraceStart at every resume — **never set a + custom JLinkScript** (it replaces the built-in script: "Required trace + components for pin trace not found", 0 fetches). What TRACE_ETM (set by + this board's board.cmake) does in firmware: (a) clears TIMER0/1 DBGPAUSE + — J-Link does NOT clear it, and with the reset default the us-timer + freezes while a core is debug-halted, so sleep_ms() spins forever after + any debugger session (measured: DBGPAUSE reads 0x7 and TIMERAWL stands + still until the clear); (b) compile-time pin-conflict checks — #error if + the UART console lands on a trace pin GP1-5, #pragma message if the + default I2C does. The console itself is full-duplex on GP12/13 (the + carrier routes it off GP0/1; the old TX-only fallback is gone with the + fly-wire rig). PCB A/B validation did remove the 12 mA fast-slew trace + pads (default pads pass 3/3 with a wider idle eye) — do not re-add + without fresh PCB evidence. A runtime clk_sys switch **silently + truncates the capture at the switch** (no decoder error — profile just + ends; verified 3/3 with a board_init-time 120->156 step), so nothing may + re-switch the clock at runtime. + **This is the only trace-capable board in the rp2040 family** - it owns the + sole ozone reference, so `--board ` exits + with "cannot resolve J-Link device" (the script's board.cmake fallback + cannot help: this family sets `JLINK_DEVICE` in family.cmake). Capture with + `--board pico2_etm_trace`. + **Arm-phase flake**: an occasional instant unknown-packet death at + offset ~0x10-0x6C right at trace start — just re-run; only mid-stream + deaths indicate a real problem. **Loose MIPI-20 cable symptom ladder**: + flash "Failed to perform RAMCode-sided Prepare()" / "Download failed" + first, then "Target voltage too low" (VTref lost) — reseat the cable at + both ends before debugging software. Empty reset/download hooks in the + reference: the bootrom must run the IMAGE_DEF (setting SP/PC from the + vector table bypasses it and the pico-sdk runtime never comes up). If + the chip ends up wedged/un-attachable: J-Link `erase` + reset drops it + into BOOTSEL (2e8a:000f) for picotool. *Historical*: bring-up used a + fly-wire rig (same GPIO1-5 -> MIPI20 map) whose wire SI capped TRACECLK + at 24-40 MHz and motivated the removed workarounds; it is retired — + details in git history (the 48/80 MHz PLL rows left with it). - **same54_xplained**: the CM4 trace unit is clocked from **GCLK channel 47 (GCLK_CM4_TRACE)** — with it disabled the pins mux fine, TPIU/ETM arm fine, and the port stays perfectly silent (zero fetches, no errors); diff --git a/.idea/cmake.xml b/.idea/cmake.xml index 23e8af7ea..f5f1fda2d 100644 --- a/.idea/cmake.xml +++ b/.idea/cmake.xml @@ -9,6 +9,8 @@ + + diff --git a/docs/reference/boards.rst b/docs/reference/boards.rst index 8b0f798ba..794c4fa51 100644 --- a/docs/reference/boards.rst +++ b/docs/reference/boards.rst @@ -236,19 +236,20 @@ nrf54lm20dk Nordic nRF54LM20 DK nrf ht Raspberry Pi ------------ -================================ ============================================ ============== ========================================================== ====== -Board Name Family URL Note -================================ ============================================ ============== ========================================================== ====== +================================ ============================================ ============== ================================================================ ====== +Board Name Family URL Note +================================ ============================================ ============== ================================================================ ====== raspberrypi_zero Raspberry Pi Zero broadcom_32bit https://www.raspberrypi.org/products/raspberry-pi-zero/ raspberrypi_cm4 Raspberry CM4 broadcom_64bit https://www.raspberrypi.org/products/compute-module-4 raspberrypi_zero2 Raspberry Zero2 broadcom_64bit https://www.raspberrypi.org/products/raspberry-pi-zero-2-w adafruit_feather_rp2040_usb_host Adafruit Feather RP2040 with USB Type A Host rp2040 https://www.adafruit.com/product/5723 adafruit_fruit_jam Adafruit Fruit Jam - Mini RP2350 rp2040 https://www.adafruit.com/product/6200 adafruit_metro_rp2350 Adafruit Metro RP2350 rp2040 https://www.adafruit.com/product/6003 +pico2_etm_trace Pico 2 ETM Trace Carrier rp2040 https://github.com/hathach/pcb/tree/main/pico2_trace_motherboard raspberry_pi_pico Pico rp2040 https://www.raspberrypi.com/products/raspberry-pi-pico/ raspberry_pi_pico2 Pico2 rp2040 https://www.raspberrypi.com/products/raspberry-pi-pico-2/ raspberry_pi_pico_w Pico rp2040 https://www.raspberrypi.com/products/raspberry-pi-pico/ -================================ ============================================ ============== ========================================================== ====== +================================ ============================================ ============== ================================================================ ====== Renesas ------- diff --git a/hw/bsp/BoardPresets.json b/hw/bsp/BoardPresets.json index a480efc3e..280d6d592 100644 --- a/hw/bsp/BoardPresets.json +++ b/hw/bsp/BoardPresets.json @@ -506,6 +506,10 @@ "name": "nutiny_sdk_nuc505", "inherits": "default" }, + { + "name": "pico2_etm_trace", + "inherits": "default" + }, { "name": "pico_sdk", "inherits": "default" @@ -1640,6 +1644,11 @@ "description": "Build preset for the nutiny_sdk_nuc505 board", "configurePreset": "nutiny_sdk_nuc505" }, + { + "name": "pico2_etm_trace", + "description": "Build preset for the pico2_etm_trace board", + "configurePreset": "pico2_etm_trace" + }, { "name": "pico_sdk", "description": "Build preset for the pico_sdk board", @@ -3900,6 +3909,19 @@ } ] }, + { + "name": "pico2_etm_trace", + "steps": [ + { + "type": "configure", + "name": "pico2_etm_trace" + }, + { + "type": "build", + "name": "pico2_etm_trace" + } + ] + }, { "name": "pico_sdk", "steps": [ diff --git a/hw/bsp/rp2040/boards/pico2_etm_trace/board.cmake b/hw/bsp/rp2040/boards/pico2_etm_trace/board.cmake new file mode 100644 index 000000000..53f9132b5 --- /dev/null +++ b/hw/bsp/rp2040/boards/pico2_etm_trace/board.cmake @@ -0,0 +1,35 @@ +set(PICO_PLATFORM rp2350-arm-s) +set(PICO_BOARD pico2) + +# ETM trace is wired on this carrier only (GP1-5 -> MIPI-20), so the trace +# build flag lives here rather than being a global -D anyone can pass: on a +# board whose PIO-USB D+ sits on GP1 (e.g. adafruit_fruit_jam) it would fight +# the trace clock. +set(TRACE_ETM 1) + +# Point the pico-sdk's own defaults at the carrier's wiring: pico2.h guards +# every PICO_DEFAULT_* with #ifndef, so these win. Without them anything that +# talks to the SDK directly instead of the TinyUSB BSP (e.g. stdio_init_all() +# in examples/device/cdc_uac2) would mux GP0/GP1 for UART - and GP1 is +# TRACECLK, so it would silently kill the trace clock mid-capture. +add_compile_definitions( + PICO_DEFAULT_UART_TX_PIN=12 + PICO_DEFAULT_UART_RX_PIN=13 + PICO_DEFAULT_LED_PIN=10 + PICO_DEFAULT_I2C=0 # STEMMA-QT / Qwiic port on GP8/9; + PICO_DEFAULT_I2C_SDA_PIN=8 # the sdk default GP4/5 is TRACEDATA2/3 + PICO_DEFAULT_I2C_SCL_PIN=9 +) + +# the carrier's MIPI-20 is driven by a J-Trace; uncomment (or pass +# -DJLINK_OPTION=...) to pin one probe by USB nickname/serial when several +# J-Links are attached during hardware validation +#set(JLINK_OPTION "-USB jtrace") + +# Clock: the rp2350 pico-sdk default, 150 MHz -> 75 MHz TRACECLK (clk_sys/2), +# validated on the trace motherboard: cdc_msc enumeration burst 3/3, zero +# overflow, +1 ns data sampling (idle eye -1000..+2000 ps; committed in the +# ozone reference). Nothing may switch clk_sys at runtime - that truncates a +# capture at the switch. Other validated rates (156000, 180000, and 240000 = +# the J-Trace PRO V2 ceiling) need PLL_SYS_* from the SDK's vcocalc.py; see +# the etm-trace skill's boards.md. diff --git a/hw/bsp/rp2040/boards/pico2_etm_trace/board.h b/hw/bsp/rp2040/boards/pico2_etm_trace/board.h new file mode 100644 index 000000000..863d0e6b9 --- /dev/null +++ b/hw/bsp/rp2040/boards/pico2_etm_trace/board.h @@ -0,0 +1,80 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2025 Ha Thach (tinyusb.org) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * This file is part of the TinyUSB stack. + */ + +/* metadata: + name: Pico 2 ETM Trace Carrier + url: https://github.com/hathach/pcb/tree/main/pico2_trace_motherboard +*/ + +// Raspberry Pi Pico 2 seated on the "pico2 trace motherboard" carrier: a +// MIPI-20 Cortex Debug+ETM adapter (SWD + 4-bit trace) plus a TinyUSB test +// bench. Same RP2350 module as raspberry_pi_pico2, different pin map: the +// carrier keeps GP1-5 free for TRACECLK/TRACEDATA0-3 and moves the console, +// LED, button and USB control pins out of the way. +// +// Carrier pin map (only the pins the BSP uses are defined below): +// 0 GND guard (JP2) 1 TRACECLK +// 2-5 TRACEDATA0-3 6 GND guard (JP3) +// 8/9 I2C0 SDA/SCL (STEMMA-QT) 10 user LED +// 11 device D+ pull-up enable 12/13 UART0 TX/RX (console) +// 14 user button (to GND, unused - BSP uses BOOTSEL) +// 15 host VBUS fault +// 16 native VBUS-detect tap 17 host VBUS enable +// 18/19 PIO-USB device D+/D- (J9) 20/21 PIO-USB host D+/D- (J5) +// 26 VBUS current sense (ADC) 27 J9 device VBUS-detect + +#ifndef TUSB_BOARD_H +#define TUSB_BOARD_H + +#ifdef __cplusplus + extern "C" { +#endif + +//--------------------------------------------------------------------+ +// LED, UART (button: the family BSP uses BOOTSEL, like every rp2040 board) +//--------------------------------------------------------------------+ +#define LED_PIN 10 +#define LED_STATE_ON 1 + +// console is on GP12/13, NOT the pico default GP0/1: GP1 is TRACECLK, so the +// console stays full-duplex while tracing +#define UART_DEV 0 // uart0 (index, see uart_get_instance) +#define UART_TX_PIN 12 +#define UART_RX_PIN 13 + +//--------------------------------------------------------------------+ +// PIO_USB +//--------------------------------------------------------------------+ +// host port J5 (USB-A): D+ = GP20, D- = GP21, load switch enable = GP17 +#define PICO_DEFAULT_PIO_USB_DP_PIN 20 +#define PICO_DEFAULT_PIO_USB_VBUSEN_PIN 17 +#define PICO_DEFAULT_PIO_USB_VBUSEN_STATE 1 + +#ifdef __cplusplus + } +#endif + +#endif diff --git a/hw/bsp/rp2040/boards/pico2_etm_trace/ozone/rp2350.jdebug b/hw/bsp/rp2040/boards/pico2_etm_trace/ozone/rp2350.jdebug new file mode 100644 index 000000000..fd5d589f2 --- /dev/null +++ b/hw/bsp/rp2040/boards/pico2_etm_trace/ozone/rp2350.jdebug @@ -0,0 +1,79 @@ +/********************************************************************* +* +* OnProjectLoad +* +* Function description +* Project load routine. Required. +* +* Notes +* Board pico2_etm_trace = a Pico 2 seated on the pico2 trace motherboard +* carrier (MIPI-20, source-terminated), GPIO1-5 to the MIPI20: +* TRACECLK=GPIO1->12, D0=GPIO2->14, D1=GPIO3->16, D2=GPIO4->18, +* D3=GPIO5->20. Firmware needs NO trace-specific code: J-Link's +* built-in RP2350 device script declares the off-ROM-table trace +* components (funnel/TPIU/ETM) and re-arms the whole chip-side path +* via OnTraceStart at every resume - do NOT set a custom JLinkScript +* here (it replaces that built-in script and J-Link then fails with +* "Required trace components for pin trace not found"). TRACE_ETM (set +* by this board's own board.cmake) clears TIMER0/1 DBGPAUSE - J-Link +* does not, and the reset default freezes the us-timer while a core is +* debug-halted - and adds compile-time checks that no console/I2C pin +* lands on the trace pins GP1-5; the console is full-duplex on GP12/13. +* clk_sys is the rp2350 pico-sdk default +* 150 MHz (75 MHz TRACECLK) and nothing may re-switch it at runtime: +* a mid-stream step silently truncates the capture. +* +********************************************************************** +*/ +void OnProjectLoad (void) { + Project.SetTraceSource ("Trace Pins"); + Project.SetTracePortWidth (4); + Project.SetSWO (0); + // +1 ns data sampling: at 75 MHz TRACECLK (DDR) on the trace motherboard + // the idle eye spans -1000..+2000 ps and cdc_msc passes 3/3 at +1000 + // (+3000 dead; TD aliases modulo the 6.67 ns UI) + Project.SetTraceTiming (1000, 1000, 1000, 1000); + Edit.SysVar (VAR_TRACE_CORE_CLOCK, 150000000); + Project.AddSvdFile ("$(InstallDir)/Config/CPU/Cortex-M33F.svd"); + + Project.SetDevice ("RP2350_M33_0"); + Project.SetHostIF ("USB", ""); + Project.SetTargetIF ("SWD"); + Project.SetTIFSpeed ("25 MHz"); + + File.Open ("../../../../../../examples/cmake-build-pico2_etm_trace/device/cdc_msc/cdc_msc.elf"); +} + +/********************************************************************* +* +* AfterTargetReset +* +* Function description +* Event handler routine. +* - Sets the PC register to program reset value. +* - Sets the SP register to program reset value on Cortex-M. +* +********************************************************************** +*/ +void AfterTargetReset (void) { + // intentionally empty: the RP2350 bootrom must run to validate the + // IMAGE_DEF and hand over to the app - setting SP/PC from the vector + // table bypasses it and the pico-sdk runtime never comes up +} + +/********************************************************************* +* +* AfterTargetDownload +* +* Function description +* Event handler routine. +* - Sets the PC register to program reset value. +* - Sets the SP register to program reset value on Cortex-M. +* +********************************************************************** +*/ +void AfterTargetDownload (void) { + // intentionally empty: the RP2350 bootrom must run to validate the + // IMAGE_DEF and hand over to the app - setting SP/PC from the vector + // table bypasses it and the pico-sdk runtime never comes up +} diff --git a/hw/bsp/rp2040/boards/raspberry_pi_pico2/board.cmake b/hw/bsp/rp2040/boards/raspberry_pi_pico2/board.cmake index 08384b0cd..0a7dd4d23 100644 --- a/hw/bsp/rp2040/boards/raspberry_pi_pico2/board.cmake +++ b/hw/bsp/rp2040/boards/raspberry_pi_pico2/board.cmake @@ -1,17 +1,3 @@ set(PICO_PLATFORM rp2350-arm-s) set(PICO_BOARD pico2) #set(OPENOCD_SERIAL E6614103E77C5A24) - -if (TRACE_ETM STREQUAL "1") - # TRACECLK is clk_sys/2 and must stay constant once trace is armed (a step - # desyncs the decoder), so the trace clock is pinned from crt0 onwards. - # 48 MHz (24 MHz TRACECLK) holds full-width trace on a typical fly-wire - # seating; a fresh, tight seating supports up to 72-80 MHz (re-qualify per - # the etm-trace skill), and >80 MHz needs a V3 probe + real trace board. - add_compile_definitions( - SYS_CLK_KHZ=48000 - PLL_SYS_VCO_FREQ_HZ=1440000000 - PLL_SYS_POSTDIV1=6 - PLL_SYS_POSTDIV2=5 - ) -endif () diff --git a/hw/bsp/rp2040/boards/raspberry_pi_pico2/ozone/rp2350.jdebug b/hw/bsp/rp2040/boards/raspberry_pi_pico2/ozone/rp2350.jdebug deleted file mode 100644 index ff48eb673..000000000 --- a/hw/bsp/rp2040/boards/raspberry_pi_pico2/ozone/rp2350.jdebug +++ /dev/null @@ -1,70 +0,0 @@ -/********************************************************************* -* -* OnProjectLoad -* -* Function description -* Project load routine. Required. -* -* Notes -* Pico 2 has no trace connector - fly-wire GPIO1-5 to the MIPI20: -* TRACECLK=GPIO1->12, D0=GPIO2->14, D1=GPIO3->16, D2=GPIO4->18, -* D3=GPIO5->20 (SEGGER validates this board the same way). Firmware must -* be built with TRACE_ETM=1: it pins clk_sys to 48 MHz (board.cmake) so -* the 4-bit port never saturates and the clock never steps mid-stream, -* and keeps the us-timer free of TIMER DBGPAUSE (family.c). The whole -* chip-side trace path (ETM/funnel/TPIU/pin mux) is armed by J-Link's -* built-in RP2350 script at every resume - do NOT set a custom -* JLinkScript here: it would replace that script and J-Link then fails -* with "Required trace components for pin trace not found". -* GPIO1 is the default UART0 RX: console TX still works, RX is lost. -* -********************************************************************** -*/ -void OnProjectLoad (void) { - Project.SetTraceSource ("Trace Pins"); - Project.SetTracePortWidth (4); - Project.SetSWO (0); - Edit.SysVar (VAR_TRACE_CORE_CLOCK, 48000000); - Project.AddSvdFile ("$(InstallDir)/Config/CPU/Cortex-M33F.svd"); - - Project.SetDevice ("RP2350_M33_0"); - Project.SetHostIF ("USB", ""); - Project.SetTargetIF ("SWD"); - Project.SetTIFSpeed ("25 MHz"); - - File.Open ("../../../../../../examples/cmake-build-raspberry_pi_pico2/device/cdc_msc/cdc_msc.elf"); -} - -/********************************************************************* -* -* AfterTargetReset -* -* Function description -* Event handler routine. -* - Sets the PC register to program reset value. -* - Sets the SP register to program reset value on Cortex-M. -* -********************************************************************** -*/ -void AfterTargetReset (void) { - // intentionally empty: the RP2350 bootrom must run to validate the - // IMAGE_DEF and hand over to the app - setting SP/PC from the vector - // table bypasses it and the pico-sdk runtime never comes up -} - -/********************************************************************* -* -* AfterTargetDownload -* -* Function description -* Event handler routine. -* - Sets the PC register to program reset value. -* - Sets the SP register to program reset value on Cortex-M. -* -********************************************************************** -*/ -void AfterTargetDownload (void) { - // intentionally empty: the RP2350 bootrom must run to validate the - // IMAGE_DEF and hand over to the app - setting SP/PC from the vector - // table bypasses it and the pico-sdk runtime never comes up -} diff --git a/hw/bsp/rp2040/family.c b/hw/bsp/rp2040/family.c index e12f51b14..32b5c2312 100644 --- a/hw/bsp/rp2040/family.c +++ b/hw/bsp/rp2040/family.c @@ -158,15 +158,34 @@ static void stdio_rtt_init(void) { } #endif -//--------------------------------------------------------------------+ -// -//--------------------------------------------------------------------+ #if defined(TRACE_ETM) && defined(PICO_RP2350) && PICO_RP2350 == 1 -// J-Link's built-in RP2350 device script re-arms the whole chip-side trace -// path (ETM/funnel/TPIU/pins) via OnTraceStart at every resume, so firmware -// must NOT touch it - it only keeps the us-timer running while cores sit -// debug-halted (default TIMER DBGPAUSE freezes it, and sleep_ms() then spins -// forever after any debugger session). +// ETM trace owns GP1-5 (GP1 = TRACECLK, GP2-5 = TRACEDATA0-3): muxing any of +// them away - even briefly - gaps the trace clock/data and desyncs the probe. +#define TRACE_PIN_CONFLICT(pin) ((pin) >= 1 && (pin) <= 5) +// board_init() muxes UART_TX_PIN/UART_RX_PIN, which are defined whenever UART_DEV is +#ifdef UART_DEV + #if TRACE_PIN_CONFLICT(UART_TX_PIN) || TRACE_PIN_CONFLICT(UART_RX_PIN) + #error "TRACE_ETM: UART TX/RX sits on a trace pin (GP1-5) - route the console elsewhere (pico2_etm_trace uses GP12/13)" + #endif +#endif +// stdio_init_all() muxes the sdk defaults even when the BSP console is elsewhere +#if defined(LIB_PICO_STDIO_UART) && defined(PICO_DEFAULT_UART_TX_PIN) && \ + (TRACE_PIN_CONFLICT(PICO_DEFAULT_UART_TX_PIN) || TRACE_PIN_CONFLICT(PICO_DEFAULT_UART_RX_PIN)) + #error "TRACE_ETM: pico-sdk default UART (stdio_init_all) sits on a trace pin (GP1-5)" +#endif +#if defined(PICO_DEFAULT_I2C_SDA_PIN) && (TRACE_PIN_CONFLICT(PICO_DEFAULT_I2C_SDA_PIN) || TRACE_PIN_CONFLICT(PICO_DEFAULT_I2C_SCL_PIN)) + // #pragma message, not #warning: examples build with -Werror, and this is + // only a hazard if the app actually uses i2c_default + #pragma message("TRACE_ETM: default I2C SDA/SCL sits on a trace pin (GP1-5) - using i2c_default will corrupt the trace stream (pico2_etm_trace routes I2C to GP8/9)") +#endif + +// A debugger session leaves a core halted (Ozone captures halt at the end, +// openocd halts both cores to flash), and TIMER's reset default pauses the +// us-timer whenever EITHER core is debug-halted - J-Link's RP2350 script does +// NOT clear it (verified: DBGPAUSE still reads 0x7, TIMERAWL frozen while +// halted). tusb_time_millis_api()/sleep_ms() then spin forever and the board +// looks dead, so free the timer for trace builds, which always run under a +// probe. static void trace_etm_init(void) { *(volatile uint32_t*) 0x400B002Cu = 0; // TIMER0 DBGPAUSE *(volatile uint32_t*) 0x400B802Cu = 0; // TIMER1 DBGPAUSE @@ -177,6 +196,8 @@ static void trace_etm_init(void) { void board_init(void) { + trace_etm_init(); + #if (CFG_TUH_ENABLED && CFG_TUH_RPI_PIO_USB) || (CFG_TUD_ENABLED && CFG_TUD_RPI_PIO_USB) // Set the system clock to a multiple of 12mhz for bit-banging USB with pico-usb #if defined(PICO_RP2350) && PICO_RP2350 == 1 @@ -217,17 +238,9 @@ void board_init(void) #ifdef UART_DEV uart_inst = uart_get_instance(UART_DEV); -#if defined(TRACE_ETM) && defined(PICO_RP2350) && PICO_RP2350 == 1 - // GPIO1 (default UART RX) is TRACECLK: TX-only console, and never touch - // GPIO1 - even a brief re-mux gaps the trace clock and desyncs the probe - bi_decl(bi_1pin_with_name(UART_TX_PIN, "UART TX")); - stdio_uart_init_full(uart_inst, CFG_BOARD_UART_BAUDRATE, UART_TX_PIN, -1); -#else bi_decl(bi_2pins_with_func(UART_TX_PIN, UART_RX_PIN, GPIO_FUNC_UART)); stdio_uart_init_full(uart_inst, CFG_BOARD_UART_BAUDRATE, UART_TX_PIN, UART_RX_PIN); #endif -#endif - trace_etm_init(); #if defined(LOGGER_RTT) stdio_rtt_init(); diff --git a/tools/build.py b/tools/build.py index 0bb366e3d..aa8868cb8 100755 --- a/tools/build.py +++ b/tools/build.py @@ -34,6 +34,7 @@ ci_skip_boards = { 'adafruit_fruit_jam', 'adafruit_metro_rp2350', 'feather_rp2040_max3421', + 'pico2_etm_trace', 'pico_sdk', 'raspberry_pi_pico_w', ], -- cgit v1.3.1