VS Code for C++, don't forget CMake and GDB Printer

Previously, when I debugged C++ in VS Code, the configuration basically stopped at launch.json, maybe with an extra line for GDB. Fill in the program, fill in the gdb, set the breakpoints. And then what? Then every time before debugging, I had to manually run cmake --build in the terminal. What was even more annoying was that after setting breakpoints on custom prices, contracts, or order types, the VS Code debug window often only showed a bunch of internal fields. The data was correct, but it wasn’t human-readable. The ridiculous part is that some tutorials I saw before stopped around launch.json. It wasn’t until recently, when I had an AI set up a new project for me, that it conveniently added preLaunchTask and gdb_printers.py, that I realized: debugging C++ in VS Code isn’t just about starting GDB. You can automatically trigger CMake compilation before debugging, and after hitting a breakpoint, you can even let GDB load a Python script to format business types into something readable for us. Honestly, this isn’t some black magic. But it perfectly filled two annoying gaps in daily C++ debugging: pre-launch build and variable display after a breakpoint.

Only Half Was Configured Before

The .vscode/launch.json in many tutorials is probably something like this:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug with GDB",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/build/debug/bin/vscode_cpp_debug_demo",
      "args": [],
      "stopAtEntry": false,
      "cwd": "${workspaceFolder}",
      "MIMode": "gdb",
      "miDebuggerPath": "/usr/bin/gdb",
      "setupCommands": [
        {
          "description": "Enable pretty-printing for gdb",
          "text": "-enable-pretty-printing",
          "ignoreFailures": true
        }
      ]
    }
  ]
}

This part cannot be wrong. The official C++ example in VS Code also uses a similar structure: type uses cppdbg, MIMode uses gdb, and pretty-printing is enabled in setupCommands. But one crucial step is missing. When you press F5, it launches the executable file pointed to by program. The problem is, was this file just compiled? If not, then you are debugging the old program from last time. I used to do this stupid thing often. I changed the code and set breakpoints, debugged for a while, only to realize later: Oh, I forgot to recompile.

preLaunchTask is the hook

VS Code Tasks are essentially about hooking external commands into the editor. Compiling, testing, and packaging—what used to be typed in the terminal can now be written into .vscode/tasks.json. Then, preLaunchTask in launch.json finds that task by its label. For a minimal CMake project, it could look like this:

.
├── .vscode/
│   ├── launch.json
│   └── tasks.json
├── CMakeLists.txt
├── src/
│   └── main.cpp
└── tools/
    └── gdb_printers.py

Here is the CMakeLists.txt first:

cmake_minimum_required(VERSION 3.16)

project(vscode_cpp_debug_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

add_executable(vscode_cpp_debug_demo
    src/main.cpp
)

And here is .vscode/tasks.json:

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "cmake: configure debug",
      "type": "process",
      "command": "cmake",
      "args": [
        "-S", "${workspaceFolder}",
        "-B", "${workspaceFolder}/build/debug",
        "-DCMAKE_BUILD_TYPE=Debug"
      ],
      "problemMatcher": []
    },
    {
      "label": "cmake: build debug",
      "type": "process",
      "command": "cmake",
      "args": [
        "--build", "${workspaceFolder}/build/debug",
        "--parallel"
      ],
      "dependsOn": ["cmake: configure debug"],
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "problemMatcher": ["$gcc"]
    }
  ]
}

Notice the cmake --build here. The official CMake build entry point is this format: cmake --build <dir>. It calls the underlying native build tool, such as Make, Ninja, or MSBuild. This means VS Code doesn’t need to know whether your project should use make or ninja. CMake handles that part.

Before F5, first compile

Then go back to .vscode/launch.json and add preLaunchTask:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "CMake Debug with GDB",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/build/debug/bin/vscode_cpp_debug_demo",
      "args": [],
      "stopAtEntry": false,
      "cwd": "${workspaceFolder}",
      "environment": [],
      "externalConsole": false,
      "MIMode": "gdb",
      "miDebuggerPath": "/usr/bin/gdb",
      "setupCommands": [
        {
          "description": "Enable pretty-printing for gdb",
          "text": "-enable-pretty-printing",
          "ignoreFailures": true
        }
      ],
      "preLaunchTask": "cmake: build debug"
    }
  ]
}

This line is the key:

"preLaunchTask": "cmake: build debug"

The flow when pressing F5 becomes:

launch.json
  -> preLaunchTask
    -> tasks.json: cmake: build debug
      -> dependsOn: cmake: configure debug
        -> cmake -S ... -B ...
      -> cmake --build ...
  -> gdb starts the latest program

If you are on Windows + MinGW, the program probably needs to be changed to a path with .exe, and miDebuggerPath should also point to your own gdb.exe. If it’s a multi-configuration generator like Visual Studio Generator, you usually need to add --config Debug to the build arguments. But the main flow remains the same. Before debugging, let VS Code run the build task first; don’t rely on memory alone.

Another Pain Point: Incomprehensible Custom Types

Just connecting the compilation link is not enough. If you wrap a business type slightly in your C++ code, the variable window on the left side of VS Code starts looking ugly. Take this example. src/main.cpp:

#include <cstdint>
#include <cstdio>
#include <string_view>
#include <vector>

namespace market {

struct Price {
    std::int64_t raw{};

    static Price from_double(double value) {
        return Price{static_cast<std::int64_t>(value * 10000)};
    }

