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

**URL:** <https://forum.arduino.cc/t/esp32-esp32-s2-esp32-s3-hardware-debugging-features-and-how-to-use-them-in-your-own-sketches/1444484>\
**Category:** 3rd Party Boards\
**Created:** [May 16, 2026, 2:37pm UTC](https://forum.arduino.cc/t/esp32-esp32-s2-esp32-s3-hardware-debugging-features-and-how-to-use-them-in-your-own-sketches/1444484 "2026-05-16T14:37:01Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![vvb333007](https://dub1.discourse-cdn.com/arduino/user_avatar/forum.arduino.cc/vvb333007/32/974521_2.png) [@vvb333007](https://forum.arduino.cc/u/vvb333007)\
**Post date:** [May 16, 2026, 2:37pm UTC](https://forum.arduino.cc/t/esp32-esp32-s2-esp32-s3-hardware-debugging-features-and-how-to-use-them-in-your-own-sketches/1444484/1 "2026-05-16T14:37:01Z")

</div>

**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:

```cpp
// 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:

```armasm
// 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!

---

<div class="post-metadata">

**Author:** ![gilshultz](https://avatars.discourse-cdn.com/v4/letter/g/67e7ee/32.png) [@gilshultz](https://forum.arduino.cc/u/gilshultz)\
**Post date:** [May 16, 2026, 5:27pm UTC](https://forum.arduino.cc/t/esp32-esp32-s2-esp32-s3-hardware-debugging-features-and-how-to-use-them-in-your-own-sketches/1444484/2 "2026-05-16T17:27:51Z")

</div>

Nice presentation, thanks for sharing!

---

<div class="post-metadata">

**Author:** ![InquisitiveMind](https://dub1.discourse-cdn.com/arduino/user_avatar/forum.arduino.cc/inquisitivemind/32/1272398_2.png) [@InquisitiveMind](https://forum.arduino.cc/u/InquisitiveMind)\
**Post date:** [September 5, 2026, 8:02pm UTC](https://forum.arduino.cc/t/esp32-esp32-s2-esp32-s3-hardware-debugging-features-and-how-to-use-them-in-your-own-sketches/1444484/3 "2026-09-05T20:02:23Z")

</div>

> [@vvb333007](#):
>
> Hope someone can find it useful!

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

> [@Beyond the Watchdog: Post-Mortem Debugging with Checkpoints — ESP32](https://forum.arduino.cc/t/beyond-the-watchdog-post-mortem-debugging-with-checkpoints-esp32/1454331/4):
>
> I consider the ESP32 one of the mainstays of the IoT world. At least, I rely on it that way. It is small, has plenty of processing power and memory, WiFi, etc., and usually just does its job — well, provided the program we created allows it to do so. For long-running MCU applications, I see roughly three development stages: Workbench: Trying to get everything working, with the Serial Monitor available to find out where things go wrong. Workbench / field transition: The device basically works,…
