Debugging Embedded Firmware: JTAG, GDB, Logic Analyzers, and Serial Tracing
The most common debugging approach in embedded development is adding printf statements until the bug disappears — usually because the printf timing changed the system enough to hide the race condition. This is not a professional approach. It wastes hours and hides Heisenbugs.
Here is the toolkit that actually finds bugs in production firmware.
JTAG: The Foundation of Hardware Debugging
JTAG (Joint Test Action Group) is a hardware interface that gives a debugger direct access to the processor's internal state — registers, memory, peripherals — without modifying the running code. On ARM Cortex-M, this is usually exposed as SWD (Serial Wire Debug), a 2-pin subset of JTAG.
ESP32 JTAG Setup:
The ESP32 exposes JTAG on GPIO 12-15. Use an FT2232H-based adapter (ESP-Prog, or generic FT2232H breakout). Install OpenOCD:
Install OpenOCD with ESP32 support
sudo apt install openocdStart OpenOCD for ESP32
openocd -f interface/ftdi/esp32_devkitj_v1.cfg -f target/esp32.cfg
STM32 JTAG/SWD Setup:
ST-Link v2 is the standard for STM32. It uses SWD (SWDIO + SWDCLK + GND + 3.3V). Most STM32 Nucleo and Discovery boards include an on-board ST-Link, making connection trivial.
Start OpenOCD for STM32F4 with ST-Link
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
GDB Workflow: Breakpoints and Watchpoints
Once OpenOCD is running, connect GDB in a second terminal:
ESP32
xtensa-esp32-elf-gdb build/firmware.elf
(gdb) target remote :3333
(gdb) monitor reset haltSTM32
arm-none-eabi-gdb build/firmware.elf
(gdb) target remote :3333
(gdb) monitor reset halt
Hardware breakpoints: Cortex-M has 4–8 hardware breakpoints (FPB unit). ESP32 Xtensa has 2. Use them to stop at function entries:
(gdb) break mqtt_publish
(gdb) continue
Execution stops when mqtt_publish is called
(gdb) info registers # Inspect CPU registers
(gdb) backtrace # Full call stack
(gdb) print payload # Inspect variable
Watchpoints: Stop execution when a memory address is read or written — invaluable for tracking memory corruption:
(gdb) watch g_device_state.wifi_connected
GDB stops every time this variable changes
(gdb) continue
When triggered, GDB shows what instruction wrote it and its new value
Data watchpoints (Cortex-M DWT unit) can also trigger on peripheral register access, useful for diagnosing unexpected peripheral state changes.
Finding Stack Overflows and Heap Corruption
Stack overflows and heap corruption are the hardest bugs to reproduce and diagnose without a debugger.
Stack overflow with GDB:
// Enable stack canaries in your linker script or startup code
extern uint32_t _stack_start;
#define STACK_CANARY 0xDEADBEEFvoid init_stack_canary(void) {
// Fill bottom of stack with canary pattern
uint32_t *p = &_stack_start;
while (p < (uint32_t *)__get_MSP() - 64) {
*p++ = STACK_CANARY;
}
}
void check_stack_canary(void) {
uint32_t *p = &_stack_start;
if (*p != STACK_CANARY) {
// Stack has overflowed into canary region
fault_handler("stack overflow detected");
}
}
Set a GDB watchpoint on the canary address: when it changes, you catch the overflow at the exact instruction that caused it.
Heap corruption: Use a debug malloc that writes guard bytes before and after each allocation. Check them on every free:
#define GUARD_PATTERN 0xFEEDFACE
#define GUARD_SIZE 4void *debug_malloc(size_t size) {
uint8_t *raw = malloc(size + 2 * GUARD_SIZE);
if (!raw) return NULL;
// Write guard bytes
memcpy(raw, &GUARD_PATTERN, GUARD_SIZE);
memcpy(raw + GUARD_SIZE + size, &GUARD_PATTERN, GUARD_SIZE);
return raw + GUARD_SIZE;
}
void debug_free(void *ptr) {
uint8_t *raw = (uint8_t *)ptr - GUARD_SIZE;
uint32_t guard;
memcpy(&guard, raw, GUARD_SIZE);
assert(guard == GUARD_PATTERN && "Pre-guard corrupted — buffer underflow");
// Check post-guard — need to know allocation size (store it in header)
free(raw);
}
Logic Analyzer for Protocol Debugging
When your I2C sensor does not respond, a logic analyzer tells you exactly what is happening on the wire. A Saleae Logic or DSLogic at $50–400 captures millions of samples with protocol decoding.
Connect probes to SDA, SCL, and GND. Run the I2C decoder. You will see exactly which address is being sent, whether the device ACKs, and which register is being accessed. Common findings:
For SPI, look at CS timing, clock phase/polarity (CPOL/CPHA), and MISO setup time. For UART, check baud rate match and framing errors.
Serial Tracing with SEGGER RTT
UART printf is intrusive — it blocks for milliseconds, changing timing. SEGGER RTT (Real-Time Transfer) writes to a ring buffer in RAM that OpenOCD reads out via JTAG without halting the CPU. Zero timing impact on your firmware.
#include "SEGGER_RTT.h"void init_rtt(void) {
SEGGER_RTT_Init();
SEGGER_RTT_WriteString(0, "RTT initialized\r\n");
}
// In ISR — safe, non-blocking, microseconds overhead
void TIM2_IRQHandler(void) {
SEGGER_RTT_printf(0, "TIM2 ISR: tick=%lu\r\n", HAL_GetTick());
// ... control loop
}
View output in J-Link RTT Viewer or OpenOCD's RTT support. You get printf-style debug output from inside ISRs without corrupting timing.
Race Condition Detection
Race conditions in FreeRTOS firmware are notoriously hard to find. Symptoms: data corruption that only appears under load, crashes that happen once every thousand cycles, behavior that changes when you add debug prints.
Systematic approach:
The watchpoint fires on the second access to the shared memory address. If the call stacks of the two accesses do not agree on which task/ISR owns the resource, you have found your race.
[Contact Code Caracal](/contact) — we build production firmware for clients across 15+ countries.