Skip to content
Home » Embedded Systems » Debugging » GDB for Embedded Debugging: Complete Practical Guide

GDB for Embedded Debugging: Complete Practical Guide

Embedded Systems Learning Path
Part 105 of 129View Full Path →

KEY TAKEAWAYS

  • GDB connects to embedded targets through debug probes (ST-Link, J-Link) using OpenOCD or J-Link GDB Server as a bridge.
  • Hardware breakpoints (limited to 2-6 on most MCUs) halt the CPU at specific addresses without modifying flash memory.
  • Watchpoints (data breakpoints) trigger when a memory address is read or written — invaluable for catching memory corruption.
  • The x command lets you examine raw memory and peripheral registers, while set lets you modify them live.
  • GDB scripts and custom commands automate repetitive debugging tasks, saving hours during firmware development.

Why GDB for Embedded?

When your firmware doesn’t work and printf debugging isn’t an option (because you haven’t set up UART yet, or the bug crashes before you can print), you need a real debugger. GDB (GNU Debugger) connects to your microcontroller through a debug probe and gives you god-mode access: pause execution, inspect memory, step through code, read peripheral registers, and even modify variables on the fly.

Unlike desktop debugging, embedded GDB runs as a client-server system: GDB runs on your PC, communicates with a GDB server (OpenOCD or J-Link GDB Server), which talks to the debug probe (ST-Link, J-Link), which connects to the MCU’s debug port (SWD or JTAG).

/* Embedded GDB Debug Chain
 *
 * ┌──────┐    TCP/IP     ┌──────────┐    USB     ┌──────────┐   SWD/JTAG  ┌─────┐
 * │ GDB  │ ──────────── │ OpenOCD  │ ────────── │ ST-Link  │ ──────────  │ MCU │
 * │(PC)  │  port 3333   │(GDB Svr) │            │  V2/V3   │             │     │
 * └──────┘               └──────────┘            └──────────┘             └─────┘
 *
 * Alternative: J-Link GDB Server instead of OpenOCD
 */

Setting Up the Debug Environment

Before you can debug embedded firmware with GDB, you need three components: the cross-compiled GDB client (arm-none-eabi-gdb), a debug server that communicates with the target’s debug port (OpenOCD or J-Link GDB Server), and a physical debug probe connected to the target’s SWD or JTAG pins. The GDB client connects to the debug server over TCP, and the server translates GDB commands into the low-level debug protocol. Here is how to install and configure each component.

# Install required tools (Ubuntu/Debian)
# sudo apt install gdb-multiarch openocd

# For ARM targets, use arm-none-eabi-gdb:
# sudo apt install gcc-arm-none-eabi

# ─── Step 1: Start OpenOCD (in a separate terminal) ───
# For STM32F1 with ST-Link V2:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg

# For STM32F4 with ST-Link V2:
# openocd -f interface/stlink.cfg -f target/stm32f4x.cfg

# OpenOCD output:
# Info : stm32f1x.cpu: hardware has 6 breakpoints, 4 watchpoints
# Info : Listening on port 3333 for gdb connections

# ─── Step 2: Start GDB and connect ───
arm-none-eabi-gdb firmware.elf

# Inside GDB:
# (gdb) target remote localhost:3333
# (gdb) monitor reset halt
# (gdb) load
# (gdb) break main
# (gdb) continue

Essential GDB Commands for Embedded

Embedded GDB uses the same commands as desktop GDB, plus additional commands for hardware interaction — reading memory-mapped registers, setting hardware breakpoints (limited by the debug unit, typically 4-6 on Cortex-M), and controlling the target’s execution. The commands below are organized by workflow: connecting, loading firmware, controlling execution, inspecting state, and working with hardware. Master these and you can debug almost any embedded issue.

# ─── Connection and Loading ───
target remote localhost:3333    # Connect to OpenOCD
monitor reset halt              # Reset MCU and halt
load                            # Flash the firmware (.elf)
monitor reset init              # Reset and re-init

# ─── Execution Control ───
continue          # (c) Run until breakpoint or Ctrl+C
step              # (s) Step into function calls
next              # (n) Step over function calls
stepi             # (si) Execute one machine instruction
finish            # Run until current function returns
until 150         # Run until line 150

# ─── Breakpoints ───
break main                      # Break at function 'main'
break uart.c:42                 # Break at file:line
break *0x08001234               # Break at memory address
break i2c_write_byte if len==0  # Conditional breakpoint
info breakpoints                # List all breakpoints
delete 2                        # Delete breakpoint #2
disable 3                       # Temporarily disable #3
enable 3                        # Re-enable #3

