Exercise: CMake

Outline

Introduction

Recall from class that CMake is a tool that enables you to configure and construct a build process for a project. This exercise will provide you first hand experience in using basic features of CMake that you may find useful (required) in order to complete your term project. You will be constructing and modifying the CMake configuration files of a small project in order to produce the final desired results. You should feel free to look at outside sources such as the CMake Documentation for help, as long as you do not copy or share your work with another student. There are also tools that can create initial CMake project structure. When using these tools for real projects, understanding your build system is still essential for tailoring and evolving the build process for your specific needs. To access help for any command from the command line, you may use

cmake --help-command <command> | less

for example, you may print the documentation for the find_library command via

cmake --help-command find_library | less

Notes and Preparation:

source  /usr/shared/CMPT/faculty/wsumner/base/env373/bin/activate

Step 1: Compiling a Program

To start, create a new directory called cmakeExercise/. This will be the root directory of your project for the exercise. Spelling and capitalization matters, as exercises will be graded automatically.

A project built using CMake is configured and controlled through lists of CMake commands in CMakeLists.txt files. To simply compile a program using CMake, you need to define a CMakeLists.txt file containing basic information about the project in the root directory of the project. Our initial project contains the three files below:

main.cpp
#include <print>
#include <vector>

#include "SortIntegers.h"

int
main() {
  std::vector integers = {1, 13, 89, 2, 55, 21, 8, 5, 3, 1, 34};
  sortIntegers(integers);
  for (auto integer : integers) {
    std::println("{}", integer);
  }
  return 0;
}
SortIntegers.h
#pragma once

#include <span>

void sortIntegers(std::span<int> numbers);
SortIntegers.cpp
#include <algorithm>
#include <span>

void
sortIntegers(std::span<int> numbers) {
  std::ranges::sort(numbers);
}

The most basic CMakeLists.txt for a project usually includes four commands: cmake_minimum_required, project, add_executable, and target_sources. Recall that add_executable creates a target to build1. After that, you need to tell cmake which sources are associated with the target in order to do anything meaningful. Note that all work we do in this class uses C++23, which is not the default version for the compiler installed in the labs. Thus, you also need to set the version of the language used to compile a target. You can do this using target_compile_features for a target with the PUBLIC feature cxx_std_23.

Use the documentation for these commands to create a CMakeLists.txt script that defines a project called sorter and compiles the above two files into a single program called sorter. The minimum cmake version should be 4.0. When you declare the project, specify the VERSION and LANGUAGES for the project. This allows CMake to specifically configure the compilers used by the project. You can also override the compiler, e.g. to choose clang++ with the CMAKE_CXX_COMPILER command line option. Recall that you will want to use an out-of-source build process to make sure that your script works. You will lose points for any build artifacts found in your source directories.

cmake -DCMAKE_CXX_COMPILER=clang++ -S </path/to/project/source/directory> -B </path/to/project/build/directory>

This configures the build of the project but does not build it. You can then build the project using:

cmake --build </path/to/project/build/directory>

After configuring with the first line, configuration decisions are cached within the build directory. If you want to change something (like the compiler) then you need to reconfigure with the --fresh option or delete CMakeCache.txt in the build directory.

If you were to run the program under a debugger, you might notice that it does not have any debugging symbols. This certainly makes debugging a challenge! CMake allows you to build a program in different modes or types. Building in Debug mode enables debugging, while building in Release mode does not enable debugging symbols and might, for instance, do extra optimization. By default, neither debugging nor optimizations are enabled, but you can choose to control default behavior in your CMakeLists.txt. In order to select Debug mode, you must set the CMAKE_BUILD_TYPE variable when running CMake. Once again, you do this on the command line:

cmake -DCMAKE_BUILD_TYPE=Debug -S </path/to/project/source/directory> -B </path/to/project/build/directory>

When compiling with gcc or clang, this will automatically add the -g option to the standard CFLAGS variable used when invoking the compiler.

Step 2: Organizing the Project

As much as possible, we would like to organize our source directory structure in order to reflect the different components of our project. We also want to decompose the project into potentially reusable components as much as possible. Finally, we want the directory structure of the separate build directory to also reflect the structure of the different components we are building.

In order to break our source program structure into different components, we will perform two tasks simultaneously. From the perspective of design, we will break the program up into (1) a library that encapsulates related features and (2) an executable program that uses the functionality of the library to achieve a specific goal. From the perspective of file organization, we will separate the files for individual libraries and executables into their own subdirectories.

Inside the project root directory, create the following directories:

Following the above descriptions (1) move main.cpp into tools/fibSorter/, (2) move SortIntegers.cpp into lib/sortIntegers/, and (3) move SortIntegers.h into lib/sortIntegers/include/. Now you must change the CMakeLists.txt in the project's root directory in order to use the new directory structure and design.

CMake supports recursively processing subdirectories in order to decompose project structure. Commands need to be added to build the sortIntegers library and the fibSorter application. Use the add_subdirectory command to include targets in the lib/ and tools/ subdirectories. Inside these subdirectories, you will need additional CMakeLists.txt files that tell CMake to descend further into the lib/sortIntegers/ and tools/fibSorter/ directories.

To build the library, create a CMakeLists.txt file in lib/sortIntegers/ that builds a library called sortIntegers using the add_library command. In modern CMake, target_sources is used to specify both the source files and the headers that are part of a library. Recall from class that the files used internally to a library should be private to it, but headers might private to a library, published to users of the library, or both. We make use of PRIVATE, INTERFACE, and PUBLIC visibility to control just how these. Choosing the wrong visibility may make it impossible for a user of your library to "see" the header. For your library, you can make the headers usable by both the library and its users with PUBLIC. Modern CMake also uses FILE_SETs to specify header files. You can also look back at the template for exercise 0 to see additional examples of how to specify such headers using FILE_SETs.

Modern CMake libraries have one additional common convention. Rather than only a target name, e.g. sortIntegers, they typically define a namespaced alias:

add_library(sorter::sortIntegers ALIAS sortIntegers)

Namespaced library names are more robust and allow you to get better error messages at configure time if you have made typos or mistakes.

Similarly, create a CMakeLists.txt file in tools/fibSorter/ that builds a program called fibSorter using the add_executable command along with the target_link_libraries command. Notice that the location of the include directories for the library are automatically propagated to the fibSorter that uses it. This means you could change where the include directories for the library are, and the fibSorter build process would not need to be modified. If you happen to test that out, make sure to change the file paths back, as they will be checked.

Now try building your project to make sure that it compiles. Look at the directory structure in the build directory. Where is the fibSorter program? Try running it to make sure that it works. Where is the sortIntegers library?

The build directory would be more organized if all programs were built in a single bin/ subdirectory and all libraries were built in a 'lib/' subdirectory. This is again done by setting specific CMake variables in the top level CMakeLists.txt:

set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/lib")

You should be aware that the order of operations matters. Ask yourself, "Where in the CMakeLists.txt file do these lines belong?"

Note that the sortIntegers library we built is a static library. If we instead wanted to build a shared library, we can use add_library with SHARED.

From within your build directory, run cmake --build . --target clean to remove your previously compiled files and then run cmake --build . in order to check that the system correctly compiles with your modifications2.

Step 3: Using Other Libraries

Now that you know how to build and organize your own projects in isolation, you can start to incorporate other external projects and libraries into your own project. For instance, if you want to use an external library for managing networking or cryptographic protocols, you can include CMake commands to find such libraries and link against them automatically.

External libraries and development APIs are included in a project using the find_library and find_package commands. In many cases, such libraries and development packages can be found and used by your CMake project even if it may reside in different locations for different build environments or even different operating systems. Many libraries are even specially detected by CMake in order to make development easier. CMake can also automatically build a completely separate external project and allow you to use it as a part of your build process.

For this next step, you will create a new tool/program that uses the ncurses library for presenting nice text based user interfaces. You will use CMake to recognize (1) whether or not the build environment contains the necessary dependencies and (2) determine which libraries need to be linked against in order to build a program that uses ncurses functionality.

Start by creating a new subdirectory called tools/cursesExample/. Inside this directory create the following source file:

main.cpp
#include <ncurses.h>
#include <cstdio>
#include <print>

int
main() {   
  initscr();
  if(has_colors() == FALSE) {
    endwin();
    std::println(stderr, "Your terminal does not support color.");
    return 1;
  }
  start_color();
  init_pair(1, COLOR_RED, COLOR_BLACK);

  attron(COLOR_PAIR(1));
  attron(A_BOLD);
  printw("Hello World !!!");
  attroff(A_BOLD);
  attroff(COLOR_PAIR(1));

  refresh();
  getch();
  endwin();

  return 0;
}

Now add the necessary changes to the various CMakeLists.txt files in order to tell CMake to build a program called cursesExample using this source file.

Try to build the project. You should now see that it fails because the project uses ncurses, but we haven't told it that we need to link against the curses library. In order to do so, in the CMakeLists.txt that configures the build of cursesExample, we need to use the find_package command to find the required ncurses libraries. You should use find_package to search for the Curses package as a required dependency of the project. This uses CMake's built in support for recognizing the curses-like libraries. Notice from the given documentation that using ncurses requires the command

set(CURSES_NEED_NCURSES TRUE)

before calling find_package. Also notice that Curses is a normal C library that doesn't provide CMake support. As a result, we add some additional boilerplate after find_package to fully integrate it into the dependency graph that CMake uses:

if(NOT TARGET Curses::Curses)
  add_library(Curses::Curses INTERFACE IMPORTED)
  set_target_properties(Curses::Curses PROPERTIES
      INTERFACE_LINK_LIBRARIES "${CURSES_LIBRARIES}"
      INTERFACE_INCLUDE_DIRECTORIES "${CURSES_INCLUDE_DIRS}"
  )
endif()

Use the commands you have already seen in order to make the project compile using using Curses::Curses as a dependency. Notice that the program uses header files, but you don't have to say anything about where they are! You should understand at this point why that is.

Notice, if we wanted to, we could make the project compile by explicitly passing different project commands the names of the library files we want to link against and the paths to the headers. However, if we did so, changing those values in the future would be more cumbersome. In addition, using find_package as we do in this case means that if curses is not present in the build environment, we will get a clearly explained error as soon as CMake runs, instead of a potentially confusing compilation error much later. I may check for this when grading.

Once you can build and run cursesExample, move on to the next step.

Step 4: Remote Dependencies

Managing external dependencies, like Curses, can be tricky. Curses is a common and stable library and likely to be installed on many systems. When using other dependencies, you might choose to

  1. Document that users need to have a dependency installed,
  2. Directly include a specific version of the dependency in your project, or
  3. Rely on a dependency manager to fetch the correct version of the dependency at build time.

These have trade-offs. Documentation alone can be error prone. Directly including a specific version gives you more significant control over the dependency, but it also means that you must maintain and rebuild that pinned version in your source tree. Package managers provide a convenient way of including dependencies in the build process. They can manage both pre-built dependencies as well as source dependencies that are instead cloned and built in your project as needed. Beyond being able to build without a network connection, security, stability, and legal concerns can favor directly including dependencies. However, convenience and speed are often important concerns in practice.

In this step, you will add a remote dependency from github to your project using CPM (CMake Package Manager). CPM is a third-party cmake script that downloads and caches dependencies at configure time, making them available as targets in your project. Create a new cmake/ directory in the top level of your project. This is a common directory in CMake projects for holding custom or helpful CMake scripts. Download CPM.cmake from CPM and save it in the cmake/ directory. To use this in your project, add include(cmake/CPM.cmake) in your root CMakeLists.txt. After that, you can add additional remote dependencies, and they will be built as a part of your project automatically. Be careful. In this exercise, there is only one specific remote dependency that you are allowed to make use of.

Create a new tool called sosier and add the necessary subdirectories and CMake commands for it. This time, the source is:

main.cpp
#include "sos.h"

#include <print>

int
main() {
  std::println("What a lovely day for a {}", getSecret());
  return 0;
}

However, this program makes use of a library that is not in your project and that you cannot submit as a part of your source code. Instead, you will use CPM to automatically download the correct version of the library from github at build time.

Specifically, there is an example-dependency project on github. There are different versions of the library tagged in the github repo that have slightly different behaviors. You should specifically make use of the version tagged v0.2. Use the CPMAddPackage command to integrate the correct version of the library and then link in the sekrit-sos library that it contains. Notice that when you give CPM a version number, it will implicitly try to clone with the tag v + < version number >.

When you build the project now, you should see it download and compile the remote dependency as a part of the process. You can run sosier to see it make use of the dependency.

Step 5: Installing

Building the project is important, but you also want to be able to install it once the build process is complete. Installing all of the desired files once they are built can be done using the install command. install copies the selected TARGETS and FILES into paths rooted at the directory pointed to by the CMAKE_INSTALL_PREFIX variable. For instance, if you wanted to install the project to ~/testing/, you would set the install prefix when invoking CMake using a command like

