Modern C++ Project With CMake
“Wondering child, so lost, so helpless,
Yearning for my guidance.”
— The Phantom of the Opera
Prologue
As a Visual Studio user from the age of Visual C++ 6.0, I’ve always been using .sln
(or as old as .dsw
) to manage my C++ projects. It works pretty well with Visual Studio, but is not portable for other toolchains. When I started to program on Linux, I have to use other tools like Make and CMake.🤔
Unlike Make, which is the low-level build tool, CMake is a high-level cross-platform build system generator. Take a look at some popular projects on GitHub, you will find that most of them are using CMake to manage their projects. And all that that implies — CMake is the de-facto standard for building C++ code.😲
Besides CMake, you may also notice a file called conanfile.txt. It is yet another modern C++ package manager called Conan. I haven’t get used to it yet, so it is not our topic today.
Although it’s been a while since I first came across CMake, I still find my CMakeLists.txt
extremely messy. Then one day, I found spdlog, whose CMakeLists.txt
is laconic and straightforward. All of a sudden, the vision is clear, and now I’m writing this article to share with you the best practices of managing a modern C++ project with CMake.😎
Preparation
CMake is a project management tool, it only generate the build system. So you still need to install the underlying toolchain, e.g. Make on Linux or MSBuild (the one used by Visual Studio) on Windows.
Install CMake
Not much to say, just download the installer from CMake official website, and follow the instructions to install it. For convenience, I’ll do it on my Windows machine. Don’t worry if you are a Linux user, the cmake
command is available everywhere. If you also use Windows installer, make sure you check the option to add CMake to the system PATH. Otherwise, you have to add it manually. After installation, run the following command to check if CMake is installed correctly.
1 | cmake --version |
About CMakeLists.txt
This is the most important file in your CMake project. One CMakeLists.txt
takes care of the build process of a directory. If you have a large project, you may have multiple CMakeLists.txt
files in different directories. CMakeLists.txt
is written in some kind of script language instead of a bunch of configuration, therefore it is super flexible and powerful.
Workspace
Well, I bet you do not write code with notepad. Modern IDEs like Visual Studio and CLion have built-in support for CMake, making it easier to interact with CMake. However, CMake itself is not limited to any IDE, so I recommend you use a code editor with fewer integration to go through the original CMake experience at first. Yes, I’m talking about Visual Studio Code. However, we’re not primitives. CMake Tools and Test Explorer UI are good extensions to make this process more enjoyable.
Attached Repository
All projects of this article is available at Lord-Turmoil/CMakeDemo. You can build each project separately, or all together. See README.md for more information.
Getting Started with CMake
All projects in this chapter are placed under chapter_1
.
In this chapter, I’ll show you the very basic set up with CMake, which should be sufficient for small projects, and large projects that do not require much configuration.
Your First CMake Project
Project of this section is under chapter_1/demo_11
.
So, let’s start with a single file project to see how CMake works. First, prepare two files in your workspace.
1 | . |
In main.cpp
, write whatever you like. Or you can copy this to save some time.
1 |
|
Now, it comes to CMakeLists.txt
. For this simplest project, three lines are enough.
1 | cmake_minimum_required(VERSION 3.16) |
If you use Visual Studio Code, you can get the documentation when you hover on the commands. But for the first time, I’ll list them below.
cmake_minimum_required()
: Require a minimum version of CMake.project()
: Sets the name of the project, and stores it in the variablePROJECT_NAME
. When called from the top-levelCMakeLists.txt
also stores the project name in the variableCMAKE_PROJECT_NAME
.add_executable()
: Add an executable to the project using the specified source files.
Now, you can see that CMake commands are quite intuitive. However, you may find some new terms like “variable”, which I’ll cover later.
Build with CMake
Now, how to build our project? If you use the CMake extension, you can easily find the “Build” button and run it with a few clicks. Command line build is also simple. When you build your project for the first time, or after you modify the CMakeLists.txt
, you need to (re)generate the build system.
The complete command to do this is as follows. Basically cmake
needs to know the source path and build path.
1 | cmake [options] <path-to-source> |
Personally, I prefer the following command. You just run it under the project directory.
1 | cmake -Bbuild |
Or, you can specify source path instead. It does the same thing, but takes extra commands.
1 | mkdir build |
Then, to actually build our project, we can run the following command under project directory.
1 | cmake --build build |
After this, you can find the output executable file somewhere under build
directory (usually build/Debug
).
Since CMake itself only manages our project, it invokes related tools to build our project. For example, it may use Make on Linux and MSBuild on Windows. And if you are a curious reader, you may already found this by looking into the build
directory. Therefore, some folks may directly use the underlying toolchain instead of cmake
to build their project.
A fun tip for you. If you want to speed up the build speed, you can specify compile flags after build. On Linux, CMake uses Make, so you can specify
-j [N]
to utilize your many cores. Note that Make uses infinite jobs ifN
is missing.
1 cmake --build build -- -jFor MSBuild, you need to use
/m[:N]
. Also, you can let it choose automatically withoutN
.
1 cmake --build build -- /m
Classic Project Structure
Project of this section is placed under chapter_1/demo_12
.
I suppose no one write projects with only one file. So it’s time to consider a proper way to organize our source files and header files. A classic project structure is shown below. We put header files into include
and source files into src
.
1 | demo_12 |
When your project includes many libraries, it is likely that some header files happen to have the same name. In this case, we add an extra directory under include
(here is demo_12
). In this case, we will include our header file as demo_12/app.h
so as to tell header files from different projects.
Now, how should we write the CMakeLists.txt
? Only one more line of script. Note that we only need to add source files, header files will be included into source file when the compilation happens. However, you can include header files in it.
1 | cmake_minimum_required(VERSION 3.16) |
After we call add_executable()
, we add a target called demo_12
. Then we use target_include_directories
to set the include directory of this target. Here, after the target name, we have a PRIVATE
keyword. It means that the include directory (include
) is private to this target, and won’t be inherited by targets that depend on it.
Here our target is an executable, which apparently cannot be referenced, so it doesn’t matter whether it is PRIVATE
or PUBLIC
. It is usually a consideration for library project and you’ll see it later.
Because we’ve set the target include directory, we can use absolute include path for #include
. Although both ""
and <>
are OK here, we usually use the former as it is a custom header.
1 |
|
Tada! You project looks organized now!😆
It’s Getting Larger
Everything looks great, until there are too many source files. Adding them one by one is really tiring. So in this case, you can use the following command to automatically find source files in your project.
1 | file(GLOB_RECURSE SRC_LIST CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/src/*.cpp") |
Here, SRC_LIST
is a custom variable, which stores all .cpp
files under ${PROJECT_SOURCE_DIR}
, which is a built-in variable as its literal meaning.
Use CMake Like a Pro
All projects in this chapter are placed under chapter_2
.
In the last chapter, we learned the basic use of CMake. And now, let’s take a step further to become “professional”.
Your First CMake Library
Project of this section is under chapter_2/demo_21
.
Not all projects are executables. Sometimes a library is a better choice to reuse our code. Or we just divide our logic into several libraries. So in section, I’ll talk about the basic structure of a library project. More specifically, static library.
In this project, our final target is an executable, but we depends on a library called app
. The app
library has both public headers and private ones. In this case, a good project structure is as follows.
1 | demo_21 |
Since all our dependencies are moved into the library, the top-level include
directory is omitted. The lib/app
directory is a complete CMake library project, which has its own include
and src
.
Usually, for a library, we will have an all-in-one header, so that client can get access to all functions with one include. For example, this is our app.h
.
1 |
Then in our main.cpp
, we can simply write one line of #include
. Here we use <>
because we can take library as external code.
1 |
|
Usually, it is OK to make all header files public. However, if you do care about privacy, you can place private headers in src
directory along with source files.
First, let’s see the CMakeLists.txt
under lib/app
. This time, we use add_library
to make it a library instead of an executable. Also, we use the built-in ${PROJECT_NAME}
to avoid writing app
everywhere. Then, for the target_include_directories()
, we set it more carefully by separating public and private headers.
1 | cmake_minimum_required(VERSION 3.16) |
Since library can be referenced by other projects, we can use ${CMAKE_CURRENT_LIST_DIR}
to make it always points to the path in our library. Or you can use ${CMAKE_CURRENT_SOURCE_DIR}
.
Then, in lib/CMakeLists.txt
, we simply use add_subdirectory
to include our library.
1 | add_subdirectory(app) |
This is something new. By executing add_subdirectory
, we go into that directory and run CMakeLists.txt
there. This way, we can build large projects recursively. When there are more libraries, you can put them all here, so that we can add them all by executing add_subdirectory(lib)
.
Finally, our top-level CMakeLists.txt
, we first add lib
, and finally link app
to our executable. The PRIVATE
here has the same meaning as that of target_include_directories()
.
1 | cmake_minimum_required(VERSION 3.16) |
This is how you build a project with library dependency.
No Source File?
Project of this section is placed under chapter_2/demo_22
.
Sometimes, a library may only contain a bunch of header files, which is known as header-only libarary. In this case, there is no source file to be added to the target. Therefore, CMake provides a special target called INTERFACE
. Now, we convert our library app
in the last section into a header-only library.
1 | demo_22 |
Except the changes in source code, only app_ho/CMakeLists.txt
is slightly different. Now, instead of listing source files in add_library()
, we use INTERFACE
to indicate that it is header-only. Then, we also need to specify INTERFACE
in target_include_directories()
.
1 | cmake_minimum_required(VERSION 3.16) |
That’s it. You can reference this library in the same way.
Unit Test with CMake
Project of this section is placed under chapter_2/demo_23
.
As a final step, I’d like to talk about unit testing with CMake. You may already heard about unit testing somewhere else. It is especially useful for library projects. To add unit test to your project, first you need to add enable_testing()
in your top-level CMakeLists.txt
.
enable_testing()
must be placed top-level before you add the test directory. Otherwise, it will be ignored.
Usually, we place our unit tests under tests
. And the complete project structure looks like this.
1 | fawefdemo_23 |
The content of tests/CMakeLists.txt
is as follows. The unit test is in fact an executable, and we add it as a test. You can add more as you wish.
1 | add_executable(add_test add_test.cpp) |
Whether a test passes is indicated by its return value. Here, our test contains one assertion. If the assertion fails, it will return non-zero value, thus the unit test will fail.
1 |
|
Unit test is backed by ctest
. You can run them simply with the following command.
1 | > ctest --test-dir build |
If you have build type specified, you may need to pass configuration argument to
ctest
, for example:
1 ctest --test-dir build -C Debug
Or if you have Test Explorer UI extension installed, you can view test cases in the sidebar of Visual Studio Code.
Now, I believe you are capable of managing a nice CMake project.😉
CMake Best Practices
Project of this chapter is under chapter_3
.
Overview
First of all, it is a library project with dependencies, a demo and a set of unit tests, which makes it a comprehensive example. As you’ve already learned the essential of CMake in the previous chapters, I’ll focus on more advanced topics here, showing below.
- Automatically version your project.
- Using options to allow more flexible build.
- Managing compiler flags.
- Simplifying your
CMakeLists.txt
with loop.
Auto Versioning
It is a bad habit to copy code in your project, as it causes duplication, making it hard to maintain in the long run. One example is the project version. Usually, you will have a version in your code, but you have to write the same version again in the project configuration file, e.g. CMakeLists.txt
. Fortunately, CMake is able to read files, so that you can automatically extract version from your code.
First, you can set the version of your project using MACROS in one of your header files. Here, I put it in c3/include/version.h
.
1 |
Then, in your CMakeLists.txt
, define a function called c3_extract_version
to extract version from our header file via regex.
1 | function(c3_extract_version) |
This function is quite long, and may fill up your main CMakeLists.txt
. So, usually we will create a cmake
directory and store such functions to utils.cmake
. Then, we just need to include it before we use it. Note that if sub-directories will inherit the inclusion.
1 | include(cmake/utils.cmake) |
Options
Not all targets are needed when we build a project. For example, we may only build tests under development. Or, you may want to disable assertion for some builds. In this case, you can use option()
command.
1 | option(C3_BUILD_TEST "Build unit tests" ON) |
To demonstrate assertion, I placed a 💣 in the source code. If you compile and run it with assertion enabled, you will see 💥.
We can override the choice in command line using -D
.
1 | cmake -Bbuild -DC3_BUILD_TEST=OFF -DC3_ENABLE_ASSERTION=ON |
Then, the options can be directly used in if()
command.
1 | if(C3_ENABLE_ASSERTION) |
Compiler Flags
To force yourself a better programming habit, you can enable more warnings for your project. Likewise, you can also turn off annoying warnings from 3rd-party dependencies. To achieve this, you can write some utility functions.
target_compile_options()
will add compiler flags to the given target. It is always recommended to specify target to avoid unwanted pollution. Here I have to complain about MSVC, it has incompatible flags.☹️
1 | # Turn on warnings on the given target |
Since header file will be compiled with the source files that include it, a header-only library will not invoke compilation. Therefore, we cannot set compile options for interface library.
Final Touch
Now, your CMake project should look professional already.😁However, there is still some tips I’d like to share with you.
C/C++ standard
You can specify desired language standard by setting the built-in variable.
1 | set(CMAKE_CXX_STANDARD 17) # for C++ |
Am I the Master?
When we develop a project, usually a library, we build it as the master project. That is to say, its CMakeLists.txt
is at the top-level. However, after it is done, it will be referenced by another project, making it a sub-project. For this, we can write a simple function to detect whether it is built as a master project or not.
1 | # a helper variable to check if the project is built as a master project |
Then, we can optionally enable strict warnings when built as the master project.
1 | # enable extra warnings if build as master project |
How’s it going?
Can not help from wondering what CMake is doing? You can use message()
command for logging.
Epilogue
Finally, it comes to an end, but there is still much to learn. For modern C++ projects, the most important part missing here is package management. CMake has install()
and find_package()
commands, and there are other tools like Conan and vcpkg for this purpose.
Anyway, this article is only a beginning of your CMake journey. Good luck, and have fun! ᓚᘏᗢ