While developing cross-platform software, I tend to frequently bounce across Linux, Windows, and OSX. They each have their pros and cons, but for daily development I tend to favor Linux for myriad reasons. While I’m generally not very picky about what text editor and/or IDE I use (vim, Sublime Text, vscode, etc), what I do often miss about the full Visual Studio for Windows are the convenient, integrated debugging tools. While Linux has the mighty gdb, most of the frontends I’ve worked with (such as cgdb), haven’t been quite as productive for me. More recently, I’ve largely settled on Visual Studio Code for most of my work. It strikes a nice balance as a capable text editor and lightweight frontend for stepping through C/C++ code. And I’ve been using it a lot for general web development as well, so win-win.

The main point of this post is to quickly explain how to set up Visual Studio code for debugging C/C++ code, both for anyone that hasn’t done it before, and as a future reminder to myself.

Debugging C/C++ in Visual Studio Code

One of my general goals is to remain as independent from the high level tools as possible, primarily to simplify cross-platform builds. Thankfully, vscode doesn’t require elaborate project files– it mostly works at the directory level for a “project.” There are some fancy extensions for managing CMake and other build tools, but I prefer to do everything through simple scripts that vscode invokes as needed. The only required extension is Microsoft’s vscode-cpptools.

My workflow generally involves two generic scripts: config.sh – which just invokes CMake (debug) configuration. I run this when my project configuration has changed. And build.sh – which issues the build command for the CMake project. These are arbitrary, and independent of vscode. You can configure your project however you want, ideally with a simple way to build it from the terminal.

The basic goal is to have vscode launch the build script any time you launch the debugger (F5 by default), build any changes, execute the binary, and attach for debugging. To do that, there are basically two things that need to be configured: launch.json and tasks.json, both of which should end up in the .vscode directory under the main project.

If you go to the debug tab on the left (then configurations), or just hit F5, vscode should offer to create the launch.json for you. Here’s an example (depending on what tool set you choose):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "g++-5 build and debug",
      "type": "cppdbg",
      "request": "launch",
      "program": "${workspaceFolder}/build/output",
      "args": [],
      "stopAtEntry": false,
      "cwd": "${workspaceFolder}/build/",
      "environment": [],
      "externalConsole": false,
      "MIMode": "gdb",
      "setupCommands": [
        {
          "description": "Enable pretty-printing for gdb",
          "text": "-enable-pretty-printing",
          "ignoreFailures": true
        }
      ],
      "preLaunchTask": "build",
      "miDebuggerPath": "/usr/bin/gdb"
    }
  ]
}

Most of it should work out of the box. The changes I made to make it more generic (so I can quickly create new projects with the same configuration files) are the following:

  • Line 8: This specifies where my build script dumps the final debug binary to execute.
  • Line 11: This is the working directory for the launch binary.
  • Line 22: This is a task to run before each launch, which is described in the next step…

That last point, which invokes “build” is referring to the tasks.json file. Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "tasks": [
    {
      "type": "shell",
      "label": "build",
      "command": "sh ${workspaceFolder}/build.sh",
      "args": [],
      "options": {
        "cwd": "${workspaceFolder}"
      }
    }
  ],
  "version": "2.0.0"
}

As you can see, the label “build” on line 5 matches the task in the launch.json, and it simply invokes the build script to compile any changes since the last run.

That’s pretty much all there is to it. There are likely better ways to do more advanced configurations– the documentation includes a lot of detail. But I’ve found this kind of basic approach is great for getting up and running quickly. It requires very little setup when I want to fire up new projects for prototyping, etc, and doesn’t get in the way of any of my normal build processes.