Building code with CMake

These exercises are adapted from the CodeRefinery lesson “CMake introduction and hands-on workshop”.

Note

Running the examples below on Dardel? In this case you may need to load the CMake module:

$ module load PDC CMake

Exercise CMake-1: Compiling “Hello, world” with CMake

In this exercise we will compile a single source file to an executable. Choose your favorite language.

  1. Create a new folder and in the folder create a source file:

hello.cpp:

#include <cstdlib>
#include <iostream>

void say_hello() {
  std::cout << "Hello world" << std::endl;
}

int main() {
  say_hello();

  return EXIT_SUCCESS;
}
  1. The folder contains only the source code. We need to add a file called CMakeLists.txt to it. CMake reads the contents of these special files when generating the build system:

cmake_minimum_required(VERSION 3.14)

project(example LANGUAGES CXX)

add_executable(hello hello.cpp)
  1. We are ready to call CMake and get our build system:

$ cmake -S. -Bbuild
  1. And finally build our executable:

$ cmake --build build
  1. Try to also run the executable.

Note

Instead of creating a build directory, configuring, and building implicitly:

$ cmake -S. -Bbuild
$ cmake --build build

… some people prefer to do this more explicitly (the build directory does not have to be called “build”):

$ mkdir build
$ cd build
$ cmake ..
$ make

Both approaches will work for us.


Exercise CMake-2: Building and linking a library

Only rarely we have one-source-file projects and more realistically, as projects grow, we split them up into separate files. This simplifies (re)compilation but also helps humans maintaining and understanding the project.

We stay with the toy project but also here things got more real and more modular and we decided to split the project up into several files:

hello.cpp:

#include "greeting.hpp"

#include <cstdlib>

int main() {
  say_hello();

  return EXIT_SUCCESS;
}

greeting.cpp:

#include "greeting.hpp"

#include <iostream>

void say_hello() {
  std::cout << "Hello world" << std::endl;
}

greeting.hpp:

#pragma once

void say_hello();

Your first goal: try to build this by adapting the CMakeLists.txt from earlier by first adding all the source files into the same add_executable.

CMake can of course be used to produce libraries as well as executables. The relevant command is add_library. You can link libraries can be linked into other targets (executables or other libraries) with target_link_libraries.

Your second goal: now try to build a greeting library and link against this library instead of collecting all sources into the executable target:

cmake_minimum_required(VERSION 3.14)

project(example LANGUAGES CXX)

add_executable(hello hello.cpp)

add_library(greeting
  SHARED
    greeting.cpp
    greeting.hpp
  )

target_link_libraries(hello PRIVATE greeting)

Which solution did you like better? Discuss the pros and cons.


Exercise CMake-3: Detecting your environment

Sometimes we need to write code that performs different operations based on compile-time constants. Like in this example (example.cpp):

#include <cstdlib>
#include <iostream>
#include <string>

std::string say_hello() {
#ifdef IS_WINDOWS
  return std::string("Hello from Windows!");
#elif IS_LINUX
  return std::string("Hello from Linux!");
#elif IS_MACOS
  return std::string("Hello from macOS!");
#else
  return std::string("Hello from an unknown system!");
#endif
}

int main() {
  std::cout << say_hello() << std::endl;
  return EXIT_SUCCESS;
}

We can do this with the following CMakeLists.txt:

# set minimum cmake version
cmake_minimum_required(VERSION 3.14)

# project name and language
project(example LANGUAGES CXX)

# define executable and its source file
add_executable(example example.cpp)