    double to_double() const {
        return static_cast<double>(raw) / 10000.0;
    }
};

struct Instrument {
    char symbol[16]{};
    Price last;
};

Instrument make_instrument(std::string_view symbol, double price) {
    Instrument instrument;
    std::snprintf(instrument.symbol, sizeof(instrument.symbol), "%s", symbol.data());
    instrument.last = Price::from_double(price);
    return instrument;
}

} // namespace market

int main() {
    std::vector<market::Instrument> watchlist{
        market::make_instrument("IF2406", 3578.6),
        market::make_instrument("IH2406", 2468.2),
    };

    market::Price limit = market::Price::from_double(3600.5);

    // Set a breakpoint here to observe watchlist and limit.
    return watchlist.empty() || limit.raw == 0;
}

In the business logic, market::Price represents a price. But what the debugger might default to showing is only:

limit = {raw = 36005000}

Yes, the data is correct. But in my head, I still have to manually convert 36005000 back to 3600.5000. If a project has a bunch of Price, Quantity, OrderId, Instrument, AlgoState, the debug window quickly turns into a structure graveyard. This is when you need GDB pretty-printers. The official GDB manual states it very clearly: It provides a mechanism that can use Python code to pretty-print values, and this mechanism works for both MI and command line. VS Code’s C++ debugging uses exactly this GDB/MI path.

Writing a Python Presentation Layer for Business Types

Create tools/gdb_printers.py:

import gdb
import gdb.printing


class PricePrinter:
    def __init__(self, val):
        self.val = val

    def to_string(self):
        raw = int(self.val["raw"])
        return f"{raw / 10000.0:.4f}"


class InstrumentPrinter:
    def __init__(self, val):
        self.val = val

    def to_string(self):
        symbol = self.val["symbol"].string()
        price_raw = int(self.val["last"]["raw"])
        price = price_raw / 10000.0
        return f"{symbol} last={price:.4f}"


def build_pretty_printer():
    printer = gdb.printing.RegexpCollectionPrettyPrinter("market")
    printer.add_printer("Price", "^market::Price$", PricePrinter)
    printer.add_printer("Instrument", "^market::Instrument$", InstrumentPrinter)
    return printer


gdb.printing.register_pretty_printer(
    gdb.current_objfile(),
    build_pretty_printer(),
    replace=True,
)

Then source it in launch.json’s setupCommands:

"setupCommands": [
  {
    "description": "Enable pretty-printing for gdb",
    "text": "-enable-pretty-printing",
    "ignoreFailures": true
  },
  {
    "description": "Load project pretty-printers",
    "text": "-interpreter-exec console \"source ${workspaceFolder}/tools/gdb_printers.py\"",
    "ignoreFailures": false
  }
]

Press F5 again. When you look at the debug window this time, the display for Price should be close to:

limit = 3600.5000

And Instrument will change from a blob of arrays and structs to something closer to the business logic:

IF2406 last=3578.6000

I deliberately say “close to” here because the pretty-printer needs to match the compiler, type names, and actual field layout. If your business types contain nested std::vector, std::array, or smart pointers, you might need to write more code in Python to access those fields. Don’t expect one script to cover everything. But the idea is solid. Separate what “you want to see when debugging” into its own layer of Python presentation logic.

Don’t treat launch.json as everything

Looking at this set of configurations now, I think there are at least three layers. .vscode/launch.json is responsible for “how to start the debugger.” .vscode/tasks.json is responsible for “building the project first before starting the debugger.” tools/gdb_printers.py is responsible for “displaying variables in a human-readable way after a breakpoint.” Before, I only configured the first layer. It worked, but it was very rudimentary. Smooth C++ debugging shouldn’t be like: “I have to compile it in the terminal first, then come back and press F5, and then manually calculate the meaning of fields in the variables window.” The more comfortable workflow should be: Press F5, VS Code automatically runs CMake, GDB automatically loads the project’s Python printer, and when a breakpoint is hit, the debug window directly speaks business language. What was most valuable about AI helping me set up this project wasn’t how much C++ code it generated. It was that it bridged these old tools together. It’s not that I didn’t know CMake before, or that I didn’t know GDB. I just missed configuring those intermediate hooks.

References

Writing Notes

Original Prompt

Prompt: $blog-writer vscode for C++ development, previous tutorials only showed simple configuration of gdb debugging information in launch.json, and didn’t mention configuring preLaunchTask. Being able to automatically trigger cmake compilation is a great feature when setting up new projects with AI; if the code has many custom data types that cannot be directly displayed in the VS Code debug window, one can introduce a Python script for preprocessing so that they can all be displayed. Regarding the above content, actual code examples are needed for everything.

Writing Idea Summary

  • The main thread should focus on the idea that “previously, only launch.json was configured, but it missed the two layers of glue: pre-build and runtime display during debugging.”
  • Keep the trigger point provided by the user: Discovering that when setting up a new AI project, preLaunchTask can automatically trigger CMake compilation.
  • Organize code examples around a minimal CMake project, including CMakeLists.txt, tasks.json, launch.json, custom C++ types, and GDB Python pretty-printer.
  • For factual verification, prioritize official documentation from VS Code, CMake, and GDB; the main text should not translate entire documents but only retain facts relevant to this debugging pipeline.
  • Provide a warning regarding the boundaries of the pretty-printer: Type names, standard library implementations, and field layouts can affect the Python script, so users must adapt it for their specific types in the project.
A financial IT programmer's tinkering and daily life musings
Built with Hugo
Theme Stack designed by Jimmy