Why I Started Using This Tool

After the last episode, one command got my program onto the DE10-Nano and paused under gdbserver. But then I still had to start GDB myself and type the same five commands to connect, load symbols, and set a breakpoint. I was also debugging from a black terminal window, when VS Code had a perfectly good debugger UI sitting right there. I wanted breakpoints I could click, variables I could hover over, and one key to get there. That meant launch.json.

What It Does

.vscode/launch.json defines one debug configuration, "Debug on DE10 (F5)". Here's the whole file:

{
  // F5 = build -> deploy -> gdbserver (tasks.json), then attach cross-GDB and stop at main().
  // All board/host values come from .vscode/settings.json (${config:xxx}).
  // The Linaro GDB 7.10 quirks below are deliberate. Don't simplify these away.
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug on DE10 (F5)",
      "type": "cppdbg",
      "request": "launch",
      "program": "${config:localDir}/${config:bin}",
      "cwd": "${config:localDir}",
      "preLaunchTask": "gdbserver",

      "MIMode": "gdb",
      "miDebuggerPath": "${config:toolchain}/bin/arm-linux-gnueabihf-gdb.exe",
      // Required: without it, stepping fails with
      // "Cannot find bounds of current function".
      "targetArchitecture": "arm",

      // Send target remote ourselves instead of using miDebuggerServerAddress.
      // Forward slashes only: GDB 7.10 mangles Windows backslash paths.
      "customLaunchSetupCommands": [
        { "text": "-file-exec-and-symbols ${config:localDir}/${config:bin}", "description": "Load binary and symbols" },
        { "text": "target remote ${config:host}:${config:gdbPort}",          "description": "Connect to gdbserver on the board" },
        { "text": "-break-insert main",                                      "description": "Break at main()" }
      ],
      // gdbserver has the program paused before main(); run until the breakpoint.
      "launchCompleteCommand": "exec-continue",

      "setupCommands": [
        { "text": "-enable-pretty-printing", "ignoreFailures": true }
      ],
      "stopAtEntry": false,
      "externalConsole": false
    }
  ]
}

What happens when you press F5

  1. preLaunchTask: "gdbserver" runs the gdbserver task from last episode. Because of its dependsOn chain, that means build, then deploy, then start gdbserver on the board. If the build fails, the chain stops and the debugger never starts, so you can't end up debugging an old binary.
  2. VS Code waits until the task's background matcher sees Listening on port. That's the signal that the board is ready and it's safe to connect.
  3. The C/C++ extension ("type": "cppdbg") starts the Linaro ARM GDB on my PC, from miDebuggerPath. This has to be the cross GDB from the same toolchain that built the binary, not a regular Windows GDB, because it needs to understand ARM code.
  4. Instead of the extension's default launch steps, it sends my own customLaunchSetupCommands, in order:
    • -file-exec-and-symbols loads the binary and its debug symbols, so GDB knows which line of C each instruction came from.
    • target remote connects to gdbserver using the board's IP address and port from settings.json.
    • -break-insert main sets a breakpoint on main.
  5. launchCompleteCommand: "exec-continue". gdbserver started the program paused, before it reached main. This lets it run until it hits the breakpoint and stops at main().

From there it's normal VS Code debugging: click in the gutter to add breakpoints, F10 and F11 to step, hover over variables, and watch the call stack, all while the code runs on the actual board.

The old-toolchain quirks

The harder part was the old toolchain. Linaro GDB 7.10 is about ten years old, and the C/C++ extension expects something newer. Three things tripped me up:

  • "targetArchitecture": "arm" is required. Without it, stepping fails with Cannot find bounds of current function. Setting it explicitly tells the extension it's debugging ARM code instead of leaving it to guess.
  • Send target remote yourself. The extension has a built-in miDebuggerServerAddress setting that's supposed to make the connection for you. With this GDB, it handled the connection better when I sent target remote in customLaunchSetupCommands myself, which also means I control exactly what runs and in what order.
  • Forward slashes everywhere. This GDB mangles Windows backslash paths like C:\projects\my_embedded_app\main. C:/projects/my_embedded_app/main works, so every path in settings.json and launch.json uses /.

Where did my printf go?

Your program's output doesn't show up in the Debug Console. The program runs on the board, under gdbserver, so its printf output goes to the gdbserver task's terminal. Keep that terminal visible while you debug.

Final Verdict

This is the payoff for the whole setup series. settings.json holds the facts, tasks.json does the build and deploy, and launch.json puts a real debugger on the board behind F5. Getting it working with a ten-year-old GDB took more trial and error than I'd like to admit, which is exactly why the comments in my launch.json say "don't simplify these away". If you're on a newer toolchain, some of the quirks may not apply to you, but the structure is the same. And keep an eye on the gdbserver terminal, because that's where your printf output goes.

If you're doing embedded Linux from Windows, I'd call this the minimum setup before writing real application code. Stepping through code on the actual hardware beats guessing from printf output every time.