ESP32 / ESP32-S2 / ESP32-S3 hardware debugging features and how to use them in your own sketches

ESP32 / ESP32-S2 / ESP32-S3 hardware debugging features and how to use them in your own sketches.

Our beloved ESP32/S2/S3 chips actually support hardware debugging features: two hardware code breakpoints and two hardware data breakpoints.

In Espressif terminology these are called IBREAK and DBREAK, and Espressif provides the following API:

esp_cpu_set_watchpoint()
esp_cpu_set_breakpoint()
esp_cpu_clear_watchpoint()
esp_cpu_clear_breakpoint()

And... thats pretty much where the documentation ends.

If you try setting a breakpoint or watchpoint, surprisingly it does trigger - but all you get is a register dump and:

Guru Meditation: exception was unhandled (DebugException)

That is not exactly what we want. Wed rather intercept it, take control, and do something useful - log the event, for example.

Or anything else.

Below is a sketch (Arduino framework, for simplicity) for ESP32, ESP32-S2 and ESP32-S3. The same thing could be done on RISC-V as well, but I was too lazy to implement it.

The sketch consists of two files which should be placed in the same directory.

What the sketch does:

It installs Watchpoint #0 on writes to a variable and then repeatedly writes to it while printing some stats.

(Watchpoint #1 was already eaten by FreeRTOS canaries.)

Displayed output includes:

a) number of watchpoint triggers
b) current protected variable value
c) address of the instruction that caused the trigger

The implementation details and caveats are explained in the comments.

Why I needed this:

I was logging accesses to WiFi registers (0x60033xxx) to capture initialization sequences and timing behavior.

Why someone else might care:

This is also an example of handling a high-priority interrupt while dispatching into a C handler. With minor modifications the code can be turned into a controlled single-step execution mechanism.

File di.ino:

// Example sketch showing how on ESP32, ESP32-S2 and ESP32-S3:
//
// 1. Set up memory write protection (a variable in this example) (see esp_cpu_set_watchpoint())
// 2. Skip an instruction (debug_exception_handler)
// 3. Handle a high-priority interrupt (di.S)
//
// A breakpoint can be set and intercepted in a similar way (see esp_cpu_set_breakpoint()).
// DBREAK and IBREAK can be combined for controlled single-step execution.

#include <Arduino.h>

// Exception Frame.
//
struct exc_frame {
  uint32_t ps;
  uint32_t pc;            // pointer to the instruction that triggered the interrupt
  uint32_t registers[20];
};


extern "C" void debug_exception_handler(struct exc_frame *frame);

// Interrupt trigger counter
static int triggered = 0;

// Address of the instruction that triggered the interrupt
static uintptr_t address = 0;

// DebugInterrupt handler. (Generated by the CPU when protected memory is accessed)
// Data watchpoints and code breakpoints arrive here, but in this example
// we only handle data watchpoints.
// Skips the instruction that triggered the interrupt and increments
// the event counter.
// Called from di.S, parameters are passed via stack.
//
void IRAM_ATTR debug_exception_handler(struct exc_frame *f) {

  // Instruction length lookup table.
  // Instruction length determined by its first byte.
  // Length may vary from 2 to 4 bytes, including 3-byte instructions.
  //
  static const uint8_t xt_insn_len[] = { XCHAL_BYTE0_FORMAT_LENGTHS };

  // PC register = address of the instruction that triggered the interrupt.
  // Read the first instruction byte through IBUS. Harvard architecture after all.
  //
  // 1. IBUS requires aligned 32-bit access.
  // 2. Xtensa instructions can be 2, 3, or 4 bytes long.
  //
  // Therefore we read an aligned word and then shift it.
  // No other option — otherwise the CPU will hang.
  //
  // The first instruction byte (`opcode`) determines the instruction length.
  //
  uintptr_t pc = (uintptr_t)f->pc;

  // Save the address of the instruction that triggered the interrupt
  address = pc;

  uint32_t aligned = pc & ~3;
  uint32_t word = *(volatile uint32_t *)aligned;
  uint32_t shift = (pc & 3) * 8;
  uint8_t opcode = (word >> shift) & 255;

  // Skip the instruction
  f->pc = f->pc + xt_insn_len[opcode];

  // Or this way, if we do not want to skip it:
  // esp_cpu_clear_watchpoint(0);
  //

  // Increment the counter
  triggered++;
}


