Debugging Embedded Firmware with JTAG, SWD, GDB and a Logic Analyzer

Printing messages over a UART is the first debugging tool everyone uses, and it remains useful. But printing changes timing, needs a working UART, and tells you nothing when the chip crashes before the message goes out. Professional firmware work relies on two other tools: a hardware debug probe connected over JTAG or SWD, which lets you stop the CPU and inspect everything, and a logic analyzer, which shows what the pins actually did over time.

This article explains both interfaces, gives a working OpenOCD and GDB session, shows how to decode an ARM Cortex-M HardFault, and how to measure code timing with a spare GPIO pin.

JTAG and SWD

JTAG (IEEE 1149.1) was created for boundary-scan testing of circuit boards and later used for CPU debugging. SWD (Serial Wire Debug) is ARM's two-wire alternative that gives the same debug access to Cortex-M cores with fewer pins. Most Cortex-M microcontrollers support SWD, and many support both.

SignalInterfacePurpose
TCKJTAGTest clock
TMSJTAGMode select, drives the TAP state machine
TDI / TDOJTAGData in and data out, allow daisy-chaining several chips
nTRSTJTAG (optional)Test reset
SWCLKSWDClock, shares the TCK pin
SWDIOSWDBidirectional data, shares the TMS pin
SWOSWD (optional)Trace output for printf-style messages without a UART
nRESET, VTref, GNDBothTarget reset, target voltage reference, common ground

Common probes include the ST-LINK built into STM32 Nucleo and Discovery boards, SEGGER J-Link, and CMSIS-DAP probes such as the Raspberry Pi Debug Probe. Always connect GND and VTref; a missing ground is the most common reason a probe cannot find the target.

A Working OpenOCD and GDB Session

OpenOCD talks to the probe and opens a GDB server, by default on port 3333. GDB then loads the ELF file, which contains both the machine code and the debug symbols. Build with -g and preferably -Og so variables are not optimised away. The commands below use an ST-LINK and an STM32F4 target; change the config files for other hardware.

# terminal 1: start the GDB server
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg

# terminal 2: connect GDB
arm-none-eabi-gdb build/firmware.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt        # pass a command to OpenOCD
(gdb) load                      # program flash
(gdb) break main
(gdb) continue
(gdb) next / step               # step over / into a line
(gdb) print rx_count            # read a variable
(gdb) watch error_count         # stop when this variable is written
(gdb) info registers
(gdb) backtrace                 # call stack
(gdb) x/8xw 0x20000000          # dump 8 words of RAM in hex

Code running from flash cannot be patched with software breakpoints, so the debugger uses the core's hardware breakpoint comparators. There are only a few, the exact number depends on the core, and GDB reports an error when you exceed them. Watchpoints use the Data Watchpoint and Trace unit and stop the CPU when a chosen address is written, which is the fastest way to find out who is corrupting a variable.

Decoding a Cortex-M HardFault

When a Cortex-M core hits an invalid memory access, an undefined instruction or a divide-by-zero trap, it enters the HardFault handler. The default handler is an infinite loop, which tells you nothing. Before entering any exception, the core pushes R0, R1, R2, R3, R12, LR, PC and xPSR onto the active stack, so the stacked PC is the address of the instruction that faulted. The handler below captures it, along with the fault status registers. It is MCU-specific: GCC syntax for Cortex-M3, M4 and M7 (the ite instruction is not available on Cortex-M0).

MCU-specific: HardFault handler that saves the stacked PC and fault status registers (Cortex-M3/M4/M7, GCC, CMSIS)
#include <stdint.h>
#include "stm32f4xx.h"   /* any CMSIS header for a Cortex-M3/M4/M7 part */

typedef struct {
    uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;   /* hardware-stacked frame */
    uint32_t cfsr, hfsr, mmfar, bfar;
} fault_info_t;

volatile fault_info_t g_fault;   /* inspect with: print/x g_fault */

void hardfault_c(uint32_t *frame)
{
    g_fault.r0   = frame[0];
    g_fault.r1   = frame[1];
    g_fault.r2   = frame[2];
    g_fault.r3   = frame[3];
    g_fault.r12  = frame[4];
    g_fault.lr   = frame[5];
    g_fault.pc   = frame[6];          /* address of the faulting instruction */
    g_fault.xpsr = frame[7];
    g_fault.cfsr  = SCB->CFSR;        /* configurable fault status */
    g_fault.hfsr  = SCB->HFSR;
    g_fault.mmfar = SCB->MMFAR;       /* valid if CFSR.MMARVALID is set */
    g_fault.bfar  = SCB->BFAR;        /* valid if CFSR.BFARVALID is set */

    __asm volatile ("bkpt #0");       /* halt here when a debugger is attached */
    for (;;) { }
}

/* Pick the stack that was active (MSP or PSP) and pass it to hardfault_c */
__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile (
        "tst   lr, #4      \n"
        "ite   eq          \n"
        "mrseq r0, msp     \n"
        "mrsne r0, psp     \n"
        "b     hardfault_c \n"
    );
}

When it stops, type info symbol g_fault.pc or list *0x08001234 (using the stacked PC value) in GDB to see the source line. The CFSR bits tell you the type: for example, the PRECISERR bit means a bus fault at the exact address in BFAR, often a NULL or wild pointer, and the UNDEFINSTR bit means an undefined instruction, often a corrupted function pointer or a return through a smashed stack.

Using a Logic Analyzer

A logic analyzer samples several digital lines and shows them on a time axis. Even inexpensive USB analyzers with open-source software such as PulseView (sigrok) decode UART, SPI and I2C into bytes, which answers questions a debugger cannot: is the slave acknowledging its address, is the chip-select released between bytes, what is the real baud rate.

  • Sample at several times the fastest signal frequency; four or more samples per bit is a common rule of thumb for reliable decoding.
  • Set the logic threshold to match the target voltage, for example 3.3 V logic.
  • Trigger on a specific event, such as the falling edge of chip-select, so the capture starts where the problem is.
  • Share ground between the analyzer and the target, close to the signals being measured.

Timing code with a GPIO pin

Set a spare pin high at the start of an ISR and low at the end, and the analyzer shows exactly how long the ISR takes, how often it runs, and its worst case over thousands of runs. The cost is one or two instructions per edge, far less than printing. On STM32, writing the BSRR register sets or clears a pin in a single store: GPIOA->BSRR = (1u << 5) sets PA5 and GPIOA->BSRR = (1u << (5 + 16)) clears it.

Common Mistakes

  • Reconfiguring the SWD pins as GPIO in firmware, which locks out the debugger. Recover by connecting under reset (holding nRESET while the probe attaches).
  • Entering deep sleep, which can drop the debug connection. Many parts have a debug register option to keep debug alive in low-power modes during development.
  • Debugging an -O2 build and wondering why variables show as optimised out. Use -Og for development builds.
  • Trusting a backtrace after stack corruption. Use the stacked PC and LR from the fault frame instead.

Summary

JTAG and SWD give a debug probe full control of the CPU: breakpoints, single-stepping, memory inspection and watchpoints, driven from GDB through OpenOCD or a vendor server. A HardFault handler that saves the stacked PC and the fault status registers turns mysterious crashes into a source line. A logic analyzer shows what the pins really did and, with a toggled GPIO, measures code timing with almost no overhead. Together they replace guesswork with evidence.