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.
22 KiB
Xtensa LX6 (ESP32) Debug Exception Architecture — GDB Stub Reference
Target: ESP32 (Xtensa LX6, core esp32_v3_49_prod, XEA2, CALL0 ABI)
Purpose: Definitive reference for building a bare-metal GDB stub
Sources: core-isa.h, corebits.h, specreg.h, xt_specreg.h, xtensa_vectors.S, IDF esp_gdbstub, xt_utils.h
1. Debug Exception Mechanism
How break triggers a debug exception
The break instruction is a 3-byte opcode. When executed, it raises a debug exception at interrupt level 6 (XCHAL_DEBUGLEVEL = 6). Debug exceptions do NOT go through the normal EXCCAUSE/vector mechanism for level 1–5; they vector to a dedicated debug exception vector.
Key facts:
break s, t— the two immediate operands (s, t) are information-only (available to the debug handler via DEBUGCAUSE).break 0, 0is the standard software breakpoint.break.n— a 16-bit (narrow) variant of break, flagged separately in DEBUGCAUSE.- Debug exceptions are taken at INTLEVEL 6, which is higher than all normal interrupts (levels 1–5) but below NMI (level 7).
- The EXCCAUSE register is NOT set by the debug exception itself for
break— DEBUGCAUSE records the reason. (However, the IDF panic handler manually writes EXCCAUSE toPANIC_RSN_DEBUGEXCEPTIONfor its own bookkeeping.)
Registers used on debug exception entry
| Register | SR# | Purpose |
|---|---|---|
| EPC6 | 182 | Exception PC — saved PC of the instruction that triggered (or the next instruction for ICOUNT step) |
| EPS6 | 198 | Exception PS — saved PS (processor state) register |
| EXCSAVE6 | 214 | Scratch save register for level 6 — conventionally used to save a0 (return address) at vector entry |
The hardware automatically saves:
PC → EPC6PS → EPS6
Then vectors to the debug exception vector address.
What EXCSAVE6 is used for
At the debug vector entry, a0 is the only register you can use (no stack frame yet). The standard convention (from xtensa_vectors.S) is:
_DebugExceptionVector:
wsr a0, EXCSAVE6 /* save a0 — only safe scratch at level 6 */
j xt_debugexception /* jump to handler */
EXCSAVE6 is the handler's dedicated scratch slot to preserve the interrupted code's a0 before it sets up a stack frame.
EXCCAUSE for debug exceptions
The debug exception does not use the standard EXCCAUSE values (0–63). Instead, the reason is in DEBUGCAUSE (SR 233). However, when the panic handler is invoked, it manually sets:
EXCCAUSE = PANIC_RSN_DEBUGEXCEPTION // (an IDF-specific value, not an ISA EXCCAUSE)
2. Special Register Numbers (ESP32 / LX6)
All register numbers confirmed from specreg.h / xt_specreg.h:
Debug-specific registers
| Register | SR Number | Symbol | R/W | Description |
|---|---|---|---|---|
| IBREAKENABLE | 96 | IBREAKENABLE |
RW | Bitmask: bit n enables IBREAKA_n |
| IBREAKA_0 | 128 | IBREAKA_0 |
RW | Instruction breakpoint address 0 |
| IBREAKA_1 | 129 | IBREAKA_1 |
RW | Instruction breakpoint address 1 |
| DBREAKA_0 | 144 | DBREAKA_0 |
RW | Data breakpoint (watchpoint) address 0 |
| DBREAKA_1 | 145 | DBREAKA_1 |
RW | Data breakpoint address 1 |
| DBREAKC_0 | 160 | DBREAKC_0 |
RW | Data breakpoint control 0 |
| DBREAKC_1 | 161 | DBREAKC_1 |
RW | Data breakpoint control 1 |
| DEBUGCAUSE | 233 | DEBUGCAUSE |
RO | Debug exception cause (set by hardware on debug exception) |
| ICOUNT | 236 | ICOUNT |
RW | Instruction count register (for single-stepping) |
| ICOUNTLEVEL | 237 | ICOUNTLEVEL |
RW | Interrupt level at which ICOUNT triggers a debug exception |
Level-6 exception state registers
| Register | SR Number | Symbol | Description |
|---|---|---|---|
| EPC_6 | 182 | EPC_6 |
Exception PC for level 6 (debug) |
| EPS_6 | 198 | EPS_6 |
Exception PS for level 6 (debug) |
| EXCSAVE_6 | 214 | EXCSAVE_6 |
Scratch save for level 6 |
| DEPC | 192 | DEPC |
(Alias for EPC at debug level in some docs; ESP32 uses EPC_6) |
Other relevant registers
| Register | SR Number | Symbol | Description |
|---|---|---|---|
| CONFIGID0 | 176 | CONFIGID0 |
RO — processor config ID (hi 32 bits) |
| CONFIGID1 | 208 | CONFIGID1 |
RO — processor config ID (lo 32 bits) |
| PS | 230 | PS |
Processor state register |
| VECBASE | 231 | VECBASE |
Vector base address register |
| EXCCAUSE | 232 | EXCCAUSE |
Exception cause register |
| EXCVADDR | 238 | EXCVADDR |
Exception virtual address |
| CCOUNT | 234 | CCOUNT |
Cycle count register |
| PRID | 235 | PRID |
Processor ID |
EPC/EPS/EXCSAVE series base values
EPC series base: 176 → EPC_1 = 177, EPC_2 = 178, ..., EPC_6 = 182, EPC_7 = 183
EPS series base: 192 → EPS_2 = 194, EPS_3 = 195, ..., EPS_6 = 198, EPS_7 = 199
EXCSAVE series base: 208 → EXCSAVE_1 = 209, ..., EXCSAVE_6 = 214, EXCSAVE_7 = 215
Note: There is no EPC_0 or EPS_0 or EPS_1 (level 0 and level 1 exceptions use different mechanisms — level 1 uses EPC_1/EPS_1... wait, actually: EPC_1=177, but EPS_1 does not exist in the table because level 1 exception PS is saved/restored differently in XEA2).
Hardware config values
XCHAL_NUM_IBREAK = 2 /* 2 instruction breakpoints */
XCHAL_NUM_DBREAK = 2 /* 2 data watchpoints */
XCHAL_DEBUGLEVEL = 6 /* debug interrupt level */
XCHAL_NMILEVEL = 7 /* NMI level */
XCHAL_NUM_INTLEVELS = 6 /* levels 1-6 (not counting level 0) */
XCHAL_EXCM_LEVEL = 3 /* level masked by PS.EXCM */
XCHAL_HW_CONFIGID0 = 0xC2BCFFFE
XCHAL_HW_CONFIGID1 = 0x1CC5FE96
3. DEBUGCAUSE Register (SR 233)
Read-only. Set by hardware on entry to the debug exception vector. Bit layout from corebits.h:
| Bit | Mask | Name | Meaning |
|---|---|---|---|
| 0 | 0x01 | ICOUNT | ICOUNT reached zero (single-step triggered) |
| 1 | 0x02 | IBREAK | Instruction breakpoint match |
| 2 | 0x04 | DBREAK | Data breakpoint (watchpoint) match |
| 3 | 0x08 | BREAK | break instruction (3-byte) executed |
| 4 | 0x10 | BREAKN | break.n instruction (2-byte narrow) executed |
| 5 | 0x20 | DEBUGINT | External debug interrupt (OCD) |
Reading: rsr a0, DEBUGCAUSE gives the OR of all active causes.
Interpreting DEBUGCAUSE in a GDB stub
DEBUGCAUSE & 0x01 → ICOUNT → single-step completed
DEBUGCAUSE & 0x02 → IBREAK → hardware breakpoint hit
DEBUGCAUSE & 0x04 → DBREAK → watchpoint hit (load/store)
DEBUGCAUSE & 0x08 → BREAK → software breakpoint (break 0,0) hit
DEBUGCAUSE & 0x10 → BREAKN → narrow software breakpoint hit
DEBUGCAUSE & 0x20 → DEBUGINT→ OCD external debug interrupt
For a software breakpoint placed by GDB (break 0,0), DEBUGCAUSE bit 3 (BREAK) is set.
4. Vector Table Layout
ESP32 uses relocatable vectors (XCHAL_HAVE_VECBASE = 1). All vectors are offset from VECBASE (SR 231).
Vector offsets in VECBASE
| Offset | Vector | Symbol |
|---|---|---|
| 0x000 | Window overflow 4 | _WindowOverflow4 |
| 0x040 | Window underflow 4 | _WindowUnderflow4 |
| 0x080 | Window overflow 8 | |
| 0x0C0 | Window underflow 8 | |
| 0x100 | Window overflow 12 | |
| 0x140 | Window underflow 12 | |
| 0x180 | Level 2 interrupt | _Level2InterruptVector |
| 0x1C0 | Level 3 interrupt | _Level3InterruptVector |
| 0x200 | Level 4 interrupt | _Level4InterruptVector |
| 0x240 | Level 5 interrupt | _Level5InterruptVector |
| 0x280 | Level 6 interrupt (= DEBUG) | _DebugExceptionVector |
| 0x2C0 | Level 7 interrupt (= NMI) | _NMIExceptionVector |
| 0x300 | Kernel exception | _KernelExceptionVector |
| 0x340 | User exception | _UserExceptionVector |
| 0x3C0 | Double exception | _DoubleExceptionVector |
Debug exception vector
XCHAL_DEBUG_VECOFS = 0x00000280
XCHAL_DEBUG_VECTOR_VADDR = 0x40000280 (at reset VECBASE = 0x40000000)
XCHAL_INTLEVEL6_VECOFS = 0x00000280 (same as DEBUG — level 6 IS debug)
Critical: On the ESP32, interrupt level 6 IS the debug level. There are no regular interrupts at level 6 (XCHAL_INTLEVEL6_MASK = 0x00000000). The debug exception vector at offset 0x280 from VECBASE is the entry point for ALL debug exceptions.
Reset VECBASE value
XCHAL_VECBASE_RESET_VADDR = 0x40000000 (IRAM0 — instruction RAM)
Debug vector code (from xtensa_vectors.S)
.section .DebugExceptionVector.text, "ax"
.global _DebugExceptionVector
.align 4
_DebugExceptionVector:
wsr a0, EXCSAVE6 /* preserve a0 in EXCSAVE6 */
j xt_debugexception /* jump to handler (not call — a0 is saved) */
The vector is minimal — it saves a0 to EXCSAVE6 and jumps to the full handler. The handler then:
- Sets up a stack frame
- Saves EPC6, EPS6 to the frame
- Reads DEBUGCAUSE to determine why
- Handles the debug event
- Restores context and executes
rfi 6
5. PS Register (SR 230) — Full Bit Layout
From corebits.h:
31 19 18 17 16 15 9 8 6 5 4 3 0
+-----------------------+-----+-------+--------+-------+-----+----+--------+
| reserved | WOE | CALLINC| reserved| OWB | UM |EXCM| INTLEVEL|
+-----------------------+-----+-------+--------+-------+-----+----+--------+
| Bits | Mask | Field | Description |
|---|---|---|---|
| [3:0] | 0x0000000F | INTLEVEL | Current interrupt level (0–15). Interrupts at or below this level are masked. |
| [4] | 0x00000010 | EXCM | Exception mode. When set, masks interrupts to XCHAL_EXCM_LEVEL (= 3 on ESP32). |
| [5] | 0x00000020 | UM | User mode (1 = user/kernel mode distinction; 0 = privileged) |
| [7:6] | 0x000000C0 | RING | Current protection ring (0–3). ESP32 has XCHAL_MMU_RINGS = 1, so always 0. |
| [11:8] | 0x00000F00 | OWB | Old Window Base (saved WINDOWBASE on exception entry for windowed ABI) |
| [17:16] | 0x00030000 | CALLINC | Call increment (windowed ABI — how many registers the current call frame uses: 4, 8, or 12) |
| [18] | 0x00040000 | WOE | Window Overflow Enable (1 = window exceptions enabled; 0 = disabled) |
CALL0 ABI considerations
For the CALL0 ABI (no windowed registers):
WOE = 0— window overflow exceptions disabledCALLINC = 0— no window call incrementOWB— unused/irrelevant
ABI-specific PS values for setting up a handler:
// CALL0 ABI:
#define PS_GDBSTUB_HANDLER PS_INTLEVEL(5) | PS_UM // = 0x25
// The IDF gdbstub uses: PS_INTLEVEL(5) | PS_UM | PS_WOE // but WOE is harmless if WOE=0 for CALL0
// For user exception exit (CALL0):
#define PS_FOR_C_CALL PS_INTLEVEL(XCHAL_DEBUGLEVEL - 2) | PS_UM // INTLEVEL(4) | UM = 0x24
INTLEVEL semantics
INTLEVEL = 0: All interrupts enabledINTLEVEL = N: Masks interrupts at level ≤ NINTLEVEL = 6: Debug level — masks everything except NMI and debug exceptionsINTLEVEL = 7: NMI level — masks everything
When a debug exception is taken, PS is saved to EPS6 and the new PS.INTLEVEL is set to 6.
Important PS note for rfi 6
Before executing rfi 6, EPS6 must contain the PS value you want to restore. The INTLEVEL in EPS6 determines the post-return interrupt mask. Typically, you restore the original (pre-exception) PS from your saved frame.
6. The rfi 6 Instruction (Return from Interrupt)
Syntax
rfi n
where n is the interrupt level (1–7). For debug exceptions: rfi 6.
Semantics
rfi n performs an atomic return-from-interrupt:
- Restores PS from
EPS_n(for n=6: fromEPS6, SR 198) - Sets PC from
EPC_n(for n=6: fromEPC6, SR 182) - Begins execution at the restored PC
- The new PS.INTLEVEL takes effect (unmasking interrupts as appropriate)
What must be in EPC6/EPS6 before rfi 6
| Register | Must contain |
|---|---|
| EPC6 (SR 182) | The PC to resume at. For a breakpoint: the address of the instruction after the break (or the same address if you want to re-execute). For a single-step: the PC of the next instruction to execute. |
| EPS6 (SR 198) | The PS to restore — the original pre-exception PS (interrupt level, UM, EXCM, etc. of the interrupted code). |
Critical detail for software breakpoints
When break 0,0 fires, EPC6 points to the address of the break instruction itself (or the next instruction depending on the implementation). The IDF gdbstub reads EPC6 into the frame and adjusts it:
rsr a0, (EPC + XCHAL_DEBUGLEVEL) /* read EPC6 */
s32i a0, sp, XT_STK_PC /* save to frame */
On return, the handler writes the (possibly modified) PC back to EPC6 before rfi 6:
l32i a0, sp, XT_STK_PC
wsr a0, (EPC + XCHAL_DEBUGLEVEL) /* restore EPC6 */
...
rfi 6
If you placed a software breakpoint (break 0,0) in flash/IRAM, you MUST:
- Restore the original instruction bytes before resuming
- Set EPC6 to the address of that (now-restored) instruction
- Execute
rfi 6
Otherwise the CPU will re-hit the breakpoint immediately.
rsync requirement
After wsr to PS, you should execute rsync before relying on the new PS value. After wsr to other registers, isync may be needed before the change takes effect on the instruction stream.
7. ICOUNT Single-Step Mechanism
How it works
ICOUNT (SR 236) is a signed 32-bit counter that increments on every instruction executed at an interrupt level ≥ ICOUNTLEVEL. When ICOUNT transitions from negative to zero (i.e., reaches zero), a debug exception is generated if the current interrupt level ≥ ICOUNTLEVEL.
The magic value: ICOUNT = 0xFFFFFFFE (= -2)
ICOUNT = 0xFFFFFFFE (= signed -2)
ICOUNTLEVEL = N
Sequence of events:
- Set
ICOUNTLEVEL = N— only count instructions executed at INTLEVEL ≥ N - Set
ICOUNT = 0xFFFFFFFE— start at -2 - Execute
rfi 6to return to the target code - First instruction executes: ICOUNT increments to
0xFFFFFFFF(-1) - Second instruction executes: ICount increments to
0x00000000(0) — this triggers the debug exception
Wait — this would step TWO instructions. But the IDF code says "do one step." Let me clarify:
Actually, the correct interpretation: The debug exception fires when ICOUNT would increment to zero, meaning the exception is taken before executing the instruction that would make it zero. So:
- ICOUNT = -2, after
rfi 6: - Instruction 1 executes → ICOUNT becomes -1
- Instruction 2 is about to execute → ICOUNT would become 0 → debug exception fires INSTEAD of executing instruction 2
So one instruction executes, then the debug exception fires. EPC6 points to the instruction that was about to execute (the second one), i.e., the next instruction after the step.
Correction/Refinement (from IDF code):
The IDF esp_gdbstub_do_step() does:
void esp_gdbstub_do_step(esp_gdbstub_frame_t *frame)
{
uint32_t level = s_scratch.regfile.ps;
level &= 0x7; // current INTLEVEL from saved PS
level += 1; // set ICOUNTLEVEL one above current
WSR(ICOUNTLEVEL, level);
WSR(ICOUNT, -2); // 0xFFFFFFFE
}
The -2 value means: execute one more instruction after returning, then trap.
ICOUNTLEVEL selection
ICOUNTLEVEL must be set such that the instruction to be stepped runs at a level ≥ ICOUNTLEVEL. The IDF sets it to (current PS.INTLEVEL + 1).
Why +1? Because when the code resumes after rfi 6, it runs at the PS.INTLEVEL saved in EPS6. If ICOUNTLEVEL equals that INTLEVEL, the counter would NOT increment (instructions only count when INTLEVEL ≥ ICOUNTLEVEL... actually instructions count when the current level is ≤ ICOUNTLEVEL per the ISA — see clarification below).
ISA rule (from Xtensa ISA Reference): ICOUNT increments on each instruction executed at an interrupt level less than or equal to ICOUNTLEVEL. So to count all normal code (INTLEVEL 0–5), set ICOUNTLEVEL ≥ the code's INTLEVEL.
The IDF sets ICOUNTLEVEL = current_intlevel + 1 to ensure:
- The stepped instruction (at the code's original INTLEVEL) is counted
- But interrupts at higher levels are not accidentally counted
Clearing single-step
void esp_gdbstub_clear_step(void)
{
WSR(ICOUNT, 0); // Set to 0 — but since 0 is the trigger, this would immediately fire
WSR(ICOUNTLEVEL, 0); // Set level to 0 — no instructions execute at level ≤ 0...
// actually INTLEVEL 0 means count nothing useful
}
Setting ICOUNTLEVEL = 0 effectively disables stepping because no executed instruction has INTLEVEL ≤ 0 (except... level 0 code itself, but the check is "INTLEVEL ≤ ICOUNTLEVEL" — if both are 0, it would count).
The reliable way to disable: Set ICOUNTLEVEL = 0 AND ICOUNT = 0. When ICOUNTLEVEL is 0, only level-0 instructions increment ICOUNT. When ICOUNT is already 0, it stays at 0 and the debug exception condition (transition through zero) doesn't re-fire because there's no increment happening.
Actually, the precise ISA semantics: a debug exception due to ICOUNT occurs when ICOUNT transitions from non-zero to zero during an increment. Setting ICOUNT=0 directly (via wsr) does not trigger the exception — only the hardware increment path does.
Summary of single-step setup
To single-step exactly ONE instruction:
1. Read the target code's PS (from EPS6 / saved frame)
2. Extract INTLEVEL: level = ps & 0xF
3. Set ICOUNTLEVEL = level (count instructions at this level or below)
4. Set ICOUNT = 0xFFFFFFFE (-2)
5. Execute rfi 6 to resume
6. One instruction executes, ICOUNT → -1 → next increment would be 0 → debug exception
7. EPC6 now points to the instruction AFTER the one that executed
Quick Reference: Building the GDB Stub
Software breakpoint (insert)
Replace the target instruction with break 0, 0 (3-byte encoding). Save the original bytes for restoration.
Software breakpoint (handle)
; In debug exception handler:
rsr a0, DEBUGCAUSE
bbci a0, 3, not_a_break ; bit 3 = BREAK
; It's a software breakpoint
; EPC6 points at/near the break instruction
; Restore original instruction, report to GDB
Hardware breakpoint (IBREAK)
// Set breakpoint at addr using IBREAKA0:
wsr(IBREAKA_0, addr);
uint32_t en = rsr(IBREAKENABLE);
en |= (1 << 0); // enable bit 0
wsr(IBREAKENABLE, en);
Watchpoint (DBREAK)
// Set watchpoint at addr, size bytes:
uint32_t dbreakc = 0x3F; // mask: all 6 bits = full 32-bit
dbreakc <<= (ffs(size) - 1); // shift for byte select
dbreakc &= 0x3F;
if (on_read) dbreakc |= (1 << 30); // DBREAKC_LOADBREAK
if (on_write) dbreakc |= (1 << 31); // DBREAKC_STOREBREAK
wsr(DBREAKA_0, addr);
wsr(DBREAKC_0, dbreakc);
DBREAKC register bit layout
| Bits | Field | Description |
|---|---|---|
| [31] | STOREBREAK | Trigger on store |
| [30] | LOADBREAK | Trigger on load |
| [5:0] | MASK | Byte select mask (0x3F = all bytes; shift left by log2(size)) |
Debug exception handler skeleton (CALL0 ABI)
_DebugExceptionVector:
wsr a0, EXCSAVE6 ; save a0
j _gdbstub_debug_handler ; jump to handler
_gdbstub_debug_handler:
; Save context (a0 already in EXCSAVE6)
mov a0, sp
addi sp, sp, -FRAME_SIZE
s32i a0, sp, OFF_SP
rsr a0, EPS6 ; save PS
s32i a0, sp, OFF_PS
rsr a0, EPC6 ; save PC
s32i a0, sp, OFF_PC
rsr a0, EXCSAVE6 ; recover original a0
s32i a0, sp, OFF_A0
rsr a0, DEBUGCAUSE ; get debug cause
s32i a0, sp, OFF_DEBUGCAUSE
; ... save remaining regs, call C handler ...
; Set up PS for C handler execution (allow level 5 interrupts, clear EXCM)
movi a0, PS_INTLEVEL(5) | PS_UM ; = 0x25 for CALL0
wsr a0, PS
rsync
; Call C handler with frame pointer
mov a2, sp ; arg0 = frame pointer (CALL0 ABI)
call0 gdbstub_debug_entry
; Restore and return
l32i a0, sp, OFF_PC
wsr a0, EPC6 ; restore PC (may be modified by GDB)
l32i a0, sp, OFF_PS
wsr a0, EPS6 ; restore PS
; ... restore remaining regs ...
l32i a0, sp, OFF_SP
addi sp, sp, FRAME_SIZE
rfi 6 ; return from debug exception
Key Constants Summary
| Constant | Value | Source |
|---|---|---|
XCHAL_DEBUGLEVEL |
6 | core-isa.h:405 |
XCHAL_NMILEVEL |
7 | core-isa.h:407 |
XCHAL_NUM_IBREAK |
2 | core-isa.h:613 |
XCHAL_NUM_DBREAK |
2 | core-isa.h:614 |
| Debug vector offset | 0x280 | core-isa.h:588 |
XCHAL_VECBASE_RESET_VADDR |
0x40000000 | core-isa.h:549 |
XCHAL_EXCM_LEVEL |
3 | core-isa.h:351 |
| CONFIGID0 | 0xC2BCFFFE | core-isa.h:187 |
| CONFIGID1 | 0x1CC5FE96 | core-isa.h:188 |
Register quick-access (for rsr/wsr)
EPC_6 = 182 EPS_6 = 198 EXCSAVE_6 = 214
DEBUGCAUSE = 233 ICOUNT = 236 ICOUNTLEVEL = 237
IBREAKENABLE = 96 IBREAKA_0 = 128 IBREAKA_1 = 129
DBREAKA_0 = 144 DBREAKA_1 = 145 DBREAKC_0 = 160 DBREAKC_1 = 161
PS = 230 VECBASE = 231 EXCCAUSE = 232
CONFIGID0 = 176 CONFIGID1 = 208
Document generated from ESP-IDF v5.5.4 HAL source and Xtensa LX6 configuration headers.