# Important: Embedded MCUs have LIMITED hardware breakpoints!
# STM32F1: 6 breakpoints, STM32F4: 6, STM32F0: 4
# If you exceed the limit, GDB will fail silently or error.
# Use "info breakpoints" regularly and delete unused ones.

# ─── Watchpoints (Data Breakpoints) ───
watch my_variable               # Break when my_variable is WRITTEN
rwatch my_variable              # Break when my_variable is READ
awatch my_variable              # Break on READ or WRITE
watch *(uint32_t *)0x20000100   # Watch a specific memory address
watch buffer[15]                # Watch array element

# Hardware watchpoints are also limited (typically 2-4)
# info watchpoints

# ─── Inspecting Variables ───
print variable_name             # (p) Print variable value
print/x variable_name           # Print in hexadecimal
print/t variable_name           # Print in binary
print/d variable_name           # Print in decimal
print sizeof(my_struct)         # Print expression
print *array@10                 # Print first 10 elements
display variable_name           # Auto-print at every stop

# ─── Examining Memory ───
x/4xw 0x40010C00    # 4 words (32-bit), hex, at GPIO registers
x/16xb 0x20000000   # 16 bytes, hex, at SRAM start
x/s 0x08004000      # String at flash address
x/10i $pc            # 10 instructions at program counter

# Format: x/[count][format][size] address
# Formats: x(hex), d(decimal), u(unsigned), t(binary), c(char), s(string), i(instruction)
# Sizes: b(byte), h(halfword=16bit), w(word=32bit), g(giant=64bit)

# ─── Stack and Registers ───
backtrace             # (bt) Show call stack
frame 2               # Switch to stack frame #2
info registers        # Show all CPU registers
info reg sp pc lr     # Show specific registers
print $pc             # Print program counter
print $sp             # Print stack pointer
print $lr             # Print link register (return address)

Reading Peripheral Registers

One of GDB’s most powerful embedded features is direct memory access. Since peripheral registers are memory-mapped, you can read and write them directly, turning GDB into an interactive hardware exploration tool.

# ─── Reading STM32 Peripheral Registers ───

# GPIO Port B registers (STM32F1)
# CRL=0x40010C00, CRH=0x40010C04, IDR=0x40010C08, ODR=0x40010C0C
(gdb) x/4xw 0x40010C00
0x40010c00:  0x44444444  0x44488444  0x0000FF00  0x00000020

# Decode: ODR = 0x00000020 → bit 5 is HIGH → PB5 LED is ON

# USART1 registers (STM32F1)
# SR=0x40013800, DR=0x40013804, BRR=0x40013808
(gdb) x/3xw 0x40013800
0x40013800:  0x000000C0  0x00000000  0x00000271
# SR bit 7 (TXE) = 1: transmit buffer empty
# SR bit 6 (TC) = 1: transmission complete
# BRR = 0x271 = 625 → BAUD = 72MHz/16/625 = 7200? Check your clock!

# RCC registers — check which peripherals are clocked
(gdb) x/xw 0x40021018    # RCC_APB2ENR
0x40021018:  0x00005E0D
# Bit 0 (AFIOEN)=1, Bit 2 (IOPAEN)=1, Bit 3 (IOPBEN)=1
# Bit 14 (USART1EN)=1 — good, USART1 clock is enabled

# ─── Modifying Registers Live ───

# Turn on LED (set PB5)
(gdb) set *(uint32_t *)0x40010C10 = (1 << 5)
# Wrote to BSRR register → PB5 is now HIGH

# Turn off LED (reset PB5)
(gdb) set *(uint32_t *)0x40010C14 = (1 << 5)
# Wrote to BRR register → PB5 is now LOW

# Change a variable at runtime
(gdb) set variable my_counter = 100
(gdb) set variable debug_mode = 1

Debugging Hard Faults

Hard faults are the most common crash in embedded systems — caused by accessing invalid memory, executing an undefined instruction, or dividing by zero. When a hard fault occurs, the CPU pushes context onto the stack. GDB can help you decode it.

/* Hard Fault debugging with GDB
 *
 * When a Hard Fault occurs on ARM Cortex-M:
 * 1. The CPU pushes R0-R3, R12, LR, PC, xPSR onto the stack
 * 2. Execution jumps to HardFault_Handler
 *
 * The stacked PC tells you EXACTLY where the fault occurred.
 */

