Developer Tools · Debuggers
The GDB Remote Serial Protocol: Packets, Checksums, and Stubs
A debugger talking to a target it cannot link against needs a wire format. How RSP frames commands, negotiates capabilities, and survives a serial line.
Debugging a target that has no operating system, or one whose operating system is the thing being debugged, rules out the interface described in ptrace and the stop-notify model. There is no kernel to call, no process to attach to, and often no shared file system on which to put a helper. What remains is a byte stream: a serial line, a socket, or a pipe connected to a small program running on the target. The GDB Remote Serial Protocol is the format of that stream, and its constraints follow from the environments it was designed to reach: text rather than binary, a checksum on every message, an acknowledgement for every message, and no state beyond what the stub chooses to keep.
A protocol that assumes almost nothing
The target end of the connection is called a stub. It may be a few kilobytes of code in a bootloader, a monitor in ROM, a kernel’s built-in debug facility, or a full server process on a Linux machine. The protocol cannot assume the stub has threads, dynamic memory, a file system, or even a C library. It cannot assume the transport delivers bytes reliably, because a serial line at the end of a cable does not.
Those assumptions produce a design in which every message is a short ASCII string with an explicit checksum, every message is acknowledged, and every request is independent of the ones before it. There is no session state beyond what the stub chooses to keep, no streaming, and no compression beyond a run-length encoding for repeated characters. A stub can be written as a loop over a receive buffer with a switch statement, which is exactly what most stubs are.
The cost is bandwidth. A register read is a request packet and a response packet, each with framing and a checksum; reading a hundred registers one at a time is two hundred round trips. Reading a block of memory is one round trip per block, so the block size matters more than the protocol overhead once packets are large. This is why the protocol has a negotiation for packet size rather than a fixed maximum.
Framing, checksums, and the escape hatch
A packet begins with $, contains a payload, and ends with # followed by two hexadecimal digits. The checksum is the sum of the payload bytes modulo 256. The receiver replies with a single + for acceptance or - for a checksum failure, and the sender retransmits on -. Because the acknowledgement is one byte and the checksum is two, the framing overhead per packet is small but the round-trip cost is not: a sender that waits for + before sending the next packet serialises the entire exchange.
Two mechanisms reduce that cost. The first is negotiation: after a qSupported exchange, a stub that reports support for the no-acknowledgement mode accepts a request to turn acknowledgements off, after which packets are sent back to back and errors are handled by timeout rather than by retransmission. The second is run-length encoding, in which * followed by a character means “repeat the previous character n times”, with n encoded as the following character’s value minus 29. A block of zeros becomes a few bytes, which matters when a register set or a memory region is mostly empty.
Binary payloads need a third mechanism, because a payload cannot contain #, $, *, or } without confusing the framing. The escape character is }, and the escaped byte is the original exclusive-ored with 0x20. A memory write packet that carries raw bytes therefore escapes those four values and nothing else; a stub that forgets to unescape writes corrupted data, which is a classic symptom of a hand-written stub that works until the first payload containing a hash.
# Target-specific helpers (read_memory, insert_breakpoint) are the stub's own.
def checksum(payload: bytes) -> bytes:
return b"%02x" % (sum(payload) & 0xFF)
def handle(pkt: bytes) -> bytes:
if pkt == b"?":
return b"S05" # stopped, signal 5 (SIGTRAP)
if pkt.startswith(b"m"): # read memory
addr, length = (int(x, 16) for x in pkt[1:].split(b","))
return read_memory(addr, length).hex().encode()
if pkt.startswith(b"Z0,"): # insert software breakpoint
_, addr, kind = pkt.split(b",")
return b"OK" if insert_breakpoint(int(addr, 16), int(kind, 16)) else b"E01"
return b"" # empty reply: not supported
Breakpoints are requests, not bytes
The debugger does not write 0xCC into the target’s memory through the protocol. It sends a request to insert a breakpoint of a given kind at a given address, and the stub decides how. The packet carries a type (software, hardware, write watchpoint, read watchpoint, or access watchpoint) and a kind field whose meaning depends on the type: the length of the breakpoint instruction in bytes for an execution breakpoint, the length of the watched region for a watchpoint.
That division of responsibility is the important design choice. On a target where text is in flash, the stub can implement a software breakpoint by programming a hardware comparator instead, and the debugger does not need to know. On a target with a variable-length instruction set, the stub knows the instruction size and the debugger may not. On a target where the breakpoint must be inserted by a hypervisor call, the stub makes the call.
The reply is OK or an error with a numeric code. There is no partial success and no query for how many breakpoints remain; a stub that runs out of hardware slots reports an error, and the debugger’s only recourse is to try another type or tell the user. Because the debugger tracks which breakpoints it believes are inserted, a stub that silently drops one produces a program that runs past a breakpoint with no indication that anything failed.
Watchpoint packets have the same shape with a different type, and the length is the number of bytes to watch. A request that asks for a length the target cannot represent, because of the alignment and encoding limits described in hardware breakpoints and debug registers, fails at insertion rather than at the access, which is the only point at which the debugger can report it.
Capability negotiation and the description problem
Early versions of the protocol hard-coded the register layout: register 0 was the first general-purpose register, register 16 was the program counter, and so on, per architecture. That breaks as soon as a target has floating-point, vector, or system registers, or as soon as two implementations of the same architecture number them differently.
The modern protocol solves this by asking the target to describe itself. A feature query at connection time returns a set of capabilities, including the maximum packet size and whether the target supports multiprocess debugging, per-thread continue, and the extended stop replies that identify which thread stopped. A separate request reads an XML description of the target’s registers in chunks, with an offset and a length so that a description larger than the packet limit can be fetched in pieces. The debugger parses the description and builds its register model from it rather than from a table compiled into the debugger.
The same chunked-read mechanism carries a memory map, which tells the debugger which address ranges are flash and which are RAM, so that a breakpoint in flash can be implemented without writing to it. A stub that does not implement the query returns an empty reply, and the debugger falls back to assuming a flat read-write address space.
This is where most of the compatibility surface lives. A stub that implements only the mandatory packets, which are halt reason, register read, memory read, continue, step, breakpoint insert, and breakpoint remove, works with the debugger for basic sessions and fails in specific, diagnosable ways when the debugger asks for something it does not have. The failure is usually an empty reply rather than an error, because an unimplemented packet is defined to be answered with nothing.
Trusted transport, all-stop defaults, no unsolicited events
Three properties are consequences of the design rather than accidents.
There is no authentication and no encryption. Anyone who can reach the stub’s transport can read and write the target’s memory, set breakpoints, and resume it. The protocol assumes the channel is trusted, which is reasonable for a cable between a workstation and a board and unreasonable for a listening socket. A stub bound to a network interface is a remote code execution service with a debugging interface, and treating it as anything else is a mistake.
The all-stop model is built into the basic packets: continue and step apply to the target, not to a thread, and a stop report describes one event for the whole target. Per-thread control was added later, through a continue packet that names an action and a thread, and through stop replies that identify the thread. A stub that does not implement it forces the debugger into all-stop even when the target could do better, and a stub that implements it partially produces a debugger that believes threads are running when they are stopped.
Finally, the protocol is a request-response protocol with no server-initiated messages. A stub cannot report that the target has hit a breakpoint until the debugger asks; the debugger discovers a stop by issuing a continue and waiting for the reply, or by polling the halt reason. Asynchronous events, such as a watchpoint firing in a thread the debugger did not resume, have to be reported in the reply to whatever request is outstanding. That is workable, but it makes the debugger’s event loop a state machine over pending requests, and it is why remote sessions behave differently from local ones when the target stops for a reason nobody asked about.
References
- GDB Remote Serial Protocol — GNU Project
- GDB Documentation — GNU Project
- Debugging with GDB: The GNU Source-Level Debugger — Free Software Foundation