# let the preprocessor know about the system name
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_compile_definitions(example PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_compile_definitions(example PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_compile_definitions(example PUBLIC "IS_WINDOWS")
endif()

Please try it out before moving on.

We achieved this with a combination of host system introspection and the target_compile_definitions command.

Now before moving on, create a new directory. A common customization is to apply processor-specific compiler flags. We can gain such information on the host system with the built-in cmake_host_system_information command.

Another thing that is common and convenient is to have a single file containing all these compile-time constants, rather than passing them to preprocessor. This can be achieved by having a scaffold file and then letting CMake configure it with configure_file after discovering the values for all the necessary compile-time constants.

Here is an example source file (example.cpp):

#include "config.h"

#include <cstdlib>
#include <iostream>

int main() {
  std::cout << "Number of logical cores: " << NUMBER_OF_LOGICAL_CORES << std::endl;
  std::cout << "Number of physical cores: " << NUMBER_OF_PHYSICAL_CORES << std::endl;
  std::cout << "Processor is 64Bit: " << IS_64BIT << std::endl;
  std::cout << "Processor supports SSE2 instructions: " << HAS_SSE2 << std::endl;
  std::cout << "OS name: " << OS_NAME << std::endl;
  std::cout << "OS sub-type: " << OS_RELEASE << std::endl;

  // we will add these two later:
  // std::cout << "Compiler: " << COMPILER << std::endl;
  // std::cout << "Compiler version: " << COMPILER_VERSION << std::endl;

  return EXIT_SUCCESS;
}

The file config.h does not exist, it will be generated at configure time from config-template.h:

#pragma once

#define NUMBER_OF_LOGICAL_CORES  @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define IS_64BIT                 @_IS_64BIT@
#define HAS_SSE2                 @_HAS_SSE2@
#define OS_NAME                 "@_OS_NAME@"
#define OS_RELEASE              "@_OS_RELEASE@"

Here is the CMakeLists.txt which takes care of introspection and also generates the file config.h:

cmake_minimum_required(VERSION 3.14)

project(example LANGUAGES CXX)

include(CMakePrintHelpers)

foreach(key
  IN ITEMS
    NUMBER_OF_LOGICAL_CORES
    NUMBER_OF_PHYSICAL_CORES
    IS_64BIT
    HAS_SSE2
    OS_NAME
    OS_RELEASE
  )
  # query the item ${key} and save its value in the variable _${key}
  cmake_host_system_information(RESULT _${key} QUERY ${key})
  cmake_print_variables(_${key})
endforeach()

add_executable(example example.cpp)

# this is here because the config.h will be generated in PROJECT_BINARY_DIR
target_include_directories(example
  PRIVATE
    ${PROJECT_BINARY_DIR}
  )

configure_file(config-template.h config.h @ONLY)

Your goal is to adapt the above example and make it possible to get the compiler and compiler version into the output of your code:

#include "config.h"

#include <cstdlib>
#include <iostream>

int main() {
  std::cout << "Number of logical cores: " << NUMBER_OF_LOGICAL_CORES << std::endl;
  std::cout << "Number of physical cores: " << NUMBER_OF_PHYSICAL_CORES << std::endl;
  std::cout << "Processor is 64Bit: " << IS_64BIT << std::endl;
  std::cout << "Processor supports SSE2 instructions: " << HAS_SSE2 << std::endl;
  std::cout << "OS name: " << OS_NAME << std::endl;
  std::cout << "OS sub-type: " << OS_RELEASE << std::endl;

  // we will add these two later:
  // std::cout << "Compiler: " << COMPILER << std::endl;
  // std::cout << "Compiler version: " << COMPILER_VERSION << std::endl;

  return EXIT_SUCCESS;
}

Hints:

  • You will only need to uncomment these and only have to modify configure-template.h.

  • You will not need to modify CMakeLists.txt.

  • The relevant variable names are CMAKE_CXX_COMPILER_ID and CMAKE_CXX_COMPILER_VERSION (or replace CXX by C or Fortran).

What else to record when configuring? Here are some ideas:

  • Code version and/or Git hash

  • Compiler versions

  • Compiler flags

  • Compile-time definitions

  • Library versions

  • Some environment variables

Discuss how they can be useful for you and others running your code.


Exercise CMake-4: Finding and using dependencies

CMake offers a family of commands to find artifacts installed on your system:

  • find_file to retrieve the full path to a file.

  • find_library to find a library, shared or static.

  • find_package to find and load settings from an external project.

  • find_path to find the directory containing a file.

  • find_program to find an executable.

For this exercise, choose one of the examples below which is closest to your work and most relevant for your code.

  1. Try to compile and run.

  2. Browse the online documentation of the Find<PackageName>.cmake module (e.g. FindOpenMP.cmake).

  3. Try to compile with verbosity (cmake --build build -- VERBOSE=1) and verify how the imported target modified compile flags and definitions.

  4. Bonus: Try to adapt what we learned to an example which uses the BLAS or LAPACK library or your favorite library.

Note

Running the examples below on Dardel? In this case you may need to load the PrgEnv-gnu module:

$ module load PrgEnv-gnu

Source file (example.cpp):

#include <cstdlib>
#include <iostream>

#include <omp.h>

int main() {

  #pragma omp parallel
  {
    std::cout << "hello from thread " << omp_get_thread_num() << std::endl;
  }

  return EXIT_SUCCESS;
}

And the CMakeLists.txt file:

cmake_minimum_required(VERSION 3.14)

project(example LANGUAGES CXX)

add_executable(example example.cpp)

find_package(OpenMP REQUIRED COMPONENTS CXX)

target_link_libraries(example PRIVATE OpenMP::OpenMP_CXX)

Exercise CMake-5: CMake-ify your own project

This may not be easy and you will probably need help from a TA or the instructor but is a great exercise and we can try to do this together. You can find good tips in “CMake introduction and hands-on workshop”.


Where to find more examples and exercises

Curious about more CMake? As a next step, we recommend to work through the “CMake introduction and hands-on workshop”.