crosspad-mcp-server 8.1.2 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +14 -0
- package/.mcp.json +9 -0
- package/README.md +95 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +381 -57
- package/dist/index.js.map +1 -1
- package/dist/tools/idf-flash.js +2 -2
- package/dist/tools/idf-flash.js.map +1 -1
- package/dist/tools/idf-monitor.d.ts +3 -1
- package/dist/tools/idf-monitor.js +19 -3
- package/dist/tools/idf-monitor.js.map +1 -1
- package/dist/tools/midi.js +20 -16
- package/dist/tools/midi.js.map +1 -1
- package/dist/tools/symbols.d.ts +3 -1
- package/dist/tools/symbols.js +31 -1
- package/dist/tools/symbols.js.map +1 -1
- package/dist/tools/trace-buffer.d.ts +40 -0
- package/dist/tools/trace-buffer.js +74 -0
- package/dist/tools/trace-buffer.js.map +1 -0
- package/dist/tools/trace-device.d.ts +10 -0
- package/dist/tools/trace-device.js +26 -0
- package/dist/tools/trace-device.js.map +1 -0
- package/dist/tools/trace-doctor.d.ts +43 -0
- package/dist/tools/trace-doctor.js +150 -0
- package/dist/tools/trace-doctor.js.map +1 -0
- package/dist/tools/trace-export.d.ts +4 -0
- package/dist/tools/trace-export.js +14 -0
- package/dist/tools/trace-export.js.map +1 -0
- package/dist/tools/trace-session.d.ts +118 -0
- package/dist/tools/trace-session.js +346 -0
- package/dist/tools/trace-session.js.map +1 -0
- package/dist/tools/trace-symbols.d.ts +24 -0
- package/dist/tools/trace-symbols.js +44 -0
- package/dist/tools/trace-symbols.js.map +1 -0
- package/dist/tools/trace-webui.d.ts +53 -0
- package/dist/tools/trace-webui.js +222 -0
- package/dist/tools/trace-webui.js.map +1 -0
- package/dist/utils/device.d.ts +5 -0
- package/dist/utils/device.js +43 -15
- package/dist/utils/device.js.map +1 -1
- package/dist/utils/exec.js +26 -0
- package/dist/utils/exec.js.map +1 -1
- package/dist/utils/userConfig.d.ts +13 -0
- package/dist/utils/userConfig.js +43 -0
- package/dist/utils/userConfig.js.map +1 -0
- package/package.json +12 -4
- package/skills/crosspad/SKILL.md +58 -0
- package/skills/crosspad/reference/faq.md +40 -0
- package/skills/crosspad/reference/install.md +84 -0
- package/skills/crosspad/reference/repos.md +29 -0
- package/skills/crosspad/reference/role-contributor.md +64 -0
- package/skills/crosspad/reference/role-fw-dev.md +44 -0
- package/skills/crosspad/reference/role-user.md +49 -0
- package/skills/crosspad/reference/tools.md +68 -0
- package/skills/crosspad/scripts/doctor.sh +65 -0
- package/skills/crosspad/scripts/setup.sh +53 -0
- package/skills/swd-tracer/SKILL.md +135 -0
- package/skills/swd-tracer/reference/signals.md +42 -0
- package/skills/swd-tracer/scripts/detect-env.sh +61 -0
- package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
- package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
- package/tracer/PROTOCOL.md +260 -0
- package/tracer/README.md +327 -0
- package/tracer/swd_tracer.py +1083 -0
- package/tracer/ui/index.html +834 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CrossPad SWD real-time tracer daemon (pyOCD).
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
symbols --elf PATH [--query STR] -> prints JSON {symbols:[...]} and exits
|
|
6
|
+
trace --elf PATH --signals NAMES [--rate HZ] [--out FILE] [--probe UID]
|
|
7
|
+
-> NDJSON frames on stdout until stdin {"cmd":"stop"}
|
|
8
|
+
Optional SWO/ITM:
|
|
9
|
+
--swo PORT:NAME[,...] -> EXPERIMENTAL: decode ITM stimulus ports onto named
|
|
10
|
+
signals. Requires firmware that emits ITM data on the
|
|
11
|
+
SWO pin — the current CrossPad firmware does NOT do this,
|
|
12
|
+
so this path is UNTESTED against a real ITM source.
|
|
13
|
+
If SWV initialisation fails the daemon continues with
|
|
14
|
+
plain RAM polling (fail-soft).
|
|
15
|
+
--cpu-hz HZ -> core clock for SWO baud derivation (default 64000000).
|
|
16
|
+
--swo-hz HZ -> desired SWO baud (must match firmware TPIU config,
|
|
17
|
+
default 2000000).
|
|
18
|
+
|
|
19
|
+
Output contract: machine JSON/NDJSON on stdout, human logs on stderr ONLY.
|
|
20
|
+
"""
|
|
21
|
+
import argparse, json, sys
|
|
22
|
+
|
|
23
|
+
def log(*a):
|
|
24
|
+
print(*a, file=sys.stderr, flush=True)
|
|
25
|
+
|
|
26
|
+
# DWARF DW_AT_encoding -> our tag.
|
|
27
|
+
_ENC = {1: "address", 2: "bool", 4: "float", 5: "int", 7: "uint", 8: "uchar", 6: "char"}
|
|
28
|
+
|
|
29
|
+
def _iter_addr_globals(dwarf):
|
|
30
|
+
"""Yield (name, address, die, cu) for every fixed-address global/static.
|
|
31
|
+
|
|
32
|
+
A fixed-address variable has a DW_AT_location of the form
|
|
33
|
+
DW_OP_addr (0x03) + 4-byte little-endian address. Shared by both the flat
|
|
34
|
+
`symbols` listing and the symbol-table the spec resolver walks from.
|
|
35
|
+
"""
|
|
36
|
+
for cu in dwarf.iter_CUs():
|
|
37
|
+
for die in cu.iter_DIEs():
|
|
38
|
+
if die.tag != "DW_TAG_variable":
|
|
39
|
+
continue
|
|
40
|
+
loc = die.attributes.get("DW_AT_location")
|
|
41
|
+
if not loc:
|
|
42
|
+
continue
|
|
43
|
+
expr = loc.value
|
|
44
|
+
if not isinstance(expr, list) or len(expr) != 5 or expr[0] != 0x03:
|
|
45
|
+
continue
|
|
46
|
+
addr = expr[1] | (expr[2] << 8) | (expr[3] << 16) | (expr[4] << 24)
|
|
47
|
+
# GCC (-Og/-O>0) splits an external global into a located definition
|
|
48
|
+
# DIE carrying only the address + DW_AT_specification, plus a separate
|
|
49
|
+
# declaration DIE that holds the name and type. Follow the
|
|
50
|
+
# specification so external globals (not just file-static ones) are
|
|
51
|
+
# traceable; read name/type from the declaration DIE.
|
|
52
|
+
info_die = die
|
|
53
|
+
name = die.attributes.get("DW_AT_name")
|
|
54
|
+
if name is None:
|
|
55
|
+
spec = die.attributes.get("DW_AT_specification") \
|
|
56
|
+
or die.attributes.get("DW_AT_abstract_origin")
|
|
57
|
+
if spec is not None:
|
|
58
|
+
try:
|
|
59
|
+
info_die = cu.dwarfinfo.get_DIE_from_refaddr(spec.value + cu.cu_offset)
|
|
60
|
+
name = info_die.attributes.get("DW_AT_name")
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
if not name:
|
|
64
|
+
continue
|
|
65
|
+
nm = name.value.decode("utf-8", "replace")
|
|
66
|
+
yield nm, addr, info_die, cu
|
|
67
|
+
|
|
68
|
+
def resolve_symbols(elf_path, query=None):
|
|
69
|
+
from elftools.elf.elffile import ELFFile
|
|
70
|
+
out = []
|
|
71
|
+
with open(elf_path, "rb") as f:
|
|
72
|
+
elf = ELFFile(f)
|
|
73
|
+
if not elf.has_dwarf_info():
|
|
74
|
+
raise RuntimeError("ELF has no DWARF info (build Debug with -g).")
|
|
75
|
+
dwarf = elf.get_dwarf_info()
|
|
76
|
+
for nm, addr, die, cu in _iter_addr_globals(dwarf):
|
|
77
|
+
if query and query.lower() not in nm.lower():
|
|
78
|
+
continue
|
|
79
|
+
enc, size = _resolve_type(die, cu)
|
|
80
|
+
sym = {"name": nm, "address": addr, "encoding": enc, "size": size}
|
|
81
|
+
# §8: best-effort richer metadata for UI autocomplete. Never fatal —
|
|
82
|
+
# on any DWARF surprise we fall back to the back-compat fields only.
|
|
83
|
+
try:
|
|
84
|
+
_enrich_symbol(sym, die, cu)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
out.append(sym)
|
|
88
|
+
# De-dup by (name,address); stable order by address.
|
|
89
|
+
seen, uniq = set(), []
|
|
90
|
+
for s in sorted(out, key=lambda x: x["address"]):
|
|
91
|
+
k = (s["name"], s["address"])
|
|
92
|
+
if k not in seen:
|
|
93
|
+
seen.add(k); uniq.append(s)
|
|
94
|
+
return uniq
|
|
95
|
+
|
|
96
|
+
def build_symbol_table(elf_path):
|
|
97
|
+
"""Map base name -> {address, type_die, cu} for every fixed-address global.
|
|
98
|
+
|
|
99
|
+
`type_die` is the resolved DW_AT_type DIE (or None) — the entry point for the
|
|
100
|
+
spec resolver's DWARF walk. Like resolve_symbols this de-dups by name (first
|
|
101
|
+
occurrence by address wins) so plain-name lookups stay deterministic.
|
|
102
|
+
"""
|
|
103
|
+
from elftools.elf.elffile import ELFFile
|
|
104
|
+
table = {}
|
|
105
|
+
with open(elf_path, "rb") as f:
|
|
106
|
+
elf = ELFFile(f)
|
|
107
|
+
if not elf.has_dwarf_info():
|
|
108
|
+
raise RuntimeError("ELF has no DWARF info (build Debug with -g).")
|
|
109
|
+
dwarf = elf.get_dwarf_info()
|
|
110
|
+
# Collect then sort by address so the de-dup winner is stable (lowest addr).
|
|
111
|
+
rows = sorted(_iter_addr_globals(dwarf), key=lambda r: r[1])
|
|
112
|
+
for nm, addr, die, cu in rows:
|
|
113
|
+
if nm in table:
|
|
114
|
+
continue
|
|
115
|
+
t = die.attributes.get("DW_AT_type")
|
|
116
|
+
type_die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset) if t else None
|
|
117
|
+
table[nm] = {"address": addr, "type_die": type_die, "cu": cu}
|
|
118
|
+
return table
|
|
119
|
+
|
|
120
|
+
def _resolve_type(die, cu):
|
|
121
|
+
"""Walk DW_AT_type to a base type, returning (encoding_tag, byte_size).
|
|
122
|
+
|
|
123
|
+
For arrays/pointers we return the underlying base-type encoding and the
|
|
124
|
+
*total* byte_size of the outermost type that carries DW_AT_byte_size.
|
|
125
|
+
"""
|
|
126
|
+
enc, size = "uint", 4
|
|
127
|
+
size_set = False
|
|
128
|
+
t = die.attributes.get("DW_AT_type")
|
|
129
|
+
depth = 0
|
|
130
|
+
while t is not None and depth < 16:
|
|
131
|
+
depth += 1
|
|
132
|
+
ref = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
|
|
133
|
+
bs = ref.attributes.get("DW_AT_byte_size")
|
|
134
|
+
if bs and not size_set:
|
|
135
|
+
size = bs.value
|
|
136
|
+
size_set = True
|
|
137
|
+
if ref.tag == "DW_TAG_base_type":
|
|
138
|
+
e = ref.attributes.get("DW_AT_encoding")
|
|
139
|
+
if e:
|
|
140
|
+
enc = _ENC.get(e.value, "uint")
|
|
141
|
+
return enc, size
|
|
142
|
+
if ref.tag == "DW_TAG_pointer_type":
|
|
143
|
+
return "uint", (size if size_set else 4)
|
|
144
|
+
t = ref.attributes.get("DW_AT_type")
|
|
145
|
+
return enc, size
|
|
146
|
+
|
|
147
|
+
def _enrich_symbol(sym, var_die, cu):
|
|
148
|
+
"""Annotate a symbol dict in place with §8 metadata.
|
|
149
|
+
|
|
150
|
+
Classifies the variable's type DIE (typedef/cv-stripped) into a `kind`:
|
|
151
|
+
scalar base/pointer/enum → (no extra fields)
|
|
152
|
+
array DW_TAG_array_type → dims,count,elem_size,elem_encoding
|
|
153
|
+
struct DW_TAG_structure_type → members[]
|
|
154
|
+
union DW_TAG_union_type → members[]
|
|
155
|
+
other anything else → (no extra fields)
|
|
156
|
+
Best-effort: leaves `sym` untouched (kind="other") if the type can't be
|
|
157
|
+
classified. Uses the shared _array_dim_counts / _resolve_type_from helpers.
|
|
158
|
+
"""
|
|
159
|
+
t = var_die.attributes.get("DW_AT_type")
|
|
160
|
+
if t is None:
|
|
161
|
+
sym["kind"] = "other"
|
|
162
|
+
return
|
|
163
|
+
die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
|
|
164
|
+
die = _strip_cv_typedef(die, cu)
|
|
165
|
+
if die is None:
|
|
166
|
+
sym["kind"] = "other"
|
|
167
|
+
return
|
|
168
|
+
tag = die.tag
|
|
169
|
+
if tag in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
|
|
170
|
+
sym["kind"] = "scalar"
|
|
171
|
+
elif tag == "DW_TAG_array_type":
|
|
172
|
+
sym["kind"] = "array"
|
|
173
|
+
counts = _array_dim_counts(die, cu)
|
|
174
|
+
et = die.attributes.get("DW_AT_type")
|
|
175
|
+
elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
|
|
176
|
+
elem = _strip_cv_typedef(elem, cu)
|
|
177
|
+
if counts:
|
|
178
|
+
prod = 1
|
|
179
|
+
for c in counts:
|
|
180
|
+
prod *= c
|
|
181
|
+
sym["dims"] = counts
|
|
182
|
+
sym["count"] = prod
|
|
183
|
+
if elem is not None and elem.tag in (
|
|
184
|
+
"DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
|
|
185
|
+
e_enc, e_size = _resolve_type_from(elem, cu)
|
|
186
|
+
sym["elem_size"] = e_size
|
|
187
|
+
sym["elem_encoding"] = e_enc
|
|
188
|
+
else:
|
|
189
|
+
es = _type_byte_size(elem, cu)
|
|
190
|
+
if es is not None:
|
|
191
|
+
sym["elem_size"] = es
|
|
192
|
+
elif tag in ("DW_TAG_structure_type", "DW_TAG_union_type"):
|
|
193
|
+
sym["kind"] = "struct" if tag == "DW_TAG_structure_type" else "union"
|
|
194
|
+
members = []
|
|
195
|
+
for child in die.iter_children():
|
|
196
|
+
if child.tag != "DW_TAG_member":
|
|
197
|
+
continue
|
|
198
|
+
nm = child.attributes.get("DW_AT_name")
|
|
199
|
+
if nm:
|
|
200
|
+
members.append(nm.value.decode("utf-8", "replace"))
|
|
201
|
+
if members:
|
|
202
|
+
sym["members"] = members
|
|
203
|
+
else:
|
|
204
|
+
sym["kind"] = "other"
|
|
205
|
+
|
|
206
|
+
def cmd_symbols(args):
|
|
207
|
+
syms = resolve_symbols(args.elf, args.query)
|
|
208
|
+
print(json.dumps({"symbols": syms}))
|
|
209
|
+
|
|
210
|
+
# --- trace mode ---------------------------------------------------------------
|
|
211
|
+
import io, os, re, struct, threading, time
|
|
212
|
+
|
|
213
|
+
def _try_open_session(probe, target, timeout_s):
|
|
214
|
+
"""Open a pyOCD session with a hard timeout and no-probe fast-fail.
|
|
215
|
+
|
|
216
|
+
Returns (session_or_None, error_str_or_None, timed_out_bool). The connect
|
|
217
|
+
work (USB enumeration + session.open()) runs in a daemon worker thread so a
|
|
218
|
+
libusb wedge cannot hang the process forever — if the worker doesn't finish
|
|
219
|
+
within timeout_s it is abandoned (uninterruptible C) and we report a timeout.
|
|
220
|
+
|
|
221
|
+
`blocking=False, return_first=True` makes a MISSING probe return immediately
|
|
222
|
+
instead of pyOCD blocking forever waiting for one to be plugged in.
|
|
223
|
+
"""
|
|
224
|
+
from pyocd.core.helpers import ConnectHelper
|
|
225
|
+
holder = {}
|
|
226
|
+
|
|
227
|
+
def opener():
|
|
228
|
+
# pyOCD prints "No connected debug probes" (and possibly probe-selection
|
|
229
|
+
# chatter) directly to stdout via print() — NOT through logging. stdout is
|
|
230
|
+
# our machine-JSON channel, so redirect it to stderr for the duration of
|
|
231
|
+
# the connect. Safe: main() is blocked in th.join() here, so nothing else
|
|
232
|
+
# writes stdout concurrently; we restore it before returning.
|
|
233
|
+
saved_stdout = sys.stdout
|
|
234
|
+
sys.stdout = sys.stderr
|
|
235
|
+
try:
|
|
236
|
+
s = ConnectHelper.session_with_chosen_probe(
|
|
237
|
+
blocking=False, return_first=True,
|
|
238
|
+
unique_id=probe or None,
|
|
239
|
+
# connect_mode='attach' = do NOT halt the core (pyOCD default
|
|
240
|
+
# 'halt' freezes RAM so every poll reads stale values).
|
|
241
|
+
options={"target_override": target, "connect_mode": "attach"})
|
|
242
|
+
if s is None:
|
|
243
|
+
holder["noprobe"] = True
|
|
244
|
+
return
|
|
245
|
+
s.open()
|
|
246
|
+
holder["session"] = s
|
|
247
|
+
except BaseException as e: # capture everything from the worker thread
|
|
248
|
+
holder["error"] = e
|
|
249
|
+
finally:
|
|
250
|
+
sys.stdout = saved_stdout
|
|
251
|
+
|
|
252
|
+
th = threading.Thread(target=opener, daemon=True)
|
|
253
|
+
th.start()
|
|
254
|
+
th.join(timeout_s)
|
|
255
|
+
if th.is_alive():
|
|
256
|
+
return None, "connect timeout after %gs (probe wedged? replug ST-Link)" % timeout_s, True
|
|
257
|
+
if holder.get("noprobe"):
|
|
258
|
+
return None, "no debug probe detected (replug ST-Link)", False
|
|
259
|
+
if "error" in holder:
|
|
260
|
+
return None, str(holder["error"]), False
|
|
261
|
+
return holder["session"], None, False
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# EXPERIMENTAL: SWO / ITM sink (only used when --swo is passed)
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
class _ITMValueSink:
|
|
268
|
+
"""Collects the latest ITM stimulus-port word.
|
|
269
|
+
|
|
270
|
+
Implements the TraceEventSink interface (receive(event)) without
|
|
271
|
+
subclassing so that the import of pyocd.trace.sink is deferred to the
|
|
272
|
+
--swo code path and never touched on the negative (plain-polling) path.
|
|
273
|
+
|
|
274
|
+
Thread-safe-ish: dict writes are GIL-guarded on CPython.
|
|
275
|
+
"""
|
|
276
|
+
def __init__(self):
|
|
277
|
+
self.latest = {} # port:int -> value:int
|
|
278
|
+
|
|
279
|
+
def receive(self, event):
|
|
280
|
+
try:
|
|
281
|
+
from pyocd.trace.events import TraceITMEvent
|
|
282
|
+
if isinstance(event, TraceITMEvent):
|
|
283
|
+
self.latest[event.port] = event.data
|
|
284
|
+
except Exception:
|
|
285
|
+
pass # never crash the probe thread
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _setup_swo(session, cpu_hz, swo_hz):
|
|
289
|
+
"""Wire up SWVReader with our custom ITM sink.
|
|
290
|
+
|
|
291
|
+
Strategy: call SWVReader.init() with a dummy StringIO console (so the
|
|
292
|
+
standard SWVEventSink is constructed), then immediately replace the
|
|
293
|
+
parser's connected sink with our own _ITMValueSink. This is the only
|
|
294
|
+
way to inject a custom sink given pyOCD 0.44's SWVReader.init() API
|
|
295
|
+
(signature: init(sys_clock, swo_clock, console:TextIO) -> bool).
|
|
296
|
+
|
|
297
|
+
Returns (reader, sink) on success, (None, None) on any failure.
|
|
298
|
+
The function is fail-soft: it logs to stderr and never raises.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
from pyocd.trace.swv import SWVReader
|
|
302
|
+
sink = _ITMValueSink()
|
|
303
|
+
reader = SWVReader(session, 0)
|
|
304
|
+
dummy_console = io.StringIO()
|
|
305
|
+
ok = reader.init(cpu_hz, swo_hz, dummy_console)
|
|
306
|
+
if not ok:
|
|
307
|
+
# init() already printed a pyOCD warning; add our own context.
|
|
308
|
+
log("[swo] SWVReader.init() returned False (probe/target may lack SWO support); "
|
|
309
|
+
"continuing with plain RAM polling only.")
|
|
310
|
+
return None, None
|
|
311
|
+
# Redirect the parser's downstream sink to ours.
|
|
312
|
+
# reader._parser is a SWOParser; SWOParser.connect(sink) replaces _sink.
|
|
313
|
+
reader._parser.connect(sink)
|
|
314
|
+
log(f"[swo] SWV reader started (cpu_hz={cpu_hz}, swo_hz={swo_hz}); "
|
|
315
|
+
"ITM data will be merged into sample frames when available.")
|
|
316
|
+
return reader, sink
|
|
317
|
+
except Exception as e:
|
|
318
|
+
log(f"[swo] setup failed, continuing with polling only: {e}")
|
|
319
|
+
return None, None
|
|
320
|
+
|
|
321
|
+
_NP = struct.Struct("<I") # little-endian u32 for the file header length prefix
|
|
322
|
+
|
|
323
|
+
# Spec grammar (PROTOCOL.md §1 + §1.1):
|
|
324
|
+
# base ( .member | [int] | [*] | [a:b] )*
|
|
325
|
+
# Base name first, then an ordered list of accessors. The wildcard/slice forms
|
|
326
|
+
# ([*], [a:b]) and a bare/trailing array dimension drive expansion (§1.1).
|
|
327
|
+
_BASE_RE = re.compile(r"^([A-Za-z_]\w*)")
|
|
328
|
+
# Order matters: try the wildcard/slice forms before the plain [int] alternative.
|
|
329
|
+
_ACCESS_RE = re.compile(
|
|
330
|
+
r"\.([A-Za-z_]\w*)" # 1: .member
|
|
331
|
+
r"|\[(\*)\]" # 2: [*] (whole dimension)
|
|
332
|
+
r"|\[(\d+):(\d+)\]" # 3,4: [a:b] (half-open slice)
|
|
333
|
+
r"|\[(\d+)\]") # 5: [int]
|
|
334
|
+
|
|
335
|
+
def _tokenize_spec(spec):
|
|
336
|
+
"""Split a spec into (base, [accessors]).
|
|
337
|
+
|
|
338
|
+
Returns (base:str, accessors:list) where each accessor is one of:
|
|
339
|
+
('member', name) .member
|
|
340
|
+
('index', int) [i]
|
|
341
|
+
('all',) [*] — whole dimension (expansion)
|
|
342
|
+
('slice', a, b) [a:b] — half-open slice (expansion)
|
|
343
|
+
Returns None on any syntax error (stray characters, empty base, malformed
|
|
344
|
+
accessor).
|
|
345
|
+
"""
|
|
346
|
+
m = _BASE_RE.match(spec)
|
|
347
|
+
if not m:
|
|
348
|
+
return None
|
|
349
|
+
base = m.group(1)
|
|
350
|
+
pos = m.end()
|
|
351
|
+
accessors = []
|
|
352
|
+
while pos < len(spec):
|
|
353
|
+
am = _ACCESS_RE.match(spec, pos)
|
|
354
|
+
if not am:
|
|
355
|
+
return None
|
|
356
|
+
if am.group(1) is not None:
|
|
357
|
+
accessors.append(("member", am.group(1)))
|
|
358
|
+
elif am.group(2) is not None:
|
|
359
|
+
accessors.append(("all",))
|
|
360
|
+
elif am.group(3) is not None:
|
|
361
|
+
accessors.append(("slice", int(am.group(3)), int(am.group(4))))
|
|
362
|
+
else:
|
|
363
|
+
accessors.append(("index", int(am.group(5))))
|
|
364
|
+
pos = am.end()
|
|
365
|
+
return base, accessors
|
|
366
|
+
|
|
367
|
+
def _strip_cv_typedef(die, cu):
|
|
368
|
+
"""Follow DW_TAG_typedef / const / volatile / restrict wrappers transparently.
|
|
369
|
+
|
|
370
|
+
Returns the first underlying DIE that is not one of those wrappers (or None
|
|
371
|
+
if the chain dead-ends without a DW_AT_type).
|
|
372
|
+
"""
|
|
373
|
+
transparent = ("DW_TAG_typedef", "DW_TAG_const_type",
|
|
374
|
+
"DW_TAG_volatile_type", "DW_TAG_restrict_type")
|
|
375
|
+
depth = 0
|
|
376
|
+
while die is not None and die.tag in transparent and depth < 16:
|
|
377
|
+
depth += 1
|
|
378
|
+
t = die.attributes.get("DW_AT_type")
|
|
379
|
+
if t is None:
|
|
380
|
+
return None
|
|
381
|
+
die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
|
|
382
|
+
return die
|
|
383
|
+
|
|
384
|
+
def _type_byte_size(die, cu):
|
|
385
|
+
"""Best-effort total byte size of a type DIE (follows wrappers / pointers)."""
|
|
386
|
+
depth = 0
|
|
387
|
+
while die is not None and depth < 16:
|
|
388
|
+
depth += 1
|
|
389
|
+
bs = die.attributes.get("DW_AT_byte_size")
|
|
390
|
+
if bs:
|
|
391
|
+
return bs.value
|
|
392
|
+
if die.tag == "DW_TAG_pointer_type":
|
|
393
|
+
return 4
|
|
394
|
+
t = die.attributes.get("DW_AT_type")
|
|
395
|
+
if t is None:
|
|
396
|
+
return None
|
|
397
|
+
die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def _member_offset(member_die):
|
|
401
|
+
"""DW_AT_data_member_location -> int byte offset.
|
|
402
|
+
|
|
403
|
+
Handles the common encodings: a plain integer, or a DWARF location
|
|
404
|
+
expression of the form [DW_OP_plus_uconst (0x23), N]. Returns None if the
|
|
405
|
+
form is unrecognised.
|
|
406
|
+
"""
|
|
407
|
+
loc = member_die.attributes.get("DW_AT_data_member_location")
|
|
408
|
+
if loc is None:
|
|
409
|
+
return 0 # absent => offset 0 (e.g. first member / union member)
|
|
410
|
+
v = loc.value
|
|
411
|
+
if isinstance(v, int):
|
|
412
|
+
return v
|
|
413
|
+
if isinstance(v, list) and len(v) >= 2 and v[0] == 0x23:
|
|
414
|
+
return v[1]
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
def _find_member(struct_die, name):
|
|
418
|
+
"""Return the DW_TAG_member child of a struct/union DIE matching `name`."""
|
|
419
|
+
for child in struct_die.iter_children():
|
|
420
|
+
if child.tag != "DW_TAG_member":
|
|
421
|
+
continue
|
|
422
|
+
nm = child.attributes.get("DW_AT_name")
|
|
423
|
+
if nm and nm.value.decode("utf-8", "replace") == name:
|
|
424
|
+
return child
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def _resolve_spec(spec, table):
|
|
428
|
+
"""Resolve a full spec against build_symbol_table() output.
|
|
429
|
+
|
|
430
|
+
Walks DWARF from the base type DIE applying each accessor:
|
|
431
|
+
[i] consumes one DW_TAG_array_type dimension (subrange child),
|
|
432
|
+
stride = element type byte size; multi-dim arrays expressed as
|
|
433
|
+
multiple DW_TAG_subrange_type children are consumed left-to-right.
|
|
434
|
+
.member consumes a DW_TAG_structure_type / DW_TAG_union_type member,
|
|
435
|
+
offset from DW_AT_data_member_location.
|
|
436
|
+
The final node must resolve to a scalar (base type / pointer / enum);
|
|
437
|
+
otherwise the spec is unresolved. Returns {name,address,size,encoding} or None.
|
|
438
|
+
"""
|
|
439
|
+
tok = _tokenize_spec(spec)
|
|
440
|
+
if tok is None:
|
|
441
|
+
return None
|
|
442
|
+
base, accessors = tok
|
|
443
|
+
entry = table.get(base)
|
|
444
|
+
if not entry:
|
|
445
|
+
return None
|
|
446
|
+
addr = entry["address"]
|
|
447
|
+
cu = entry["cu"]
|
|
448
|
+
cur = _strip_cv_typedef(entry["type_die"], cu)
|
|
449
|
+
|
|
450
|
+
# Pending array dimensions: when an array_type is reached we expand its
|
|
451
|
+
# subrange children into a list of (stride, count) consumed by successive [i].
|
|
452
|
+
pending_dims = [] # list of (stride_bytes, count) tuples, outer-to-inner
|
|
453
|
+
# count==0 means the bound is unknown (no validation possible)
|
|
454
|
+
pending_elem = None # element type DIE once the dim list is exhausted
|
|
455
|
+
|
|
456
|
+
def _load_array(arr_die):
|
|
457
|
+
"""Populate pending_dims/pending_elem from a DW_TAG_array_type."""
|
|
458
|
+
nonlocal pending_dims, pending_elem
|
|
459
|
+
et = arr_die.attributes.get("DW_AT_type")
|
|
460
|
+
elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
|
|
461
|
+
elem = _strip_cv_typedef(elem, cu)
|
|
462
|
+
subranges = [c for c in arr_die.iter_children()
|
|
463
|
+
if c.tag == "DW_TAG_subrange_type"]
|
|
464
|
+
elem_size = _type_byte_size(elem, cu)
|
|
465
|
+
if elem_size is None:
|
|
466
|
+
return False
|
|
467
|
+
# Strides: innermost dimension has stride=elem_size; each outer dimension
|
|
468
|
+
# multiplies by the inner dimension's element count.
|
|
469
|
+
counts = []
|
|
470
|
+
for sr in subranges:
|
|
471
|
+
ub = sr.attributes.get("DW_AT_upper_bound")
|
|
472
|
+
cnt = sr.attributes.get("DW_AT_count")
|
|
473
|
+
if cnt is not None and isinstance(cnt.value, int):
|
|
474
|
+
counts.append(cnt.value)
|
|
475
|
+
elif ub is not None and isinstance(ub.value, int):
|
|
476
|
+
counts.append(ub.value + 1)
|
|
477
|
+
else:
|
|
478
|
+
counts.append(0) # unknown bound; stride math below still works
|
|
479
|
+
if not subranges:
|
|
480
|
+
counts = [0]
|
|
481
|
+
strides = [0] * len(counts)
|
|
482
|
+
acc = elem_size
|
|
483
|
+
for i in range(len(counts) - 1, -1, -1):
|
|
484
|
+
strides[i] = acc
|
|
485
|
+
acc *= counts[i] if counts[i] else 1
|
|
486
|
+
pending_dims = list(zip(strides, counts))
|
|
487
|
+
pending_elem = elem
|
|
488
|
+
return True
|
|
489
|
+
|
|
490
|
+
for acc in accessors:
|
|
491
|
+
if cur is None and not pending_dims:
|
|
492
|
+
return None
|
|
493
|
+
if acc[0] == "index":
|
|
494
|
+
# Need an array dimension to consume.
|
|
495
|
+
if not pending_dims:
|
|
496
|
+
cur = _strip_cv_typedef(cur, cu)
|
|
497
|
+
if cur is None or cur.tag != "DW_TAG_array_type":
|
|
498
|
+
return None
|
|
499
|
+
if not _load_array(cur):
|
|
500
|
+
return None
|
|
501
|
+
stride, count = pending_dims.pop(0)
|
|
502
|
+
# Reject out-of-bounds indices when the bound is known (count>0) — a
|
|
503
|
+
# firmware-symbol tracer must not silently read adjacent RAM. With an
|
|
504
|
+
# unknown bound (count==0) we keep the raw stride arithmetic.
|
|
505
|
+
if count and not (0 <= acc[1] < count):
|
|
506
|
+
return None
|
|
507
|
+
addr += acc[1] * stride
|
|
508
|
+
if not pending_dims:
|
|
509
|
+
cur = pending_elem
|
|
510
|
+
pending_elem = None
|
|
511
|
+
else: # member
|
|
512
|
+
if pending_dims:
|
|
513
|
+
return None # can't take .member with array dims still pending
|
|
514
|
+
cur = _strip_cv_typedef(cur, cu)
|
|
515
|
+
if cur is None or cur.tag not in ("DW_TAG_structure_type", "DW_TAG_union_type"):
|
|
516
|
+
return None
|
|
517
|
+
found = None
|
|
518
|
+
for child in cur.iter_children():
|
|
519
|
+
if child.tag != "DW_TAG_member":
|
|
520
|
+
continue
|
|
521
|
+
nm = child.attributes.get("DW_AT_name")
|
|
522
|
+
if nm and nm.value.decode("utf-8", "replace") == acc[1]:
|
|
523
|
+
found = child
|
|
524
|
+
break
|
|
525
|
+
if found is None:
|
|
526
|
+
return None
|
|
527
|
+
off = _member_offset(found)
|
|
528
|
+
if off is None:
|
|
529
|
+
return None
|
|
530
|
+
addr += off
|
|
531
|
+
mt = found.attributes.get("DW_AT_type")
|
|
532
|
+
cur = cu.dwarfinfo.get_DIE_from_refaddr(mt.value + cu.cu_offset) if mt else None
|
|
533
|
+
cur = _strip_cv_typedef(cur, cu)
|
|
534
|
+
|
|
535
|
+
if pending_dims:
|
|
536
|
+
return None # spec stopped mid-array → still an aggregate
|
|
537
|
+
cur = _strip_cv_typedef(cur, cu)
|
|
538
|
+
if cur is None:
|
|
539
|
+
return None
|
|
540
|
+
# Final node must be a scalar.
|
|
541
|
+
if cur.tag not in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
|
|
542
|
+
return None
|
|
543
|
+
enc, size = _resolve_type_from(cur, cu)
|
|
544
|
+
return {"name": spec, "address": addr, "size": size, "encoding": enc}
|
|
545
|
+
|
|
546
|
+
EXPAND_CAP = 256 # PROTOCOL §1.1: expansions larger than this are skipped.
|
|
547
|
+
|
|
548
|
+
def _array_dim_counts(arr_die, cu):
|
|
549
|
+
"""Per-dimension element counts of a DW_TAG_array_type (outer-to-inner).
|
|
550
|
+
|
|
551
|
+
Returns [] if any bound is unknown / unbounded (count==0) so callers can
|
|
552
|
+
refuse to expand an array whose size DWARF doesn't pin down.
|
|
553
|
+
"""
|
|
554
|
+
counts = []
|
|
555
|
+
for sr in arr_die.iter_children():
|
|
556
|
+
if sr.tag != "DW_TAG_subrange_type":
|
|
557
|
+
continue
|
|
558
|
+
ub = sr.attributes.get("DW_AT_upper_bound")
|
|
559
|
+
cnt = sr.attributes.get("DW_AT_count")
|
|
560
|
+
if cnt is not None and isinstance(cnt.value, int):
|
|
561
|
+
counts.append(cnt.value)
|
|
562
|
+
elif ub is not None and isinstance(ub.value, int):
|
|
563
|
+
counts.append(ub.value + 1)
|
|
564
|
+
else:
|
|
565
|
+
return [] # unknown bound → not expandable
|
|
566
|
+
return counts
|
|
567
|
+
|
|
568
|
+
def _spec_name(base, parts):
|
|
569
|
+
"""Re-render a base name + concrete accessor parts into a spec string.
|
|
570
|
+
|
|
571
|
+
`parts` items are ('index', i) or ('member', name); rendered as
|
|
572
|
+
base[i][j].member ... matching the §1.1 concrete-element form.
|
|
573
|
+
"""
|
|
574
|
+
s = base
|
|
575
|
+
for p in parts:
|
|
576
|
+
s += ("[%d]" % p[1]) if p[0] == "index" else ("." + p[1])
|
|
577
|
+
return s
|
|
578
|
+
|
|
579
|
+
def _expand_spec(spec, table):
|
|
580
|
+
"""Expand a (possibly array-bearing) spec into concrete scalar specs.
|
|
581
|
+
|
|
582
|
+
Walks the DWARF type chain following the tokenized accessors. Whenever a
|
|
583
|
+
dimension must be materialised — an explicit `[*]`/`[a:b]`, a bare/trailing
|
|
584
|
+
array, or an array dim sitting in front of a `.member` — it enumerates the
|
|
585
|
+
selected indices and recurses, producing concrete `name[i]` / `name[i][j]`
|
|
586
|
+
forms. Returns (specs:list[str], count_estimate:int).
|
|
587
|
+
|
|
588
|
+
`count_estimate` is the total number of concrete elements the spec would
|
|
589
|
+
yield (used by the caller for the §1.1 256-cap report). On any
|
|
590
|
+
unexpandable / malformed input returns ([], 0) — the caller then treats the
|
|
591
|
+
spec as a plain unresolved scalar.
|
|
592
|
+
"""
|
|
593
|
+
tok = _tokenize_spec(spec)
|
|
594
|
+
if tok is None:
|
|
595
|
+
return [], 0
|
|
596
|
+
base, accessors = tok
|
|
597
|
+
entry = table.get(base)
|
|
598
|
+
if not entry:
|
|
599
|
+
return [], 0
|
|
600
|
+
cu = entry["cu"]
|
|
601
|
+
|
|
602
|
+
def _idxs_for(acc, n):
|
|
603
|
+
"""Resolve one index/all/slice accessor against a dimension of size n.
|
|
604
|
+
|
|
605
|
+
Returns the list of concrete indices, or None if `acc` is an out-of-range
|
|
606
|
+
index (a hard failure for that branch).
|
|
607
|
+
"""
|
|
608
|
+
if acc[0] == "index":
|
|
609
|
+
return [acc[1]] if 0 <= acc[1] < n else None
|
|
610
|
+
if acc[0] == "all":
|
|
611
|
+
return list(range(n))
|
|
612
|
+
# slice a:b — half-open, clamped to [0, n).
|
|
613
|
+
a, b = max(0, acc[1]), min(n, acc[2])
|
|
614
|
+
return list(range(a, b)) if b > a else []
|
|
615
|
+
|
|
616
|
+
# Walk the type chain. `cur` is the current type DIE (cv/typedef-stripped),
|
|
617
|
+
# `ai` the next accessor to apply, `parts` the concrete accessor prefix built
|
|
618
|
+
# so far. Returns a list of concrete accessor-part lists, or None on a hard
|
|
619
|
+
# mismatch (the spec is unexpandable / out of range).
|
|
620
|
+
def walk(cur, ai, parts):
|
|
621
|
+
cur = _strip_cv_typedef(cur, cu)
|
|
622
|
+
if cur is None:
|
|
623
|
+
return None
|
|
624
|
+
|
|
625
|
+
if cur.tag == "DW_TAG_array_type":
|
|
626
|
+
# Enumerate this array's dimensions (it may carry several subranges).
|
|
627
|
+
counts = _array_dim_counts(cur, cu)
|
|
628
|
+
if not counts:
|
|
629
|
+
return None
|
|
630
|
+
et = cur.attributes.get("DW_AT_type")
|
|
631
|
+
elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
|
|
632
|
+
return walk_dims(counts, 0, elem, ai, parts)
|
|
633
|
+
|
|
634
|
+
if ai < len(accessors):
|
|
635
|
+
acc = accessors[ai]
|
|
636
|
+
if acc[0] != "member":
|
|
637
|
+
return None # an index/all/slice on a non-array → mismatch
|
|
638
|
+
if cur.tag not in ("DW_TAG_structure_type", "DW_TAG_union_type"):
|
|
639
|
+
return None
|
|
640
|
+
member = _find_member(cur, acc[1])
|
|
641
|
+
if member is None:
|
|
642
|
+
return None
|
|
643
|
+
mt = member.attributes.get("DW_AT_type")
|
|
644
|
+
mtd = cu.dwarfinfo.get_DIE_from_refaddr(mt.value + cu.cu_offset) if mt else None
|
|
645
|
+
return walk(mtd, ai + 1, parts + [("member", acc[1])])
|
|
646
|
+
|
|
647
|
+
# No more accessors and not an array → scalar element or dead end.
|
|
648
|
+
if cur.tag in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
|
|
649
|
+
return [parts]
|
|
650
|
+
return None # struct/union with no trailing array → unresolved
|
|
651
|
+
|
|
652
|
+
def walk_dims(counts, dim, elem, ai, parts):
|
|
653
|
+
"""Enumerate dimension `dim` of an array (counts = per-dim sizes).
|
|
654
|
+
|
|
655
|
+
A following index/all/slice accessor selects the indices for this dim and
|
|
656
|
+
is consumed (ai advances); otherwise the whole dimension is enumerated and
|
|
657
|
+
ai is preserved (so a trailing `.member` applies after the dims are gone).
|
|
658
|
+
Recurses into inner dims, then back into walk() for the element type.
|
|
659
|
+
"""
|
|
660
|
+
n = counts[dim]
|
|
661
|
+
if n == 0:
|
|
662
|
+
return None
|
|
663
|
+
if ai < len(accessors) and accessors[ai][0] in ("index", "all", "slice"):
|
|
664
|
+
idxs = _idxs_for(accessors[ai], n)
|
|
665
|
+
if idxs is None:
|
|
666
|
+
return None
|
|
667
|
+
ai2 = ai + 1
|
|
668
|
+
else:
|
|
669
|
+
idxs = list(range(n))
|
|
670
|
+
ai2 = ai
|
|
671
|
+
out = []
|
|
672
|
+
for i in idxs:
|
|
673
|
+
p = parts + [("index", i)]
|
|
674
|
+
if dim + 1 < len(counts):
|
|
675
|
+
sub = walk_dims(counts, dim + 1, elem, ai2, p)
|
|
676
|
+
else:
|
|
677
|
+
sub = walk(elem, ai2, p)
|
|
678
|
+
if sub is None:
|
|
679
|
+
return None
|
|
680
|
+
out.extend(sub)
|
|
681
|
+
return out
|
|
682
|
+
|
|
683
|
+
cur0 = _strip_cv_typedef(entry["type_die"], cu)
|
|
684
|
+
parts_lists = walk(cur0, 0, [])
|
|
685
|
+
if not parts_lists:
|
|
686
|
+
return [], 0
|
|
687
|
+
specs = [_spec_name(base, p) for p in parts_lists]
|
|
688
|
+
return specs, len(specs)
|
|
689
|
+
|
|
690
|
+
def _resolve_specs(specs, table):
|
|
691
|
+
"""Resolve a list of specs, applying §1.1 array/vector/matrix expansion.
|
|
692
|
+
|
|
693
|
+
For each spec: first try the expander. If it yields >1 element (or exactly
|
|
694
|
+
one but via a wildcard/slice/trailing array) those concrete element specs
|
|
695
|
+
are resolved individually. A plain scalar spec falls through to
|
|
696
|
+
_resolve_spec. Returns (resolved:list[dict], unresolved:list[str]).
|
|
697
|
+
|
|
698
|
+
Cap (§1.1): an expansion exceeding EXPAND_CAP elements is skipped and
|
|
699
|
+
reported as "<spec> (expands to <N> > 256)".
|
|
700
|
+
"""
|
|
701
|
+
resolved, unresolved = [], []
|
|
702
|
+
for spec in specs:
|
|
703
|
+
elems, n = _expand_spec(spec, table)
|
|
704
|
+
if n > EXPAND_CAP:
|
|
705
|
+
unresolved.append("%s (expands to %d > %d)" % (spec, n, EXPAND_CAP))
|
|
706
|
+
continue
|
|
707
|
+
if elems:
|
|
708
|
+
# Expanded (possibly to a single concrete element). Resolve each;
|
|
709
|
+
# any element that fails to resolve is silently dropped (the spec as
|
|
710
|
+
# a whole produced concrete names, so it isn't "unresolved").
|
|
711
|
+
for e in elems:
|
|
712
|
+
r = _resolve_spec(e, table)
|
|
713
|
+
if r:
|
|
714
|
+
resolved.append(r)
|
|
715
|
+
continue
|
|
716
|
+
# Not expandable — try as a plain concrete scalar spec.
|
|
717
|
+
r = _resolve_spec(spec, table)
|
|
718
|
+
if r:
|
|
719
|
+
resolved.append(r)
|
|
720
|
+
else:
|
|
721
|
+
unresolved.append(spec)
|
|
722
|
+
return resolved, unresolved
|
|
723
|
+
|
|
724
|
+
def _resolve_type_from(die, cu):
|
|
725
|
+
"""Encoding/size of a scalar type DIE (base/pointer/enum).
|
|
726
|
+
|
|
727
|
+
Mirrors _resolve_type but starts from an already-resolved type DIE rather
|
|
728
|
+
than from a variable DIE's DW_AT_type.
|
|
729
|
+
"""
|
|
730
|
+
if die.tag == "DW_TAG_pointer_type":
|
|
731
|
+
bs = die.attributes.get("DW_AT_byte_size")
|
|
732
|
+
return "uint", (bs.value if bs else 4)
|
|
733
|
+
if die.tag == "DW_TAG_base_type":
|
|
734
|
+
e = die.attributes.get("DW_AT_encoding")
|
|
735
|
+
enc = _ENC.get(e.value, "uint") if e else "uint"
|
|
736
|
+
bs = die.attributes.get("DW_AT_byte_size")
|
|
737
|
+
return enc, (bs.value if bs else 4)
|
|
738
|
+
if die.tag == "DW_TAG_enumeration_type":
|
|
739
|
+
bs = die.attributes.get("DW_AT_byte_size")
|
|
740
|
+
return "int", (bs.value if bs else 4)
|
|
741
|
+
bs = die.attributes.get("DW_AT_byte_size")
|
|
742
|
+
return "uint", (bs.value if bs else 4)
|
|
743
|
+
|
|
744
|
+
def _coalesce(sigs):
|
|
745
|
+
"""sigs: list of {name,address,size,encoding}. Returns [(start,length,[(name,off,size,enc)])]."""
|
|
746
|
+
items = sorted(sigs, key=lambda s: s["address"])
|
|
747
|
+
ranges = []
|
|
748
|
+
for s in items:
|
|
749
|
+
a, ln = s["address"], s["size"]
|
|
750
|
+
if ranges and a <= ranges[-1][0] + ranges[-1][1] + 4: # merge if within 4 bytes of prev end
|
|
751
|
+
start, length, members = ranges[-1]
|
|
752
|
+
new_end = max(start + length, a + ln)
|
|
753
|
+
ranges[-1] = (start, new_end - start, members + [(s["name"], a - start, ln, s["encoding"])])
|
|
754
|
+
else:
|
|
755
|
+
ranges.append((a, ln, [(s["name"], 0, ln, s["encoding"])]))
|
|
756
|
+
return ranges
|
|
757
|
+
|
|
758
|
+
def _decode(buf, off, size, enc):
|
|
759
|
+
raw = bytes(buf[off:off + size])
|
|
760
|
+
if enc == "float" and size == 4:
|
|
761
|
+
return struct.unpack("<f", raw)[0]
|
|
762
|
+
if enc == "float" and size == 8:
|
|
763
|
+
return struct.unpack("<d", raw)[0]
|
|
764
|
+
signed = enc in ("int", "char")
|
|
765
|
+
return int.from_bytes(raw, "little", signed=signed)
|
|
766
|
+
|
|
767
|
+
def cmd_trace(args):
|
|
768
|
+
names = [n for n in args.signals.split(",") if n]
|
|
769
|
+
# §11.3 ELF / DWARF guard: a bad/missing ELF must surface as an error frame,
|
|
770
|
+
# not a raw traceback to stderr that leaves the TS layer guessing.
|
|
771
|
+
try:
|
|
772
|
+
table = build_symbol_table(args.elf)
|
|
773
|
+
except Exception as e:
|
|
774
|
+
print(json.dumps({"type": "error", "error": "ELF/DWARF error: %s" % e}), flush=True)
|
|
775
|
+
return
|
|
776
|
+
# §1.1: expand whole-array / vector / matrix specs into concrete elements.
|
|
777
|
+
resolved, missing = _resolve_specs(names, table)
|
|
778
|
+
if not resolved:
|
|
779
|
+
# Nothing resolved at all → hard error (matches the old all-missing path).
|
|
780
|
+
print(json.dumps({"type": "error", "error": "unknown symbols: " + ",".join(missing)}), flush=True)
|
|
781
|
+
return
|
|
782
|
+
# Any specs that failed to resolve/expand are reported via the live "signals"
|
|
783
|
+
# frame's `unresolved`, not as a fatal error (mirrors the add path).
|
|
784
|
+
initial_unresolved = list(missing)
|
|
785
|
+
|
|
786
|
+
# Live poll set, mutated by the stdin reader thread (add/remove). Guarded by
|
|
787
|
+
# a lock; the poll loop re-coalesces ranges whenever `dirty` is set.
|
|
788
|
+
state_lock = threading.Lock()
|
|
789
|
+
# name -> {name,address,size,encoding}; ordered insertion preserves request order.
|
|
790
|
+
sigset = {s["name"]: s for s in resolved}
|
|
791
|
+
state = {"dirty": True, "ranges": _coalesce(list(sigset.values())),
|
|
792
|
+
"unresolved": initial_unresolved, "signals": list(sigset.values())}
|
|
793
|
+
|
|
794
|
+
def _signals_frame():
|
|
795
|
+
"""Build the {"type":"signals",...} frame from the current poll set."""
|
|
796
|
+
with state_lock:
|
|
797
|
+
sigs = [{"name": s["name"], "address": s["address"],
|
|
798
|
+
"size": s["size"], "encoding": s["encoding"]} for s in state["signals"]]
|
|
799
|
+
unres = list(state["unresolved"])
|
|
800
|
+
return {"type": "signals", "signals": sigs, "unresolved": unres}
|
|
801
|
+
|
|
802
|
+
# --- EXPERIMENTAL: parse --swo mapping (negative path: swo_map is empty) ---
|
|
803
|
+
swo_map = {} # port:int -> signal_name:str
|
|
804
|
+
if args.swo:
|
|
805
|
+
for spec in args.swo.split(","):
|
|
806
|
+
spec = spec.strip()
|
|
807
|
+
if not spec:
|
|
808
|
+
continue
|
|
809
|
+
try:
|
|
810
|
+
port_str, sig_name = spec.split(":", 1)
|
|
811
|
+
swo_map[int(port_str)] = sig_name
|
|
812
|
+
except (ValueError, TypeError):
|
|
813
|
+
log(f"[swo] ignoring malformed port:name spec: {spec!r}")
|
|
814
|
+
if swo_map:
|
|
815
|
+
log(f"[swo] EXPERIMENTAL: ITM port mapping: {swo_map} "
|
|
816
|
+
"(requires firmware that emits ITM on SWO — NOT present in current CrossPad firmware)")
|
|
817
|
+
|
|
818
|
+
# NOTE: the .cptrace file header is written ONCE here with the initial set.
|
|
819
|
+
# add/remove on a live trace change the live poll set (and the NDJSON
|
|
820
|
+
# "signals" frames) but deliberately do NOT rewrite this on-disk header — the
|
|
821
|
+
# header reflects the trace's *initial* signal set only.
|
|
822
|
+
fh = open(args.out, "wb") if args.out else None
|
|
823
|
+
if fh:
|
|
824
|
+
hdr = json.dumps({"signals": [{"name": s["name"], "encoding": s["encoding"], "size": s["size"]} for s in resolved]}).encode()
|
|
825
|
+
fh.write(b"CPTR"); fh.write(_NP.pack(len(hdr))); fh.write(hdr)
|
|
826
|
+
|
|
827
|
+
target_dt = 1.0 / args.rate if args.rate > 0 else 0.0
|
|
828
|
+
stop = {"v": False}
|
|
829
|
+
|
|
830
|
+
def _apply_add(specs):
|
|
831
|
+
# §1.1: expansion applies to live add too, so the emitted "signals"
|
|
832
|
+
# frame lists the concrete expanded element names.
|
|
833
|
+
added, unres = _resolve_specs(specs, table)
|
|
834
|
+
with state_lock:
|
|
835
|
+
for r in added:
|
|
836
|
+
sigset[r["name"]] = r # replace/insert
|
|
837
|
+
state["signals"] = list(sigset.values())
|
|
838
|
+
state["unresolved"] = unres
|
|
839
|
+
state["dirty"] = True
|
|
840
|
+
|
|
841
|
+
def _apply_remove(specs):
|
|
842
|
+
with state_lock:
|
|
843
|
+
for spec in specs:
|
|
844
|
+
sigset.pop(spec, None) # no-op if absent
|
|
845
|
+
state["signals"] = list(sigset.values())
|
|
846
|
+
state["dirty"] = True
|
|
847
|
+
|
|
848
|
+
def stdin_reader():
|
|
849
|
+
for line in sys.stdin:
|
|
850
|
+
line = line.strip()
|
|
851
|
+
if not line:
|
|
852
|
+
continue
|
|
853
|
+
try:
|
|
854
|
+
msg = json.loads(line)
|
|
855
|
+
except Exception:
|
|
856
|
+
continue
|
|
857
|
+
cmd = msg.get("cmd")
|
|
858
|
+
if cmd == "stop":
|
|
859
|
+
stop["v"] = True
|
|
860
|
+
return
|
|
861
|
+
elif cmd == "add":
|
|
862
|
+
sigs = msg.get("signals")
|
|
863
|
+
if isinstance(sigs, list):
|
|
864
|
+
_apply_add([s for s in sigs if isinstance(s, str)])
|
|
865
|
+
elif cmd == "remove":
|
|
866
|
+
sigs = msg.get("signals")
|
|
867
|
+
if isinstance(sigs, list):
|
|
868
|
+
_apply_remove([s for s in sigs if isinstance(s, str)])
|
|
869
|
+
threading.Thread(target=stdin_reader, daemon=True).start()
|
|
870
|
+
|
|
871
|
+
log(f"connecting probe (serial={args.probe or 'auto'}, target={args.target}, "
|
|
872
|
+
f"connect_timeout={args.connect_timeout}s)...")
|
|
873
|
+
n = 0
|
|
874
|
+
|
|
875
|
+
# §11.1 connect with hard timeout + no-probe fast-fail. A wedged libusb open()
|
|
876
|
+
# cannot be interrupted, so on timeout we flush the error frame and os._exit —
|
|
877
|
+
# the OS reclaims the abandoned worker thread. Never hang here.
|
|
878
|
+
session, cerr, timed_out = _try_open_session(args.probe, args.target, args.connect_timeout)
|
|
879
|
+
if session is None:
|
|
880
|
+
log(f"trace connect failed: {cerr}")
|
|
881
|
+
print(json.dumps({"type": "error", "error": cerr}), flush=True)
|
|
882
|
+
if fh:
|
|
883
|
+
fh.close()
|
|
884
|
+
sys.stdout.flush(); sys.stderr.flush()
|
|
885
|
+
if timed_out:
|
|
886
|
+
os._exit(2) # worker wedged in C — only safe recovery
|
|
887
|
+
os._exit(3 if "no debug probe" in cerr else 1)
|
|
888
|
+
|
|
889
|
+
target = session.target
|
|
890
|
+
log("connected; polling (non-halting)")
|
|
891
|
+
# §11.2 persistent-fault tracking: a single read fault = stop_suspected, but
|
|
892
|
+
# faults lasting longer than --lost-timeout mean the probe/target is gone.
|
|
893
|
+
fault_since = None
|
|
894
|
+
lost = False
|
|
895
|
+
|
|
896
|
+
# --- EXPERIMENTAL: set up SWV reader (only when --swo was given) ---
|
|
897
|
+
swo_reader, swo_sink = None, None
|
|
898
|
+
if swo_map:
|
|
899
|
+
swo_reader, swo_sink = _setup_swo(session, args.cpu_hz, args.swo_hz)
|
|
900
|
+
# If _setup_swo failed, swo_reader/swo_sink are both None and the loop
|
|
901
|
+
# below behaves identically to the no-swo path.
|
|
902
|
+
|
|
903
|
+
t0 = time.monotonic()
|
|
904
|
+
try:
|
|
905
|
+
while not stop["v"]:
|
|
906
|
+
cyc = time.monotonic()
|
|
907
|
+
# Re-coalesce when the poll set changed, and (re)emit the "signals"
|
|
908
|
+
# frame. dirty is preset True so this fires ONCE right after connect.
|
|
909
|
+
if state["dirty"]:
|
|
910
|
+
with state_lock:
|
|
911
|
+
state["ranges"] = _coalesce(list(state["signals"]))
|
|
912
|
+
state["dirty"] = False
|
|
913
|
+
print(json.dumps(_signals_frame()), flush=True)
|
|
914
|
+
ranges = state["ranges"]
|
|
915
|
+
values, in_stop = {}, False
|
|
916
|
+
for (start, length, members) in ranges:
|
|
917
|
+
try:
|
|
918
|
+
data = target.read_memory_block8(start, length)
|
|
919
|
+
except Exception:
|
|
920
|
+
in_stop = True
|
|
921
|
+
break
|
|
922
|
+
for (name, off, size, enc) in members:
|
|
923
|
+
values[name] = _decode(data, off, size, enc)
|
|
924
|
+
t = time.monotonic() - t0
|
|
925
|
+
if in_stop:
|
|
926
|
+
now = time.monotonic()
|
|
927
|
+
if fault_since is None:
|
|
928
|
+
fault_since = now
|
|
929
|
+
elif now - fault_since > args.lost_timeout:
|
|
930
|
+
# §11.2 persistent read fault → probe/target lost; stop looping.
|
|
931
|
+
msg = "probe/target lost (persistent read fault %.1fs)" % (now - fault_since)
|
|
932
|
+
log(msg)
|
|
933
|
+
print(json.dumps({"type": "status", "device_state": "probe_lost", "t": round(t, 6)}), flush=True)
|
|
934
|
+
print(json.dumps({"type": "error", "error": msg}), flush=True)
|
|
935
|
+
lost = True
|
|
936
|
+
break
|
|
937
|
+
print(json.dumps({"type": "status", "device_state": "stop_suspected", "t": round(t, 6)}), flush=True)
|
|
938
|
+
time.sleep(0.2)
|
|
939
|
+
continue
|
|
940
|
+
fault_since = None # a successful read clears the fault timer
|
|
941
|
+
|
|
942
|
+
# --- EXPERIMENTAL: merge ITM values (no-op when swo_sink is None) ---
|
|
943
|
+
if swo_sink is not None:
|
|
944
|
+
for port, sig_name in swo_map.items():
|
|
945
|
+
v = swo_sink.latest.get(port)
|
|
946
|
+
if v is not None:
|
|
947
|
+
values[sig_name] = v
|
|
948
|
+
|
|
949
|
+
print(json.dumps({"type": "sample", "t": round(t, 6), "values": values}), flush=True)
|
|
950
|
+
if fh:
|
|
951
|
+
fh.write(json.dumps({"t": round(t, 6), "v": values}).encode() + b"\n")
|
|
952
|
+
n += 1
|
|
953
|
+
if target_dt:
|
|
954
|
+
slp = target_dt - (time.monotonic() - cyc)
|
|
955
|
+
if slp > 0:
|
|
956
|
+
time.sleep(slp)
|
|
957
|
+
except Exception as e:
|
|
958
|
+
log(f"trace run failed: {e}")
|
|
959
|
+
print(json.dumps({"type": "error", "error": str(e)}), flush=True)
|
|
960
|
+
finally:
|
|
961
|
+
if swo_reader is not None:
|
|
962
|
+
try:
|
|
963
|
+
swo_reader.stop()
|
|
964
|
+
log("[swo] SWV reader stopped.")
|
|
965
|
+
except Exception as e:
|
|
966
|
+
log(f"[swo] error stopping SWV reader (ignored): {e}")
|
|
967
|
+
try:
|
|
968
|
+
session.close()
|
|
969
|
+
except Exception:
|
|
970
|
+
pass
|
|
971
|
+
if fh:
|
|
972
|
+
fh.close()
|
|
973
|
+
if not lost:
|
|
974
|
+
log(f"stopped after {n} samples")
|
|
975
|
+
print(json.dumps({"type": "status", "device_state": "stopped", "samples": n}), flush=True)
|
|
976
|
+
|
|
977
|
+
# --- device-state mode -------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
_REGS = {
|
|
980
|
+
"PWR_CR1": 0x40007000,
|
|
981
|
+
"PWR_SR1": 0x40007010,
|
|
982
|
+
"RCC_CR": 0x40021000,
|
|
983
|
+
"RCC_CFGR": 0x40021008,
|
|
984
|
+
"SCB_SCR": 0xE000ED10,
|
|
985
|
+
"DBGMCU_CR": 0x40015804,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
def cmd_device_state(args):
|
|
989
|
+
out = {"type": "device_state", "regs": {}, "decoded": {}, "accessible": True}
|
|
990
|
+
# §11.1: same hard-timeout / no-probe fast-fail as trace, but graceful — a
|
|
991
|
+
# one-shot read reports inaccessible instead of os._exit. The wedged worker
|
|
992
|
+
# (if any) is a daemon thread, abandoned on process exit.
|
|
993
|
+
session, cerr, _timed_out = _try_open_session(args.probe, args.target, args.connect_timeout)
|
|
994
|
+
if session is None:
|
|
995
|
+
out["accessible"] = False
|
|
996
|
+
out["error"] = cerr
|
|
997
|
+
print(json.dumps(out), flush=True)
|
|
998
|
+
return
|
|
999
|
+
try:
|
|
1000
|
+
t = session.target
|
|
1001
|
+
for name, addr in _REGS.items():
|
|
1002
|
+
try:
|
|
1003
|
+
out["regs"][name] = t.read32(addr)
|
|
1004
|
+
except Exception:
|
|
1005
|
+
out["regs"][name] = None
|
|
1006
|
+
out["accessible"] = False
|
|
1007
|
+
scr = out["regs"].get("SCB_SCR") or 0
|
|
1008
|
+
out["decoded"]["SLEEPDEEP"] = bool(scr & (1 << 2))
|
|
1009
|
+
cr1 = out["regs"].get("PWR_CR1") or 0
|
|
1010
|
+
out["decoded"]["LPMS"] = cr1 & 0x7 # low-power mode select (STM32G0 PWR_CR1[2:0])
|
|
1011
|
+
out["decoded"]["interpretation"] = (
|
|
1012
|
+
"STOP/low-power likely" if out["decoded"]["SLEEPDEEP"] else "run/sleep")
|
|
1013
|
+
except Exception as e:
|
|
1014
|
+
out["accessible"] = False
|
|
1015
|
+
out["error"] = str(e)
|
|
1016
|
+
finally:
|
|
1017
|
+
try:
|
|
1018
|
+
session.close()
|
|
1019
|
+
except Exception:
|
|
1020
|
+
pass
|
|
1021
|
+
print(json.dumps(out), flush=True)
|
|
1022
|
+
|
|
1023
|
+
def main():
|
|
1024
|
+
# Claim the root logger with a stderr handler BEFORE pyOCD is imported, so
|
|
1025
|
+
# pyOCD's own logging (e.g. the coloured "No connected debug probes" notice)
|
|
1026
|
+
# cannot leak onto stdout — the stdout channel is machine JSON ONLY (§ output
|
|
1027
|
+
# contract). level=ERROR also mutes pyOCD's routine warnings.
|
|
1028
|
+
import logging
|
|
1029
|
+
logging.basicConfig(stream=sys.stderr, level=logging.ERROR)
|
|
1030
|
+
|
|
1031
|
+
ap = argparse.ArgumentParser()
|
|
1032
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1033
|
+
sp = sub.add_parser("symbols")
|
|
1034
|
+
sp.add_argument("--elf", required=True)
|
|
1035
|
+
sp.add_argument("--query", default=None)
|
|
1036
|
+
sp.set_defaults(func=cmd_symbols)
|
|
1037
|
+
|
|
1038
|
+
tp = sub.add_parser("trace")
|
|
1039
|
+
tp.add_argument("--elf", required=True)
|
|
1040
|
+
tp.add_argument("--signals", required=True)
|
|
1041
|
+
tp.add_argument("--rate", type=float, default=0.0) # 0 = as fast as possible
|
|
1042
|
+
tp.add_argument("--out", default=None)
|
|
1043
|
+
tp.add_argument("--probe", default=None)
|
|
1044
|
+
tp.add_argument("--target", default="cortex_m",
|
|
1045
|
+
help="pyOCD target_override. Default 'cortex_m' (generic; no CMSIS pack needed, "
|
|
1046
|
+
"sufficient for RAM polling). For part-specific features install the pack "
|
|
1047
|
+
"(pyocd pack install stm32g0b1) and pass e.g. --target stm32g0b1retx.")
|
|
1048
|
+
tp.add_argument("--swo", default=None,
|
|
1049
|
+
help="EXPERIMENTAL: comma list of port:name mappings for ITM stimulus ports, "
|
|
1050
|
+
"e.g. '0:phase,1:isr_us'. Requires firmware that emits ITM data on the "
|
|
1051
|
+
"SWO pin (NOT present in current CrossPad firmware — UNTESTED against real "
|
|
1052
|
+
"ITM). Omit for plain RAM polling. If SWV init fails the daemon continues "
|
|
1053
|
+
"polling (fail-soft).")
|
|
1054
|
+
tp.add_argument("--cpu-hz", type=int, default=64_000_000,
|
|
1055
|
+
help="Core clock frequency in Hz for SWO baud derivation (SWO path only). "
|
|
1056
|
+
"Default 64000000 (STM32G0 max). Must match the actual firmware clock — "
|
|
1057
|
+
"CrossPad r20 runs at 64 MHz but confirm in the .ioc/.clock config.")
|
|
1058
|
+
tp.add_argument("--swo-hz", type=int, default=2_000_000,
|
|
1059
|
+
help="Desired SWO output baud in Hz (SWO path only). Default 2000000. "
|
|
1060
|
+
"Must match the TPIU_ACPR configuration in the firmware.")
|
|
1061
|
+
tp.add_argument("--connect-timeout", type=float, default=8.0,
|
|
1062
|
+
help="§11.1: hard cap (s) on probe connect. A missing probe fails fast; "
|
|
1063
|
+
"a wedged libusb open() past this triggers an error frame + os._exit. "
|
|
1064
|
+
"Never hang. Default 8.")
|
|
1065
|
+
tp.add_argument("--lost-timeout", type=float, default=10.0,
|
|
1066
|
+
help="§11.2: after this many seconds of continuous read faults the "
|
|
1067
|
+
"probe/target is declared lost (error frame + exit) instead of "
|
|
1068
|
+
"looping stop_suspected forever. Default 10.")
|
|
1069
|
+
tp.set_defaults(func=cmd_trace)
|
|
1070
|
+
|
|
1071
|
+
dp = sub.add_parser("device-state")
|
|
1072
|
+
dp.add_argument("--probe", default=None)
|
|
1073
|
+
dp.add_argument("--target", default="cortex_m")
|
|
1074
|
+
dp.add_argument("--connect-timeout", type=float, default=6.0,
|
|
1075
|
+
help="§11.1: hard cap (s) on probe connect for the one-shot device-state "
|
|
1076
|
+
"read. Reports inaccessible on timeout/no-probe. Default 6.")
|
|
1077
|
+
dp.set_defaults(func=cmd_device_state)
|
|
1078
|
+
|
|
1079
|
+
args = ap.parse_args()
|
|
1080
|
+
args.func(args)
|
|
1081
|
+
|
|
1082
|
+
if __name__ == "__main__":
|
|
1083
|
+
main()
|