Skip to content

Add GDB backend (for QEMU) - #399

Merged
BrianPugh merged 3 commits into
BrianPugh:mainfrom
slash-proc:gdb-backend
Jul 30, 2026
Merged

Add GDB backend (for QEMU)#399
BrianPugh merged 3 commits into
BrianPugh:mainfrom
slash-proc:gdb-backend

Conversation

@slash-proc

Copy link
Copy Markdown
Contributor

Adds a GDBBackend (OCDBackend registry member "gdb") that connects to a QEMU instance's gdbstub over TCP, so gnwmanager can target emulated devices the same way it targets physical ones via openocd/pyocd. Selected via the existing --backend/-b flag, with --gdb-host/--gdb-port to configure the target (defaults to localhost:1234).

Adds a GDBBackend (OCDBackend registry member "gdb") that connects to
a QEMU instance's gdbstub over TCP, so gnwmanager can target emulated
devices the same way it targets physical ones via openocd/pyocd.
Selected via the existing --backend/-b flag, with --gdb-host/--gdb-port
to configure the target (defaults to localhost:1234).
Address ruff SIM105/N806/B904 and unused-import findings, and
black formatting.
Addresses review feedback on the GDB/QEMU backend.

- Route all socket I/O through _read()/_write() helpers that translate
  socket.timeout and OSError into GDBError. Previously raw socket
  timeouts escaped past the TransferErrors retry machinery and main.py's
  DebugProbeConnectionError handling everywhere except halt().
- Validate reply length in read_memory(). A stub may legally return
  fewer bytes than requested; that previously yielded silently truncated
  data to callers.
- Decode run-length-encoded reply packets. QEMU does not emit these
  (gdb_put_packet_binary() copies payloads verbatim; its RLE handling is
  receive-side only), but the remote protocol permits any stub to.
- reset() no longer suppresses GDBError, so a reset that did nothing can
  no longer report success, and it now unconditionally leaves the target
  running to match OpenOCDBackend/PyOCDBackend.
- start_gdbserver() raises NotImplementedError naming QEMU's port rather
  than silently returning, which left `gnwmanager gdbserver` exiting
  instantly and `gnwmanager gdb` pointing gdb at a dead port.
- Decode hex replies via _decode_hex() so malformed input raises
  GDBError instead of a bare ValueError.
- close() resets _is_running.
- Trim read_memory()'s justification comment to the general
  cpu_memory_rw_debug() argument, dropping references to a private tree.

Adds tests/test_gdb_backend.py (20 tests) covering RLE expansion, short
reads, malformed hex, timeout translation and reset/close state.

Verified against qemu-gnw: info, tree, ls, push/pull, dump (256KB bank1
and 4MB ext, both byte-exact), flash, format, blank-bank boot, and
flash-patch zelda, whose output is byte-identical to the reference
zelda-bank1-patched.bin.
@slash-proc

Copy link
Copy Markdown
Contributor Author

Correction: the RLE finding doesn't hold against QEMU

The report's only ≥80-confidence finding was that QEMU RLE-compresses reply packets, so bytes.fromhex() in read_memory() would crash routinely on erased flash. The mechanism is described correctly,
but the premise isn't right: QEMU's gdbstub never run-length-encodes its output. gdb_put_packet_binary() (gdbstub/gdbstub.c) appends the payload verbatim; the only RLE handling is receive-side, in
the RS_GETLINE_RLE state of the packet parser that decodes packets from GDB.

I also checked it empirically rather than just by reading source — dumped 1 MB of erased 0xFF external flash, which is exactly the "runs of repeated bytes" case, and got back 1048576 bytes of 0xFF
in 0.6 s.

I've implemented the RLE decoder anyway, since the remote protocol does permit any stub to use it and it's a few lines — just flagging that it's spec compliance rather than a bug fix, in case that
changes how you want to weigh it.

Changes

  • Socket timeouts now route through GDBError. This was the most valuable finding in the report. All socket I/O goes through _read()/_write() helpers that translate socket.timeout/OSError;
    previously raw timeouts escaped TransferErrors retry and the friendly DebugProbeConnectionError path everywhere except halt().
  • read_memory() validates reply length. Not in the report, but worse than the RLE issue: a stub may legally short-read, and that silently yielded truncated data to flash/filesystem callers.
  • reset() no longer suppresses GDBError (it could report success having reset nothing) and unconditionally leaves the target running, matching OpenOCDBackend/PyOCDBackend.
  • start_gdbserver() raises NotImplementedError naming QEMU's port instead of returning silently. QEMU's stub is single-client and this backend already occupies it, so proxying isn't feasible —
    the error tells you to point gdb at QEMU directly.
  • Malformed hex raises GDBError via _decode_hex() rather than a bare ValueError; close() resets _is_running.
  • Trimmed the read_memory() comment. Fair criticism — it cited a commit in my QEMU tree that you can't check. The substance holds for stock QEMU too (m/M go through cpu_memory_rw_debug(),
    safe against a running VM), so it now says that and nothing about a private fork.

New tests/test_gdb_backend.py — 20 tests over a fake socket, covering RLE expansion, short reads, malformed hex, timeout translation, and reset/close state.

Verified against gwemu

info (0.5 s), tree, ls, push/pull (md5-identical), dump bank1 256 KB and dump ext 4 MB (both byte-exact vs source images), flash + readback, format, completely blank banks (reports
UNKNOWN/MISSING/CORRUPT and recovers), and flash-patch zelda — whose output is byte-identical to the known-good reference patched image, and boots.

Caveat: a pre-existing intermittent timeout

While testing I hit a timeout roughly 1 command in 8, at no consistent step. I A/B'd it by stashing my changes and running the same 8-command sequence 3× on each build: 4 failures / 24 steps before, 2
/ 24 after
. So it predates this PR and these changes reduce it, but it's real and it's the substance behind the "timeouts bypass the retry machinery" note.

Root cause of the cascade, at least: once a socket.timeout fires, Python's makefile() BufferedReader is permanently poisoned (cannot read from timed out object), so one timeout takes down the rest
of the session. The fix is to reconnect or drop the buffered reader on timeout. I've left that out of this PR to keep it scoped — happy to do it here instead if you'd prefer.

@BrianPugh
BrianPugh merged commit 3c0da24 into BrianPugh:main Jul 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants