Modern way to set compiler flags in cross-platform cmake project

Don't do it!

Especially not in CMake 3.19+ where presets are an option. Put optional settings like warning flags in the presets and put only hard build requirements in the CMakeLists.txt.

Read on for an explanation why.


I want to write a cmake file that sets different compiler options for clang++, g++ and MSVC in debug and release builds.

Here's the thing: you don't want to write a CMakeLists.txt that sets different options, you just want to have a convenient place to store your flags. That's where presets and toolchain files come in (more below).

What I'm doing currently looks something like this:

if(MSVC)
    # ...
else()
    # ...
    if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
        # ...
    else()
        # ...
    endif()
endif()

[...] I've read multiple times, that one should not manually set CMAKE_CXX_FLAGS and similar variables in the first place, but im not sure what other mechanism to use.

Here's the problems with this structure:

  1. There are too many compiler vendors. There's MSVC, Clang, and GCC, yes, but there's also the Intel compiler, the PGI compiler, and so on.
  2. There are too many compiler variants. Not only is there Clang, there's also ClangCL and the Clang CUDA compiler. The Intel compiler can also switch between MSVC and GCC compatible modes.
  3. There are too many compiler versions. The meanings of warning flags change across versions. From one version to the next, a given warning might be more or less sensitive, especially the more advanced ones that perform dataflow analysis. With warnings-as-errors enabled, this translates into broken builds for your users.

You do not want to be in the business of maintaining flag compatibility tables for your build. Fortunately, there's a simple solution: don't.

Store your intended flags in a preset (CMake 3.19+) or a toolchain file (CMake 3.0+, maybe earlier) and let your users opt-in to those settings if they so choose.

With a preset, it's as simple as writing a CMakePresets.json file. There are some extensive examples in the documentation. Then you let your users tell you which set of flags they want to use:

# Using presets:
$ cmake --preset=msvc
$ cmake --preset=gcc
$ cmake --preset=clang

# Using toolchains:
$ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$PWD/cmake/msvc.cmake
$ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$PWD/cmake/gcc.cmake
$ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$PWD/cmake/clang.cmake

and so on.

Most importantly: The way I do it here, I need a separate build directory for each compiler and configuration Ideally I'd like to transform that into havin multiple targets in the same directory so I can e.g. call make foo_debug_clang.

This is an odd requirement because each one of those has to fully build everything anyway. I would honestly just recommend setting up a wrapper Makefile to support this workflow because (a) CMake doesn't support it natively and (b) there's no real advantage to extending CMake to do so.

  • b) Is there something like an accepted, modern best practice of how to set up such projects?

I don't know about "accepted", but I have absolutely found that splitting the hard build requirements from the ideal build settings between the CMakeLists.txt and the presets (or toolchain files) respectively solves (or sidesteps) many of these sorts of issues. The end result is more robust builds that are easier to work with.


Another way is to use .rsp files.

set(rsp_file "${CMAKE_CURRENT_BINARY_DIR}/my.rsp")
configure_file(my.rsp.in ${rsp_file} @ONLY)
target_compile_options(mytarget PUBLIC "@${rsp_file}")

which might make the inclusion of multiple and esoteric options easier to manage.


Your approach would - as @Tsyvarev has commented - be absolutely fine, just since you've asked for the "new" approach in CMake here is what your code would translate to:

cmake_minimum_required(VERSION 3.8)

project(HelloWorld)

string(
    APPEND _opts
    "$<IF:$<CXX_COMPILER_ID:MSVC>,"
        "/W4;$<$<CONFIG:RELEASE>:/O2>,"
        "-Wall;-Wextra;-Werror;"
            "$<$<CONFIG:RELEASE>:-O3>"
            "$<$<CXX_COMPILER_ID:Clang>:-stdlib=libc++>"
    ">"
)

add_compile_options("${_opts}")

add_executable(HelloWorld "main.cpp")

target_compile_features(HelloWorld PUBLIC cxx_lambda_init_captures)

You take add_compile_options() and - as @Al.G. has commented - "use the dirty generator expressions".

There are some downsides of generator expressions:

  1. The very helpful $<IF:...,...,...> expression is only available in CMake version >= 3.8
  2. You have to write it in a single line. To avoid it I used the string(APPEND ...), which you can also use to "optimize" your set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ... calls.
  3. It's difficult to read and understand. E.g. the semicolons are needed to make it a list of compile options (otherwise CMake will quote it).