/* Add this handler to your firmware */
void HardFault_Handler(void) {
    /* Get the stack pointer that was active when the fault occurred */
    volatile uint32_t *stack;

    __asm volatile (
        "TST LR, #4   \n"  /* Check EXC_RETURN bit 2 */
        "ITE EQ        \n"
        "MRSEQ %0, MSP \n"  /* Main Stack Pointer */
        "MRSNE %0, PSP \n"  /* Process Stack Pointer (RTOS task) */
        : "=r" (stack)
    );

    /* Stacked registers */
    volatile uint32_t r0   = stack[0];
    volatile uint32_t r1   = stack[1];
    volatile uint32_t r2   = stack[2];
    volatile uint32_t r3   = stack[3];
    volatile uint32_t r12  = stack[4];
    volatile uint32_t lr   = stack[5];  /* Link Register */
    volatile uint32_t pc   = stack[6];  /* Program Counter — fault address! */
    volatile uint32_t psr  = stack[7];  /* Program Status Register */

    /* Fault status registers */
    volatile uint32_t cfsr  = *(volatile uint32_t *)0xE000ED28;
    volatile uint32_t hfsr  = *(volatile uint32_t *)0xE000ED2C;
    volatile uint32_t mmfar = *(volatile uint32_t *)0xE000ED34;
    volatile uint32_t bfar  = *(volatile uint32_t *)0xE000ED38;

    /* Breakpoint here — inspect variables in GDB */
    __asm volatile ("BKPT #0");

    /* Prevent unused variable warnings */
    (void)r0; (void)r1; (void)r2; (void)r3;
    (void)r12; (void)lr; (void)pc; (void)psr;
    (void)cfsr; (void)hfsr; (void)mmfar; (void)bfar;

    while (1);  /* Halt */
}

/* In GDB when you hit the BKPT:
 *
 * (gdb) print/x pc
 * $1 = 0x08001a3c        ← Address where fault occurred
 *
 * (gdb) list *0x08001a3c  ← Show the source line
 * 0x08001a3c is in spi_transfer (spi.c:87)
 *
 * (gdb) print/x cfsr
 * $2 = 0x00008200
 * Bit 9 (IBUSERR) = 1 → Instruction bus error
 * Bit 15 (BFARVALID) = 1 → BFAR register contains fault address
 *
 * (gdb) print/x bfar
 * $3 = 0x60000000        ← Tried to access this invalid address
 */

GDB Init Scripts for Automation

Creating a .gdbinit file in your project directory automates the connection and setup process, saving you from typing the same commands every debug session.

# .gdbinit — Project-specific GDB initialization

# Connect to OpenOCD
target remote localhost:3333

# Reset and halt the target
monitor reset halt

# Load the firmware
load

# Set a breakpoint at main
break main

# ─── Custom Commands ───

# Dump GPIO states
define gpio_dump
    printf "GPIOA IDR: "
    x/xw 0x40010808
    printf "GPIOB IDR: "
    x/xw 0x40010C08
    printf "GPIOC IDR: "
    x/xw 0x40011008
    printf "GPIOA ODR: "
    x/xw 0x4001080C
    printf "GPIOB ODR: "
    x/xw 0x40010C0C
end
document gpio_dump
    Print all GPIO input and output data registers
end

# Dump UART status
define uart_status
    printf "USART1 SR:  "
    x/xw 0x40013800
    printf "USART1 BRR: "
    x/xw 0x40013808
    printf "USART1 CR1: "
    x/xw 0x4001380C
end

# Print fault registers
define fault_info
    printf "CFSR:  "
    x/xw 0xE000ED28
    printf "HFSR:  "
    x/xw 0xE000ED2C
    printf "MMFAR: "
    x/xw 0xE000ED34
    printf "BFAR:  "
    x/xw 0xE000ED38
end

# Reset and reload
define reload
    monitor reset halt
    load
    continue
end

# Now just type 'reload', 'gpio_dump', 'uart_status', or 'fault_info'

Debugging RTOS Tasks with GDB

When debugging RTOS-based firmware, you often need to inspect the state of multiple tasks (threads). OpenOCD has built-in support for FreeRTOS, ThreadX, and other RTOS kernels.

# Enable RTOS awareness in OpenOCD config:
# openocd -f interface/stlink.cfg -f target/stm32f4x.cfg 
#   -c "stm32f4x.cpu configure -rtos FreeRTOS"

# Now GDB shows RTOS tasks as threads:
(gdb) info threads
  Id   Target Id         Frame
* 1    Thread 1 (main)   main_task () at main.c:45
  2    Thread 2 (sensor) sensor_task () at sensor.c:120
  3    Thread 3 (comm)   comm_task () at comm.c:88
  4    Thread 4 (idle)   idle_task () at freertos.c:200

# Switch to a specific task
(gdb) thread 2
(gdb) bt                 # See that task's call stack
(gdb) info locals        # See its local variables

# Set a breakpoint that only triggers in a specific task
(gdb) break sensor.c:130 thread 2

