universalisos/docs/xtensa-debug-architecture.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

22 KiB
Raw Blame History

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 15; 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, 0 is 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 15) 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 to PANIC_RSN_DEBUGEXCEPTION for 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 → EPC6
  • PS → 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 (063). 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:

  1. Sets up a stack frame
  2. Saves EPC6, EPS6 to the frame
  3. Reads DEBUGCAUSE to determine why
  4. Handles the debug event
  5. 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 (015). 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 (03). 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 disabled
  • CALLINC = 0 — no window call increment
  • OWB — 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 enabled
  • INTLEVEL = N: Masks interrupts at level ≤ N
  • INTLEVEL = 6: Debug level — masks everything except NMI and debug exceptions
  • INTLEVEL = 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 (17). For debug exceptions: rfi 6.

Semantics

rfi n performs an atomic return-from-interrupt:

  1. Restores PS from EPS_n (for n=6: from EPS6, SR 198)
  2. Sets PC from EPC_n (for n=6: from EPC6, SR 182)
  3. Begins execution at the restored PC
  4. 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:

  1. Restore the original instruction bytes before resuming
  2. Set EPC6 to the address of that (now-restored) instruction
  3. 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:

  1. Set ICOUNTLEVEL = N — only count instructions executed at INTLEVEL ≥ N
  2. Set ICOUNT = 0xFFFFFFFE — start at -2
  3. Execute rfi 6 to return to the target code
  4. First instruction executes: ICOUNT increments to 0xFFFFFFFF (-1)
  5. 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 05), 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.