CMake: Detecting Environment

CMake: Detecting Environment
CMake: Detecting Environment

Introduction:

Through previous studies, we have mastered the basic knowledge of CMake and C++. Although CMake is cross-platform, sometimes the source code is not entirely portable. To ensure that our source code can be cross-platform, configuring and/or building the code according to different platforms is an essential part of the project build process.

CMake: Detecting Environment

Detecting Operating System

CMake is a set of cross-platform tools. In actual development, we need operating system-specific CMake code, which enables conditional compilation based on the operating system or uses compiler-specific extensions when available or necessary.

Here is a specific example:

Windows and Unix systems have distinct file structures. For instance, when integrating a deep learning model into a C++ software system, we want to copy the deep learning model (*.pth) to a specified file:

if (MSVC)
  file(GLOB MODEL "${CMAKE_SOURCE_DIR}/resource/*.pt")
  add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
                     COMMAND ${CMAKE_COMMAND} -E copy_if_different
                     ${MODEL}
                     $<TARGET_FILE_DIR:${PROJECT_NAME}>)
elseif(UNIX)
  file(GLOB MODEL "${CMAKE_SOURCE_DIR}/resource/*.pt")
  file(COPY ${MODEL} DESTINATION ${EXECUTE_FILE})
endif()

This CMake code is used to copy the model files to the output directory after building the project, depending on the target platform (Windows or UNIX/Linux), ensuring that the required model files (with a .pt extension) are located in the same directory as the executable.

For MSVC (Microsoft Visual C++ compiler, typically used on Windows platform):

  • Use the file() command with the GLOB option to find all .pt model files in the resource directory under the CMake source directory (${CMAKE_SOURCE_DIR}).

  • Then, use the add_custom_command() command to add a custom post-build command to the target ${PROJECT_NAME}.

  • The custom command will copy all found .pt model files to the output directory ($<TARGET_FILE_DIR:${PROJECT_NAME}>). Using the copy_if_different parameter ensures that files are copied only when the target file differs from the source or when the target directory does not contain the file.

For UNIX platforms (including Linux):

  • Use the file() command with the GLOB option to find all .pt model files in the resource directory under the CMake source directory (${CMAKE_SOURCE_DIR}).

  • Then, use the file() command with the COPY option to copy all found .pt model files to the specified target directory (${EXECUTE_FILE}).

Next, we will demonstrate how to use CMake to detect the operating system through an example that does not require compiling any source code.

Project address:

https://gitee.com/jiangli01/tutorials/tree/master/cmake-tutorial/chapter2/01

CMakeLists.txt

cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(os_test)

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    message(STATUS "Configuring on/for Linux")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
    message(STATUS "Configuring on/for macOs")
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
    message(STATUS "Configuring on/for Windows")
elseif(CMAKE_SYSTEM_NAME STREQUAL "AIX")
    message(STATUS "Configuring on/for IBM AIX")
else()
    message(STATUS "Configuring on/for ${CMAKE_SYSTEM_NAME}")
endif()

Output Result

-- The C compiler identification is GNU 9.4.0
-- The CXX compiler identification is GNU 9.4.0
-- Check for working C compiler: /usr/bin/cc
-- Check for working C compiler: /usr/bin/cc -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring on/for Linux
-- Configuring done
-- Generating done
-- Build files have been written to: /home/jiangli/repo/tutorials/cmake-tutorial/chapter2/01/build

CMake defines the CMAKE_SYSTEM_NAME variable for the target operating system, so there is no need to use custom commands, tools, or scripts to query this information. The value of this variable can then be used to implement OS-specific conditions and solutions.

  • On macOS, CMAKE_SYSTEM_NAME is Darwin.

  • On Linux and Windows, CMAKE_SYSTEM_NAME is Linux and Windows respectively. We have learned how to execute specific CMake code on specific operating systems. Of course, we should minimize such customizations to simplify the process of migrating to new platforms.

