Detecting your environment

Objectives

  • Learn how to discover the operating system and processor capabilities.

  • Learn how to handle platform- and compiler-dependent source code.

CMake comes pre-configured with sane defaults for a multitude of properties of the environments in which it can be used. Default generator, default compilers, and compiler flags are few and most notable examples of this up-front configuration.

Run the following if you are curious about what kind of configuration ships for your version of CMake and operating system:

$ cmake --system-information

In this episode, we will show how to use CMake to introspect the environment in which we are running. This is very common in build systems, since it allows to customize the creation of artifacts on-the-fly.

Discovering the operating system

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;
}

Discovering processor capabilities and generating a configuration file

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)

Exercise: record more useful information using configure_file

Your goal is to adapt the above example (content/examples/configure-file/problem/) 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.

  • To find the relevant CMake variable names, have a look in Compile flags, definitions, and debugging.

Discussion

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.

Keypoints

  • CMake can introspect the host system.

  • You can build source code differently, based on the OS, the processor, the compiler, or any combination thereof.

  • You can generate source code when configuring the project with configure_file.