universalisos/docs/esp-idf-gdbstub-rsp-spec.md
Fábio Coutada 98ed638f3c WIP: emergency commit — ESP32 GDB stub, boot chain work, docs, AGENTS.md rules
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.
2026-07-17 01:36:58 +01:00

29 KiB
Raw Permalink Blame History

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

  1. Packet Framing (packet.c)
  2. Command Handlers (gdbstub.c)
  3. Register File — g/G packets
  4. Signal Mapping
  5. vCont Support
  6. Memory Validity
  7. 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:5862 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:2628, 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:148153):

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:164172):

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:2326).

GDB also sends +/- to ACK/NAK stub packets. The stub's receive loop (gdbstub.c:8492, 258278) 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, returns GDBSTUB_ST_ERR (packet.c:155157).
  • After #, payload is NUL-terminated: s_cmd[p] = 0 (packet.c:138).

1.6 Hex parsing — esp_gdbstub_gethex

(packet.c:79116). 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:4755). 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 474476) → 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 12 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:103109):

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:2786. 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
164 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
87102 348 f[0..15] 16×4=64 FP (f0f15)
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:388396)

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: bswap0x34120D40, 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:399406)

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:668697)

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 683690):

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:352427) — P reg_index mapping

reg_index Target
0 frame->pc
116 (&frame->a0)[reg_index-1] = a0..a15
1727 (would write past a15 into sar/exccause/etc. — latent bug, GDB doesn't send these)
87102 f0..f15 (only if CPENABLE≠0)
103 FCR
104 FSR

4. Signal Mapping

Source: gdbstub_xtensa.c:261268.

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 is vCont;c;s but 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 send S05/T05 on next trap. (IDF doesn't implement this via vCont; GDB uses s instead.)

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:1621.

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:2130
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 0x600000000x6000007F 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:408417) and esp_gdbstub_writemem (419436) 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:445448, 466469): readmem(addr) < 0 || readmem(addr+size-1) < 0. If either fails → $E01#.

After a write on Xtensa, an ISYNC is executed (gdbstub.c:431433):

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:122164. 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:82143). Key transformations:

  1. 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.
  2. a0a15 copied directly from frame; a16a63 = 0xDEADBEEF (only the active window's 16 registers are saved in the frame).
  3. windowbase = 0, windowstart = 0x1 (forced — presents the saved window as window 0 to GDB).
  4. configid0/configid1 read live from RSR.
  5. ps: if UM (user-mode) bit set, clear EXCM; else pass through.
  6. FPU regs (f0f15, fcr, fsr): if CPENABLE≠0, read live from FPU regs; else read from the TCB's saved coprocessor state.
  7. a[0]: if bit 27 set, treat as PC-relative → esp_cpu_pc_to_addr.
  8. a[1] (SP): if not sane, replaced with 0xDEADBEEF.

7.4 Step mechanism (Xtensa ICOUNT)

(gdbstub_xtensa.c:319331). 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 7887): spin until uart_ll_get_rxfifo_len() > 0, read 1 byte.
  • putchar (line 8996): spin until TX FIFO has ≥127 bytes free, write 1 byte.
  • flush (line 98105): spin until uart_ll_is_tx_idle().
  • getfifo (line 108124, 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:

  1. UART: polled RX/TX at the configured baud (IDF uses the console UART). Detect bare 0x03 for async break.
  2. Entry: save an XtExcFrame-compatible frame (§7.2) on the debug exception / UART interrupt. Call the C command loop with a frame pointer.
  3. Main loop (gdbstub.c:8197 / 254278):
    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
    }
    
  4. Registers: 105-entry big-endian g/G (§3). Copy frame→regfile with DEADBEEF fill for a16a63.
  5. Memory: validate 0x20000000 ≤ addr < 0x80000000, exclude UART regs.
  6. Breakpoints: use IBREAKx/DBREAKx via WSR (IDF wraps this in esp_cpu_set_breakpoint).
  7. Step: ICOUNT=-2, ICOUNTLEVEL=curlevel+1.

Appendix B — Known IDF Quirks (replicate or avoid)

  1. M command double-$ (gdbstub.c:474476): wraps send_str_packet inside send_start/end, producing malformed $$OK#..#... GDB tolerates it. A clean stub should just send_str_packet("OK").
  2. vCont? unhandled but vContSupported+ advertised. Safe to add a real vCont;c;s reply.
  3. P reg_index 1727 writes out of frame bounds. A safe stub should clamp to the 16 saved AR registers.
  4. qSupported swbreak-: software breakpoints (patching instructions) are NOT supported — only hardware IBREAK. GDB will only set Z0 at addresses and rely on the stub's hardware BP.
  5. No vCont;s / no vKill / no X (binary write): GDB falls back to legacy packets.
  6. 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.