![]() |
||
---|---|---|
.. | ||
Readme.md | ||
server.cpp | ||
server.hpp | ||
watchpoint.hpp |
Readme.md
Debug Server
The file server.cpp
adds a gdb-server compatible with several GDB versions and IDEs like VScode and CLion.
It is implemented as a standalone server independent of any specific system, and even ares itself.
This allows for easy integration with systems without having to worry about the details of GDB itself.
Managing the server itself, including the underlying TCP connection, is done by ares.
System specific logic is handled via (optional) call-backs that a can be registered,
as well as methods to report events to GDB.
The overall design of this server is to be as neutral as possible.
Meaning that things like stopping, stepping and reading memory should not affect the game.
This is done to make sure that games behave the same as if they were running without a debugger, down to the cycle.
Integration Guide
This section describes how to implement the debugger for a system in ares.
It should not be necessary to modify the server itself, or to know much about the GDB protocol.
Simply registering callbacks and reporting events are enough to get the full set of features working.
For a minimal working debugging session, register/memory reads and a way to report the PC are required.
Although implementing as much as possible is recommended to make GDB more stable.
Interactions with the server can be split in three categories:
- Hooks: lets GDB call functions in your ares system (e.g.: memory read)
- Report-functions: notify GDB about events (e.g.: exceptions)
- Status-functions: helper to check the GDB status (e.g.: are breakpoints set or not)
Hooks can be set via setting the callbacks in GDB::server.hooks.XXX
.
Report functions are prefixed GDB::server.reportXXX()
, and status functions a documented here separately.
All hooks/report/status functions can be safely set or called even if the server is not running.
As an example of a fictional system, this is what a memory read could look like:
GDB::server.hooks.regRead = [](u32 regIdx) {
return hex(cpu.readRegister(regIdx), 16, '0');
};
Or the main execution loop:
while(!endOfFrame && GDB::server.reportPC(cpu.getPC())) {
cpu.step();
}
For a real reference implementation, you can take a look at the N64 system.
Hooks
Memory Read - read = (u64 address, u32 byteCount) -> string
Reads byteCount
bytes from address
and returns them as a hex-string.
Both the hex-encoding / single-byte reads are dictated by the GDB protocol.
It is important to implement this in a neutral way: no exceptions and status changes.
The GDB-client may issue reads from any address at any point while halted.
If not handled properly, this can cause game crashes or different emulation behavior.
If your system emulates cache, make sure to also handle this here.
A read must be able to see the cache, but never cause a flush.
Example response (reading 3 bytes): A1B200
Memory Write - write = (u64 address, u32 unitSize, u64 value) -> void
Writes value
of byte-size unitSize
to address
.
For example, writing a 32-bit value would issue a call like this: write(0x80001230, 4, 0x0000000012345678)
.
Contrary to read, this is not required to be neutral, and is allowed to cause exceptions.
If your system emulates cache, make sure to also handle this here.
The write should behave the same as if it was done via a CPU instruction, incl. flushing the cache if needed.
Normalize Address - normalizeAddress = (u64 address) -> u64
Normalizes an address into something that makes it comparable.
This is only used for memory-watchpoints, which needs to compare what GDB send to what ares has internally.
If your system has virtual addresses or masks, this should de-virtualize it.
It's OK to not set this function, or to simply return the input untouched.
In case that memory-watchpoint are not working, this is probably the place to fix it.
Example implementation:
GDB::server.hooks.normalizeAddress = [](u64 address) {
return address & 0x0FFF'FFFF;
};
Register Read - regRead = (u32 regIdx) -> string
Reads a single register at regIdx
and returns it as a hex-string.
The size of the hex-string is dictated by the specific architecture.
Same as for memory-read, this must be implemented in a neutral way.
Any invalid register can be returned as zero.
Example response: 00000000000123AB
Register Write - regWrite = (u32 regIdx, u64 regValue) -> bool
Writes the value regValue
to the register at regIdx
.
This write is allowed to have side effects.
If the specific register is not writable or doesn't exist, false
must be returned.
On success, true
must be returned.
Register Read (General) - regReadGeneral = () -> string
Most common way for GDB to read registers, this fetches all registers at once.
The amount and order of registers is dictated by the specific architecture and GDB.
When implementing this, GDB will usually complain if the order/size is incorrect.
Same as for single reads, this must be implemented in a neutral way.
Due to some issues regarding exception handling, you are given the option to return a different PC.
This PC-override can be accessed via GDB::server.getPcOverride() -> maybe<u64>
.
The reasons for that are explained later in reportSignal()
.
Other than that, this can be implemented by looping over hooks.regRead
and returning a concatenated string.
Example response: 0000000000000000ffffffff8001000000000000000000420000000000000000000000000000000100000
...
Register Write (General) - regWriteGeneral = (const string ®Data) -> void
Writes all registers at once, this happens very rarely.
The format of regData
is the same as the response of hooks.regReadGeneral
.
Any register that is not writable or doesn't exist can be ignored.
Emulator Cache - emuCacheInvalidate = (u64 address) -> void
Should invalidate the emulator's cache at address
.
This is only necessary if you have a re-compiler or some form of instruction cache.
Target XML - targetXML = () -> string
Provides an XML description of the target system.
The XML must not contain any newlines, and should be as short as possible.
If the client has access to an .elf
file, this will be mostly ignored.
Example implementation:
GDB::server.hooks.targetXML = []() -> string {
return "<target version=\"1.0\">"
"<architecture>mips:4000</architecture>"
"</target>";
};
Documentation: https://sourceware.org/gdb/onlinedocs/gdb/Target-Description-Format.html#Target-Description-Format
Report-Functions
Signal reportSignal(Signal sig, u64 originPC) -> bool
Reports a signal/exception sig
that occurred at originPC
.
The architecture specific exception must be mapped to the enum in Signal
.
As a default, Signal::TRAP
can be used.
It will return false
if the exception occurred while the game was already paused.
This can be safely ignored.
Since you may not be able to stop the execution before an exception occurs,
The originPC
value will be saved until the next time the game is resumed.
An hooks.regReadGeneral
implementation may use this to temp. return a different PC.
This is done to allow GDB to halt on the causing instruction instead of the exception handler.
If you can halt before an exception occurs, you can ignore this.
PC reportPC(u64 pc) -> bool
Sets a new PC, this will internally check for break- and watch-points.
For convenience, it will return false
if you should halt execution.
If no debugger is running, it will always return true
.
You must only call this once per step, before the instruction at the given address gets executed.
This also means a return value of false
should make it halt before the instruction too.
Once halted, it's safe to call this with the same PC each iteration.
If a re-compiler is used, you may not want to call this for every single instruction.
In that case take a look at hasBreakpoints()
on how to optimize this.
In case you need the information if a halt is required multiple times, use GDB::server.isHalted()
instead.
Memory Read reportMemRead(u64 address, u32 size) -> void
Reports that a memory read occurred at address
with size
bytes.
The passed address must be the raw un-normalized address.
This is exclusively used for memory-watchpoints.
No PC override mechanism is provided here, since it's breaks GDB.
Memory Write reportMemWrite(u64 address, u32 size) -> void
Exactly the same as reportMemRead
, but for writes instead.
The new value of that location will be automatically fetched by the client via a memory read,
and is therefore not needed here.
Status-Functions
Halted isHalted() -> bool
Returns if the game should be currently halted or not.
For convenience, the same value gets directly returned from reportPC
.
Breakpoints hasBreakpoints() -> bool
Return true
if at least one break- or watch-point is set.
If you use a block-based re-compiler, stopping at every instruction may not be possible.
You may use this information to force single-instruction execution in that case.
If it returns false, you can safely resume using the block-based execution again.
PC Override getPcOverride() -> maybe<u64>
Returns a value if a PC override is active.
As mentioned in reportSignal()
, this can be used to return a different PC letting GDB halt at the causing instruction.
You can safely call this function multiple times.
Once a single step is taken, or the game is resumed, the override is cleared.
API Usage
This API can also be used without GDB, which allows for more use cases.
For example, you can write automated tooling or custom debugging UIs.
To make access easier, no strict checks are performed.
This means that the handshake protocol is optional, and checksums are not verified.
TCP
TCP connections behave the same way as a GDB session.
The connection is kept open the entire time, and commands are sent sequentially, each waiting for an response before sending the next command.
However, it is possible to send commands even if the game is still running, this allows for real-time data access.
Keep in minds that the server uses the RDP-commands, which are different from what you would type into a GDB client.
For a list of all commands, see: https://sourceware.org/gdb/onlinedocs/gdb/Packets.html#Packets
As an example, reading from memory would look like this:
$m8020a504,100#00
This reads 100 bytes from address 0x8020a504
, the $
and #
define the message start/end, and the 00
is the checksum (which is not checked).
One detail, and security check, is that new connections must send +
as the first byte in the first payload.
It's also a good idea to send a proper disconnect-command before closing the socket.
Otherwise, the debugger will not accept new connections until a reset or restart occurs.