// Test variable. This one will be protected.
// volatile so the compiler doesn't optimize it away
static volatile int test = 666;


void setup() {

  Serial.begin(115200);

  // Enable protection using ESP-IDF:
  // Watchpoint#0, variable test, length = 4 bytes
  //
  // Length may be from 1 to 64 bytes, but the address must be aligned
  // to the protection size. For example, a 16-byte array must be aligned
  // on a 16-byte boundary. __attribute__((aligned)) to the rescue.
  //
  // Watchpoint#1 is already occupied by FreeRTOS and there are no others.
  // There are only two hardware watchpoints available.
  //
  esp_cpu_set_watchpoint(0, (const void *)&test, 4 , ESP_CPU_WATCHPOINT_STORE);
}


void loop() {

  // Write into protected memory:
  // this should trigger an interrupt and invoke debug_exception_handler().
  //
  // The variable value will NOT change because our handler simply skips
  // the instruction.
  //
  // The handler may re-execute the instruction — in that case there is no
  // need to modify PC, but esp_cpu_clear_watchpoint(0) should be called.
  //
  // Otherwise, after re-executing the instruction, another interrupt will
  // immediately be generated and so on forever.
  //
  test = triggered;

  // A sketch without delay is not a sketch.
  delay(1000);

  // Expected output (an example): "Triggered=321, test=666, address=4200xxxx"
  Serial.printf("Triggered=%d, test=%d, address=%x\r\n", triggered, test, address);

}

File di.S:

// Intercept DebugInterrupt and call a handler written in C (Xtensa, windowed ABI)
//
// Interrupt handlers for levels 4, 5, 6 and 7 must be written in assembly.
// This is because the system does not invoke them like a regular C function.
// This file contains an assembly adapter that calls regular C code.
//

#ifdef __XTENSA__

#include <xtensa/coreasm.h>
#include <xtensa/corebits.h>
#include <xtensa/config/system.h>

#include "sdkconfig.h"
#include "xtensa_rtos.h"
#include "soc/soc.h"
#include "xt_asm_utils.h"
#include "xtensa_context.h"

#define CAUSE_DEBUGEXCEPTION    (1)
#define XT_DEBUGCAUSE_DI        (5)

#if (CONFIG_ESP32_ECO3_CACHE_LOCK_FIX && CONFIG_BTDM_CTRL_HLI)
// ESP32 v3.00 contains a hardware bug. We do not really care about the details,
// and honestly we do not fully understand them either.
//
// The code below contains two workaround snippets copied from xtensa_vectors.S
// (without the slightest understanding of how they actually work).
//
#  define BUGGY_CHIP
#endif


.section    .iram1, "ax"

// User C function
.extern     debug_exception_handler
.type       debug_exception_handler, @function

// Weak symbol, may be overridden
.global     xt_debugexception
.type       xt_debugexception, @function

.align      4
.literal_position
.align      4

// DebugExceptionVector saves register A0 and jumps to xt_debugexception
// A0 is saved using:
// wsr a0, EXCSAVE+XCHAL_DEBUGLEVEL
//
// See xtensa_vectors.S:_DebugExceptionVector
//
xt_debugexception:

#ifdef BUGGY_CHIP

// The original workaround code contains a strange line.
// Commented it out just in case: A0 is already saved,
// no need to trash somebody else's stack.
//
 // s32i a0, sp, XT_STK_EXIT

// Read bit 13 of CPU CoreID.
// If it is 0, we are running on core 0; otherwise on core 1.
  rsr.prid a0
  extui    a0, a0, 13, 1

// Try to determine whether this interrupt came from BT
// or whether this is a regular DebugInterrupt.
//
// If we think BT caused it, execute some magical delay.
// No idea why. Espressif does this in their code,
// so let's not argue.
//
#if (CONFIG_BTDM_CTRL_PINNED_TO_CORE == PRO_CPU_NUM)
  beqz    a0, 1f
#else
  bnez    a0, 1f
#endif

  rsr     a0, DEBUGCAUSE
  extui   a0, a0, XT_DEBUGCAUSE_DI, 1
  bnez    a0, debug_di_exc
1:
#endif // BUGGY_CHIP

// Set exception code in EXCCAUSE just in case.
//
movi    a0, CAUSE_DEBUGEXCEPTION
wsr     a0, EXCCAUSE

