oep-client-python 0.0.54__py3-none-any.whl → 0.0.55__py3-none-any.whl

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.
oep_client/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.0.54"
5
+ __version__ = "0.0.55"
oep_client/brokers.py ADDED
@@ -0,0 +1,149 @@
1
+ """Finding a broker that serves a probe, and opening a session that shares its lock (OEP host guide §7.1, §7.2).
2
+
3
+ A broker publishes itself in the user's directory `oep/brokers/<unit_id>.json`: where to connect (`endpoints`, each with
4
+ a `kind`; `tcp` is transports §1's TCP form) and the sharing extensions it takes (`share`). A tool tries, in order:
5
+
6
+ 1. the environment variable `OEP_BROKER` (`<host>:<port>`), for a broker on another machine or a test;
7
+ 2. the file: each endpoint of a kind it knows, checking that fn 0's describe names the file's unit_id (a dead broker's
8
+ port may now be another probe's broker);
9
+ 3. the probe itself.
10
+
11
+ The tool joins with the sharing extension `oep.probe.share` (critical): sessions and resources are the client's, the
12
+ broker holds the probe's lock and lease. A tool never deletes a broker's files: only the broker holding
13
+ `<unit_id>.lock` removes or rewrites `<unit_id>.json`, and `<unit_id>.lock` stays.
14
+
15
+ from oep_client import brokers
16
+ hst = brokers.open_host("30eda0e31108", owner="my-tool") # through the broker when one serves it, else directly
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import stat
24
+ import sys
25
+ import tempfile
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ SHARE = "oep.probe.share" # host guide §7.1
30
+ ENV = "OEP_BROKER" # host guide §7.2: <host>:<port>, before the file
31
+ LEASE_MS = 60000
32
+
33
+
34
+ def directory() -> Path:
35
+ """The user's broker directory (host guide §7.2): Linux $XDG_RUNTIME_DIR/oep/brokers, else
36
+ $TMPDIR/oep-<uid>/brokers (/tmp without TMPDIR); macOS $TMPDIR/oep/brokers; Windows %LOCALAPPDATA%\\oep\\brokers."""
37
+ if sys.platform == "win32":
38
+ return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "oep" / "brokers"
39
+ if sys.platform == "darwin":
40
+ return Path(os.environ.get("TMPDIR") or tempfile.gettempdir()) / "oep" / "brokers"
41
+ runtime = os.environ.get("XDG_RUNTIME_DIR")
42
+ if runtime:
43
+ return Path(runtime) / "oep" / "brokers"
44
+ return Path(os.environ.get("TMPDIR") or "/tmp") / f"oep-{os.getuid()}" / "brokers"
45
+
46
+
47
+ def _private(path: Path) -> bool:
48
+ """The directory is the user's own and closed to others (0700); not checked on Windows (its ACLs)."""
49
+ if sys.platform == "win32":
50
+ return path.is_dir()
51
+ try:
52
+ st = path.stat()
53
+ except OSError:
54
+ return False
55
+ return stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and stat.S_IMODE(st.st_mode) & 0o077 == 0
56
+
57
+
58
+ @dataclass
59
+ class Broker:
60
+ """One broker as its file (or OEP_BROKER) names it."""
61
+ unit_id: str
62
+ endpoints: list[dict] = field(default_factory=list)
63
+ share: list[str] = field(default_factory=list)
64
+ pid: int | None = None
65
+ implementation: dict = field(default_factory=dict)
66
+
67
+ def tcp(self) -> list[tuple[str, int]]:
68
+ """The endpoints this client can use: kind tcp, `host:port`. Other kinds are skipped."""
69
+ out = []
70
+ for e in self.endpoints:
71
+ if isinstance(e, dict) and e.get("kind") == "tcp" and isinstance(e.get("address"), str):
72
+ host, _, port = e["address"].rpartition(":")
73
+ if host and port.isdigit():
74
+ out.append((host, int(port)))
75
+ return out
76
+
77
+
78
+ def find(unit_id: str, *, environ=None, base: Path | None = None) -> Broker | None:
79
+ """The broker for this probe: OEP_BROKER, else its file in the user's directory, else None. Not connected yet."""
80
+ environ = os.environ if environ is None else environ
81
+ unit = unit_id.lower()
82
+ forced = environ.get(ENV)
83
+ if forced:
84
+ return Broker(unit, [{"kind": "tcp", "address": forced}], [SHARE])
85
+ if unit.startswith("x-"):
86
+ return None # not unique: never keyed by (core §7.5)
87
+ folder = base or directory()
88
+ if not _private(folder):
89
+ return None
90
+ try:
91
+ doc = json.loads((folder / f"{unit}.json").read_text(encoding="utf-8"))
92
+ except (OSError, ValueError):
93
+ return None
94
+ if not isinstance(doc, dict) or str(doc.get("unit_id", "")).lower() != unit:
95
+ return None
96
+ share = [s for s in doc.get("share") or [] if isinstance(s, str)]
97
+ endpoints = [e for e in doc.get("endpoints") or [] if isinstance(e, dict)]
98
+ pid = doc.get("pid") if isinstance(doc.get("pid"), int) else None
99
+ impl = doc.get("implementation") if isinstance(doc.get("implementation"), dict) else {}
100
+ return Broker(unit, endpoints, share, pid, impl)
101
+
102
+
103
+ def connect(broker: Broker, *, timeout: float = 3.0):
104
+ """A Host on the first endpoint that answers with the broker's unit_id in fn 0's describe, not opened; None when
105
+ none does (a dead broker, or its port reused by another probe's broker)."""
106
+ from . import core, host as oh, link
107
+ for address, port in broker.tcp():
108
+ try:
109
+ lk = link.SerialLink.on_stream(link.TcpStream(address, port), "length", timeout)
110
+ except OSError:
111
+ continue
112
+ lk.resend = False # the broker retries towards the probe itself (link.open_host)
113
+ lk.transport = "tcp"
114
+ h = oh.Host(lk.send)
115
+ lk.attach_host(h)
116
+ h.link = lk
117
+ try:
118
+ same = (core.unit_id(h) or "").lower() == broker.unit_id
119
+ except Exception:
120
+ same = False
121
+ if same:
122
+ return h
123
+ lk.close()
124
+ return None
125
+
126
+
127
+ def open_host(unit_id: str, owner: str, *, lease_ms: int = LEASE_MS, direct: str | None = None, environ=None):
128
+ """An opened Host on the probe: sharing the broker's lock when one serves it (host guide §7.2), else the probe
129
+ itself (`direct`, default oep://<unit_id>). A broker that does not take oep.probe.share is used without it."""
130
+ from . import host as oh, link, message as m
131
+ broker = find(unit_id, environ=environ)
132
+ h = connect(broker) if broker else None
133
+ if h is not None:
134
+ try:
135
+ if SHARE in broker.share:
136
+ try:
137
+ h.open(lease_ms, owner=owner, extensions=[SHARE])
138
+ return h
139
+ except oh.Unsupported as e:
140
+ if e.tag != m.TAG_NAMED_EXTENSION | m.TAG_CRITICAL:
141
+ raise
142
+ h.open(lease_ms, owner=owner)
143
+ return h
144
+ except Exception:
145
+ h.link.close()
146
+ raise
147
+ h = link.open_host(direct or f"oep://{unit_id}")
148
+ h.open(lease_ms, owner=owner)
149
+ return h
@@ -0,0 +1,528 @@
1
+ """A GDB remote server for a Cortex-M on an OEP probe's SWD: what an IDE's debugger connects to.
2
+
3
+ The Arduino IDE debugs through cortex-debug, which with `servertype: external` connects gdb to `gdbTarget`
4
+ (`target extended-remote HOST:PORT`) and sends `monitor reset halt`, `load` and `monitor halt`. This server answers
5
+ gdb's remote protocol on a TCP port and does the work over the probe: registers and memory through the MEM-AP, run,
6
+ stop and single-step through DHCSR, breakpoints in the FPB, `load` into flash by the chip's own routine.
7
+
8
+ The target is a `Target`-shaped object (`Rp2040Debug` here: an RP2040's core 0 through `rp2040_swd`), so the server
9
+ runs against a fake one in tests.
10
+
11
+ What gdb gets:
12
+ - `qSupported`: PacketSize, no-ack mode, `qXfer:features:read` (an M-profile target.xml: r0-r12, sp, lr, pc, xpsr)
13
+ and `qXfer:memory-map:read` (the target's flash, RAM and the rest; gdb then puts breakpoints in flash as hardware
14
+ ones and writes flash with vFlashErase / vFlashWrite / vFlashDone).
15
+ - `g` `G` `p` `P` `m` `M` `X`, `c` `s` `vCont`, Ctrl-C, `Z0` / `Z1` (both in the FPB) `z0` / `z1`, `qRcmd`
16
+ (`monitor reset halt`, `monitor reset`, `monitor halt`, `monitor resume`), `D`, `k`, `qAttached`, threads as one.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import binascii
22
+ import select
23
+ import socket
24
+ import struct
25
+ import threading
26
+ import time
27
+ from typing import Callable
28
+
29
+ POLL = 0.05 # how often a running core is looked at
30
+ KEEPALIVE_S = 5.0 # a request to the probe this often while gdb is idle (the session's lease)
31
+
32
+ TARGET_XML = """<?xml version="1.0"?>
33
+ <!DOCTYPE target SYSTEM "gdb-target.dtd">
34
+ <target version="1.0">
35
+ <architecture>arm</architecture>
36
+ <feature name="org.gnu.gdb.arm.m-profile">
37
+ """ + "".join(f'<reg name="r{i}" bitsize="32" regnum="{i}" type="uint32" group="general"/>\n' for i in range(13)) + """\
38
+ <reg name="sp" bitsize="32" regnum="13" type="data_ptr" group="general"/>
39
+ <reg name="lr" bitsize="32" regnum="14" type="uint32" group="general"/>
40
+ <reg name="pc" bitsize="32" regnum="15" type="code_ptr" group="general"/>
41
+ <reg name="xpsr" bitsize="32" regnum="16" type="uint32" group="general"/>
42
+ </feature>
43
+ </target>
44
+ """
45
+ NREGS = 17
46
+
47
+
48
+ def _locked(fn):
49
+ """One operation at a time on the probe: the gdb session's and an upload's (the IDE's Upload while gdb is
50
+ connected) come from two threads. An upload holds it from its halt to its run, so gdb never sees it stopped."""
51
+ def run(self, *a, **k):
52
+ with self.lock:
53
+ return fn(self, *a, **k)
54
+ run.__name__, run.__doc__ = fn.__name__, fn.__doc__
55
+ return run
56
+
57
+
58
+ class Rp2040Debug:
59
+ """An RP2040's core 0 on an OEP port (`oep://<probe>/<port>`), for the server. Opens the probe on `open()`."""
60
+
61
+ FLASH, FLASH_SIZE, SECTOR = 0x10000000, 2 * 1024 * 1024, 4096
62
+ RAM, RAM_SIZE = 0x20000000, 0x42000
63
+ FP_CTRL, FP_COMP0 = 0xE0002000, 0xE0002008
64
+ C_STEP = 4
65
+
66
+ def __init__(self, address: str, owner: str = "gdb"):
67
+ self.address, self.owner = address, owner
68
+ self.host = self.t = None
69
+ self.bp: dict[int, int] = {} # address -> comparator
70
+ self.lock = threading.RLock()
71
+
72
+ # session
73
+ def open(self) -> None:
74
+ from . import rp2040_swd as rp
75
+ unit, port = rp.parse(self.address)
76
+ self.host = rp.open_host(unit, self.owner)
77
+ self.t = rp.Target(self.host, port)
78
+ self.core, self.mem = self.t.core, self.t.mem
79
+ self.mem.write32(self.FP_CTRL, 3) # FPB on (KEY | ENABLE)
80
+ n = (self.mem.read32(self.FP_CTRL) >> 4) & 0xF
81
+ self.comparators = n or 4
82
+ for i in range(self.comparators):
83
+ self.mem.write32(self.FP_COMP0 + 4 * i, 0)
84
+
85
+ def close(self) -> None:
86
+ if self.t is None:
87
+ return
88
+ try:
89
+ for i in range(self.comparators):
90
+ self.mem.write32(self.FP_COMP0 + 4 * i, 0)
91
+ self.core.release()
92
+ except Exception:
93
+ pass
94
+ try:
95
+ self.t.close()
96
+ finally:
97
+ try:
98
+ self.host.end()
99
+ finally:
100
+ self.host.link.close()
101
+ self.t = None
102
+
103
+ @_locked
104
+ def keepalive(self) -> None:
105
+ self.host.keepalive()
106
+
107
+ # run control
108
+ @_locked
109
+ def halted(self) -> bool:
110
+ return self.core.halted()
111
+
112
+ @_locked
113
+ def halt(self) -> None:
114
+ self.core.halt()
115
+
116
+ @_locked
117
+ def resume(self) -> None:
118
+ self.core.resume()
119
+
120
+ @_locked
121
+ def step(self) -> None:
122
+ k = self.core
123
+ k.mem.write32(k.DHCSR, k.KEY | k.C_DEBUGEN | k.C_HALT | k.C_MASKINTS)
124
+ k.mem.write32(k.DHCSR, k.KEY | k.C_DEBUGEN | k.C_MASKINTS | self.C_STEP)
125
+ k._wait(k.S_HALT, 1.0)
126
+ k.mem.write32(k.DHCSR, k.KEY | k.C_DEBUGEN | k.C_HALT)
127
+
128
+ @_locked
129
+ def reset_halt(self) -> None:
130
+ self.core.reset_halt()
131
+
132
+ @_locked
133
+ def reset_run(self) -> None:
134
+ """The core reset (the boot ROM runs boot2 and the image again) and let run. Not the ROM's reboot: that
135
+ resets the whole chip and the debug link with it, and gdb's session would be lost."""
136
+ self.core.reset_halt()
137
+ self.core.resume()
138
+
139
+ # registers
140
+ @_locked
141
+ def read_registers(self) -> list[int]:
142
+ return [self.core.reg(n) for n in range(NREGS)]
143
+
144
+ @_locked
145
+ def write_register(self, n: int, value: int) -> None:
146
+ self.core.set_reg(n, value)
147
+
148
+ # memory
149
+ @_locked
150
+ def read_memory(self, address: int, length: int) -> bytes:
151
+ start, end = address & ~3, (address + length + 3) & ~3
152
+ words = self.mem.read_block(start, (end - start) // 4) if end > start else []
153
+ raw = struct.pack(f"<{len(words)}I", *words)
154
+ return raw[address - start:address - start + length]
155
+
156
+ @_locked
157
+ def write_memory(self, address: int, data: bytes) -> None:
158
+ start, end = address & ~3, (address + len(data) + 3) & ~3
159
+ buf = bytearray(self.read_memory(start, end - start)) if (start != address or end != address + len(data)) \
160
+ else bytearray(end - start)
161
+ buf[address - start:address - start + len(data)] = data
162
+ self.mem.write_block(start, list(struct.unpack(f"<{len(buf) // 4}I", bytes(buf))))
163
+
164
+ def memory_map(self) -> str:
165
+ return ('<?xml version="1.0"?>\n<!DOCTYPE memory-map PUBLIC "+//IDN gnu.org//DTD GDB Memory Map V1.0//EN" '
166
+ '"http://sourceware.org/gdb/gdb-memory-map.dtd">\n<memory-map>\n'
167
+ '<memory type="rom" start="0x0" length="0x4000"/>\n'
168
+ f'<memory type="flash" start="{self.FLASH:#x}" length="{self.FLASH_SIZE:#x}">'
169
+ f'<property name="blocksize">{self.SECTOR:#x}</property></memory>\n'
170
+ f'<memory type="ram" start="{self.RAM:#x}" length="{self.RAM_SIZE:#x}"/>\n'
171
+ '<memory type="ram" start="0x40000000" length="0x20000000"/>\n'
172
+ '<memory type="ram" start="0xd0000000" length="0x10000000"/>\n'
173
+ '<memory type="ram" start="0xe0000000" length="0x10000000"/>\n'
174
+ '</memory-map>\n')
175
+
176
+ @_locked
177
+ def upload(self, image: bytes, log: Callable[[str], None]) -> None:
178
+ """A whole flash image from the flash's start (the IDE's Upload while gdb is connected), then run it."""
179
+ self.core.halt()
180
+ self.core.reset_halt()
181
+ self.write_flash(self.FLASH, image, log)
182
+ self.reset_run()
183
+
184
+ @_locked
185
+ def write_flash(self, address: int, image: bytes, log: Callable[[str], None]) -> None:
186
+ from . import rp2040
187
+ if not rp2040.program_and_verify(self.core, image, address - self.FLASH, log=log):
188
+ raise RuntimeError("the flash did not read back as written")
189
+
190
+ # breakpoints (the FPB, v1: an address below 0x20000000, the half-word picked by REPLACE)
191
+ @_locked
192
+ def set_breakpoint(self, address: int) -> bool:
193
+ if address in self.bp:
194
+ return True
195
+ if address >= 0x20000000:
196
+ return False
197
+ free = [i for i in range(self.comparators) if i not in self.bp.values()]
198
+ if not free:
199
+ return False
200
+ i = free[0]
201
+ replace = 2 if address & 2 else 1
202
+ self.mem.write32(self.FP_COMP0 + 4 * i, (replace << 30) | (address & 0x1FFFFFFC) | 1)
203
+ self.bp[address] = i
204
+ return True
205
+
206
+ @_locked
207
+ def clear_breakpoint(self, address: int) -> bool:
208
+ i = self.bp.pop(address, None)
209
+ if i is None:
210
+ return False
211
+ self.mem.write32(self.FP_COMP0 + 4 * i, 0)
212
+ return True
213
+
214
+
215
+ # ---- the protocol ------------------------------------------------------------------------------------------------
216
+
217
+ def checksum(body: bytes) -> bytes:
218
+ return b"%02x" % (sum(body) & 0xFF)
219
+
220
+
221
+ def frame(body: bytes) -> bytes:
222
+ return b"$" + body + b"#" + checksum(body)
223
+
224
+
225
+ def unescape(data: bytes) -> bytes:
226
+ out, i = bytearray(), 0
227
+ while i < len(data):
228
+ c = data[i]
229
+ if c == 0x7D and i + 1 < len(data):
230
+ out.append(data[i + 1] ^ 0x20)
231
+ i += 2
232
+ else:
233
+ out.append(c)
234
+ i += 1
235
+ return bytes(out)
236
+
237
+
238
+ def _hex32(v: int) -> bytes:
239
+ return binascii.hexlify(struct.pack("<I", v & 0xFFFFFFFF))
240
+
241
+
242
+ class Session:
243
+ """One gdb connection: reads packets, answers them, runs the target."""
244
+
245
+ def __init__(self, conn: socket.socket, target, log: Callable[[str], None] = print):
246
+ self.conn, self.target, self.log = conn, target, log
247
+ self.ack = True
248
+ self.buf = b""
249
+ self.flash: dict[int, bytearray] = {} # vFlashWrite: start -> contiguous bytes
250
+ self.last_io = time.monotonic()
251
+
252
+ # transport
253
+ def _recv(self, timeout: float | None) -> bytes:
254
+ r, _, _ = select.select([self.conn], [], [], timeout)
255
+ if not r:
256
+ return b""
257
+ data = self.conn.recv(65536)
258
+ if not data:
259
+ raise ConnectionError("gdb closed")
260
+ return data
261
+
262
+ def next_packet(self) -> bytes | None:
263
+ """The next packet's body (b"\\x03" for an interrupt); None after KEEPALIVE_S of silence."""
264
+ while True:
265
+ while self.buf and self.buf[:1] in (b"+", b"-"):
266
+ self.buf = self.buf[1:]
267
+ if self.buf[:1] == b"\x03":
268
+ self.buf = self.buf[1:]
269
+ return b"\x03"
270
+ start = self.buf.find(b"$")
271
+ if start >= 0:
272
+ end = self.buf.find(b"#", start)
273
+ if end >= 0 and len(self.buf) >= end + 3:
274
+ body, cs = self.buf[start + 1:end], self.buf[end + 1:end + 3]
275
+ self.buf = self.buf[end + 3:]
276
+ if self.ack:
277
+ self.conn.sendall(b"+" if checksum(body) == cs.lower() else b"-")
278
+ return body
279
+ elif self.buf and self.buf[:1] not in (b"$", b"+", b"-", b"\x03"):
280
+ self.buf = self.buf[1:]
281
+ continue
282
+ data = self._recv(KEEPALIVE_S)
283
+ if not data:
284
+ return None
285
+ self.buf += data
286
+
287
+ def send(self, body: bytes | str) -> None:
288
+ if isinstance(body, str):
289
+ body = body.encode()
290
+ self.conn.sendall(frame(body)) # gdb's '+' is skipped by the next read
291
+
292
+ # the loop
293
+ def serve(self) -> None:
294
+ while True:
295
+ body = self.next_packet()
296
+ if body is None:
297
+ self.target.keepalive()
298
+ continue
299
+ if body == b"\x03":
300
+ self.target.halt()
301
+ self.send("T02")
302
+ continue
303
+ reply = self.handle(body)
304
+ if reply is not None:
305
+ self.send(reply)
306
+ if body[:1] in (b"k", b"D"):
307
+ return
308
+
309
+ def wait_halt(self) -> str:
310
+ """Run until the core halts or gdb interrupts."""
311
+ while True:
312
+ if self.target.halted():
313
+ return "T05"
314
+ data = self._recv(POLL)
315
+ if data:
316
+ self.buf += data
317
+ if b"\x03" in self.buf:
318
+ self.buf = self.buf.replace(b"\x03", b"", 1)
319
+ self.target.halt()
320
+ return "T02"
321
+
322
+ def handle(self, body: bytes) -> str | bytes | None:
323
+ t = self.target
324
+ cmd = body[:1]
325
+ try:
326
+ if body.startswith(b"qSupported"):
327
+ return "PacketSize=4000;QStartNoAckMode+;qXfer:features:read+;qXfer:memory-map:read+;vContSupported+"
328
+ if body == b"QStartNoAckMode":
329
+ self.send("OK")
330
+ self.ack = False
331
+ return None
332
+ if body.startswith(b"qXfer:features:read:target.xml:"):
333
+ return self._xfer(TARGET_XML, body)
334
+ if body.startswith(b"qXfer:memory-map:read::"):
335
+ return self._xfer(t.memory_map(), body)
336
+ if body == b"qAttached":
337
+ return "1"
338
+ if body == b"qC":
339
+ return "QC1"
340
+ if body == b"qfThreadInfo":
341
+ return "m1"
342
+ if body == b"qsThreadInfo":
343
+ return "l"
344
+ if body.startswith(b"qRcmd,"):
345
+ return self._monitor(binascii.unhexlify(body[6:]).decode(errors="replace").strip())
346
+ if cmd == b"?":
347
+ return "T05" if t.halted() else self._stop_then("T02")
348
+ if cmd == b"H" or body.startswith(b"T"):
349
+ return "OK"
350
+ if cmd == b"g":
351
+ return b"".join(_hex32(v) for v in t.read_registers())
352
+ if cmd == b"G":
353
+ raw = binascii.unhexlify(body[1:])
354
+ for n in range(min(NREGS, len(raw) // 4)):
355
+ t.write_register(n, struct.unpack_from("<I", raw, 4 * n)[0])
356
+ return "OK"
357
+ if cmd == b"p":
358
+ n = int(body[1:], 16)
359
+ return _hex32(t.read_registers()[n]) if n < NREGS else "E01"
360
+ if cmd == b"P":
361
+ n, v = body[1:].split(b"=")
362
+ t.write_register(int(n, 16), struct.unpack("<I", binascii.unhexlify(v))[0])
363
+ return "OK"
364
+ if cmd == b"m":
365
+ a, n = (int(x, 16) for x in body[1:].split(b","))
366
+ return binascii.hexlify(t.read_memory(a, n))
367
+ if cmd == b"M":
368
+ head, data = body[1:].split(b":", 1)
369
+ a, _ = (int(x, 16) for x in head.split(b","))
370
+ t.write_memory(a, binascii.unhexlify(data))
371
+ return "OK"
372
+ if cmd == b"X":
373
+ head, data = body[1:].split(b":", 1)
374
+ a, n = (int(x, 16) for x in head.split(b","))
375
+ if n:
376
+ t.write_memory(a, unescape(data))
377
+ return "OK"
378
+ if body == b"vCont?":
379
+ return "vCont;c;C;s;S;t"
380
+ if body.startswith(b"vCont;"):
381
+ action = body[6:7]
382
+ if action in (b"s", b"S"):
383
+ t.step()
384
+ return "T05"
385
+ if action == b"t":
386
+ t.halt()
387
+ return "T00"
388
+ t.resume()
389
+ return self.wait_halt()
390
+ if cmd in (b"c", b"C"):
391
+ t.resume()
392
+ return self.wait_halt()
393
+ if cmd in (b"s", b"S"):
394
+ t.step()
395
+ return "T05"
396
+ if cmd in (b"Z", b"z") and body[1:2] in (b"0", b"1"):
397
+ a = int(body[3:].split(b",")[0], 16)
398
+ ok = t.set_breakpoint(a) if cmd == b"Z" else t.clear_breakpoint(a)
399
+ return "OK" if ok else "E01"
400
+ if body.startswith(b"vFlashErase:"):
401
+ return "OK" # the write erases what it covers
402
+ if body.startswith(b"vFlashWrite:"):
403
+ head, data = body[12:].split(b":", 1)
404
+ self._flash_put(int(head, 16), unescape(data))
405
+ return "OK"
406
+ if body == b"vFlashDone":
407
+ return self._flash_done()
408
+ if body == b"vMustReplyEmpty" or body.startswith(b"vKill"):
409
+ return "" if body == b"vMustReplyEmpty" else "OK"
410
+ if cmd == b"D":
411
+ t.resume()
412
+ return "OK"
413
+ if cmd == b"k":
414
+ t.resume()
415
+ return None
416
+ return ""
417
+ except (ValueError, IndexError):
418
+ return "E01"
419
+ except TimeoutError as e:
420
+ self.log(f"gdb: {body[:40]!r}: {e}")
421
+ return "E02"
422
+
423
+ def _stop_then(self, reply: str) -> str:
424
+ self.target.halt()
425
+ return reply
426
+
427
+ def _xfer(self, text: str, body: bytes) -> str:
428
+ offset, length = (int(x, 16) for x in body.rsplit(b":", 1)[1].split(b","))
429
+ data = text[offset:offset + length]
430
+ return ("l" if offset + length >= len(text) else "m") + data
431
+
432
+ def _monitor(self, line: str) -> str:
433
+ t = self.target
434
+ words = line.split()
435
+ if words in (["reset", "halt"], ["reset", "init"]):
436
+ t.reset_halt()
437
+ msg = "core reset and halted\n"
438
+ elif words in (["reset"], ["reset", "run"]):
439
+ t.reset_run()
440
+ msg = "core reset, running\n"
441
+ elif words == ["halt"]:
442
+ t.halt()
443
+ msg = "core halted\n"
444
+ elif words in (["resume"], ["go"]):
445
+ t.resume()
446
+ msg = "core running\n"
447
+ else:
448
+ msg = f"unknown monitor command: {line}\n"
449
+ self.send("O" + binascii.hexlify(msg.encode()).decode())
450
+ return "OK"
451
+
452
+ def _flash_put(self, address: int, data: bytes) -> None:
453
+ for start, buf in self.flash.items():
454
+ if start + len(buf) == address:
455
+ buf += data
456
+ return
457
+ self.flash[address] = bytearray(data)
458
+
459
+ def _flash_done(self) -> str:
460
+ """The pieces of this load as one image from the first piece's erase block to the last byte, the gaps 0xFF
461
+ (gdb erases whole blocks; its sections can leave a few bytes between them): one write of the chip's routine."""
462
+ if not self.flash:
463
+ return "OK"
464
+ try:
465
+ block = getattr(self.target, "SECTOR", 4096)
466
+ start = min(self.flash) & ~(block - 1)
467
+ end = max(a + len(b) for a, b in self.flash.items())
468
+ image = bytearray(b"\xff" * (end - start))
469
+ for a, b in self.flash.items():
470
+ image[a - start:a - start + len(b)] = b
471
+ self.log(f"gdb: writing {len(image)} B of flash at {start:#010x}")
472
+ self.target.write_flash(start, bytes(image), self.log)
473
+ except Exception as e:
474
+ self.log(f"gdb: flash write failed: {e}")
475
+ return "E03"
476
+ finally:
477
+ self.flash.clear()
478
+ return "OK"
479
+
480
+
481
+ class Server:
482
+ """Listens on a TCP port; each gdb connection opens the target, is served, and closes it."""
483
+
484
+ def __init__(self, make_target: Callable[[], object], port: int = 3333, bind: str = "127.0.0.1",
485
+ log: Callable[[str], None] = print):
486
+ self.make_target, self.log = make_target, log
487
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
488
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
489
+ self.sock.bind((bind, port))
490
+ self.sock.listen(1)
491
+ self.port = self.sock.getsockname()[1]
492
+ self.lock = threading.Lock() # one gdb at a time
493
+
494
+ def serve_forever(self, stop: threading.Event | None = None) -> None:
495
+ self.sock.settimeout(0.5)
496
+ while not (stop and stop.is_set()):
497
+ try:
498
+ conn, peer = self.sock.accept()
499
+ except socket.timeout:
500
+ continue
501
+ except OSError:
502
+ return
503
+ threading.Thread(target=self._client, args=(conn, peer), daemon=True).start()
504
+
505
+ def _client(self, conn: socket.socket, peer) -> None:
506
+ if not self.lock.acquire(blocking=False):
507
+ conn.close()
508
+ return
509
+ target = None
510
+ try:
511
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
512
+ target = self.make_target()
513
+ target.open()
514
+ self.log(f"gdb: {peer[0]}:{peer[1]} connected")
515
+ Session(conn, target, self.log).serve()
516
+ except ConnectionError:
517
+ pass
518
+ except Exception as e:
519
+ self.log(f"gdb: session ended: {e}")
520
+ finally:
521
+ if target is not None:
522
+ target.close()
523
+ conn.close()
524
+ self.lock.release()
525
+ self.log("gdb: disconnected")
526
+
527
+ def close(self) -> None:
528
+ self.sock.close()