1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
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 <sn> --device <JLINK_DEVICE> [--seconds N] [-i]
OpenOCD route (native probes: ST-Link/CMSIS-DAP; console/capture, any channel)
rtt.py --backend openocd [--probe <sn>] [--vid-pid "0xVVVV 0xPPPP"] \\
--cfg "-f interface/stlink.cfg -f target/stm32h7x.cfg" \\
(--elf <flashed.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 <out.bin> --probe <sn> --device <JLINK_DEVICE> \\
(--elf <flashed.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=<your-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=<your-nm>, or pass --addr)')
for line in r.stdout.splitlines():
# "<addr> <type> _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: <name>'); 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())
|