On this page
- What the Debugger Needs
- Starting Point
- Keep Debug Symbols Enabled
- Add a VSCode Launch Configuration
- Point Cortex-Debug at the Arm Toolchain
- What the Launch Fields Mean
- Build Before Debugging
- Start the Debug Session
- Step Through the Placeholder Firmware
- Inspect Registers
- Inspect Memory
- Stop at Reset Handler Instead
- Common Mistakes
- What This Proves
- What This Does Not Prove
- Next Steps
The previous article proved that the CMake project can build a firmware image and flash it to the STM32 Nucleo L433RC-P through OpenOCD.
That is an important milestone, but flashing alone gives very little visibility. The placeholder firmware has no LED toggle, UART log, or display output. It may be running correctly, but there is no external signal to confirm where the CPU is or what the program is doing.
This article adds the missing visibility: a VSCode debug session connected to the same firmware.elf file that the command-line build already produces.
What the Debugger Needs
An embedded debug session has several moving parts:
- The firmware ELF file contains the program, symbols, and debug information.
- ST-LINK provides the physical debug connection to the STM32 target.
- OpenOCD talks to ST-LINK and exposes a GDB server.
arm-none-eabi-gdbcontrols the target through that GDB server.- VSCode provides the debug user interface.
- Cortex-Debug connects VSCode to the embedded GDB/OpenOCD workflow.
VSCode is not replacing the build system. It is only becoming the front end for a debug session. CMake still builds the firmware, and OpenOCD still provides the target connection.
Starting Point
This article assumes the project from the previous lessons has these files:
stm32-cmake-minimal/
├── CMakeLists.txt
├── cmake/
│ └── arm-none-eabi-gcc.cmake
├── linker/
│ └── STM32L433RCTx_FLASH.ld
└── src/
├── main.c
└── startup_stm32l433xx.c
It also assumes the build produces:
build/
├── firmware.elf
├── firmware.bin
├── firmware.hex
└── firmware.map
The debugger will use build/firmware.elf, not the .bin or .hex file. The ELF file includes symbol information, so the debugger can connect machine addresses back to function names and source lines.
Keep Debug Symbols Enabled
Before configuring VSCode, make sure the firmware is built with debug information.
For this series, use a debug build:
cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake -DCMAKE_BUILD_TYPE=Debug
cmake --build build
If your CMakeLists.txt uses explicit compile options instead of CMake build types, the important flag is:
-g
Optimization also affects debugging. At high optimization levels, variables may appear optimized out, source lines may not step exactly as expected, and instructions may be reordered. Use Debug or a low-optimization debug profile while learning the workflow.
Add a VSCode Launch Configuration
Create a .vscode directory at the project root if it does not already exist:
mkdir .vscode
Then create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug STM32 with OpenOCD",
"type": "cortex-debug",
"request": "launch",
"servertype": "openocd",
"cwd": "${workspaceFolder}",
"executable": "${workspaceFolder}/build/firmware.elf",
"device": "STM32L433RC",
"configFiles": [
"interface/stlink.cfg",
"target/stm32l4x.cfg"
],
"runToEntryPoint": "main"
}
]
}
This tells Cortex-Debug to start OpenOCD, connect GDB to the target, load the firmware symbols, and run until main.
Point Cortex-Debug at the Arm Toolchain
VSCode may not inherit the same PATH that works in your terminal. If Cortex-Debug cannot find arm-none-eabi-gdb, tell it where the Arm GNU Toolchain is installed.
Create .vscode/settings.json:
{
"cortex-debug.armToolchainPath": "/Applications/ArmGNUToolchain/15.3.rel1/arm-none-eabi/bin"
}
This path should point to the directory that contains arm-none-eabi-gdb.
If your toolchain is installed somewhere else, use that installation's bin directory instead. For example, if this command prints a different path:
which arm-none-eabi-gdb
Use the containing directory in cortex-debug.armToolchainPath.
The launch.json file tells Cortex-Debug what firmware to debug and how to connect to the target. The settings.json file tells Cortex-Debug where to find the Arm debug tools.
What the Launch Fields Mean
The type field selects the VSCode debug extension. For Cortex-M projects using Cortex-Debug, this is cortex-debug.
The servertype field tells Cortex-Debug which debug server to use. Here it is openocd because the previous article already used OpenOCD for flashing through ST-LINK.
The cwd field sets the working directory for the debug session. Using ${workspaceFolder} keeps paths relative to the project root.
The executable field points to the ELF file. This path must match the build output. If the file does not exist, the debugger cannot start correctly.
The device field identifies the target MCU to the debug extension. STM32L433RC matches the STM32 Nucleo L433RC-P target used in this series.
The configFiles field passes OpenOCD script files. interface/stlink.cfg selects ST-LINK, and target/stm32l4x.cfg selects the STM32L4 target family.
The runToEntryPoint field makes the first debug stop easier to understand. Instead of halting at an arbitrary reset location, the debugger runs until it reaches main.
Build Before Debugging
VSCode can be configured to run a build task automatically before debugging, but keep the first version explicit.
From the project root, build the firmware:
cmake --build build
This avoids hiding build problems inside the debugger launch. If build/firmware.elf is stale or missing, fix the build before investigating VSCode.
Start the Debug Session
Connect the Nucleo board over USB.
In VSCode, open the Run and Debug view, select Debug STM32 with OpenOCD, and start debugging.
If the setup is correct, VSCode should:
- Start OpenOCD.
- Connect GDB to the target.
- Halt or reset the MCU.
- Load symbols from
build/firmware.elf. - Run to
mainand stop there.
At this point, the firmware is no longer a black box. You can inspect source location, registers, memory, variables, and the call stack.
Step Through the Placeholder Firmware
The current main.c is intentionally simple. A typical placeholder looks like this:
#include <stdint.h>
int main(void)
{
volatile uint32_t counter = 0;
while (1)
{
counter++;
}
}
Set a breakpoint inside the loop, continue execution, and watch the debugger stop on the increment.
Then use step-over a few times. The counter variable should change as the loop executes.
This does not prove any peripheral behavior yet. It proves something narrower and more important at this stage: the CPU is executing the firmware image that CMake built, and the debugger can observe it through ST-LINK.
Inspect Registers
Cortex-Debug can show CPU registers while the target is halted.
Useful registers to inspect early include:
pc, the program counter.sp, the stack pointer.lr, the link register.xpsr, the program status register.
When stopped in main, pc should point at code in flash. The stack pointer should point into RAM. Those two observations connect back to the linker script and startup code from earlier articles.
If the stack pointer is outside RAM, suspect the initial stack value in the vector table or the linker symbol used to define it. If the program counter is in an unexpected location, suspect reset flow, vector table placement, or a fault.
Inspect Memory
Debugging also lets you inspect memory directly.
For example, the STM32L433 flash starts at:
0x08000000
The first word at that address should be the initial stack pointer value from the vector table. The second word should be the reset handler address.
This is a useful check because it ties together several pieces that are easy to treat as separate:
- The linker script places
.isr_vectorat the start of flash. - The startup file defines the vector table.
- The reset sequence reads the initial stack pointer and reset handler from that table.
- The debugger can show the actual bytes programmed into target flash.
You do not need to inspect memory constantly, but knowing how to check the vector table is valuable when startup code fails before main.
Stop at Reset Handler Instead
Running directly to main is convenient, but it skips the startup path.
To debug startup behavior, change the launch configuration:
"runToEntryPoint": "Reset_Handler"
Now the debugger should stop in the reset handler instead of main.
From there, step through the startup code carefully. You should be able to observe the path that initializes runtime state and eventually calls main.
This is useful when debugging problems with .data copying, .bss clearing, vector table placement, or early faults.
Common Mistakes
If VSCode reports that build/firmware.elf does not exist, build the project first and confirm the executable path in launch.json matches the actual output filename.
If VSCode reports this error:
GDB executable "arm-none-eabi-gdb" was not found.
Please configure "cortex-debug.armToolchainPath" or "cortex-debug.gdbPath" correctly.
Then Cortex-Debug cannot find the Arm GDB executable. Confirm that arm-none-eabi-gdb exists in the Arm GNU Toolchain bin directory, then set cortex-debug.armToolchainPath in .vscode/settings.json.
If the debugger starts but cannot connect to the target, run the plain OpenOCD connection check from the previous article. Fix ST-LINK, USB, permissions, or OpenOCD configuration before changing firmware code.
If OpenOCD reports that ST-LINK is busy, close STM32CubeIDE, STM32CubeProgrammer, another VSCode debug session, or any terminal where OpenOCD is still running.
If source lines do not match the code you expect, rebuild the firmware. A stale ELF file can make the debugger appear confusing even when the target connection is fine.
If local variables are missing or shown as optimized out, reduce optimization and make sure debug symbols are enabled.
If the program never reaches main, stop at Reset_Handler and debug the startup path. A bad vector table, incorrect linker script placement, or early fault can prevent main from running.
What This Proves
A successful debug session proves that:
- The firmware ELF file contains usable symbols.
- VSCode can launch the embedded debug workflow.
- OpenOCD can connect to the STM32 target through ST-LINK.
- GDB can halt, run, step, and inspect the MCU.
- The startup path reaches
main.
That is more than a successful flash proves. Flashing confirms that bytes were written. Debugging confirms that you can observe execution and investigate target state.
What This Does Not Prove
This still does not prove that GPIO, clocks, timers, UART, or any other peripheral code is correct.
The project is still intentionally minimal. The value of the debugger is that future peripheral bring-up will be easier to inspect. When an LED does not blink or a UART does not print, you now have a way to stop the CPU, inspect registers, verify code paths, and separate build/debug problems from firmware logic problems.
Next Steps
The next article adds STM32CubeMX-generated drivers to the CMake project.
That will introduce vendor-generated startup, HAL, and device support files without making STM32CubeIDE the project owner. The debugger configuration from this article should continue to point at the same kind of output: a CMake-built ELF file that VSCode can load and inspect.