JORDAN CAMPBELL
R&D SOFTWARE ENGINEER
cybernaut/0.1.4

CMake is a great tool for building C++ projects. Once you learn the syntax it's easy to work with, it's used extensively in production grade projects around the world, and it's reliable and moderately easy to maintain.

Unfortunately CMake can be a little bit esoteric at first. This tutorial provides what I consider to be the basic setup that will actually satisfy most projects.

Our project structure is quite basic:

.
├── bin
├── static
├── readme.md
└── src
    ├── CMakeLists.txt
    ├── main.cpp
    └── lib
        ├── CMakeLists.txt
        ├── counter.cpp
        └── counter.h

We have a root directory (the dot at the top) which contains a readme.md file, a src directory, and then bin and static directories. All of the commands we execute are performed in the root directory. The bin and static directories contain the build output and aren't part of the main project structure -- everything we care about is inside src.

There are three main components to this tutorial:

  1. Build a single source file into an executable.
  2. Link a library into an executable.
  3. Create a static library for external use.

Build a single source file into an executable

Perhaps the simplest thing you can do in C++ is build a single source file into an executable that you can then run locally and distribute externally. In this simple example all we're going to do is print something to the screen, so that you can see the mechanics of what is going on, rather than focussing on any implementation details.

The following code should go in main.cpp, which is in the src directory. It's considered best practice to keep all the source and header files inside a directory (which of course can contain other directories) and then the build outputs go inside a separate directory at the same level as src. This is why in the project tree above we can see that the src, bin, and static directories are all on the same level.

// ./src/main.cpp

#include 

int main(int argc, char **argv)
{
  std::cout << "Hello, universe!" << std::endl;
  return EXIT_SUCCESS; 
}

We have a single main function that prints Hello, universe! to the console and then exits with no error, easy! To build this into something we can run we need a _CMakeLists.txt_ file. This file should always have the same name, and there should be one in every directory that you consider to be "something you want to build" . At this stage we are only building a single file, but the CMakeLists.txt in the root directory could include any number of source files, any number of libraries that are part of the same project, and also external dependencies. In larger projects it's this root CMakeLists.txt file that will contain the build setup description for your main executable. Our basic CMakeLists.txt contains only the following lines:

# ./CMakeLists.txt 
cmake_minimum_required(VERSION 3.28)
project(basic_executable)
set (CMAKE_CXX_STANDARD 20)
add_executable ( basic_executable main.cpp )

The cmake_minimum_required line is required at the start of every CMakeLists.txt file. The version number listed should be as recent as possible, without compromising your ability to build and distribute your project as necessary. In this case I am simply using the version of cmake that is available on my local machine, which I found by executing cmake --version on the command line. For me this returns cmake version 3.28.1. Next we have the project specification. This doesn't do a lot other than set an internal variable that we don't need to worry about (PROJECT_NAME, and some project specific paths), _however_ we must always declare the project in our top-level CMakeLists.txt, otherwise cmake will generate a project name for us. CMakeLists.txt files in sub directories do not need to set the project command. The project command can also be used to set some other variables specific to the project, for instance the language used and a description of the project, however these are optional and have been skipped here. Next we call set to specify the version of the C++ standard that we wish to use. This is an optional command, but is helpful because otherwise the compiler will use its default language variant. For modern clang (>16.0) this is fine as it will default to c++17, however clang version 16 is very new and not prevalent on most machines.

We then come to the most important line of our CMakeLists.txt file: add_executable(). As is perhaps expected this line takes the list of files defined (in this case just main.cpp) and compiles them into an executable called basic_executable. Note that the name we give to our executable must be unique within the project. This means that we can't have an executable called basic_executable and then a library also called basic_executable. On MacOS this command will generate a file called basic_executable, but on other platforms such as Windows it may add .exe to the filename. This depends on the convention of the native platform we're building on.

Actually building a project with CMake

To actually build anything we just need to execute two commands:

$ cmake -S src -B bin
$ cmake --build bin

Remember to do these in the root directory. Note that the bin directory doesn't need to exist before executing these, and if you run into any problems or want to start the build from scratch you can simply delete the bin directory (i.e. with rm -rf bin). It's also typical to exclude this directory from version control, so you can add "/bin" to your .gitignore file.