// This trampoline code is based on the GDBStub trampoline.
// It should be rewritten eventually: there is no reason to copy
// everything into Level1.
//
// The code should simply use Level6 everywhere.
//
// For now...
//
// Copy EPC and EXCSAVE from DEBUGLEVEL (Level6)
// into EXCEPTIONLEVEL (Level1).
//
rsr     a0,(EPC + XCHAL_DEBUGLEVEL)
wsr     a0,EPC_1

// Restore our A0 saved by _DebugExceptionVector
// and also save it into Level1.
//
rsr     a0,(EXCSAVE + XCHAL_DEBUGLEVEL)
wsr     a0,EXCSAVE_1

// Build trampoline
//
// Interrupt levels 4,5,6 and 7 are high-priority interrupts,
// therefore handlers must be written in assembly.
//
// Since writing EVERYTHING in assembly sounds unpleasant,
// set up an environment allowing us to call a C function.
//
// Allocate stack space for exception frame.
// Pointer to this frame will be passed to the C handler.
//
// The C code may modify the frame contents
// (for example change PC).
//
addi    sp, sp, -XT_STK_FRMSZ

// Start saving context
//
// Save A0 (return address)
s32i    a0, sp, XT_STK_EXIT
s32i    a0, sp, XT_STK_A0

// Save Processor State
rsr     a0, PS
s32i    a0, sp, XT_STK_PS

// Save PC
//
// PC was copied into Level1 above,
// therefore EPC_1 instead of EPC_6
rsr     a0, EPC_1
s32i    a0, sp, XT_STK_PC

// Save standard context
call0   _xt_context_save

// Save SP
addi     a7, sp, XT_STK_FRMSZ
s32i     a7, sp, XT_STK_A1

// _xt_context_save() does not save A12 and A13.
// Have to do it manually :(
s32i    a12, sp, XT_STK_A12
s32i    a13, sp, XT_STK_A13

// Save EXCCAUSE and VADDR.
//
// Not really needed, but keep them for completeness.
rsr     a0, EXCCAUSE
s32i    a0, sp, XT_STK_EXCCAUSE

rsr     a0, EXCVADDR
s32i    a0, sp, XT_STK_EXCVADDR


rsr     a0, EXCSAVE_1      // remove this line later


// Configure PS before calling C:
//
// Clear EXCM and disable level1...level5 interrupts
// (everything except NMI and DEBUGINTERRUPT).
//
movi    a0, PS_INTLEVEL(5) | PS_UM | PS_WOE
wsr     a0, PS

// Save PC.
//
// This value will be available from C as frame->pc.
//
// It may be modified and execution will continue
// at the new PC after handler exit.
//
rsr     a0,(EPC + XCHAL_DEBUGLEVEL)
s32i    a0, sp, XT_STK_PC

// Pass stack pointer to handler.
//
// Stack already contains the exception frame.
//
// After callx4 register A6 becomes A2
// (first function argument).
mov     a6, sp

rsr     a9, EPS_6
s32i    a9, sp, XT_STK_PS

// Finally. Call C handler.
movi    a11, debug_exception_handler
callx4  a11

// Restore EPC_6 from exception frame->pc
//
l32i    a0, sp, XT_STK_PC
wsr     a0,(EPC + XCHAL_DEBUGLEVEL)

// Restore everything and return.
//
// Execution resumes from EPC_6.
call0   _xt_context_restore
l32i    a12, sp, XT_STK_A12
l32i    a13, sp, XT_STK_A13

// Return address (A0)
l32i    a0, sp, XT_STK_EXIT

// Stack (A1==SP)
addi    sp, sp, XT_STK_FRMSZ

rfi     XCHAL_DEBUGLEVEL


#ifdef BUGGY_CHIP

.align 4
debug_di_exc:

  // Just a delay
  movi    a0, 243

2:
  addi    a0, a0, -1
  .rept 4
  nop
  .endr
  bnez    a0, 2b

  // Restore return address (A0) and return
  rsr     a0, EXCSAVE+XCHAL_DEBUGLEVEL
  rfi     XCHAL_DEBUGLEVEL

#endif // BUGGY_CHIP


// Mandatory lines below.
//
// Without them the linker will not pull this file
// into the project.
.global ld_include_xt_debugexception
ld_include_xt_debugexception:

#endif // __XTENSA__

Hope someone can find it useful!

Nice presentation, thanks for sharing!

Nice work!
I see it’s attracting about as much interest as my Post-Mortem Debugging article. :grinning_face_with_smiling_eyes: