JTAG and GDB for Embedded Testing: Debug and Validate Firmware On-Target

JTAG and GDB for Embedded Testing: Debug and Validate Firmware On-Target

JTAG and GDB together give embedded engineers precise, non-invasive control over firmware execution on real hardware. This guide covers JTAG protocol fundamentals, connecting via OpenOCD, remote GDB debugging, breakpoints and watchpoints for test validation, inspecting memory and peripheral registers, and scripting GDB with Python to run automated test assertions in CI pipelines.

Key Takeaways

JTAG is the only reliable way to debug bare-metal firmware without modifying it. It requires zero code changes, does not consume UART pins, and can halt execution at exact instruction boundaries.

OpenOCD is the open-source bridge between your GDB client and the JTAG adapter. It speaks the JTAG protocol to the hardware and GDB remote serial protocol to your debugger — it is not optional, it is the glue.

Watchpoints catch memory corruption bugs that breakpoints miss. A watchpoint on a RAM address fires whenever that address is read or written, making it the fastest way to find the write that corrupts your stack or ring buffer.

GDB Python scripting turns the debugger into a test runner. You can automate breakpoints, inspect state, assert values, and exit with a pass/fail code — all without modifying the firmware binary.

Combining GDB tests with CI requires a physical device or QEMU. QEMU supports GDB remote stub natively; for real hardware, a self-hosted runner with the JTAG adapter attached is the standard approach.

JTAG Fundamentals

JTAG — Joint Test Action Group, standardized as IEEE 1149.1 — was originally designed for board-level manufacturing test (boundary scan). Embedded engineers repurposed it for runtime debugging, and it has been the dominant embedded debug interface for three decades.

The Four-Wire Interface

JTAG uses four mandatory signals plus an optional fifth:

Signal Direction Purpose
TCK In Test Clock — clocks the state machine
TMS In Test Mode Select — navigates the TAP state machine
TDI In Test Data In — data shifted into the device
TDO Out Test Data Out — data shifted out
TRST In (opt.) Test Reset — asynchronous reset of the TAP

SWD (Serial Wire Debug) is ARM's two-wire alternative: SWDCLK and SWDIO replace all four JTAG signals on Cortex-M devices. Most modern ARM adapters support both.

The TAP State Machine

The JTAG TAP (Test Access Port) is a 16-state finite state machine. TMS transitions navigate between states; TDI/TDO transfer data in the Shift-IR and Shift-DR states. You do not need to understand the state machine in detail to use JTAG — OpenOCD handles all of it — but knowing it exists helps when debugging marginal signal quality issues (slow TCK, long cables, missing pull-ups).

Common JTAG Adapters

Adapter Interface Notes
J-Link (SEGGER) USB Industry standard, fast, supports all Cortex-M/R/A
ST-Link v2/v3 USB Built into STM32 Nucleo/Discovery boards, SWD only
CMSIS-DAP USB HID Open standard, built into many dev boards
FT2232H-based USB Cheap DIY option, works with OpenOCD
Black Magic Probe USB Open firmware, runs GDB server on-device

OpenOCD: The Debug Bridge

OpenOCD (Open On-Chip Debugger) is the open-source server that sits between your GDB client and the JTAG adapter. It handles:

  • Low-level JTAG/SWD protocol to the adapter
  • Device-specific flash programming algorithms
  • GDB Remote Serial Protocol (RSP) server on a TCP port
  • Telnet/TCL command interface for scripting

Installing OpenOCD

# Ubuntu/Debian
<span class="hljs-built_in">sudo apt install openocd

<span class="hljs-comment"># macOS
brew install openocd

<span class="hljs-comment"># Verify
openocd --version
<span class="hljs-comment"># Open On-Chip Debugger 0.12.0

Configuration Files

OpenOCD uses a layered configuration: interface file (adapter), transport, target, and board. Most boards have stock configurations in /usr/share/openocd/scripts/.

# Example: STM32F4 with ST-Link
source [find interface/stlink.cfg]
transport select hla_swd
source [find target/stm32f4x.cfg]

For a J-Link with an STM32H7:

source [find interface/jlink.cfg]
transport select swd
source [find target/stm32h7x.cfg]
adapter speed 4000

Starting OpenOCD

openocd -f interface/jlink.cfg -f target/stm32f4x.cfg

Successful output:

Info : J-Link V10 compiled Apr 11 2023 17:30:52
Info : Hardware version: 10.10
Info : JTAG tap: stm32f4x.cpu tap/device found: 0x4ba00477
Info : stm32f4x.cpu: hardware has 6 breakpoints, 4 watchpoints
Info : Listening on port 3333 for gdb connections
Info : Listening on port 4444 for telnet connections

Note the watchpoint count — it tells you how many hardware watchpoints are available before software emulation kicks in.

GDB Remote Debugging

