esp32 advanced 45 min

ESP32: JTAG debugging with OpenOCD and a logic probe

Stop guessing why the firmware crashes. Set breakpoints, step through code, inspect registers, with JTAG + OpenOCD + GDB. The right tool when print debugging fails.

Code available for: ESP32 ArduinoArduino C
Published Aug 26, 2026

I had a firmware crash that took me three weeks to find. The backtrace was useless because the crash happened inside an ISR. Serial.print inside the ISR was not an option (the UART itself was what was crashing). Disabling interrupts made the crash go away, which told me nothing useful. Logic analyzer showed the GPIO state at the time of the crash but not why.

JTAG solved it in an afternoon. I set a hardware breakpoint at the start of the ISR, single-stepped through it, and watched the registers. A buffer pointer had wrapped around and the write that crashed was to address 0x00000000 because of an unsigned overflow. Three weeks of guessing, one afternoon of stepping.

JTAG is overkill for most debugging. When you need it, you need it badly. This tutorial is the setup I use.

What JTAG is

JTAG is a hardware debug interface built into most microcontrollers. It lets an external probe:

  • Halt the CPU at any instruction
  • Set breakpoints (hardware, not software)
  • Read and write CPU registers
  • Read and write memory (flash and RAM)
  • Single-step through code one instruction at a time
  • Resume execution

The CPU runs normally until the probe halts it. No code changes, no software hooks, no instrumented binaries. The probe talks to a dedicated hardware debug block on the chip over a 4-wire (or 2- wire for ESP32-S2/C3/S3) interface.

The catch: you need a hardware probe, a piece of software (OpenOCD) that knows how to talk to the probe and the chip, and a debugger (GDB) that talks to OpenOCD. The setup is fiddly. The reward is that you can debug anything that runs on the chip, with full visibility into the state at the moment of a crash.

Hardware probes

ESP-Prog ($15 from Espressif’s store):

The official Espressif probe. Built specifically for ESP32 JTAG. USB-C, on-board 3.3V regulator so you can power a small board from it, JTAG and serial over the same USB cable. The right pick for ESP32 specifically.

J-Link EDU Mini ($20 from Segger):

The J-Link is the gold standard for ARM debugging. The EDU Mini is the cheap educational version (no commercial use allowed per the license). Works with every ARM chip, including the ESP32-S2/S3/C3 family. Faster than ESP-Prog.

J-Link BASE ($400): the full version. Faster, more features, no usage restrictions. Not worth it for hobby work.

CMSIS-DAP probes ($10-30):

The CMSIS-DAP standard works with any ARM chip. Cheap clones on AliExpress. The interface is slower than J-Link but the price is right. Pick one if you are doing ESP32-S3/C3 work and want one probe for all your ARM boards.

FTDI FT2232H ($20 chip, $40 board):

Some FT2232H-based boards (like the CJMCU-2232HL) support JTAG. Older ESP32 boards (the original DevKit v1) used this pattern. Works fine but requires more wiring than the dedicated probes.

For most people starting JTAG on ESP32, the ESP-Prog is the right pick. It is designed for this exact chip, costs $15, and the JTAG

  • serial on one cable is genuinely convenient.

Wiring the JTAG pins

The classic ESP32 JTAG pins are:

Wire key: GPIOGND3.3V
ESP32 pinJTAG signal
GPIO14TMS
GPIO12TDI
GPIO13TCK
GPIO15TDO
GNDGND
3.3VVTREF

These are the default JTAG pins for the ESP32-WROOM-32 and most original ESP32 modules. The ESP32-S2, S3, and C3 use different pins (and a 2-wire “cJTAG” interface instead of the 4-wire JTAG). Always check the datasheet for the specific chip you have.

You also need to make sure these pins are not being used by your firmware. If your code initializes GPIO12 as a GPIO, the JTAG won’t work. The fix is either to not touch those pins in your firmware, or to add espefuse.py set_flash_voltage 3.3 style configuration that makes them JTAG-only at boot.

OpenOCD setup

OpenOCD is the open-source piece that talks to the probe on one side and GDB on the other. You need:

  1. The OpenOCD binary (the Espressif fork, not the upstream one, because the ESP32 support is in the Espressif fork).
  2. A config file that describes your probe + chip.

Install OpenOCD. The Arduino ESP32 board package includes a copy on most platforms. The path is something like:

~/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20240318/openocd-esp32/bin/openocd

Add that to your PATH. Then run:

openocd -f interface/esp-prog.cfg -f target/esp32.cfg

For a J-Link, replace interface/esp-prog.cfg with interface/jlink.cfg. For a CMSIS-DAP probe, use interface/cmsis-dap.cfg. The target file is the same.

OpenOCD starts a GDB server on port 3333 and a telnet interface on port 4444. Leave it running in a terminal. You will connect to it from a separate GDB session.

GDB integration with the Arduino IDE

The Arduino IDE does not have native GDB integration. You have two options:

  1. Plain GDB in a terminal. Run xtensa-esp32-elf-gdb firmware.elf (the ELF file is what the Arduino IDE produces in the build output directory). Connect with target remote localhost:3333. Set breakpoints, step, inspect.

  2. VS Code + PlatformIO. PlatformIO has built-in GDB integration. Add debug_tool = esp-prog (or jlink, cmsis-dap) to platformio.ini, click the debug button, and VS Code manages the OpenOCD + GDB session. This is what I use daily.

  3. CLion + PlatformIO. Same as VS Code but with the JetBrains IDE. Better code navigation, worse startup time.

