This tutorial describes how to set up C/C++ projects in Visual Studio Code. We will use a linux operating system (Ubuntu 24.04) and thus also the linux terminal for executing commands.
We will use the apt package manager to install the necessary tools. If a mistake was made, you can run
sudo apt-get purge packagename
sudo apt-get autoremove
Let's start by creating a folder project01
with a very simple main.cpp
file:
#include <iostream>
int main() {
    std::cout << "Hello World!";
return 0;
}
How do we execute this code? Well, it's complicated. The process of converting a C file to an executable involves the following steps:
-
Preprocessing: The preprocessor handles directives like
#include
and#define
, expanding macros and including headers. -
Compilation: The compiler translates the preprocessed C code into assembly code specific to the target architecture.
-
Assembly: The assembler converts the assembly code into machine code, producing an object file (
.o
). -
Linking: The linker combines object files and libraries to resolve symbols and produce the final executable.
We refer to this whole process as building. We will use the CMake build system with the g++ compiler (gcc is for C only, but g++ works for both C and C++ files). Installing CMake will automatically install gcc, but not g++.
sudo apt-get install cmake g++
You can check the versions using
cmake --version
gcc --version
g++ --version
CMake uses a CMakeLists.txt file to build.
cmake_minimum_required(VERSION 3.28) # Check CMake version
project(project01)
# Check GCC version
find_program(GCC_VERSION gcc)
if(NOT GCC_VERSION)
message(FATAL_ERROR "GCC not found.")
endif()
execute_process(COMMAND ${GCC_VERSION} --version OUTPUT_VARIABLE GCC_VERSION_OUTPUT)
string(REGEX MATCH "([0-9]+)\\.([0-9]+)" GCC_VERSION_MATCH ${GCC_VERSION_OUTPUT})
if(${GCC_VERSION_MATCH} VERSION_LESS "13.2")
message(FATAL_ERROR "GCC version 13.2 or higher is required.")
endif()
# Check G++ version
find_program(GPP_VERSION g++)
if(NOT GPP_VERSION)
message(FATAL_ERROR "G++ not found.")
endif()
execute_process(COMMAND ${GPP_VERSION} --version OUTPUT_VARIABLE GPP_VERSION_OUTPUT)
string(REGEX MATCH "([0-9]+)\\.([0-9]+)" GPP_VERSION_MATCH ${GPP_VERSION_OUTPUT})
if(${GPP_VERSION_MATCH} VERSION_LESS "13.2")
message(FATAL_ERROR "G++ version 13.2 or higher is required.")
endif()
add_executable(project01 main.cpp)
Finally, we specify a folder where we want to output the build and run CMake:
mkdir build
cd build
cmake ..
But oh no, what are all those files and folders in our build? Shouldn't it just be a single executable? Yes, you're right. CMake actually doesn't build anything, but is just a widely accepted way to generate build files. Thanks to CMake, we don't need to worry about platform-specific details. Just provide the source code and the CMakeLists.txt file and you are all set. Interested readers can find more information here.
So how do we get an executable? Well, we just run the make command in the build folder:
make
If make is not installed yet, you can do so by running
sudo apt-get install make
And then finally, we run the executable
./project01
We will adapt following structure:
project01
├── bin
├── build
├── include
│ ├── .h
├── libs
│ ├── external_library/
├── out
├── src
│ ├── .c
│ ├── .cpp
├── CMakeLists.txt
└── README.md
This means, of course, that we need to change our CMakeLists.txt file, but we will do that later. For now, we will focus on external libraries. There are a few types: header-only, static and shared. Every type has kind of the same structure, in generally an include
folder and a lib
folder. The include folder contains the header files. If the type is header-only, the file will be located here. If the type is static or shared, the lib folder will also contain some .a
or .so
files.
As example, we look at the mysql library.
-
Download MySQL Client Library
Go to the official website and download the development headers package. In my case, i chose:
-
Version: 8.4.2 LTS
-
OS: Ubuntu Linux
-
OS version: 24.04
-
-
Extract the Archive
Extract the .deb file:
dpkg-deb -x libmysqlclient-dev_8.4.2-1ubuntu22.04_amd64.deb .
-
Select the necessary files
We need only the include and lib folder. Create
libs/mysql
and move them there. -
*Include the library
To start using the library, you have to include it:
#include <mysql/mysql.h>
note: we use #include "something.h"
when we use our project's own header files from the include folder.
Now we can update the CMakeLists.txt file so that it automatically load the external libraries, if they have the correct structure!:
# Minimum CMake version
cmake_minimum_required(VERSION 3.28)
# Project name and version
project(project01 VERSION 1.0)
# C++ and C standards
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED True)
# Minimum CGG version
find_program(GCC_VERSION gcc)
if(NOT GCC_VERSION)
message(FATAL_ERROR "GCC not found.")
endif()
execute_process(COMMAND ${GCC_VERSION} --version OUTPUT_VARIABLE GCC_VERSION_OUTPUT)
string(REGEX MATCH "([0-9]+)\\.([0-9]+)" GCC_VERSION_MATCH ${GCC_VERSION_OUTPUT})
if(${GCC_VERSION_MATCH} VERSION_LESS "13.2")
message(FATAL_ERROR "GCC version 13.2 or higher is required.")
endif()
# Minimum G++ version
find_program(GPP_VERSION g++)
if(NOT GPP_VERSION)
message(FATAL_ERROR "G++ not found.")
endif()
execute_process(COMMAND ${GPP_VERSION} --version OUTPUT_VARIABLE GPP_VERSION_OUTPUT)
string(REGEX MATCH "([0-9]+)\\.([0-9]+)" GPP_VERSION_MATCH ${GPP_VERSION_OUTPUT})
if(${GPP_VERSION_MATCH} VERSION_LESS "13.2")
message(FATAL_ERROR "G++ version 13.2 or higher is required.")
endif()
# Header files
include_directories(include)
file(GLOB_RECURSE INCLUDES "include/*")
# Source files
include_directories(src)
file(GLOB_RECURSE SOURCES "src/*.cpp" "src/*.c")
add_executable("${PROJECT_NAME}" ${SOURCES})
#= External Libraries =======================================================================#
set(LIBS_DIR "${CMAKE_SOURCE_DIR}/libs")
function(add_library_from_dir libname)
set(LIB_DIR "${LIBS_DIR}/${libname}")
if (EXISTS "${LIB_DIR}")
message(STATUS "Found library: ${libname}")
include_directories("${LIB_DIR}/include")
file(GLOB_RECURSE LIB_SOURCES "${LIB_DIR}/lib/*.a")
if (LIB_SOURCES)
target_link_libraries("${PROJECT_NAME}" ${LIB_SOURCES})
endif()
else()
message(WARNING "Library directory ${LIB_DIR} not found.")
endif()
endfunction()
file(GLOB directories LIST_DIRECTORIES true "${CMAKE_SOURCE_DIR}/libs/*")
foreach(dir ${directories})
if(IS_DIRECTORY ${dir})
get_filename_component(dir_name ${dir} NAME)
add_library_from_dir("${dir_name}")
endif()
endforeach()
#============================================================================================#
note: we have to do use mv project01 ../bin/
to make sure the executable ends up in the bin folder.
The C/C++ extension (ms-vscode.cpptools) adds:
-
IntelliSense: Provides smart code completions based on variable types, function definitions, and imported modules.
-
Code Navigation: Allows you to navigate through your codebase with features like Go to Definition, Find References, and Peek Definition.
-
Error and Warning Squiggles: Highlights errors and warnings in the code, helping to catch issues early.
-
Code Formatting: Automatically formats your C/C++ code based on the configuration you set.
-
Debugging: Integrates with the debugger to allow you to set breakpoints, step through code, and inspect variables.
-
Configuration Support: Helps manage configuration files for compiling and debugging, such as tasks.json and launch.json.
-
...
The CMake extension (twxs.cmake) adds equivalent tools for CMakeLists files.
To make our life easier, we use tasks in VSCode. Create a folder .vscode
with a tasks.json
file in it.
{
"version": "2.0.0",
"tasks": [
// Build
{
"label": "build (debug)",
"type": "shell",
"command": "cd ${workspaceFolder}/build && cmake -DCMAKE_BUILD_TYPE=Debug .. && make && mv project01 ../bin/ && cd .. && rm -rf build/*",
"problemMatcher": ["$gcc"],
},
{
"label": "build (release)",
"type": "shell",
"command": "cd ${workspaceFolder}/build && cmake -DCMAKE_BUILD_TYPE=Release .. && make && mv project01 ../bin/ && cd .. && rm -rf build/*",
"problemMatcher": ["$gcc"],
},
// Run
{
"label": "run",
"dependsOn": ["build (release)"],
"dependsOrder": "sequence",
"type": "shell",
"command": "./bin/project01",
"problemMatcher": []
},
]
}
note: We delete the contents of build after getting our executable to prevent issues with caching when we use gdb for example.
note: More information about the different build types will be specified later. For now, just assume we build for release only.
This section was added later and is an extra part to the complete tutorial. It was added to provide functionality to the VS Code interactive 'Run - Start Debugging' (and 'Run - Run Without Debugging').
Add to the .vscode folder following file:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug (Debug Build)",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/bin/project01",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"miDebuggerPath": "/usr/bin/gdb",
"preLaunchTask": "build (debug)",
"miDebuggerArgs": "",
"logging": {
"engineLogging": false,
"programOutput": true,
"trace": true
},
"serverLaunchTimeout": 10000,
"filterStderr": true,
"filterStdout": false
},
{
"name": "Run (Release Build)",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/bin/project01",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"externalConsole": false,
"preLaunchTask": "build (release)"
}
]
}
As an example, we will rewrite our main.cpp file to have a bug!
#include <mysql/mysql.h>
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
int d = 2;
scanf("%d", d);
printf("You gave me: %d", d);
return 0;
}
Normally, when we compile our source code, we don't include any debug data. We can indicate that we actually do want this by including adding some specific flags in CMakeLists.txt. We will do this for 2 different build types: Release and Debug.
# Build Type
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -g")
elseif(CMAKE_BUILD_TYPE STREQUAL "Release")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3")
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -O3")
endif()
We leave the understanding of gbd and it's commands to the reader. For completeness sake, the reader can try to find the bug using these commands. (hint: gcc already gives us basically all the bug info when building)
-
gdb ./bin/project01
- Starts gdb.
-
lay next
- Puts the C code and the Assembly code next to each other for a nice visual overview. Other combinations are possible as well. It might be possible that you have to press enter to load the correct view.
-
break main
- Puts a breakpoint at the tag main. There is only one function so we know it has to be there.
-
run
- Starts running the program and stops at a breakpoint. It might be possible that you have to select whether or not you want to use debuginfod. You can just press yes. This is not necessary for rr, as it is a replay.
-
next
-
Go to the next C code line without going into function calls (step over).
-
The nexti variant does the same for an Assembly line.
-
-
step
- Same as next but if it's possible, we step into a function call.
-
ref
- Refresh GDB in case it has some problems. Don't worry, you will stay at the same line and everything will stay intact.
-
x/i $pc
- Examine the instruction at program counter. This is especially usefull when something went wrong and we want more details about what Assembly line when we stepped over a call.
-
info registers
- See the data in the registers. Especially usefull in combination with x/i $pc.
-
continue (c)
- Go on until error or next breakpoint.
Now, gdb is all nice, but what would be really usefull is reverse debugging. Basically going back some lines. rr is a tool that records an execution (+ the failure) so as to achieve this goal. You can install it here.
-
rr record ./bin/project01
-
while rr record ./bin/project01 ; do ; done
-
rr replay
After rr replay, we just use it normally like gdb, except we can now do things like:
-
reverse-next
-
reverse-step
For convenience sake (and not having to remember all the commands), we will create these VSCode tasks:
{
"label": "debug",
"type": "shell",
"command": "rr record ./bin/project01 && rr replay -- -q",
"problemMatcher": []
},
{
"label": "build + debug",
"dependsOn": ["build", "debug"],
"dependsOrder": "sequence",
"problemMatcher": []
},
note: To prevent the popup for pagination and debuginfod, you can create a file ~/.gdbinit
with following contents:
set pagination off
set debuginfod enabled off
A problem that might remain is that the copyright banner of gdb is displayed before actually loading .gdbinit, which can cause a popup for pagination. That is also why we use rr replay -- -q
where -q is a gdb specific option to be quiet and not show the banner.
To detect memory leaks we use Valgrind.
sudo apt install valgrind
Let's introduce a memory leak in our program:
#include <mysql/mysql.h>
#include <iostream>
int main() {
std::cout << "Hello World!" << std::endl;
int d = 2;
scanf("%d", d);
printf("You gave me: %d", d);
int *ptr = (int *) malloc(sizeof(int));
*ptr = 10;
printf("Value of ptr: %d\n", *ptr);
return 0;
}
After building, we can use the command
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes --log-file=out/valgrind-out.txt ./bin/project01
The option verbose might give a lot of bloat, so can be omitted if necessary. Here, we write the output to a valgrind-out.txt
but ommitting this option will just print to the terminal.
VSCode task:
// Detect leaks
{
"label": "detect leaks",
"dependsOn": ["build (debug)"],
"dependsOrder": "sequence",
"type": "shell",
"command": "valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes --log-file=valgrind-out.txt ./bin/project01",
"problemMatcher": []
},
For profiling memory, we will again use valgrind.
valgrind --tool=massif --massif-out-file=out/massif.out ./bin/project01
To interpret this output data, we'll look at two tools:
-
ms_print for a textual view
ms_print out/massif.out > out/massif_report.txt
-
massif-visualizer for a graphical view
sudo apt install massif-visualizer massif-visualizer out/massif.out
VSCode task:
// Profile memory
{
"label": "profile memory",
"dependsOn": ["build (debug)"],
"dependsOrder": "sequence",
"type": "shell",
"command": "valgrind --tool=massif --massif-out-file=out/massif.out ./bin/project01 && massif-visualizer out/massif.out",
"problemMatcher": []
},
For profiling execution time, we will use perf.
sudo apt-get install linux-tools-common linux-tools-generic linux-tools-$(uname -r)
sudo mkdir -p /usr/share/doc/perf-tip
sudo wget https://raw.githubusercontent.com/torvalds/linux/master/tools/perf/Documentation/tips.txt -O /usr/share/doc/perf-tip/tips.txt
Again, we will have two different ways:
-
perf report for a textual view
perf record -o out/perf.data ./bin/project01 perf report -i out/perf.data > out/perf_report.txt
-
FlameGraph for a graphical view
git clone https://github.com/brendangregg/FlameGraph out/FlameGraph perf record -o out/perf.data -g ./bin/project01 perf script -i out/perf.data | ./out/FlameGraph/stackcollapse-perf.pl > out/out.perf-folded ./out/FlameGraph/flamegraph.pl out/out.perf-folded > out/perf.svg brave-browser out/perf.svg # or chrome, firefox, etc.
VSCode task:
// Profile execution time
{
"label": "profile time",
"dependsOn": ["build (debug)"],
"dependsOrder": "sequence",
"type": "shell",
"command": "perf record -o out/perf.data -g ./bin/project01 && perf script -i out/perf.data | ./out/FlameGraph/stackcollapse-perf.pl > out/out.perf-folded && ./out/FlameGraph/flamegraph.pl out/out.perf-folded > out/perf.svg && brave-browser out/perf.svg",
"problemMatcher": []
},