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.0Configuration 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 4000Starting OpenOCD
openocd -f interface/jlink.cfg -f target/stm32f4x.cfgSuccessful 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 connectionsNote 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) disassembleBreakpoints
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 2When 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] *)0x20000100When 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 = 0x00000020For 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 = 0x00000000Scripting 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
continueIntegrating 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">continueGitHub 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.xmlPractical 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
endHardware 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.