* perf(dsp): build the untimed loop without GDB code when nothing is armed DSPExecUntimed() is now compiled twice from dsp_exec_untimed.h: a plain instance with no breakpoint test and no idle-skip gate, and an armed instance used only while gdbArmedDSP is set. DSPExec() picks one per slice, alongside the existing timed/untimed split. The armed instance keeps the breakpoint test where it was, after the IRQ dispatch and the PC-escape bail, so a breakpoint on an ISR's first instruction still fires. Arming is sampled once per slice, as before. Preprocessed, the plain loop is develop's loop with the GDB lines removed and nothing else changed. Refs #1054 Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(debug): record the #1054 DSP loop specialisation and its layout caveat The parked specialisation now ships, so the design doc's "parked" and "~2% unexplained" text no longer describes the code. Records the A/B/B/A numbers. It also records that code layout moves this loop by about +/-1%: compiling the check out makes real games slower, and the port beats the ceiling on identical C. The claim stays narrow: yarc gains, games are neutral. It also notes that the DSP_HOOK control arm will now read about 0. Refs #1054 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(dsp): keep the local-RAM opcode fetch inside dsp_ram_8 The six fetch sites (both exec loops and the four delay-slot fetches) read dsp_ram_8[off] and [off + 1] whenever dsp_pc was below base + 0x2000. So an odd PC at base + 0x1FFF read one byte past the array. cppcheck flagged it (arrayIndexOutOfBoundsCond) once the code moved into a changed file. The bound is now base + 0x2000 - 1, the same form gpu.c already uses. That one address now goes through DSPReadWord(), which aligns. Every other PC is byte-identical. clang-tidy now skips src/jerry/dsp_exec_untimed.h the way it skips the *_simd_* fragments: on its own it is only undeclared dsp.c statics. Its code is still linted through dsp.c via HeaderFilterRegex. Refs #1054 Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
38 KiB
GDB remote serial protocol stub — design
Issue #652. Approved design, not yet implemented (2026-08-26).
Purpose
Expose the emulated Jaguar to a real debugger over GDB's Remote Serial Protocol
(RSP), so that homebrew developers can set breakpoints, inspect and modify
registers and memory, and single-step the 68000, GPU and DSP — from gdb,
lldb, VS Code, or any other RSP client they already use.
This is also this project's settled answer to "should we have a scripting system". GDB ships a complete Python scripting environment on top of breakpoints and memory access, which is the entire feature list BigPEmu's bespoke C-to-bytecode VM sells. Implementing a well-documented wire protocol gets us that capability permanently, with the scripting engine maintained by someone else. See #370, which rejected a VM twice on scope grounds, and #637.
Decisions settled before design
These were decided with the maintainer and are not open for re-litigation inside the implementation.
- Shipped in every build, off by default, activated by a core option that
opens a TCP listener on demand — not a dev-only facility gated behind
TEST_EXPORTS=1likevjtrace. Rationale: the audience is homebrew developers, and requiring them to build the core themselves excludes most of them. Consequence: the design must prove near-zero cost when disabled. - A breakpoint hit blocks inside
retro_run(). The machine freezes exactly at the fault with registers, memory and cycle position coherent. The frontend UI freezing is accepted as the cost; it is mitigated, not designed away. - All three processors are exposed. The 68000 as a native GDB target; the
GPU and DSP as additional GDB threads described by custom XML target
descriptions, with disassembly served by our own tooling through
monitorcommands, because GDB has no backend for the Jaguar RISC ISA and never will. - No emulated memory is ever patched to implement a breakpoint (see "Breakpoint detection" below).
What already exists, and why this is smaller than it looks
The three hardest-sounding pieces are already in the tree:
| Need | Already present |
|---|---|
| Per-instruction 68K hook | M68KInstructionHook(), src/core/jaguar.c:364 — already called after every instruction in shipped builds for #542's traceback ring. M68K_HOOK_FUNCTION is already defined in m68kinterface.h:101. |
| 68K halt/resume | M68KDebugHalt() / M68KDebugResume(), m68kinterface.c:90 — set and clear SPCFLAG_DEBUGGER. Already wired to six memory-breakpoint sites in jaguar.c. |
| Memory watchpoints | Exported "breakpoint on memory access" vars, jaguar.c:356, checked at six sites in the memory path. |
| RISC single-step | Real hardware: G_CTRL/D_CTRL bit 3 (SINGLE_STEP). gpu.c and dsp.c already reason about a single-step-parked core's interrupt behaviour. |
| RISC disassembly | test/tools/gpu_disasm_dump, test/tools/disasm_gpu_isr.py, test/tools/bios_disasm.py. |
| Cross-platform TCP | src/jerry/jlink_tcp.c — netlink's transport, already built on every target in Makefile.common. |
The genuinely new code is the RSP protocol layer and the target glue.
Architecture
gdb / lldb / VS Code
|
TCP 127.0.0.1
|
+------------------------v-------------------------+
| src/debug/gdbsock.c listener, one client, |
| non-blocking accept |
+------------------------+-------------------------+
|
+------------------------v-------------------------+
| src/debug/gdbstub.c RSP framing: $..#cs, ack/ |
| nak, no-ack mode, dispatch |
+------------------------+-------------------------+
|
+------------------------v-------------------------+
| src/debug/gdbtarget.c register file per target, |
| memory r/w, bp table, |
| step/continue, halt loop |
+----+-----------------+------------------+--------+
| | |
M68KInstructionHook GPUExec() DSPExec()
(jaguar.c:364) (tom/gpu.c) (jerry/dsp.c)
Three new files under a new src/debug/ directory, plus a handful of call
sites. Nothing existing is refactored.
Why a new directory
src/debug/ rather than src/core/ because the stub depends on TOM, JERRY,
the 68K and the memory map, i.e. on everything — putting it under any one
subsystem's directory would misrepresent it. It also keeps the protocol layer
unit-testable without linking the emulator (see Testing).
Target and thread model
GDB threads map to processors:
| GDB thread | Processor | Register file |
|---|---|---|
| 1 | 68000 | D0-D7, A0-A7, PC, SR — native m68k description, GDB knows it |
| 2 | GPU RISC (TOM) | R0-R31, PC, FLAGS, plus the memory-mapped control registers |
| 3 | DSP RISC (JERRY) | R0-R31, PC, FLAGS, plus its control registers |
Hg / Hc select one. qfThreadInfo enumerates only thread 1 (issue #936): gdb reads exactly one
target description per connection and applies it to every thread, so offering
the GPU/DSP (a 34-register layout) next to the 68K (18 registers) made
info threads fail with "Remote 'g' packet reply is too long". Hg2/Hg3 and the
RISC descriptions remain for raw RSP clients; stock gdb reaches the RISCs
through monitor regs|disasm|halt gpu|dsp. Stop replies are therefore always T05thread:1;: a GPU/DSP halt is
reported as a stop of thread 1 (the 68K is frozen with the rest of the
machine), preceded by an O console line ("GPU halted at $F03010 -- see
monitor regs gpu"). A raw client that used Hg2 + Z0 and relied on
thread:2 in the stop reply now learns which core halted only from that O
line (behaviour change; no in-tree client depended on it).
Thread 1 is served the engine's built-in m68k description (GDBTarget68KXML
in gdbstub.c): <architecture>m68k</architecture> and the
org.gnu.gdb.m68k.core registers in exactly the g layout (d0-d7,
a0-a5, fp, sp, ps, pc), no FP feature. This replaced "no description,
GDB applies its own m68k", which cannot work: the stub advertises
qXfer:features:read+, so an empty answer to target.xml makes stock gdb abort
("conflicting enabled responses"). A description cannot declare byte order, so
set endian big is still the user's job. Source-
level debugging with DWARF does not come for free — see Open Question 2, answered during
Phase 1 implementation: rln emits classic mc68k COFF, not ELF/DWARF, and its
one debug-info flag (-g) produced byte-identical output to a build without
it on a real test case. The honest claim is symbol-level (global symbol
addresses via rln -m's load map), not source-level.
Threads 2 and 3 are served a custom description via qXfer:features:read,
defining an org.atari.jaguar.risc feature with the 32 general registers and
the flags (raw clients only, see above). GDB cannot disassemble them — no GDB
backend for this ISA exists. That gap is filled by monitor commands rather
than pretended away.
monitor commands
GDB forwards monitor <text> verbatim as qRcmd. The stub implements:
monitor disasm <addr> [count] RISC disassembly via our own disassembler
monitor regs [gpu|dsp] full control-register dump, decoded
monitor halt <gpu|dsp|68k> halt one processor without a breakpoint
monitor trace dump the #542 68K PC traceback ring
monitor watch <addr> [r|w|rw] memory watchpoint (also via GDB's own Z2/Z3/Z4)
monitor trace is deliberately included: the traceback ring is already
maintained unconditionally, so exposing it costs nothing and is exactly what a
developer wants after a wild jump.
Breakpoint detection — the armed-flag gate
Three approaches were considered.
Rejected: patch the instruction. The classic stub technique — overwrite the target instruction with a trap opcode — costs nothing when unused, but the patched bytes live in emulated memory. They would be captured by savestates, which is precisely the class of bug #400 was; they interact badly with self-modifying code; and cart ROM versus RAM need different handling. Rejected on the savestate hazard alone.
Rejected: check a breakpoint table every instruction. Simple and safe, but
an unconditional per-instruction cost in every shipped build on every target,
including the Raspberry Pi and classic ARM builds tuned in #567. This is the
mistake vjtrace made and then had to gate behind VJ_TRACE.
Chosen: per-target armed counters, no memory modification.
/* src/debug/gdbtarget.h -- read by the hot paths, written only by the stub. */
extern int gdbArmed68K;
extern int gdbArmedGPU;
extern int gdbArmedDSP;
Each is the count of armed breakpoints for that processor. The hot-path check is one load of a global that is in cache and one never-taken branch:
/* in M68KInstructionHook(), after the existing traceback ring write */
if (gdbArmed68K && GDBCheckPC(GDB_TGT_68K, m68kPC))
GDBHalt(GDB_TGT_68K, GDB_STOP_BREAKPOINT);
Per-target counters matter: a developer debugging 68K code must not make
GPUExec() pay for it.
When a counter is nonzero, GDBCheckPC() consults a 256-entry direct-mapped
cache indexed by (pc >> 1) & 0xFF holding the full PC, so the common case is
one load and one compare; a miss falls through to a linear scan of the
breakpoint array (breakpoint counts are small — the array is capped at 64 per
target).
Why this should be free, with evidence
The vjtrace investigation measured its 6-8% regression and attributed it
specifically to the per-instruction ring buffer writes, not to the hook
mechanism: a plain function call and an inlined macro writing the ring directly
measured within 0.02% of each other across 11 paired interleaved samples. The
cost was doing work, not deciding whether to. A predicted-not-taken branch on a
hot global is the cheap half of that finding.
This is a hypothesis and the implementation must prove it. See Testing.
Watchpoints
GDB's Z2/Z3/Z4 (write/read/access watchpoints) map onto the existing
exported memory-breakpoint variables at jaguar.c:356, already checked at six
sites. Those sites currently call M68KDebugHalt(); they gain a branch to
GDBHalt() when the stub owns the watchpoint. No new instrumentation.
The halt loop and retro_run()
When GDBHalt() fires, the call stack is
retro_run() -> JaguarExecuteNew() -> ... -> M68KInstructionHook(). The stub
does not unwind. It enters a loop that blocks on the socket, services RSP
packets, and returns only on c (continue), s (step), vCont, or client
disconnect. Because we never return, all emulator state is frozen exactly where
it stopped — which is the whole reason this halt model was chosen.
Consequences and mitigations:
- The frontend UI freezes and audio starves. Accepted. Mitigated by a
SET_MESSAGE_EXTbanner shown when the stub is enabled and again when a client attaches (both are moments we can still reach the frontend), warning that halts will freeze the frontend. A log line is emitted at every halt. - A user who forgets a stub is attached sees an apparent hang. Mitigated by
the optional
virtualjaguar_gdb_halt_timeoutcore option: after N seconds halted with no client activity, auto-continue and log loudly. Default isoff, because silently resuming a debugged machine is worse than a freeze for the audience this is built for. - Client disconnect while halted resumes immediately and disarms every breakpoint. A dead debugger must never leave the machine wedged.
retro_reset()/retro_unload_game()arriving while halted cannot happen — the frontend is blocked inretro_run(). Nothing to handle.
Single-step
s / vCont;s for thread 1 uses the 68K's existing SPCFLAG_DEBUGGER path.
For threads 2 and 3 the stub sets a one-shot internal breakpoint at "any next
instruction" by arming the target's counter and having GDBCheckPC() return
true unconditionally for one instruction, then re-halting. The hardware
SINGLE_STEP bit is deliberately not used: it is guest-visible state that a
game can read, and writing it would be an observable perturbation.
Security
The stub accepts a socket connection that can read and write emulated memory. Treated accordingly:
- Binds
127.0.0.1only. Never0.0.0.0, and no core option to change that. Anyone needing remote access can forward a port deliberately. - One client at a time. A second connection is accepted and immediately closed, so a stale client cannot be silently displaced.
- No GDB file-I/O extension.
vFileandFpackets are not implemented — that is the part of RSP that reaches the host filesystem, and we have no use for it. - Every address is bounds-checked against the emulated memory map before
any read or write. A malformed or hostile
M/Xpacket must never write outside emulated space. This is the single most important invariant in the implementation and gets direct unit tests. - Packet sizes are bounded and parsed without unbounded allocation. The RSP parser is the attack surface; it is written to be boring.
Savestate and determinism guarantees
- The stub adds no savestate fields and requires no version bump. The breakpoint table, the armed counters and the socket are host-side only.
- With the stub enabled but no breakpoint armed, emulation is bit-identical to the stub being disabled. This is a test, not a claim (see Testing).
- Register and memory writes issued by GDB do change emulated state — that is the user explicitly asking for it. A savestate taken after such a write is legitimately different, and this is documented rather than guarded.
- Run-ahead: a second core instance would fail to bind the port. Bind failure is logged once and the stub stays disabled for that instance rather than retrying. Debugging with run-ahead active is not supported and says so.
Portability
src/jerry/jlink_tcp.c already provides working TCP on every target in
Makefile.common, including Windows, so the stub reuses that abstraction
rather than inventing one. On any target where the listener cannot be created,
the core option is hidden via SET_CORE_OPTIONS_DISPLAY and the stub reports
disabled — it never fails a content load.
C89/GNU89 throughout, like the rest of the core: declarations at the top of every block, no C99.
Core options
| Key | Values | Default |
|---|---|---|
virtualjaguar_gdb_stub |
disabled / enabled |
disabled |
virtualjaguar_gdb_port |
2345 / 2346 / 2347 / 3333 | 2345 |
virtualjaguar_gdb_wait |
disabled / enabled — halt at boot until a client attaches |
disabled |
virtualjaguar_gdb_halt_timeout |
off / 30s / 60s / 300s |
off |
virtualjaguar_gdb_wait matters more than it looks: debugging a boot-time
fault is impossible if the machine has already run past it before you attach.
Testing
Five layers, in increasing cost:
-
Protocol unit tests, no emulator.
test/tools/test_gdbstub_proto.clinksgdbstub.calone against a fake transport: packet framing, checksum validation, escape handling,qSupportednegotiation, no-ack mode, and malformed-packet rejection. This is where the bounds-checking invariant is proven directly, including adversarialM/Xaddresses. -
Target glue tests. Register read/write round-trips for all three processors, breakpoint arm/disarm counting, and the direct-mapped cache's behaviour on collisions.
-
Determinism. Savestate digest identical across three configurations: stub disabled; stub enabled and unattached; stub enabled, attached, zero breakpoints armed. Any difference is a bug in the gate.
-
Perf, and this one gates the design. A/B/B/A interleaved benchmark of a build with the hooks present against a build with them
#ifdef'd out. The armed-flag hypothesis is only validated if the difference is within noise on an idle machine. Per this repo's documented history, measurements must be interleaved, never sequential, anduptimechecked first — a sequential "-O3 +12.5%" once flipped to -11.7% when interleaved. If the cost is not within noise, the shipped-by-default decision must be revisited rather than the number explained away.Measured, twice, and the first result was wrong (2026-08-27).
A re-run found the hooks cost ~4.5%, not nothing:
Run median delta best-run delta Mann-Whitney 1 +4.1% +4.5% z=-5.20, p=0.0000 2 +4.7% +4.5% z=-4.97, p=0.0000 Both via
test/tools/opt_ab.sh 'VJ_GDB_STUB_DISABLE_HOOKS=0' 'VJ_GDB_STUB_DISABLE_HOOKS=1' 12(12 quartets, 24 samples per arm), host load average 5-7 -- not idle, but interleaving is the defense against exactly that, and the best-run figures (least contaminated) agree exactly across both runs.The earlier measurement recorded here claimed +1.9% at p=0.50, "within noise". It also described the host as idle while reporting a load average of 9-10, which cannot both be true. The likely mechanism for the null result:
VJ_GDB_STUB_DISABLE_HOOKSwas a local Makefile edit that was never added toBUILD_AXESand was reverted after the run, so it is plausible both arms were the same binary.opt_ab.shprints a per-arm binary hash precisely so this can be checked; the re-runs above show two distinct hashes (c861b6628cdfvs236f3467e128), identical across both runs.The switch is now a permanent, documented make variable listed in
BUILD_AXES, so the comparison is reproducible and cannot silently benchmark one binary against itself.Consequence: the armed-flag hypothesis does not hold as stated, and per this document's own rule that revisits shipping the stub enabled by default. Before reversing that decision, the cost should be localised --
VJ_GDB_STUB_DISABLE_HOOKSgates two different things, and they have very different fixes:- the per-instruction
gdbArmed*PC check inM68KInstructionHook()/GPUExec()/DSPExec()-- the thing the hypothesis was actually about; and if (gdbArmed{GPU,DSP}) idleSkipActive = 0;on the idle-skip path. Idle-loop fast-forward is where this core spends most of its budget (#569 measured 99.7% of budget in idle loops for some titles), so a branch there plausibly dominates the 4.5%.
Fully attributed (2026-08-27). Each component was measured on its own control arm (all
test/tools/opt_ab.sh ... 12, 24 samples/arm, host load 5-7):Component Cost Significance 68K hook ( M68KInstructionHook)~0% p=0.8366 Idle-skip gating ( idleSkipActive = 0)~0% p=0.2238 (re-measured, see below) GPU per-instruction check 1.8% fixed, see below DSP per-instruction check ~2.3-2.7% p=0.0000 Correction (2026-08-28): the idle-gating row above was originally vacuous, and has been re-measured.
VJ_GDB_STUB_DISABLE_IDLE_GATEwas inert as shipped -- itsifeqcarried a backslash-continuation inside the$(...), so make read one variable reference namedVJ_GDB_STUB_DISABLE_IDLE_GATE VJ_GDB_STUB_DISABLE_68K_HOOK VJ_GDB_STUB_DISABLE_DSP_HOOK, which does not exist. It expanded empty,-DVJ_GDB_STUB_DISABLE_IDLE_GATEwas never defined for any value of any of those three variables, and both A/B arms compiled to a byte-identical binary (de95224107feb3c2on both, against81e65904a50a39e3/6a6a365e09e8dfbb/9f3f63421f1a0275for the three arms that did work). The original p=0.4897 was therefore one binary measured against itself -- the same failure mode this section was written to correct in #709, one level down.After fixing the conditional the two arms differ (
8e2a80ecedc0vsf28825134333) and the re-run agrees with the original conclusion on real evidence: 12 quartets, 24 samples/arm, median delta -1.4%, z=+1.22, p=0.2238 -- under the noise floor, no effect. So idle-skip gating really is free, and it is not a candidate for the residual below. Host load 6-8 (not idle; the A/B/B/A interleave is what makes the number usable, not a quiet box).The three per-processor arms were unaffected --
opt_ab.shrunsmake cleanbetween arms, so the two axes missing fromBUILD_AXES(..._68K_HOOK,..._DSP_HOOK, both added there by the same fix) could not have mixed objects into those runs.Fix applied: cache the armed flag in a slice-entry local, exactly as #532 does for
pipeTiming/riscScalein the same function. The globals were being reloaded GOT-indirect once per emulated instruction because the opcode call is opaque -- the branch was never the problem, the reload was. That took 4.5% -> 2.7%, and the GPU's share to zero.~2.0% remains, in the DSP loop, and is NOT explained. Loop specialisation was tried (body extracted to a header, included into a plain loop and an armed loop, so the non-debug path contains no GDB code at all): it recovered only a further 0.7% on the DSP and zero on the GPU. Parked on branch
perf/gdb-dsp-loop-specialisation(digest-verified, C89-clean) rather than merged -- 0.7% did not justify an extracted-body header in the emulator's hottest function.Update (issue #1054, 2026-10): the DSP half is now specialised. By October develop had split DSPExec() into timed and untimed loops, and the untimed loop is now built twice from
src/jerry/dsp_exec_untimed.h: a plain instance with no GDB code, and an armed instance that DSPExec() enters only whilegdbArmedDSPis set. Measured on an M4 Max, A/B/B/A, load 2-4 unless noted:Arms ROM Median Best-run p develop vs DSP check compiled out (the ceiling) yarc +2.7% +2.3% 0.0003 develop vs port yarc +2.6% +3.5% 0.0006 develop vs port (repeat, 5-min load ~10) yarc +1.6% +3.3% 0.0092 develop vs port, both DSP-armed (load rose 2 -> 17) yarc +2.5% +0.3% 0.1078 develop vs port Tempest 2000 -0.2% +0.2% 0.7415 develop vs port Doom -0.8% -0.4% 0.6801 develop vs ceiling Tempest 2000 -1.0% -1.3% 0.0007 develop vs ceiling Doom -0.6% -1.3% 0.0168 ceiling vs port yarc +1.1% +0.9% 0.0018 Read the last three rows before quoting a cause. Compiling the check out makes real games about 1% slower, and the port beats the ceiling on yarc although its plain loop is the same C. Code layout moves this loop by about +/-1%, the same size as the check. So the claim is narrow: the default DSP path carries no GDB code, yarc gains, and games are neutral. A "the check cost 2%" reading is not supported.
One consequence for the control arms: after this change
VJ_GDB_STUB_DISABLE_DSP_HOOK=1compares two nearly identical plain loops, so the DSP row of a future attribution run should read about 0. That does not mean the arm is broken.Next step is real-hardware profiling, not more host measurement. Every number here comes from an arm64 Mac that never dropped below load 5. The residual should be re-attributed on a real target (tvOS/A-series, RPi) before any further surgery: the per-processor control arms
VJ_GDB_STUB_DISABLE_{HOOKS,IDLE_GATE,68K_HOOK,DSP_HOOK}exist for exactly that and are all listed inBUILD_AXES, so the attribution can be repeated there in four runs without re-deriving any of this. Check the two per-arm binary hashesopt_ab.shprints before believing any null result -- that is what the inert-gate correction above turned on, and it is the second time on this feature that identical arms produced a confident-looking p-value.Shipped-by-default stands for now at a measured ~2% cost, deliberately, pending those numbers. (Since #1054 the default DSP path carries no GDB code at all; see the update above.)
This is not merely a stand-in for gdb being unavailable. Phase 1's
GDBHandlePacketnever sent the low-level+/-acknowledgement byte a real GDB client requires for every packet beforeQStartNoAckModeis negotiated (and that negotiation itself needs one such ack to complete). Phase 1'sgdb_attach_probe.pypassed without noticing this because it never checked for an ack — a realm68k-elf-gdbwould have stalled on its very firstqSupported.Phase 2 closed that gap and extended this layer:
test/tools/gdb_attach_probe.pynow verifies a+precedes every reply in ack mode, and that acks stop arriving afterQStartNoAckMode— including the off-by-one (theOKreply toQStartNoAckModeitself is still acked; only packets after it are not).test/tools/gdb_breakpoint_probe.pyis new: it launches the core headlessly (viatest/tools/gdb_determinism_probeas a free-running host process), halts it at boot (virtualjaguar_gdb_wait), injects a single real 68K instruction (BRA.S *, a branch-to-self loop) into scratch RAM viaM, redirects PC to it viaG, arms aZ0there, continues, and asserts the stop reply names thread 1 and a fresh register read shows PC still at that exact address — a real breakpoint actually halting real (if synthetic) execution, not just a protocol-level OK reply. The gdb_wait halt is load-bearing, not just convenient: an early version of this script picked an arbitrary "probably unused" RAM address without halting first, and the ROM's own boot code overwrote the injected instruction before the CPU fetched it, within a single frame — confirmed by reading the address back and finding the ROM's own data there instead.test/tools/gdb_reconnect_probe.pycovers what a single-connection probe structurally cannot: ack mode is per-CONNECTION, not per-content-load. It attaches twice to one long-lived core — the first client negotiatingQStartNoAckModeexactly as a real gdb/lldb does, then dropping — and asserts the second client's very firstqSupportedreply is acked again. BeforeGDBTargetResetState()clearednoAckMode, it wasn't: the flag stayed latched from the dead client and the second client's handshake blocked on an ack that never came (observed with lldb, then this repo's owngdb_attach_probe.py, against one running core).Issue #936 (stock-gdb compatibility) added three layers. The engine unit tests in
test_gdbstub_proto(part ofmake test) pin the 68Ktarget.xml(m68k architecture, 18 registers ingorder, no RSP special characters), the 68K-only thread roster,D/vKill/kand theO-packet monitor streaming.test/tools/gdb_stock_compat_probe.pychecks the same against the live core (target.xml for the real ops,Z2,monitor trace 1024complete, detach/kill leave the emulator running and the listener open).test/tools/gdb_stock_gdb_check.shdrives a real m68k-capable gdb end to end with no workaround settings (skips when none is installed). - the per-instruction
Out of scope
- Tracepoints (
QTDPand the rest of GDB's tracepoint machinery). - GDB file I/O (
vFile), permanently — see Security. - Reverse debugging (
bc/bs). Interesting given we have savestates, but it is a separate feature with its own design. - Debugging across savestate load. Loading a state while attached leaves breakpoints armed at addresses that may no longer mean anything; documented, not solved.
- Non-stop mode. All three processors halt together. Halting the 68K while the GPU runs is not modelled.
Open questions
-
Does the #581 toolchain include
m68k-elf-gdb? Answered 2026-08-26, Phase 1 implementation: no.tools/jaguar-toolchain/PINpins exactly five tools —rmac,rln,lyxass,pc_jagcrypt,new_bjl— andmake jaguar-toolchain-buildwas actually run to confirm: the built tree (tools/vendor/jaguar-toolchain/) contains nogdbbinary anywhere, and none of the five tools is a debugger. There is no supported source of a Jaguar-flavoured GDB in this repo's toolchain. Consequence: test layer 5 is permanently a scripted RSP client (test/tools/gdb_attach_probe.py) rather than a stopgap until gdb shows up, and the user-facing docs must tell developers to bring their ownm68k-elf-gdb(a stock--target=m68k-elfGNU binutils/gdb build, e.g. from the m68k bare-metal toolchains distributed for classic Mac/Amiga/Atari ST development, works against a plainm68karchitecture with no custom target description) rather than implying this repo provides one.Further verified 2026-08-27: no bottled
m68k-elf-gdbexists on Homebrew either (brew search:aarch64-elf-gdb,arm-none-eabi-gdb,i386-elf-gdb,riscv64-elf-gdb,x86_64-elf-gdbare all real bottles;m68k-elf-gdbis not among them) — a developer has to build GDB from source with--target=m68k-elfthemselves; upstream GDB does support the architecture, there is just no pre-built binary to point users at. Homebrew does bottle a genuinely separate, actively-maintained C toolchain for this target —m68k-elf-gcc(16.2.0) andm68k-elf-binutils(2.47) — but that is GCC+GNUld, unrelated to this repo'srmac/rlnassembler pipeline (#581); using it would mean writing C and building a new path from its ELF+DWARF output to something loadable on the emulated Jaguar, not something this repo's build wires up today. -
What DWARF, if any, does
rlnemit? Answered 2026-08-26, Phase 1 implementation: none. Verified by building the toolchain and actually linking a test object:rln -eoutput identifies asmc68k COFF object not stripped(file(1)) — a pre-ELF Atari-era object format, not ELF — soreadelf -Sdoes not apply at all, andobjdump -h(Xcode'sllvm-objdump) fails outright with "not recognized as a valid object file" against it: no toolchain on this host can parse it as DWARF-bearing.rln's own-gflag ("output source-level debugging") produced a file byte-for-byte identical (SHA-256) to the same link without-g, and neither contains any string referencing the source filename or a.debug_*-style section name. What Jaguar-era linking does give you isrln -m's load map: global symbol names resolved to segment-relative addresses (confirmed working — see the TEXT/DATA/BSS-SEGMENT RELOCATABLE SYMBOLS tables it printed for the smoke test). Conclusion: the honest claim is symbol-level debugging (breakpoints and reads by symbol name/address, resolved fromrln -m's map or the object's own symbol table), never source-level or line-level. Every "source-level debugging" claim in this document has been corrected accordingly; do not reintroduce it without new evidence that some other tool in the chain (notrln) emits real DWARF.Further verified 2026-08-27, by reading
rmac/rlnsource directly (mwenge/rmac@50e66ea, rln@a617009, both pinned inPIN) rather than only testing one linked output: exhaustive grep fordwarfand.debug_(case-insensitive) across both tools' entire source trees returns zero matches, under any flag.rln's object-file magic-number detection only recognises BSD a.out (0107), DRI/Alcyon (601A), andararchives — there is no ELF magic (0x7F454C46) check anywhere in it.rmaccan emit an ELF object with a real.symtab/.strtab(object.c'sELFSectionNames/AddELFSymEntry), but that path is orphaned:rlnstructurally cannot read it, so it is never exercised by the actual workingrmac→rlnpipeline. And-g's real mechanism, perrln.c'sOSTAdd(), is a filter that keeps or drops symbols whose type carries a0xF0000000"debug" tag inrln's own proprietary Atari-era symbol table — which is what explains the byte-identical link above: whatever symbol category that tag marks was never present to filter in the tested case, so the flag had nothing to do either way. -
Register numbering for the RISC target descriptions. Answered 2026-08-27, Phase 3 implementation: R0-R31 = index 0-31, PC = 32, FLAGS = 33 (34 registers total), identical for the GPU and DSP descriptions. Fixed now — do not renumber; see "GPU/DSP register files" below.
Phase 2/3 implementation notes (2026-08-27)
Phases 2-4 landed together. A few things resolved or turned out different from how this document originally described them:
- The
ALPINE_FUNCTIONSsix-site claim ("Watchpoints") was verified true, but not for the reason it looked true at first.ALPINE_FUNCTIONSis not a build-system flag passed by any Makefile target — it is#defined unconditionally near the top ofsrc/core/jaguar.citself (line 80), so the sixm68k_read/write_memory_{8,16,32}sites really are compiled into every build, exactly as this document said. (An earlier pass at this implementation grepped only the Makefiles for-DALPINE_FUNCTIONS, found nothing, and nearly reported this as a design- doc inaccuracy before re-checking the source directly and finding the#defineinjaguar.c.) What was true is that the six sites were functionally dead:bpmActivewas default-falseand nothing in the tree ever set it, and the six sites' only consumer,M68KDebugHalt(), setsSPCFLAG_DEBUGGER, which makesm68k_execute()return early (unwind) rather than block — and nothing ever called the matchingM68KDebugResume(), so the pre-existing "feature" would have wedged the 68K forever if anything had ever armed it. The six sites now callGDBMemWatchHit()instead ofM68KDebugHalt(); this module is the first and only thing that ever setsbpmActive/bpmAddress1. - Single watchpoint slot, not a table. "Watchpoints ride the existing
exported memory-access breakpoint vars" was implemented literally: one
slot, matching
bpmAddress1's singular shape. A secondZ2/Z3/Z4while one is already armed at a different address is refused (E01) rather than silently displacing it or growing a table. Documented as a known limitation in the user guide. - Stepping does NOT use the 68K's
SPCFLAG_DEBUGGERpath, contrary to what this document originally specified. All three processors' stepping (andmonitor halt <target>, and thegdb_waitboot halt) share one one-shot primitive on the same per-target breakpoint-table structureGDBCheckPC()already uses: arm a flag that matches unconditionally on that processor's very next instruction hook, consumed immediately on match. Reasoning for the deviation:SPCFLAG_DEBUGGERmakesm68k_execute()return early to its caller rather than block in place, so something outside the hook would have to notice "a step just completed" and re-invokeGDBHalt()— an extra layer of scheduler plumbing the one-shot primitive doesn't need, and which wasn't worth the risk of getting subtly wrong under time pressure when a uniform mechanism across all three processors was available and already necessary for the GPU/DSP anyway.SPCFLAG_DEBUGGER/M68KDebugHalt/M68KDebugResumeremain unused by this module. - GPU/DSP register/PC/flags accessors needed adding or un-gating.
GPUGetReg/GPUGetPCpredate this feature (issue #406) and were already unconditional;GPUGetFlagsandDSPGetRegexisted but were#ifdef VJ_TRACE-only and are now unconditional (matchingGPUGetReg's existing precedent, not a new exception).DSPGetPC, and the write side of all of these (GPUSetReg/GPUSetPC/GPUSetFlags/DSPSetReg/DSPSetPC/DSPSetFlags), plus read-onlyGPUGetControl/DSPGetControlformonitor regs, did not exist at all and were added. The writes are raw pokes (likem68k_set_reg), not simulated MMIO writes, specifically so that GDB setting PC or FLAGS does not also trigger a G_CTRL/D_CTRL- style side effect (e.g. toggling GPUGO) as a surprise side effect of a register-inspection action. - The RISC disassembler is genuinely shared, not just "not duplicated
in spirit."
src/debug/gdbdisasm.his a header-only module included by bothsrc/debug/gdbtarget.c(monitor disasm, live) andtest/tools/gpu_disasm_dump.c(offline, dlsym-based) — the latter was refactored to drop its own inline copy of the mnemonic table and operand decode logic in favour of the shared one. - Multi-architecture GDB sessions (m68k thread 1 + custom-description
threads 2/3) do NOT work with a real gdb -- verified against gdb 18.1
(issue #936). gdb reads one target description per connection and applies
it to every thread; the first
gfor thread 2 fails ("reply is too long (expected 72 bytes, got 136 bytes)") and a GPU description is rejected without an<architecture>. Resolution: only the 68K is enumerated and described to gdb; the RISC cores aremonitortargets (see "Targets" above). Same issue: the 68K now gets a real m68k description (without ittarget remoteaborted), the watch gate ingdbtarget.cthat duplicatedALPINE_FUNCTIONS(never defined there) was removed so Z2/Z3/Z4 work,D/vKill/kend a session without stopping the emulator, and longmonitoroutput streams asOpackets (the old 512-byte buffer could also be overrun by an unclampedsnprintfaccumulation inmonitor regs). None of this touches a per-instruction or per-access path: the watch sites injaguar.cand thegdbArmed*checks are unchanged.
Sequencing
Each phase is independently useful and independently abandonable.
- Protocol layer + 68K target, no breakpoints. Attach, read registers, read memory, detach. Proves the transport and framing. Shipped (issue #652, PR #662).
- 68K breakpoints, watchpoints and stepping, on the armed-flag gate. Run test layer 4 here — this is the go/no-go for the whole shipped-by-default premise. Shipped — see the PR for this phase for the perf measurement's result and the machine load it was (or wasn't) taken under; do not treat this document as the record of that number.
- GPU and DSP threads, XML descriptions,
monitor disasm. Shipped, with the multi-architecture-session caveat above. - Documentation: a user guide covering attaching, the frontend-freeze
behaviour, and the RISC disassembly limitation. Shipped as
docs/gdb-stub-guide.md.