The first line cmake -S src -B bin _configures_ the project and only needs to be run once, while the second line cmake --build bin generates the actual build. The final executable will be bin/basic_executable, which you can run with ./bin/basic_executable. Note that these directories can have any names you like, however I stick with the common src / bin idiom (it's probably more common to use build instead of bin, however I like to use bin for executables as we may have other build targets in separate output directories later, so bin directly signifies that this is contains a *_binary*).

We should see the following output:

$ ./bin/basic_executable
> Hello, universe!

Link a library into an executable.

That's fun, but what if we want to move some of our code into a separate library and build it independently? This might seem trivial for a small example like this, but the need can actually arise pretty quickly. We might have some different code for iOS vs Android for instance, or perhaps we want to include some specific code that's used for visualisation during debug that we don't want to compile into our final release build.

In this simple example we're going to build a library that provides two functions: increment() and decrement(). Since we've only got one library in this example we're going to put all the source code inside the lib directory, which itself is inside the src directory. Since this is a separate 'module' that we want to build we're also going to put a CMakeLists.txt file inside the lib directory, but this one will be a bit different from the file we used to compile our executable originally.

To start, let's write the library code. Our header file is:

// ./src/lib/counter.h

#pragma once

struct Counter
{
  int count = 0;
  void increment(const int amount);
  void decrement(const int amount);
};
and our source file is:
// ./src/lib/counter.cpp

#include "counter.h"

void Counter::increment(const int amount)
{
  count += amount;
}

void Counter::decrement(const int amount)
{
  count -= amount;
}

Using this struct we can define a counter object that either increments or decrements its internal value by the specified amount. Let's update main.cpp to do this:

// ./src/main.cpp

#include 
#include "counter.h"

int main(int argc, char **argv)
{
  std::cout << "Linking _count_ library into executable" << std::endl; 
  
  Counter counter; 
  std::cout << "Count: " << counter.count << std::endl;
  counter.increment(20);
  std::cout << "Count: " << counter.count << std::endl;
  counter.decrement(9);
  std::cout << "Count: " << counter.count << std::endl;
  return EXIT_SUCCESS;
} 

Now, to build count and make it availble to our executable we're going to perform two steps. (1) we're going to compile a library from the counter code, and (2) we're going to link this library into our executable. This is what we need in the counter CMakeLists.txt:

cmake_minimum_required(VERSION 3.28) 
add_library( Counter counter.cpp )

Again we use the same cmake_minimum_required, and this time we're skipping declaring the project or specifying the language version. Importantly, rather than calling add_executable we're now calling add_library. The first parameter to the add_library function is the name of the library, which in this case is "Counter" . We then specify a list of source files that we want to be included in the library. Note that we don't need to include the header files, but we can if we want to. Note also that it's considered best practice _not_ to use file globbing to define the files for compilation, but rather to define all the files manually. At this stage we aren't going to invoke this CMakeLists.txt ourselves, but rather we're going to refer to it from the root CMakeLists.txt. Replace the root CMakeLists.txt with the following:

cmake_minimum_required(VERSION 3.28) 
project(basic_executable_with_library) 
set (CMAKE_CXX_STANDARD 20) 
add_executable (basic_executable_with_library main.cpp ) 
add_subdirectory( lib )
target_link_libraries(basic_executable_with_library Counter) 
target_include_directories(basic_executable_with_library PRIVATE lib )

Most of this is the same as before, but there's three new functions we're calling: add_subdirectory, target_link_libraries, and target_include_directories. The first new function, add_subdirectory, steps down into the directory specified (in this case: "lib" ) and looks for a CMakeLists.txt in that directory. If found it adds the commands from there into the build graph. We then _link_ the static library that will be built from the add_subdirectory call by calling target_link_libraries and passing in our _target_ (i.e. the thing we are currently building, which happens to be the executable) and the library we want to link against, which in this case is called "Counter" . These two steps essentially provide access to the _symbols_ that get defined in our library (i.e. the actual source code itself), however we still need to know where the header files are. This is achieved by calling target_include_directories and first specifying the target (again our executable), then specifying PRIVATE as the _scope_ (don't worry about this, just use PRIVATE) and finally specifying the path to the directory that contains the header files. We invoke cmake the same way we did before, by running:

bash $ cmake -S src -B bin $ cmake --build bin

Remember that you only need to call cmake -S src -B bin once (although it doesn't matter if it gets called multiple times). As expected, running this gives the following output:

Linking _count_ library into executable
Count: 0
Count: 20
Count: 11

Create a static library for external use.

What about if we want to distribute our library for someone else to use, or for us to use somewhere other than the current project? Easy -- we just distribute the static library we've built. Behind the scenes cmake has built the Counter library for us, and put it inside the bin directory -- this is what we've been linking against in the previous example. Specifically, the library is the ./bin/lib/libCounter.a file (static libraries on macos have the .a file extension and 'lib' at the start). You can share this library as you wish (don't forget to share the header files as well), or we can independently build this library, without changing the code or cmake setup. To build this library independantly of the executable we're just going to tell cmake to build what's inside lib, rather than what's inside src -- easy!

$ cmake -S src/lib -B static
$ cmake --build static

First we tell cmake that the source directory is actually src/lib rather than just src, and then we tell it to put everything into a build folder called static (this can be any name you like). Finally, we tell cmake to --build the static directory, which will generate our library. When we do this we will get two warnings from cmake. The first is telling us that we didn't define a project name, and the second is regarding "default member initialisation" in our header file. These two warnings can be fixed by adding in the following lines to the CMakeLists.txt file in src/lib after the cmake_minimum_required call.

project( Counter ) 
set (CMAKE_CXX_STANDARD 20)