With OpenOCD running, connect GDB to port 3333:

arm-none-eabi-gdb build/firmware.elf

(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()

(gdb) monitor reset halt
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x08000298 msp: 0x20020000

(gdb) load
Loading section .text, size 0x12a4c lpr 0x8000000...
Loading section .data, size 0x3c lpr 0x801_2a4c...
Start address 0x0800_0298, load size 77452

(gdb) <span class="hljs-built_in">continue
Continuing.

Useful GDB Commands for Embedded

# Stop execution
(gdb) interrupt

# Inspect CPU registers
(gdb) info registers
(gdb) info registers pc sp lr

# Read peripheral register (GPIOA ODR on STM32)
(gdb) x/1wx 0x40020014
0x40020014:     0x00000020

# Print a variable
(gdb) print sensor_data.temperature
$1 = 23

# Print array
(gdb) print/x uart_rx_buffer[0]@16

# Stack backtrace
(gdb) backtrace
#0  hal_uart_receive () at src/hal_uart.c:47
#1  0x08001234 in protocol_parse () at src/protocol.c:112
#2  0x08001890 in main_loop () at src/main.c:89

# Disassemble current function
(gdb) disassemble

Breakpoints

Hardware breakpoints halt the CPU at a specific instruction address without modifying flash memory. Cortex-M devices typically provide 4–8 hardware breakpoints via the FPB (Flash Patch and Breakpoint) unit.

# Break at function entry
(gdb) break hal_uart_receive

# Break at file:line
(gdb) break src/protocol.c:112

# Conditional breakpoint — only halt if condition is true
(gdb) break sensor_read if sensor_data.raw_adc > 4000

# Temporary breakpoint — fires once, then removed
(gdb) tbreak main

# List breakpoints
(gdb) info breakpoints

# Delete breakpoint 2
(gdb) delete 2

When hardware breakpoints are exhausted, GDB falls back to software breakpoints — which temporarily overwrite flash with a BKPT instruction. This requires the flash to be writable at runtime and may not work on read-protected devices.

Watchpoints: Finding Memory Corruption

Watchpoints monitor memory access without halting execution until the access occurs. They are the most powerful tool for finding:

  • Stack overflows (detect the exact write that corrupts the return address)
  • Ring buffer bounds violations
  • Interrupt handlers that overwrite shared state unexpectedly
  • Use-after-free (watch the freed address for unexpected writes)
# Break on any write to a variable
(gdb) watch sensor_data.temperature

# Break on read of a variable
(gdb) rwatch some_global_counter

# Break on read OR write
(gdb) awatch uart_rx_buffer[0]

# Watch a raw memory address (e.g., a stack canary)
(gdb) watch *(uint32_t *)0x2001ff00

# Watch a 4-byte range
(gdb) watch -l *(uint32_t[4] *)0x20000100

When a watchpoint fires:

Hardware watchpoint 1: sensor_data.temperature

Old value = 23
New value = -1
hal_uart_receive () at src/hal_uart.c:52
52          sensor_data.temperature = (int16_t)(raw >> 4);

This immediately pinpoints the write location — no printf debugging, no guesswork.

Memory Inspection

Direct memory reads without going through C symbols are essential for peripheral register debugging:

# Read 4 bytes at address (hex)
(gdb) x/1wx 0x40020014

# Read 16 bytes as bytes (useful for network buffers)
(gdb) x/16xb uart_rx_buffer

# Read as string
(gdb) x/s log_buffer

# Dump 256 bytes of stack
(gdb) x/64wx $sp

# Write to memory (use with caution on live system)
(gdb) set {uint32_t}0x40020018 = 0x00000020

For peripheral registers, the svd-tools GDB plugin reads CMSIS SVD files and provides named register access:

(gdb) source ~/.gdbinit.d/svd.py
(gdb) svd load STM32F407.svd
(gdb) svd GPIOA
GPIOA @ 0x40020000
  MODER   = 0xA8000000  (MODER15=2, MODER14=2, MODER13=2)
  OTYPER  = 0x00000000
  OSPEEDR = 0x00000000
  PUPDR   = 0x64000000
  IDR     = 0x00002000
  ODR     = 0x00000000

Scripting GDB with Python for Automated Testing

GDB has a full Python 3 API that lets you write test scripts that run inside the debugger session. This transforms GDB from an interactive tool into an automated test runner.

A Simple GDB Python Test

#!/usr/bin/env python3
# gdb_test_sensor.py — run with: arm-none-eabi-gdb -x gdb_test_sensor.py firmware.elf

import gdb
import sys

class SensorReadTest:
    def __init__(self):
        self.passed = 0
        self.failed = 0

    def assert_equal(self, name, expected, actual):
        if expected == actual:
            print(f"  PASS: {name} = {actual}")
            self.passed += 1
        else:
            print(f"  FAIL: {name}: expected {expected}, got {actual}")
            self.failed += 1

    def run(self):
        print("=== Sensor Read Test ===")

        # Connect and halt
        gdb.execute("target remote :3333")
        gdb.execute("monitor reset halt")
        gdb.execute("load")

        # Set breakpoint at the function we want to test
        bp = gdb.Breakpoint("sensor_process")

        # Inject a known ADC value via a variable the driver reads
        gdb.execute("set raw_adc_value = 2048")

        gdb.execute("continue")
        # GDB will halt at sensor_process

        # Inspect the output after the function runs
        gdb.execute("next 10")  # step through the function

        temp = int(gdb.parse_and_eval("sensor_data.temperature"))
        humidity = int(gdb.parse_and_eval("sensor_data.humidity"))

        self.assert_equal("temperature", 25, temp)
        self.assert_equal("humidity", 50, humidity)

        bp.delete()

        print(f"\n{self.passed} passed, {self.failed} failed")
        gdb.execute("quit %d" % (1 if self.failed > 0 else 0))

test = SensorReadTest()
test.run()

Run it non-interactively:

arm-none-eabi-gdb --batch \
  -ex "set pagination off" \
  -x gdb_test_sensor.py \
  build/firmware.elf
<span class="hljs-built_in">echo <span class="hljs-string">"Exit code: $?"

The exit code propagates to your CI script.

Using GDB Breakpoint Commands

For simpler tests, GDB's built-in commands syntax attaches a script to a breakpoint:

# In a .gdb script file
target remote :3333
monitor reset halt
load

break assert_failed
commands
  silent
  printf "ASSERTION FAILED at %s:%d\n", filename, lineno
  quit 1
end

break test_runner_complete
commands
  silent
  set $result = test_result_code
  if $result == 0
    printf "ALL TESTS PASSED\n"
    quit 0
  else
    printf "TESTS FAILED: %d failures\n", $result
    quit 1
  end
end

continue

Integrating JTAG Tests into CI

For CI with real hardware, the setup requires a self-hosted runner with the JTAG adapter attached. For QEMU-based runs, the GDB stub is built in.

QEMU + GDB (no hardware required)

# Terminal 1: Start QEMU with GDB server on port 1234
qemu-system-arm \
  -machine mps2-an385 \
  -kernel build/firmware.elf \
  -nographic \
  -S \                    <span class="hljs-comment"># halt CPU at startup
  -gdb tcp::1234          <span class="hljs-comment"># GDB server on port 1234

<span class="hljs-comment"># Terminal 2: Connect GDB
arm-none-eabi-gdb build/firmware.elf
(gdb) target remote :1234
(gdb) <span class="hljs-built_in">continue

GitHub Actions with Self-Hosted Runner

# .github/workflows/hardware-tests.yml
name: On-Target Tests

on: [push]

jobs:
  jtag-test:
    runs-on: [self-hosted, jtag-stm32]  # runner with ST-Link attached
    steps:
      - uses: actions/checkout@v4

      - name: Build firmware
        run: |
          cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake
          cmake --build build

      - name: Flash and test via GDB
        run: |
          openocd \
            -f interface/stlink.cfg \
            -f target/stm32f4x.cfg \
            -c "program build/firmware.elf verify reset exit" &
          OPENOCD_PID=$!
          sleep 2

          arm-none-eabi-gdb --batch \
            -ex "set pagination off" \
            -x tests/gdb/test_all.py \
            build/firmware.elf
          TEST_RESULT=$?

          kill $OPENOCD_PID 2>/dev/null
          exit $TEST_RESULT

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: gdb-test-results
          path: test-results.xml

Practical Tips

Always use monitor reset halt before load. Flashing a running MCU can corrupt state or miss the reset vector.

Use set arm force-mode thumb when debugging Cortex-M. GDB sometimes misidentifies the instruction set for code at the reset vector.

Log GDB sessions for postmortem analysis. set logging on before connecting writes all output to gdb.txt.

Use .gdbinit for per-project defaults. Place a .gdbinit in your project root with target connection, architecture settings, and SVD file loading. GDB loads it automatically on startup.

# .gdbinit
set pagination off
set print pretty on
set print array on
define connect
  target remote :3333
  monitor reset halt
end

Hardware watchpoints are precious — ration them. Cortex-M3/M4 has four hardware watchpoints. If you need more, implement a software watchpoint using MPU region faults or shadow memory.

Conclusion

JTAG and GDB provide a level of visibility into firmware execution that no amount of printf debugging can match. The ability to halt execution at any instruction, inspect and modify any memory location, and monitor writes to any address — without modifying the firmware binary — makes them indispensable for both debugging and test validation. By scripting GDB with Python and integrating it into CI, teams can run automated on-target tests on every commit, catching hardware-specific bugs before they reach integration testing or, worse, customers.

Read more