# Print all task stack usage (approximate)
(gdb) print *pxReadyTasksLists@5

Semihosting: printf Without UART

Semihosting lets your firmware use printf() to print to the GDB console — no UART required. The debug probe intercepts special SVC instructions and routes the output to your terminal. It’s slow (each character triggers a debug exception) but invaluable during early bring-up.

/* Enable semihosting in your firmware */

/* In your linker script or startup code, ensure you're using
 * the semihosting-compatible newlib specs:
 *
 * arm-none-eabi-gcc ... --specs=rdimon.specs -lrdimon
 *
 * Or for nano specs:
 * arm-none-eabi-gcc ... --specs=nano.specs --specs=rdimon.specs
 */

#include 

/* Call this before using printf */
extern void initialise_monitor_handles(void);

int main(void) {
    initialise_monitor_handles();  /* Enable semihosting */

    printf("Firmware started!\n");
    printf("System clock: %lu Hzn", SystemCoreClock);
    printf("Build: %s %s\n", __DATE__, __TIME__);

    uint32_t sensor_val = read_adc(0);
    printf("ADC0 raw value: %lu (%.2fV)\n",
           sensor_val, sensor_val * 3.3 / 4096.0);

    /* Each printf goes through the debug probe — SLOW
     * Don't use in time-critical code or ISRs! */

    while (1) {
        /* ... */
    }
}

/* In OpenOCD, enable semihosting:
 * (gdb) monitor arm semihosting enable
 *
 * Printf output appears in the OpenOCD terminal window.
 */

ITM/SWO: Fast Trace Output

For faster debug output without the overhead of semihosting, Cortex-M3/M4/M7 MCUs support ITM (Instrumentation Trace Macrocell) via the SWO (Serial Wire Output) pin. This outputs debug data at wire speed alongside normal SWD debugging.

/* ITM/SWO trace output — much faster than semihosting */

#define ITM_STIM0  (*(volatile uint32_t *)0xE0000000)
#define ITM_TER    (*(volatile uint32_t *)0xE0000E00)
#define DCB_DEMCR  (*(volatile uint32_t *)0xE000EDFC)
#define ITM_TCR    (*(volatile uint32_t *)0xE0000E80)
#define TPI_SPPR   (*(volatile uint32_t *)0xE0040010)
#define TPI_ACPR   (*(volatile uint32_t *)0xE0040010)

/* Send a character via ITM stimulus port 0 */
void itm_putchar(char c) {
    /* Check if ITM port 0 is enabled */
    if ((ITM_TER & 1) == 0) return;

    /* Wait for port to be ready */
    while (!(ITM_STIM0 & 1));

    /* Write character */
    ITM_STIM0 = (uint32_t)c;
}

/* Override _write for printf support */
int _write(int file, char *ptr, int len) {
    for (int i = 0; i < len; i++) {
        itm_putchar(ptr[i]);
    }
    return len;
}

/* Configure SWO in OpenOCD:
 * (gdb) monitor tpiu config internal /tmp/swo.log uart off 72000000
 *
 * Or use a SWO viewer tool to see output in real-time.
 * ST-Link Utility and STM32CubeIDE both have SWO viewers.
 */

GDB Tips and Tricks

These productivity tips come from years of daily embedded GDB use. Each one saves a small amount of time individually, but combined they dramatically speed up your debugging workflow. The init script automation alone can save minutes per debug session by eliminating repetitive setup commands.

# ─── Useful GDB tricks for embedded ───

# Print a buffer as hex dump
(gdb) x/32xb buffer_ptr
# Shows 32 bytes in hex, like a hex editor

# Monitor a variable every time you stop
(gdb) display/x my_register
# Now it auto-prints at every breakpoint/step

# Call a function on the target (be careful!)
(gdb) call uart_send_string("hello from GDBrn")

# Find where a global variable is modified
(gdb) watch global_counter
(gdb) continue
# GDB will stop and show exactly which line modified it

# Time how long a function takes (approximate)
(gdb) break function_start
(gdb) commands
  > set $start = *(uint32_t *)0xE0001004
  > continue
  > end
(gdb) break function_end
(gdb) commands
  > set $elapsed = *(uint32_t *)0xE0001004 - $start
  > printf "Elapsed: %d cyclesn", $elapsed
  > continue
  > end
# Uses DWT cycle counter (CYCCNT at 0xE0001004)

# Examine the disassembly around current PC
(gdb) disassemble $pc-20, $pc+20

# Jump over code (skip a function call without executing it)
(gdb) set $pc = *($pc + 4)
# Dangerous! Only use if you know the instruction length

Related Articles

Related on this site

Leave a Reply

Your email address will not be published. Required fields are marked *