All uncommitted work from ESP32 GDB stub development session. Includes: - GDB stub (uos_gdbstub.c/.h/_entry.S) - Startup vector table rewrite - ESP32 HAL integration files - Boot chain design docs - AGENTS.md absolute rules (commit before refactor, no unauthorized changes) - All prior deepseek session work This commit prevents further data loss. No claims of correctness.
29 KiB
ESP-IDF esp_gdbstub — GDB Remote Serial Protocol (RSP) Specification
Golden reference: ESP-IDF v5.5.4 components/esp_gdbstub/ (Xtensa / ESP32).
Purpose: Exact protocol and data-structure reference for replicating the IDF
gdbstub in bare-metal UniversalisOS (no FreeRTOS, no esp_intr_alloc).
All byte layouts, field offsets, and protocol sequences below are extracted verbatim from the IDF v5.5.4 source. Line references cite the file.
Table of Contents
- Packet Framing (packet.c)
- Command Handlers (gdbstub.c)
- Register File — g/G packets
- Signal Mapping
- vCont Support
- Memory Validity
- Xtensa gdbstub-entry.S — Interrupt Entry & Frame
1. Packet Framing (packet.c)
Source: src/packet.c (174 lines). This is the transport-independent layer;
esp_gdbstub_putchar/getchar are implemented in gdbstub_transport.c (UART).
1.1 Packet wire format
$<payload>#<checksum_hi><checksum_lo>
$(0x24) — start-of-packet marker<payload>— command bytes, with run-length encoding disabled (IDF never sends*; it only parses the escape on receive, see §1.3)#(0x23) — end-of-payload marker<checksum>— 2 ASCII hex digits (lowercase), =(sum of payload bytes) mod 256
1.2 Checksum computation — simple sum mod 256
// packet.c:14, 141 (receive path)
unsigned char chsum = 0;
// for each unescaped payload byte c:
chsum += c;
On send (packet.c:58–62 esp_gdbstub_send_end):
esp_gdbstub_putchar('#');
esp_gdbstub_send_hex(s_chsum, 8); // 8 bits => 2 hex chars, lowercase
esp_gdbstub_flush();
The checksum is the 8-bit truncation of the sum of all unescaped payload
bytes. It is transmitted as exactly 2 lowercase hex digits ("0123456789abcdef").
1.3 Escape (binary) mode — the } + XOR 0x20 rule
Chars that get escaped (packet.c:26–28, on send):
| Char | ASCII | Escaped as |
|---|---|---|
$ |
0x24 | } 0x04 |
# |
0x23 | } 0x03 |
} |
0x7D | } 0x5D |
* |
0x2A | } 0x0A |
Rule: emit } (0x7D), then emit c ^ 0x20.
Checksum on escaped send (packet.c:29): the checksum adds both the }
and the XOR'd byte:
s_chsum += (c ^ 0x20) + '}';
i.e. checksum is always computed over the wire bytes actually transmitted
(including the }), never over the logical byte. (Note: this differs from the
canonical GDB spec where checksum is over logical bytes. IDF sums wire bytes.
In practice, since on the receive side it also sums the raw received bytes
including } and the XOR'd byte (packet.c:141,151), both sides agree.)
Receive-side unescape (packet.c:148–153):
if (c == '}') {
c = esp_gdbstub_getchar(); // read next byte
chsum += c; // add the escaped byte to checksum
c ^= 0x20; // restore logical byte
}
The } itself was already added to chsum at line 141 (it falls through the
general chsum += c before the } branch is taken). So on receive, checksum =
sum of all raw bytes between $ and # (including } and the XOR'd byte) —
matching the send side exactly.
1.4 ACK / NAK protocol (+/-)
After receiving a complete packet and validating the checksum (packet.c:164–172):
| Condition | Stub sends | Return |
|---|---|---|
| Checksum matches | + (0x2B) |
GDBSTUB_ST_OK (-3), sets *out_cmd/*out_size |
| Checksum mismatch | - (0x2D) |
GDBSTUB_ST_ERR (-2) |
GDBSTUB_ST_OK = -3, GDBSTUB_ST_ERR = -2, GDBSTUB_ST_ENDPACKET = -1,
GDBSTUB_ST_CONT = -4 (esp_gdbstub_common.h:23–26).
GDB also sends +/- to ACK/NAK stub packets. The stub's receive loop
(gdbstub.c:84–92, 258–278) treats these as noise: esp_gdbstub_read_command
returns the character if the first byte isn't $, and the main loop does
if (res > 0) continue; — silently ignoring GDB's +/- ACKs. The stub
does not retry on NAK from GDB.
1.5 Command buffer
GDBSTUB_CMD_BUFLEN = 512(esp_gdbstub_common.h:32).- Static buffer
s_cmd[512](packet.c:11). If payload exceeds 512 bytes, returnsGDBSTUB_ST_ERR(packet.c:155–157). - After
#, payload is NUL-terminated:s_cmd[p] = 0(packet.c:138).
1.6 Hex parsing — esp_gdbstub_gethex
(packet.c:79–116). bits = max bits to read; bits=-1 = greedy (up to 64
hex chars / 256 bits). Accepts 0-9, A-F, a-f. Stops at # or non-hex.
Returns GDBSTUB_ST_ENDPACKET (-1) on # (fixed-width mode) or
GDBSTUB_ST_ERR (-2) on non-hex (fixed-width mode).
1.7 Hex output — esp_gdbstub_send_hex(val, bits)
(packet.c:47–55). Emits bits/4 lowercase hex chars, MSB first. E.g.
send_hex(0x12345678, 32) → "12345678". send_hex(val, 8) → 2 chars.
2. Command Handlers (gdbstub.c)
Source: src/gdbstub.c (1141 lines). The dispatcher is
esp_gdbstub_handle_command() (line 711).
Two build configs affect which commands exist:
CONFIG_ESP_SYSTEM_GDBSTUB_RUNTIME— runtime debugging (breakpoints, step, continue). Gates: Z/z, S, k, s, C, P, c, qSupported.CONFIG_ESP_GDBSTUB_SUPPORT_TASKS— FreeRTOS task-as-thread listing. Gates: H, T, qC, qfThreadInfo, qsThreadInfo, qThreadExtraInfo, vCont.
Without either flag (panic-only stub), only g G m M ? are handled.
Return values from esp_gdbstub_handle_command
| Value | Meaning | Main-loop action |
|---|---|---|
GDBSTUB_ST_OK (-3) |
handled, reply already sent | loop, read next cmd |
GDBSTUB_ST_CONT (-4) |
resume target execution | break out of cmd loop → return from interrupt |
GDBSTUB_ST_ERR (-2) |
unrecognized / parse error | send empty $#<cks> packet |
When the handler returns GDBSTUB_ST_ERR, the main loop sends an empty
packet esp_gdbstub_send_str_packet(NULL) → $#00 (gdbstub.c:95, 274, 345).
When read_command itself fails (bad checksum/overflow), it sends E01
(gdbstub.c:90, 268, 339).
2.1 Command table
| Cmd | Format (GDB→Stub) | Stub Response | Handler / Notes |
|---|---|---|---|
| g | g |
$<regfile>#cks |
handle_g_command (388). All regs, big-endian (§3). |
| G | G<regfile> |
$OK#9a |
handle_G_command (399). Writes all regs. |
| m | m<addr>,<len> |
$<hexbytes>#cks or $E01# |
handle_m_command (439). addr & len are variable-width hex (gethex -1). Reads byte-by-byte. |
| M | M<addr>,<len>:<hexbytes> |
$OK#9a or $E01# |
handle_M_command (459). Writes bytes. ⚠️ bug: calls send_str_packet("OK") inside send_start/end (lines 474–476) → produces $$OK#..#... |
| ? | ? |
$T<sig>#cks |
(725). Re-sends stop reason = send_reason(). |
| c | c[<addr>] |
(no reply — resumes) | (781). Returns GDBSTUB_ST_CONT. addr ignored. |
| C | C<sig>[;<cmd>] |
$OK#9a then reads 1 more cmd |
handle_C_command (661). Replies OK, then re-reads one command (gdbstub.c:777) and returns CONT. |
| s | s[<addr>] |
(no reply — resumes) | handle_s_command (654). Sets step_in_progress=true, calls esp_gdbstub_do_step. Returns CONT. |
| S | S<sig> |
$S05# |
handle_S_command (648). Replies S05. |
| k | k |
(no reply) | (758). Restores stdout->_write, sets process_gdb_kill=true, returns CONT. |
| P | P<reg>=<val> |
$OK#9a or $E02# |
handle_P_command (668). reg is 1–2 hex digits (gethex 4 or 8 bits). val is variable-width hex, byte-swapped before writing. |
| Z0 | Z0,<addr>,<kind> |
$OK#9a or $E02# |
handle_Z0_command (525). Software/hw breakpoint. SOC_CPU_BREAKPOINTS_NUM slots. |
| z0 | z0,<addr>,<kind> |
$OK#9a |
handle_z0_command (557). Remove breakpoint. |
| Z2 | Z2,<addr>,<size> |
$OK#9a or $E02# |
handle_Z2_command (573). Write watchpoint (ESP_CPU_WATCHPOINT_STORE). |
| Z3 | Z3,<addr>,<size> |
$OK#9a or $E02# |
handle_Z3_command (593). Read watchpoint (ESP_CPU_WATCHPOINT_LOAD). |
| Z4 | Z4,<addr>,<size> |
$OK#9a or $E02# |
handle_Z4_command (612). Access watchpoint (ESP_CPU_WATCHPOINT_ACCESS). |
| z2/z3/z4 | z<x>,<addr>,<size> |
$OK#9a |
handle_zx_command (631). All three share one handler. |
| H | H<op><tid> |
$OK#9a or $E00# |
handle_H_command (980). op=g: set thread for regs; op=c: set thread for continue (no-op). tid 0 = any, -1 = all. |
| T | T<tid> |
$OK#9a |
handle_T_command (1031). Always replies OK (task alive). |
| qSupported | qSupported<:features> |
$qSupported:...# |
(701). See §2.2. |
| qC | qC |
$QC<tid># |
handle_qC_command (1006). Current thread = current_task_index+1. |
| qfThreadInfo | qfThreadInfo |
$m<tid># |
(1048). First thread (tid = task_index+1 = 1). |
| qsThreadInfo | qsThreadInfo |
$m<tid># or $l# |
(1055). Subsequent; l when done. |
| qThreadExtraInfo | qThreadExtraInfo,<tid> |
$<hexname+state># |
(1068). Hex-encoded "Name: State: |
| vCont;c | vCont;c[:<tid>] |
(resume) | (1132). Returns CONT. |
| vCont? | vCont? |
(unrecognized → empty) | IDF does NOT handle vCont?. Returns ST_ERR → $#00. ⚠️ |
| vCont;s | vCont;s |
(not handled separately) | Only vCont;c is matched (line 1132). vCont;s falls through to ST_ERR. |
2.2 qSupported response (gdbstub.c:704)
qSupported:multiprocess+;swbreak-;hwbreak+;qRelocInsn+;fork-events+;vfork-events+;exec-events+;vContSupported+;no-resumed+
| Feature | Value | Meaning |
|---|---|---|
| multiprocess | + | (claimed but TIDs are simple) |
| swbreak | - | software breakpoints NOT supported |
| hwbreak | + | hardware breakpoints supported |
| qRelocInsn | + | |
| fork-events | + | |
| vfork-events | + | |
| exec-events | + | |
| vContSupported | + | vCont claimed (but only vCont;c actually handled) |
| no-resumed | + |
2.3 Stop reply format
send_reason() (gdbstub.c:103–109):
esp_gdbstub_send_start();
esp_gdbstub_send_char('T');
esp_gdbstub_send_hex(s_scratch.signal, 8); // 2 hex digits
esp_gdbstub_send_end();
→ $T<NN>#cks where NN = signal number (§4). No register key-value pairs
(canonical GDB T packet allows T NN thread:..; but IDF sends bare T NN).
On single-step re-entry, sends S05 (gdbstub.c:226, 305):
esp_gdbstub_send_str_packet("S05") → $S05#b8.
2.4 Console output (O packet)
When GDB is attached, stdout->_write is replaced with gdbstub__swrite
(gdbstub.c:830). It emits O packets in 16-byte chunks:
$O<hex><hex>...<hex>#<cks>
Each application byte → 2 hex chars. Checksum = sum of O + all hex chars.
3. Register File — g/G packets
3.1 The esp_gdbstub_gdb_regfile_t structure (ESP32)
Source: src/port/xtensa/include/esp_gdbstub_arch.h:27–86.
ESP32 config: XCHAL_NUM_AREGS=64, all features ON (LOOPS, WINDOWED,
THREADPTR, BOOLEANS, S32C1I, MAC16, DFP_ACCEL, FP), GDBSTUB_EXTRA_TIE_SIZE=0.
Field order and GDB register indices (computed from the struct + ESP32 core-isa.h feature flags):
| GDB Reg# | Byte Offset | Field | Size | Notes |
|---|---|---|---|---|
| 0 | 0 | pc |
4 | |
| 1–64 | 4 | a[0..63] |
64×4=256 | a[0]=a0 … a[63]=a63 |
| 65 | 260 | lbeg |
4 | LOOPS |
| 66 | 264 | lend |
4 | LOOPS |
| 67 | 268 | lcount |
4 | LOOPS |
| 68 | 272 | sar |
4 | |
| 69 | 276 | windowbase |
4 | WINDOWED |
| 70 | 280 | windowstart |
4 | WINDOWED |
| 71 | 284 | configid0 |
4 | read via RSR |
| 72 | 288 | configid1 |
4 | read via RSR |
| 73 | 292 | ps |
4 | |
| 74 | 296 | threadptr |
4 | THREADPTR |
| 75 | 300 | br |
4 | BOOLEANS |
| 76 | 304 | scompare1 |
4 | S32C1I |
| 77 | 308 | acclo |
4 | MAC16 |
| 78 | 312 | acchi |
4 | MAC16 |
| 79 | 316 | m0 |
4 | MAC16 |
| 80 | 320 | m1 |
4 | MAC16 |
| 81 | 324 | m2 |
4 | MAC16 |
| 82 | 328 | m3 |
4 | MAC16 |
| 83 | 332 | expstate |
4 | DFP_ACCEL |
| 84 | 336 | f64r_lo |
4 | DFP_ACCEL |
| 85 | 340 | f64r_hi |
4 | DFP_ACCEL |
| 86 | 344 | f64s |
4 | DFP_ACCEL |
| 87–102 | 348 | f[0..15] |
16×4=64 | FP (f0–f15) |
| 103 | 412 | fcr |
4 | FP |
| 104 | 416 | fsr |
4 | FP |
| Total | 420 | 105 regs |
→ g-packet payload = 105 registers × 8 hex chars = 840 hex chars (+ $ + #XX = 844 wire bytes).
For ESP32-S2/S3 (GDBSTUB_EXTRA_TIE_SIZE=1): add tie[1] at the end → 106
regs, 424 bytes, 848 hex chars.
3.2 Byte-swap in handle_g_command (gdbstub.c:388–396)
static uint32_t gdbstub_hton(uint32_t i) {
return __builtin_bswap32(i); // reverse 4 bytes
}
static void handle_g_command(...) {
uint32_t *p = (uint32_t *) &s_scratch.regfile;
esp_gdbstub_send_start();
for (int i = 0; i < sizeof(s_scratch.regfile) / sizeof(*p); ++i) {
esp_gdbstub_send_hex(gdbstub_hton(*p++), 32);
}
esp_gdbstub_send_end();
}
Why big-endian on a little-endian target? The GDB Remote Serial Protocol
specifies that register values in g/G packets are transmitted target-byte-order
but GDB interprets each register as a sequence of bytes where the first byte is
the most-significant — effectively big-endian per-register. Xtensa is
little-endian in memory. So the stub byte-swaps each 32-bit register
(__builtin_bswap32) before emitting it MSB-first via send_hex(val, 32).
Example: register pc = 0x400D1234 (little-endian memory: 34 12 0D 40).
After gdbstub_hton: 0x4012340D? No — __builtin_bswap32(0x400D1234) = 0x34120D40. Then send_hex(0x34120D40, 32) emits "34120d40". GDB receives
"34120d40" and reconstructs the register as 0x34120D40…
Correction/precision: send_hex(val, 32) emits the value MSB-first:
hex_chars[(val>>28)&0xf] … hex_chars[(val>>0)&0xf].
For pc=0x400D1234: bswap→0x34120D40, emit → "34120d40".
GDB reads this as register bytes 0x34,0x12,0x0D,0x40 and (for a big-endian-per-
register interpretation) reconstructs 0x34120D40. This is correct: the
wire representation of pc is 400d1234 is WRONG; the wire is the byte-swapped
value so that GDB's "first hex pair = MSB" convention yields the true value.
I.e. IDF transmits registers in big-endian byte order on the wire, matching
xtensa-esp32-elf-gdb's expectation. The gdbstub_hton ("host to network")
name confirms this: network byte order = big-endian.
Net rule: transmit each 32-bit register as 8 hex chars, most-significant
byte first. On a little-endian target, that requires bswap32 before formatting.
3.3 G command (handle_G_command, gdbstub.c:399–406)
Mirror of g: reads 105 × 32-bit hex values, each gdbstub_hton-swapped back to
little-endian before storing:
*p++ = gdbstub_hton(esp_gdbstub_gethex(&cmd, 32));
Replies $OK#9a.
3.4 P command byte-swap (handle_P_command, gdbstub.c:668–697)
GDB sends P<reg>=<value> where <value> is 8 hex chars, big-endian (MSB
first), same as g-packet. gethex returns it as a uint32_t with the MSB in
the high bits — i.e. already the logical value. But IDF does an explicit
manual byte-swap (lines 683–690):
uint8_t *addr_ptr = (uint8_t *)&addr; // logical value
uint32_t p_address = 0;
uint8_t *p_addr_ptr = (uint8_t *)&p_address;
p_addr_ptr[3] = addr_ptr[0]; // byte 0 -> byte 3
p_addr_ptr[2] = addr_ptr[1];
p_addr_ptr[1] = addr_ptr[2];
p_addr_ptr[0] = addr_ptr[3];
This is another bswap32 (identical to gdbstub_hton). So the value is
double-processed: gethex already gives the logical value; this swap converts
it back to little-endian-in-register form for writing to the frame. (Functionally
it undoes the implicit MSB-first interpretation.) Then
esp_gdbstub_set_register(frame, reg_index, p_address) writes it.
3.5 set_register (gdbstub_xtensa.c:352–427) — P reg_index mapping
| reg_index | Target |
|---|---|
| 0 | frame->pc |
| 1–16 | (&frame->a0)[reg_index-1] = a0..a15 |
| 17–27 | (would write past a15 into sar/exccause/etc. — latent bug, GDB doesn't send these) |
| 87–102 | f0..f15 (only if CPENABLE≠0) |
| 103 | FCR |
| 104 | FSR |
4. Signal Mapping
Source: gdbstub_xtensa.c:261–268.
int esp_gdbstub_get_signal(const esp_gdbstub_frame_t *frame)
{
const char exccause_to_signal[] = {4, 31, 11, 11, 2, 6, 8, 0, 6, 7, 0, 0, 7, 7, 7, 7};
if (frame->exccause >= sizeof(exccause_to_signal)) {
return 11; // SIGSEGV default for unknown high causes
}
return (int) exccause_to_signal[frame->exccause];
}
The table (index = EXCCAUSE):
| EXCCAUSE | Name | Signal | Signal Name |
|---|---|---|---|
| 0 | IllegalInstruction | 4 | SIGILL |
| 1 | Syscall (InstFetch) | 31 | SIGSYS |
| 2 | InstFetchError | 11 | SIGSEGV |
| 3 | LoadStoreError | 11 | SIGSEGV |
| 4 | Level1Interrupt | 2 | SIGINT |
| 5 | Alloca (window overflow) | 6 | SIGABRT |
| 6 | Syscall (Integer) | 8 | SIGFPE |
| 7 | Level1Int (coprocessor) | 0 | (none) |
| 8 | Privileged (window underflow) | 6 | SIGABRT |
| 9 | PifDataError | 7 | SIGBUS |
| 10 | LoadStoreAlignment | 0 | (none) |
| 11 | (reserved) | 0 | |
| 12 | InstPIFDataErr | 7 | SIGBUS |
| 13 | LoadStorePIFDataErr | 7 | SIGBUS |
| 14 | InstPIFAddrErr | 7 | SIGBUS |
| 15 | LoadStorePIFAddrErr | 7 | SIGBUS |
| ≥16 | (any other) | 11 | SIGSEGV |
For debug interrupt entry (gdbstub_handle_debug_int, gdbstub.c:296),
signal is hardcoded to 5 (SIGTRAP) (gdbstub.c:310):
s_scratch.signal = 5; /* esp_gdbstub_get_db_signal(regs_frame); */
For Ctrl-C / UART break entry, signal = esp_gdbstub_get_signal(frame)
(gdbstub.c:236) — typically the interrupt cause.
5. vCont Support
5.1 What GDB sends
| Packet | Meaning | IDF handling |
|---|---|---|
vCont? |
"What actions do you support?" | NOT HANDLED → $#00 (empty). ⚠️ IDF advertises vContSupported+ in qSupported but doesn't answer the probe. GDB falls back to c/s. |
vCont;c:<tid> |
continue thread | Handled (line 1132): strncmp("vCont;c", cmd, 7)==0 → return GDBSTUB_ST_CONT. tid ignored. |
vCont;c |
continue all | Handled (same). |
vCont;s:<tid> |
step thread | NOT handled separately → falls through to ST_ERR. |
vCont;s |
step all | NOT handled. |
5.2 Required stub replies
vCont?→ canonical reply isvCont;c;sbut IDF sends nothing useful. For UniversalisOS, to be safe, reply$vCont;c;s#cks.vCont;c/vCont;c:<tid>→ no reply packet; resume target (return CONT).vCont;s/vCont;s:<tid>→ no reply; single-step then sendS05/T05on next trap. (IDF doesn't implement this via vCont; GDB usessinstead.)
5.3 Why it still works
xtensa-esp32-elf-gdb sends vCont? early; gets empty; falls back to the
legacy Hc, c, s packets which IDF fully supports.
6. Memory Validity
Source: src/port/xtensa/include/esp_gdbstub_memory_regions.h:16–21.
static inline bool is_valid_memory_region(intptr_t addr)
{
return (!is_transport_memory_region(addr)) &&
addr >= 0x20000000 && addr < 0x80000000;
}
6.1 Valid address range (Xtensa ESP32)
0x20000000 ≤ addr < 0x80000000
This single range covers:
| Range | Region |
|---|---|
| 0x3FF8_0000 – 0x3FFF_FFFF | DRAM / data |
| 0x4000_0000 – 0x400D_FFFF | IRAM / instruction (flash ICache + SRAM) |
| 0x3F40_0000 – 0x3F7F_FFFF | flash data (DROM via cache) |
| 0x3FF8_0000 – 0x3FFF_FFFF | peripheral registers (EXCEPT transport) |
| 0x6000_0000 – 0x7FFF_FFFF | APB peripheral registers |
The check deliberately excludes the UART register block (to prevent GDB from reading the FIFO and disturbing the transport):
// esp_gdbstub_memory_regions_common.h:21–30
static inline bool is_transport_memory_region(intptr_t addr)
{
return addr >= REG_UART_BASE(CONFIG_ESP_CONSOLE_UART_NUM) &&
addr <= REG_UART_BASE(CONFIG_ESP_CONSOLE_UART_NUM) + sizeof(UART0);
}
For UART0: REG_UART_BASE(0) = 0x60000000, sizeof(UART0) ≈ 0x80. So
0x60000000–0x6000007F is excluded from m/M access. (For USB-Serial-JTAG,
the USB-SJTAG register range is excluded instead.)
6.2 Memory access implementation
esp_gdbstub_readmem (gdbstub.c:408–417) and esp_gdbstub_writemem (419–436)
do byte-granular read/write via 32-bit word access:
// read byte at addr:
uint32_t val_aligned = *(uint32_t *)(addr & ~3);
uint32_t shift = (addr & 3) * 8;
return (val_aligned >> shift) & 0xff;
Validity is checked on both the start and end address of an m/M range
(gdbstub.c:445–448, 466–469): readmem(addr) < 0 || readmem(addr+size-1) < 0.
If either fails → $E01#.
After a write on Xtensa, an ISYNC is executed (gdbstub.c:431–433):
asm volatile("ISYNC\nISYNC\n");
to flush the instruction pipeline (needed if writing code/instruction memory).
7. Xtensa gdbstub-entry.S — Interrupt Entry & Frame
Source: src/port/xtensa/gdbstub-entry.S (59 lines, section .iram1).
7.1 The entry routine esp_gdbstub_int
This is registered as the UART0 interrupt handler via esp_intr_alloc
(gdbstub.c:370). It runs at interrupt level and must save/restore a full
Xtensa exception frame.
.section .iram1, "ax" ; must be in IRAM (runs with cache constraints)
.align 4
esp_gdbstub_int:
mov a0, sp ; save current SP into a0
addi sp, sp, -XT_STK_FRMSZ ; allocate full frame on stack
s32i a0, sp, XT_STK_EXIT ; frame.exit = old SP (dispatch return)
s32i a0, sp, XT_STK_A0 ; frame.a0 = old SP
#if XCHAL_HAVE_WINDOWED
s32e a0, sp, -12 ; base-save slot (backtrace debug)
#endif
rsr a0, PS ; read PS (processor state)
s32i a0, sp, XT_STK_PS ; frame.ps = interruptee's PS
rsr a0, EPC_1 ; read EPC1 (return PC for level-1 int)
s32i a0, sp, XT_STK_PC ; frame.pc = interruptee's PC
#if XCHAL_HAVE_WINDOWED
s32e a0, sp, -16 ; base-save slot
#endif
s32i a12, sp, XT_STK_A12 ; save a12, a13 (callee-saved, needed by
s32i a13, sp, XT_STK_A13 ; _xt_context_save-style routines)
rsr a0, EXCCAUSE ; save exception cause
s32i a0, sp, XT_STK_EXCCAUSE
rsr a0, EXCVADDR ; save exception vaddr
s32i a0, sp, XT_STK_EXCVADDR
rsr a0, EXCSAVE_1 ; recover interruptee's true a0
s32i a0, sp, XT_STK_A0 ; (overwrites the old-SP value)
rsr a6, excsave1 ; load arg0 = frame pointer (from vector)
rsr a3, EPS ; EPS = saved PS for this level
s32i a3, sp, XT_STK_PS ; store PS (again, from EPS this time)
movi a3, gdbstub_handle_uart_int ; C handler
callx0 a3 ; call gdbstub_handle_uart_int(frame)
l32i a0, sp, XT_STK_EXIT ; restore old SP
addi sp, sp, XT_STK_FRMSZ
ret ; return from interrupt
7.2 Frame layout — XtExcFrame (= esp_gdbstub_frame_t)
From xtensa_context.h:122–164. ESP32 (windowed, LOOPS, no CALL0):
| Offset | Field | Source | Notes |
|---|---|---|---|
| 0x00 | exit |
XT_STK_EXIT | dispatch return addr (saved old SP here) |
| 0x04 | pc |
XT_STK_PC | from EPC1 (level-1 int) |
| 0x08 | ps |
XT_STK_PS | from PS/EPS |
| 0x0C | a0 |
XT_STK_A0 | interruptee's a0 (from EXCSAVE_1) |
| 0x10 | a1 |
XT_STK_A1 | SP before interrupt (= old SP) |
| 0x14 | a2 |
||
| 0x18 | a3 |
||
| 0x1C | a4 |
||
| 0x20 | a5 |
||
| 0x24 | a6 |
||
| 0x28 | a7 |
||
| 0x2C | a8 |
||
| 0x30 | a9 |
||
| 0x34 | a10 |
||
| 0x38 | a11 |
||
| 0x3C | a12 |
XT_STK_A12 | saved explicitly in entry.S |
| 0x40 | a13 |
XT_STK_A13 | saved explicitly in entry.S |
| 0x44 | a14 |
||
| 0x48 | a15 |
||
| 0x4C | sar |
XT_STK_SAR | |
| 0x50 | exccause |
XT_STK_EXCCAUSE | from RSR |
| 0x54 | excvaddr |
XT_STK_EXCVADDR | from RSR |
| 0x58 | lbeg |
LOOPS | |
| 0x5C | lend |
LOOPS | |
| 0x60 | lcount |
LOOPS | |
| 0x64 | tmp0 |
(windowed ABI) | |
| 0x68 | tmp1 |
||
| 0x6C | tmp2 |
||
| 0x70 | (end of XtExcFrame) | XtExcFrameSize |
XT_STK_FRMSZ = ALIGNUP(0x10, XtExcFrameSize) + 0x20. For ESP32 this is
typically 0x90 (144 bytes) including the base-save area and alignment
padding below the frame.
Stack growth: the frame is allocated by addi sp, sp, -XT_STK_FRMSZ.
The lowest addresses hold exit, pc, ps, a0.... a0 (frame offset 0x0C) holds
the interruptee's return address (windowed call chain).
7.3 Frame → regfile conversion (esp_gdbstub_frame_to_regfile)
(gdbstub_xtensa.c:82–143). Key transformations:
- PC normalization:
esp_cpu_pc_to_addr(frame->pc)clears the top 2 bits (windowed-call-size encoding). If PC invalid (not executable or top bits 0), replaced with_invalid_pc_placeholder. - a0–a15 copied directly from frame; a16–a63 =
0xDEADBEEF(only the active window's 16 registers are saved in the frame). - windowbase = 0, windowstart = 0x1 (forced — presents the saved window as window 0 to GDB).
- configid0/configid1 read live from RSR.
- ps: if UM (user-mode) bit set, clear EXCM; else pass through.
- FPU regs (f0–f15, fcr, fsr): if CPENABLE≠0, read live from FPU regs; else read from the TCB's saved coprocessor state.
- a[0]: if bit 27 set, treat as PC-relative →
esp_cpu_pc_to_addr. - a[1] (SP): if not sane, replaced with
0xDEADBEEF.
7.4 Step mechanism (Xtensa ICOUNT)
(gdbstub_xtensa.c:319–331). Single-step uses the hardware ICOUNT register:
uint32_t level = s_scratch.regfile.ps & 0x7; // current int level
level += 1;
WSR(ICOUNTLEVEL, level); // count instructions at > current level
WSR(ICOUNT, -2); // count 2 instructions then trap (ICOUNT overflow)
On the next debug trap, gdbstub_handle_debug_int fires, clears the step
(ICOUNT=0; ICOUNTLEVEL=0), and sends S05. step_in_progress flag prevents
double-send.
7.5 Transport — UART getchar/putchar (gdbstub_transport.c)
Polled, blocking, no DMA:
- getchar (line 78–87): spin until
uart_ll_get_rxfifo_len() > 0, read 1 byte. - putchar (line 89–96): spin until TX FIFO has ≥127 bytes free, write 1 byte.
- flush (line 98–105): spin until
uart_ll_is_tx_idle(). - getfifo (line 108–124, runtime mode): drain entire RX FIFO, return 1 if any byte == 0x03 (Ctrl-C). This is the async-break detection: GDB sends a bare 0x03 (not in a packet) to interrupt the running target.
Appendix A — Minimal RSP State Machine for UniversalisOS
To replicate IDF behavior exactly, a bare-metal stub needs:
- UART: polled RX/TX at the configured baud (IDF uses the console UART).
Detect bare
0x03for async break. - Entry: save an
XtExcFrame-compatible frame (§7.2) on the debug exception / UART interrupt. Call the C command loop with a frame pointer. - Main loop (gdbstub.c:81–97 / 254–278):
send_reason(); // $T<sig># while (1) { int res = read_command(&cmd, &len); if (res == '-') { send_reason(); continue; } // NAK from GDB? re-send stop if (res > 0) continue; // stray char (GDB's +/-) if (res == ST_ERR) { send_str_packet("E01"); continue; } res = handle_command(cmd, len); if (res == ST_ERR) send_str_packet(NULL); // $#00 if (res == ST_CONT) break; // resume } - Registers: 105-entry big-endian g/G (§3). Copy frame→regfile with DEADBEEF fill for a16–a63.
- Memory: validate
0x20000000 ≤ addr < 0x80000000, exclude UART regs. - Breakpoints: use IBREAKx/DBREAKx via WSR (IDF wraps this in
esp_cpu_set_breakpoint). - Step: ICOUNT=-2, ICOUNTLEVEL=curlevel+1.
Appendix B — Known IDF Quirks (replicate or avoid)
- M command double-
$(gdbstub.c:474–476): wrapssend_str_packetinsidesend_start/end, producing malformed$$OK#..#... GDB tolerates it. A clean stub should justsend_str_packet("OK"). - vCont? unhandled but
vContSupported+advertised. Safe to add a realvCont;c;sreply. - P reg_index 17–27 writes out of frame bounds. A safe stub should clamp to the 16 saved AR registers.
- qSupported
swbreak-: software breakpoints (patching instructions) are NOT supported — only hardware IBREAK. GDB will only setZ0at addresses and rely on the stub's hardware BP. - No
vCont;s/ novKill/ noX(binary write): GDB falls back to legacy packets. - ACK policy: stub ignores GDB's
+/-entirely (never resends on NAK).
Document generated from ESP-IDF v5.5.4 source. All line numbers and byte offsets verified against the actual files.