Debugging small software projects can be done with something as simple as a carefully placed printf() call, but with firmware you have no terminal or GUI to display the feedback. While the STM32F4 and many other MCUs support UARTs or USB, adding that overhead may not be the best option. The STlink utility paired with GDB provides an easy way to debug firmware.
With the development board plugged in, run st-util to open a connection to the board. This will need to remain open for the duration of the debugging process, so use a separate terminal window or hide the output and run the process in the background:
$ st-util
$ st-util > /dev/null &
GDB is the actual debugger and it will interact with st-util to communicate with the microcontroller. Start GDB with flags that tell it to hide the warranty notice and connect to the st-util server. If the MCU has already been flashed you can specify the ELF when starting GDB. Otherwise, leave that off and use the load command after starting GDB to upload the firmware:
$ arm-none-eabi-gdb -silent -ex 'target extended-remote localhost:4242' firmware.elf
$ arm-none-eabi-gdb -silent -ex 'target extended-remote localhost:4242'
(gdb) load firmware.elf
When st-util starts it will establish a connection to the board and pause firmware execution. After starting GDB you can add brakepoints or watchpoints, then continue firmware execution and wait for those points to be triggered. Execution stops when any brakepoint or watchpoint is triggered, allowing you to interactively evaluate expressions, read values and step through code line-by-line.
The GDB User Manual is well written and covers all of the details. Below is a summary of the more commonly used commands when working with microcontrollers.
Brakepoints and Watchpoints
Brakepoints will pause execution before some point in the code. They can brake on a function call or line number. A condition can be used to brake only if some boolean expression evaluates as true (non-zero.) Use break or b to add a breakpoint.
break func1 # brake before all calls to func1()
break file.c:func1 # brake before all calls to func1() from file.c
break 190 # brake before line 190 of the current file
break func1 if var1 == 5 # brake before all calls to func1() when var1 is equal to 5
Watchpoints will pause execution after a variable is read or written to. They are particularly helpful when you do not know what code is altering a value. Use watch to break after writes, rwatch to break after reads, and awatch to break after reads or writes. Watchpoints are automatically deleted when the variable goes out of scope.
watch var1 # brake after var1 is written to
rwatch var1 # brake after var1 is read from
awatch var1 # brake after var1 is read or written to
When a brakepoint or watchpoint is created it is assigned a number. List them all with info brakepoints. Remove one with delete or clear. Disable one with disable and enable one with enable.
info brakepoints # list all brakepoints and watchpoints with their numbers
disable n # disable brakepoint or watchpoint number n
enable n # enable brakepoint or watchpoint number n
delete n # remove brakepoint or watchpoint number n
clear func1 # remove all breakpoints for func1()
clear 190 # remove all breakpoints for line 190 of the current file
Automatically run debugger commands when reaching a breakpoint or watchpoint with commands. It will apply to the most recently set breakpoint/watchpoint, or specify a number to apply it to a different point.
break func2 # add breakpoint for func2()
commands # debugger commands applied to most recently defined breakpoint
print var1
print var2
end
commands n # debugger commands applied to breakpoint/watchpoint number n
print var3
end
Interactive Commands
Resume execution with continue or c.
Pause execution with Ctrl-C.
Execute one line, without stepping into a function call, with next or n.
Execute one line, stepping into a function call, with step or s.
Print a stacktrace with backtrace or bt.
Evaluate an expression with print or p. It will be displayed in the same datatype as in the code. Flags can be used to change how it's displayed: /t for binary, /x for hexadecimal, etc. See Section 10.5 of the GDB User Manual for a complete list.
print var1 # print the value of var1
print /t *(uint32_t*) 0x40023830 # print the value of the RCC AHB1ENR register as binary
Exit the debugger with Ctrl-D, quit or q.