cmake -S /path/to/project -B /path/to/build -DCMAKE_INSTALL_PREFIX=~/testing/ -DCMAKE_CXX_COMPILER=clang++

Notice that this is different than the invocation we used before. The argument -DCMAKE_INSTALL_PREFIX=~/testing/ determines where the project will be installed. Afterward, you can install the project by running cmake --build /path/to/build and then cmake --install /path/to/build.

There may be many different things that we want to install. For instance, we presently build a library and a program, both of which can be installed. In order to use the library, however, the header file for sortIntegers must also be installed. If we wanted to install documentation for the user, that would also be done using the install command.

install allows you to specify a subdirectory inside the install prefix where each installed file will be placed. For a Linux system there are general patterns/rules to follow:

You can get this configuration more conveniently by using:

include(GNUInstallDirs)

which creates the variables CMAKE_INSTALL_BINDIR, CMAKE_INSTALL_LIBDIR, CMAKE_INSTALL_INCLUDEDIR, and CMAKE_INSTALL_DOCDIR with appropriate locations.

Add rules to your CMakeLists.txt files to install the header, the library, and the programs to the appropriate directories. Using FILE_SETs for header sources has the added benefit of making it easier to install the headers at this point as well by specifying FILE_SET HEADERS in install.

If you are interested in creating a more professional installer, e.g. a Debian package or a graphical installer for Windows or OS X, you can do so using CPack and its CMake commands.

Step 6: Sanity Checking and Submission

You should now have some experience with the fundamentals of CMake as discussed in class. As a part of the project, you will also start to use the add_custom_command and add_custom_target commands along with find_package in order to define a build process for the project documentation using Sphinx as well as to coordinate unit testing.

If you have written things successfully, you should be able to run the following from the project directory without errors.

cmake -S cmakeExercise -B tmpbuild -DCMAKE_INSTALL_PREFIX=../tmpinstall/
cmake --build tmpbuild
cmake --install tmpbuild
echo '#include <print>
#include <vector>
#include "SortIntegers.h"
int main() { 
  std::vector integers = {6, 3, 1, 15, 11, 16};
  sortIntegers(integers);
  for (auto integer : integers) { std::println("{}", integer); }
  return 0;
}' > test.cpp
clang++ test.cpp -std=c++23 -I../tmpinstall/include -L../tmpinstall/lib/ -lsortIntegers
./a.out
../tmpinstall/bin/fibSorter
../tmpinstall/bin/cursesExample

Once you are comfortable with your exercise, create an archive of your solution by changing into the directory containing your project and running:

tar zcvf e1.tar.gz cmakeExercise/

Note that this should not contain your build directory.

You can then upload this archive to CourSys.

Bonus Step: Using Other Tools

Ninja

As discussed in class, using CMake provides many useful features. For instance, you are not required to use make in order to build your project. You can have CMake instead generate, e.g., an Eclipse project, a Visual Studio project, an XCode project, and more. When building large projects, I like to use Ninja because it is faster than many other build systems and thus allows me to wait less for compilations to complete. To build a project using Ninja, you can use

cmake -S /path/to/project -B /path/to/build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=on
cmake --build /path/to/build
cmake --install /path/to/build

clang-tidy

In addition, the JSON compilation databases that CMake can export allow you to more easily use advanced analysis tools like clang-tidy. As demonstrated during class, such tools can provide great aid in both uncovering bugs and in identifying potential stylistic or design issues in a project. To use clang-tidy, you must pass it a generated compilation database and tell it what checks you want to run as well as which files you wish to analyze.

cmake -S /path/to/source -B /path/to/build -DCMAKE_EXPORT_COMPILE_COMMANDS=on
cd /path/to/source/
clang-tidy -p /path/to/build -checks='*' lib/*/*.cpp tools/*/*.cpp

There are many more features available in CMake. You should feel free to explore the official CMake tutorial and other documentation online and see what else is available that may be helpful in your project.


  1. A library is another type of target. ↩

  2. There are other ways that a project can be organized. One approach is to break a project into individual subprojects, each in its own directory. CMake provides additional commands that can allow projects to use/communicate with each other. You can find more information on advanced uses of CMake here. ↩