So better use a more readable and backward compatible approach with add_compile_options():

if(MSVC)
    add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
else()
    add_compile_options("-Wall" "-Wextra" "-Werror" "$<$<CONFIG:RELEASE>:-O3>")
    if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
        add_compile_options("-stdlib=libc++")
    else()
        # nothing special for gcc at the moment
    endif()
endif()

And yes, you don't explicitly specify the C++ standard anymore, you just name the C++ feature your code/target does depend on with target_compile_features() calls.

For this example I've chosen cxx_lambda_init_captures which would for e.g. an older GCC compiler give the following error (as an example what happens if a compiler does not support this feature):

The compiler feature "cxx_lambda_init_captures" is not known to CXX compiler

"GNU"

version 4.8.4.

And you need to write a wrapper script to build multiple configurations with a "single configuration" makefile generator or use a "multi configuration" IDE as Visual Studio.

Here are the references to examples:

  • Does CMake always generate configurations for all possible project configurations?
  • How do I tell CMake to use Clang on Windows?
  • How to Add Linux Compilation to Cmake Project in Visual Studio

So I've tested the following with the Open Folder Visual Studio 2017 CMake support to combine in this example the cl, clang and mingw compilers:

Configurations

CMakeSettings.json

{
    // See https://go.microsoft.com//fwlink//?linkid=834763 for more information about this file.
    "configurations": [
        {
            "name": "x86-Debug",
            "generator": "Visual Studio 15 2017",
            "configurationType": "Debug",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "buildCommandArgs": "-m -v:minimal",
        },
        {
            "name": "x86-Release",
            "generator": "Visual Studio 15 2017",
            "configurationType": "Release",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "buildCommandArgs": "-m -v:minimal",
        },
        {
            "name": "Clang-Debug",
            "generator": "Visual Studio 15 2017",
            "configurationType": "Debug",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "cmakeCommandArgs": "-T\"LLVM-vs2014\"",
            "buildCommandArgs": "-m -v:minimal",
        },
        {
            "name": "Clang-Release",
            "generator": "Visual Studio 15 2017",
            "configurationType": "Release",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "cmakeCommandArgs": "-T\"LLVM-vs2014\"",
            "buildCommandArgs": "-m -v:minimal",
        },
        {
            "name": "GNU-Debug",
            "generator": "MinGW Makefiles",
            "configurationType": "Debug",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "variables": [
                {
                    "name": "CMAKE_MAKE_PROGRAM",
                    "value": "${projectDir}\\mingw32-make.cmd"
                }
            ]
        },
        {
            "name": "GNU-Release",
            "generator": "Unix Makefiles",
            "configurationType": "Release",
            "buildRoot": "${env.LOCALAPPDATA}\\CMakeBuild\\${workspaceHash}\\build\\${name}",
            "variables": [
                {
                    "name": "CMAKE_MAKE_PROGRAM",
                    "value": "${projectDir}\\mingw32-make.cmd"
                }
            ]
        }
    ]
}

mingw32-make.cmd

@echo off
mingw32-make.exe %~1 %~2 %~3 %~4

So you can use any CMake generator from within Visual Studio 2017, there is some unhealthy quoting going on (as for September 2017, maybe fixed later) that requires that mingw32-make.cmd intermediator (removing the quotes).


Addressing the first two points, but not the third:

  1. I've read multiple times, that one should not manually set CMAKE_CXX_FLAGS and similar variables in the first place, but im not sure what other mechanism to use.

The command you want is set_property. CMake supports a bunch of properties - not everything, but lots - in a way which saves you the trouble of doing compiler-specific work. For example:

set_property(TARGET foo PROPERTY CXX_STANDARD 17)

which for some compilers will result in --std=c++17 but for earlier ones with --std=c++1z (before C++17 was finalized). or:

set_property(TARGET foo APPEND PROPERTY COMPILE_DEFINITIONS HELLO WORLD)

will result it -DHELLO -DWORLD for gcc, clang and MSVC but for weird compilers might use other switches.

Is there relly no command like append that would allow me to replace set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} Foo") with append(CMAKE_CXX_FLAGS "Foo")?

set_property can be used either in set mode, or in append mode (see above examples).

I can't say whether this is preferable to add_compile_options or target_compile_features, though.

Tags:

C++

Cmake