Note: To minimize the cost of transferring from one platform to another, we should avoid using Shell commands directly, and also avoid explicit path separators (forward slashes on Linux and macOS, and backslashes on Windows). Use only forward slashes as path separators in CMake code, and CMake will automatically convert them to the appropriate format for the operating system involved.

Handling Compiler-Related Source Code

For portability, we try to avoid writing new code, but when there are dependencies, we must address them, especially when dealing with legacy code or compiler-dependent tools.

Project address:

https://gitee.com/jiangli01/tutorials/tree/master/cmake-tutorial/chapter2/02

CMakeLists.txt

cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(hello_os LANGUAGES CXX)

add_executable(${PROJECT_NAME} hello_os.cpp)

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_compile_definitions(${PROJECT_NAME} PUBLIC "IS_LINUX")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_compile_definitions(${PROJECT_NAME} PUBLIC "IS_MACOS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_compile_definitions(${PROJECT_NAME} PUBLIC "IS_WINDOWS")
endif()

By using the target_compile_definitions() command, we add predefined macros IS_LINUX, IS_MACOS, or IS_WINDOWS to the target hello_os, which will take effect during the compilation process.

target_compile_definitions restricts the definitions to a specific target and limits their visibility using PRIVATE|PUBLIC|INTERFACE qualifiers:

  • PRIVATE: The compile definitions will only apply to the given target and not to related other targets.

  • INTERFACE: The compile definitions for the given target will only apply to the targets that use it.

  • PUBLIC: The compile definitions will apply to the given target and all other targets that use it.

Of course, in C++, we can directly use predefined macros to identify different platforms and operating systems. These predefined macros are provided by the compiler or operating system and can be used in the source code to write platform-specific code. Here are some commonly used platform identification macros:

  • __APPLE__: Defined on Apple platforms (e.g., macOS and iOS).

  • __linux__: Defined on Linux platforms.

  • _WIN32: Defined on 32-bit Windows operating systems.

  • _WIN64: Defined on 64-bit Windows operating systems.

  • _MSC_VER: Defined when using the Microsoft Visual C++ compiler, indicating the version number of the compiler.

  • __GNUC__: Defined when using the GNU compiler (e.g., g++), indicating the version number of the compiler.

hello_os.cpp

#include &lt;string&gt;
#include &lt;iostream&gt;
std::string HelloOS();

int main() {
  std::cout &lt;&lt; HelloOS() &lt;&lt; std::endl;
  return EXIT_SUCCESS;
}

std::string HelloOS() {
#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   
}

On Windows, you will see Hello from Windows. Other operating systems will produce different outputs.

Detecting and Processing Processor Architecture

In the 1970s, the emergence of 64-bit integer operations and the introduction of 64-bit addressing for personal computers in the early 21st century expanded the memory addressing range, leading developers to invest significant resources in porting hardcoded 32-bit architectures to support 64-bit addressing. While avoiding explicit hardcoding is wise, adaptations to hardcoding limitations must be made in code configured with CMake. Project address:

https://gitee.com/jiangli01/tutorials/tree/master/cmake-tutorial/chapter2/03

CMakeLists.txt

cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(arch_dependent LANGUAGES CXX)

add_executable(
    ${PROJECT_NAME}
    ${CMAKE_SOURCE_DIR}/arch_dependent.cpp
)

# Check the size of the pointer type
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
    target_compile_definitions(
        ${PROJECT_NAME}
        PUBLIC "IS_64_BIT_ARCH"
    )
    message(STATUS "Target is 64 bits")
else()
    target_compile_definitions(
        ${PROJECT_NAME}
        PUBLIC "IS_32_BIT_ARCH"
    )
    message(STATUS "Target is 32 bits")
endif()

# Define target compile definitions to inform the preprocessor about the host processor architecture
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
    message(STATUS "i386 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
    message(STATUS "i686 architecture detected")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
    message(STATUS "x86_64 architecture detected")
else()
    message(STATUS "host processor architecture is unknown")
endif()

target_compile_definitions(
    ${PROJECT_NAME}
    PUBLIC "ARCHITECTURE=${CMAKE_HOST_SYSTEM_PROCESSOR}"
)

CMake defines the CMAKE_HOST_SYSTEM_PROCESSOR variable to include the name of the processor currently running. It can be set to i386, i686, x86_64, AMD64, etc., depending on the current CPU.

CMAKE_SIZEOF_VOID_P gives the size of the void pointer. It can be queried during CMake configuration to modify target or target compile definitions. Based on the detected host processor architecture, preprocessor definitions can be used to determine which branches of source code need to be compiled.

Of course, such dependencies should be avoided when writing new code, but they are useful when dealing with legacy code or cross-compilation.

Note: Using CMAKE_SIZEOF_VOID_P is the only “truly” portable way to check whether the current CPU has 32-bit or 64-bit architecture. arch_dependent.cpp

#include &lt;cstdlib&gt;
#include &lt;iostream&gt;
#include &lt;string&gt;

#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)

std::string ArchInfo();

int main() {
  std::cout &lt;&lt; ArchInfo() &lt;&lt; std::endl;
  return EXIT_SUCCESS;
}

std::string ArchInfo() {
  std::string arch_info(TOSTRING(ARCHITECTURE));
  arch_info += std::string(" architecture.  ");
#ifdef IS_32_BIT_ARCH
  return arch_info + std::string("Compiled on a 32 bit host processor.");
#elif IS_64_BIT_ARCH
  return arch_info + std::string("Compiled on a 64 bit host processor.");
#else
  return arch_info + std::string("Neither 32 nor 64 bit, puzzling ...");
#endif
}

Output Result

mkdir build
cd build
cmake ..
-- The CXX compiler identification is GNU 9.4.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Target is 64 bits
-- x86_64 architecture detected
-- Configuring done
-- Generating done
-- Build files have been written to: /home/jiangli/repo/tutorials/cmake-tutorial/chapter2/03/build
make
Scanning dependencies of target arch_dependent
[ 50%] Building CXX object CMakeFiles/arch_dependent.dir/arch_dependent.cpp.o
[100%] Linking CXX executable arch_dependent
[100%] Built target arch_dependent
./arch_dependent
x86_64 architecture.  Compiled on a 64 bit host processor.

Appendix In addition to CMAKE_HOST_SYSTEM_PROCESSOR, CMake also defines the CMAKE_SYSTEM_PROCESSOR variable. The former contains the name of the CPU currently running in CMake, while the latter contains the name of the CPU currently being built for. This is a subtle difference that plays a crucial role in cross-compilation.

Another way to let CMake detect the host processor architecture is to use symbols defined in C or C++, combined with CMake‘s try_run function:

#if defined(__i386) || defined(__i386__) || defined(_M_IX86)
    #error cmake_arch i386
#elif defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(_M_X64)
    #error cmake_arch x86_64
#endif

This strategy is also the recommended way to detect the target processor architecture, as CMake does not seem to provide a portable intrinsic solution.

Another option is to use only CMake without any preprocessor directives, at the cost of setting different source files for each case and then using the target_source command to set them as source files dependent on the executable target arch_dependent:

add_executable(arch-dependent "")
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i386")
    message(STATUS "i386 architecture detected")
    target_sources(arch_dependent
        PRIVATE
        arch_dependent_i386.cpp
    )
elif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "i686")
    message(STATUS "i686 architecture detected")
    target_sources(arch_dependent
        PRIVATE
            arch_dependent_i686.cpp
    )
elif(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
    message(STATUS "x86_64 architecture detected")
    target_sources(arch_dependent
        PRIVATE
            arch_dependent_x86_64.cpp
    )
else()
    message(STATUS "host processor architecture is unknown")
endif()

This method clearly requires more work on existing projects, as source files need to be separated. Additionally, code duplication between different source files will definitely become an issue.

Detecting Processor Instruction Set

CMake can detect the instruction set supported by the host processor. This feature was added to CMake in newer versions and requires CMake 3.10 or higher. The detected host system information can be used to set corresponding compiler flags, implement optional source code compilation, or generate source code based on the host system.

We will generate a config.h file using config.h.in. The config.h.in file is as follows:

#ifndef CONFIG_HEADER_IN_H
#define CONFIG_HEADER_IN_H

#define NUMBER_OF_LOGICAL_CORES @_NUMBER_OF_LOGICAL_CORES@
#define NUMBER_OF_PHYSICAL_CORES @_NUMBER_OF_PHYSICAL_CORES@
#define TOTAL_VIRTUAL_MEMORY @_TOTAL_VIRTUAL_MEMORY@
#define AVAILABLE_VIRTUAL_MEMORY @_AVAILABLE_VIRTUAL_MEMORY@
#define TOTAL_PHYSICAL_MEMORY @_TOTAL_PHYSICAL_MEMORY@
#define AVAILABLE_PHYSICAL_MEMORY @_AVAILABLE_PHYSICAL_MEMORY@
#define IS_64BIT @_IS_64BIT@
#define HAS_FPU @_HAS_FPU@
#define HAS_MMX @_HAS_MMX@
#define HAS_MMX_PLUS @_HAS_MMX_PLUS@
#define HAS_SSE @_HAS_SSE@
#define HAS_SSE2 @_HAS_SSE2@
#define HAS_SSE_FP @_HAS_SSE_FP@
#define HAS_SSE_MMX @_HAS_SSE_MMX@
#define HAS_AMD_3DNOW @_HAS_AMD_3DNOW@
#define HAS_AMD_3DNOW_PLUS @_HAS_AMD_3DNOW_PLUS@
#define HAS_IA64 @_HAS_IA64@
#define OS_NAME "@_OS_NAME@"
#define OS_RELEASE "@_OS_RELEASE@"
#define OS_VERSION "@_OS_VERSION@"
#define OS_PLATFORM "@_OS_PLATFORM@"

#endif // ! CONFIG_HEADER_IN_H

CMakeLists.txt

cmake_minimum_required(VERSION 3.10 FATAL_ERROR)

project(progressor_info LANGUAGES CXX)

add_executable(${PROJECT_NAME} "")

target_sources(${PROJECT_NAME}
  PRIVATE ${CMAKE_SOURCE_DIR}/processor_info.cpp
)

target_include_directories(${PROJECT_NAME}
  PRIVATE ${PROJECT_BINARY_DIR}
)

foreach(key
  IN ITEMS
    NUMBER_OF_LOGICAL_CORES
    NUMBER_OF_PHYSICAL_CORES
    TOTAL_VIRTUAL_MEMORY
    AVAILABLE_VIRTUAL_MEMORY
    TOTAL_PHYSICAL_MEMORY
    AVAILABLE_PHYSICAL_MEMORY
    IS_64BIT
    HAS_FPU
    HAS_MMX
    HAS_MMX_PLUS
    HAS_SSE
    HAS_SSE2
    HAS_SSE_FP
    HAS_SSE_MMX
    HAS_AMD_3DNOW
    HAS_AMD_3DNOW_PLUS
    HAS_IA64
    OS_NAME
    OS_RELEASE
    OS_VERSION
    OS_PLATFORM
)
  cmake_host_system_information(RESULT _${key} QUERY ${key})
endforeach()

configure_file(config.h.in config.h @ONLY)

The foreach loop queries multiple keys and defines corresponding variables. The cmake_host_system_information queries the system information of the host system running CMake. In this example, a function call is made for each key. Then, these variables are used to configure the placeholders in config.h.in, inputting and generating config.h.

This configuration is completed using the configure_file command. Finally, config.h is included in processor_info.cpp. After compilation, it will print the values to the screen.

target_include_directories(${PROJECT_NAME}
  PRIVATE ${PROJECT_BINARY_DIR}
)

This will link the generated executable file to the folder where the executable file is located.

After compilation, config.h will be generated in build, and the content generated in the native environment is as follows:

#ifndef CONFIG_HEADER_IN_H
#define CONFIG_HEADER_IN_H

#define NUMBER_OF_LOGICAL_CORES 16
#define NUMBER_OF_PHYSICAL_CORES 16
#define TOTAL_VIRTUAL_MEMORY 2047
#define AVAILABLE_VIRTUAL_MEMORY 2047
#define TOTAL_PHYSICAL_MEMORY 7903
#define AVAILABLE_PHYSICAL_MEMORY 6007
#define IS_64BIT 1
#define HAS_FPU 1
#define HAS_MMX 1
#define HAS_MMX_PLUS 0
#define HAS_SSE 1
#define HAS_SSE2 1
#define HAS_SSE_FP 0
#define HAS_SSE_MMX 0
#define HAS_AMD_3DNOW 0
#define HAS_AMD_3DNOW_PLUS 0
#define HAS_IA64 0
#define OS_NAME "Linux"
#define OS_RELEASE "5.15.0-78-generic"
#define OS_VERSION "#85~20.04.1-Ubuntu SMP Mon Jul 17 09:42:39 UTC 2023"
#define OS_PLATFORM "x86_64"

#endif // ! CONFIG_HEADER_IN_H

processor_info.cpp

#include &lt;cstdlib&gt;
#include &lt;iostream&gt;

#include "config.h"
int main() {
  std::cout &lt;&lt; "Number of logical cores: " &lt;&lt; NUMBER_OF_LOGICAL_CORES
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Number of physical cores: " &lt;&lt; NUMBER_OF_PHYSICAL_CORES
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Total virtual memory in megabytes: " &lt;&lt; TOTAL_VIRTUAL_MEMORY
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Available virtual memory in megabytes: "
            &lt;&lt; AVAILABLE_VIRTUAL_MEMORY &lt;&lt; std::endl;
  std::cout &lt;&lt; "Total physical memory in megabytes: " &lt;&lt; TOTAL_PHYSICAL_MEMORY
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Available physical memory in megabytes: "
            &lt;&lt; AVAILABLE_PHYSICAL_MEMORY &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor is 64Bit: " &lt;&lt; IS_64BIT &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor has floating point unit: " &lt;&lt; HAS_FPU &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports MMX instructions: " &lt;&lt; HAS_MMX &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports Ext. MMX instructions: " &lt;&lt; HAS_MMX_PLUS
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports SSE instructions: " &lt;&lt; HAS_SSE &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports SSE2 instructions: " &lt;&lt; HAS_SSE2
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports SSE FP instructions: " &lt;&lt; HAS_SSE_FP
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports SSE MMX instructions: " &lt;&lt; HAS_SSE_MMX
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports 3DNow instructions: " &lt;&lt; HAS_AMD_3DNOW
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "Processor supports 3DNow+ instructions: " &lt;&lt; HAS_AMD_3DNOW_PLUS
            &lt;&lt; std::endl;
  std::cout &lt;&lt; "IA64 processor emulating x86 : " &lt;&lt; HAS_IA64 &lt;&lt; std::endl;
  std::cout &lt;&lt; "OS name: " &lt;&lt; OS_NAME &lt;&lt; std::endl;
  std::cout &lt;&lt; "OS sub-type: " &lt;&lt; OS_RELEASE &lt;&lt; std::endl;
  std::cout &lt;&lt; "OS build ID: " &lt;&lt; OS_VERSION &lt;&lt; std::endl;
  std::cout &lt;&lt; "OS platform: " &lt;&lt; OS_PLATFORM &lt;&lt; std::endl;
  
  return EXIT_SUCCESS;
}

Finally: May everything go smoothly for you, and may you become the person you want to be!

CMake: Detecting Environment
CMake: Detecting Environment

Leave a Comment