For this tutorial I will use plain GDB because it is the lowest common denominator. The same commands work from any IDE.

Setting breakpoints

Find the ELF file the Arduino IDE produced. It is in the build output directory; in recent Arduino IDEs, the path is something like ~/Arduino/build/<sketch_name>/<sketch_name>.ino.elf.

xtensa-esp32-elf-gdb sketch.ino.elf

(gdb) target remote localhost:3333
(gdb) monitor reset halt

# Set a breakpoint at the start of loop()
(gdb) break loop

# Or at a specific line in a specific file
(gdb) break src/main.cpp:42

# Or at the address of an ISR (useful for the original bug I mentioned)
(gdb) break gpio_isr_handler

# Start the program
(gdb) continue

When the breakpoint hits, GDB stops. You can:

  • print variable_name to read a variable’s value
  • info locals to see all locals in the current frame
  • info registers to see all CPU registers
  • backtrace to see the call stack
  • step to execute one source line, stepping into function calls
  • next to execute one source line, stepping over function calls
  • continue to resume until the next breakpoint

The combination of step and next is the heart of debugging. step goes into the function you are calling. next runs the function without entering it. Use next to fly past library code and step to dig into your own.

Inspecting memory and registers

The most useful GDB commands for embedded debugging:

  • info registers: all CPU registers. pc is the program counter, sp is the stack pointer, a0-a15 are the general purpose registers.
  • x/16x 0x3FF00000: dump 16 words of memory at address 0x3FF00000. Use this to inspect data structures, peripheral registers, or raw memory.
  • x/16x $sp: dump 16 words starting at the stack pointer. This is the raw stack contents, useful when you suspect a stack overflow.
  • set var x = 5: change the value of variable x. You can patch state at runtime.
  • set {int}0x3FF00000 = 42: write to a specific memory address. Useful for poking peripheral registers directly.

For ESP32 specifically, the address 0x3FF00000 is the start of the data RAM region, 0x3F400000 is the start of the peripheral register region, and 0x400D0000 is the start of the flash mapping. The exact ranges are in the ESP32 TRM (Technical Reference Manual), chapter 1.

The case where JTAG is necessary vs print debugging

Print debugging wins when:

  • The bug is a logic error, not a crash
  • The variables you need to inspect are easy to print
  • The timing is not tight (printing inside the ISR is the bug)
  • You can reproduce the bug consistently

JTAG wins when:

  • The firmware crashes and you need the state at the moment of the crash
  • The bug is timing-related and printing changes the timing
  • The bug is in an ISR or a callback that runs in interrupt context
  • You need to inspect hardware registers, peripheral state, or DMA buffers
  • You want to set a hardware breakpoint at an exact instruction and catch a rare event (e.g. “only fires when the WiFi reconnects after a 30-second timeout”)

A rule of thumb: try print debugging for 30 minutes. If you have not made progress, set up JTAG. The setup takes longer than the first debugging session, but every session after that is faster.

A real workflow (the crash from the introduction)

To find the buffer-overflow bug from my introduction:

  1. Connect ESP-Prog. Wire TMS/TDI/TCK/TDO + GND.
  2. Start OpenOCD. Connect GDB. Reset the target.
  3. break gpio_isr_handler to break at the start of the ISR.
  4. continue. Toggle the input pin to trigger the ISR.
  5. GDB stops. info registers. Look at a2 (the second argument register, which holds the buffer pointer in the Xtensa calling convention).
  6. print/x $a2 to see the address in hex. Notice it is 0x00000000. That is the crash.
  7. backtrace. See who called the ISR with that pointer.
  8. The caller is a queue handler. Step back through it. Find the line where the pointer was supposed to be set. Find the arithmetic bug that wrapped it.

What would have taken days with print debugging was 20 minutes with JTAG. Setup time was 2 hours (one time, the first time).

Common gotchas

  • The ESP32 is using JTAG pins as GPIO. Make sure your firmware does not initialize GPIO12-15.
  • OpenOCD cannot connect. Check the wiring. Check that the ESP32 is powered (some probes do not supply enough current through VTREF). Try a different USB cable.
  • GDB says “remote replied unexpectedly”. You forgot to start OpenOCD, or OpenOCD is on a different machine than GDB. They have to be on the same machine or the network has to allow port 3333.
  • Breakpoints do not trigger. The code might have been optimized out. Build with -Og (debug optimization) instead of -Os (size optimization) so the compiler keeps the lines you set breakpoints on.
  • GDB shows the wrong source line. The source file was edited after the ELF was built. Rebuild the ELF and reconnect.

What to build next

  • A test sketch with a deliberate bug (null pointer dereference, buffer overflow, infinite loop). Set a breakpoint, find it.
  • The same bug without JTAG, using only Serial.print. Notice how much longer it takes.
  • An ESP32-S3 project that uses the built-in USB-JTAG (no external probe needed). The S2/S3/C3 have USB-JTAG on the native USB port; the wiring is just a USB cable.
  • VS Code + PlatformIO if you are not already using it. The IDE integration makes JTAG debugging a one-click operation.