But don’t we hate CMake? Not me! I think it’s useful.

Some tools are purpose built with pragmatic goals, they do not let some ideal get in the way of doing meaningful work, and they empower the users of the tool rather than the ego of tool creator. And they don’t bind you to Sauron some platform or service.

CMake is one of these tools. It is a set of well tested and carefully considered capabilities which started out as a simple macro language which could stamp out concrete build definitions for a variety of build systems. Today it is a more declarative and composable target definition system, supporting several languages and many platforms… that can stamp out concrete build definitions for a variety of build systems.

It isn’t perfect by any means and at the end of the day everything has it’s rough edges, trade-offs, and pain points. But CMake is as ubiquitous and widely supported as it gets. It pays to develop a bit of skill with it.

Note
I’ll be the first to admit there are cursed CMake setups all over the place and it is easy to conclude that CMake is haunted. We’ve all worked at shops where nobody truly owns the build, and experienced the productivity disasters that follows. But that isn’t actually a CMake problem in my opinion.

Pillars of a useful CMake project Link to heading

Presets Link to heading

CMake Presets are invaluable to a contemporary project. They help your users by supplying them with simpler command lines to configure, build and test projects. They also help your users with IDEs correctly configure their environment. They make CI simpler by defining relevant configurations and workflows in one place.

Of course, the preset system has it’s rough edges. As you factor presets into composable fragments, you quickly see the combinatorial explosion of presets in front of you. Another issue is that preset names must be globally unique, which is particularly annoying to me since you can enable/disable presets based on the host platform and each category of preset (configure, build, test, and workflow) would be a lot nicer if you could line up preset names in each.

Toolchains Link to heading

When you specify a toolchain file during configuration it will be loaded before anything else during the first project call. You can only specify a single toolchain file, unlike when using CMAKE_PROJECT_TOP_LEVEL_INCLUDES which takes a list.

Despite the name, a toolchain file is just a regular CMake script and can prepopulate any configuration or global settings relevant to your project. Indeed, package managers such as vcpkg inject themselves into your build by way of a toolchain file.

Even if you intend to use a system supplied compiler, you may want to enforce a further degree of control over your linker and standard library. A CMake toolchain script is the appropriate place to do this.

FetchContent Link to heading

When CMake added the ExternalProject module it was a nice first step towards a native dependency system. One of the biggest drawbacks to using this though was that the entire build of any declared externals happened during configuration time (ahem not at all unlike the situation with vcpkg or conan today cough cough).

A portion of this system was responsible for fetching, and unpacking dependencies in a repeatable and directable way. Eventually we got those portions as an isolated feature known as the FetchContent module.

Unlike the ExternalProject module, FetchContent can and will include the fetched content into your build. Libraries such as boost who traditionally had completely alien builds and were a huge pain to leverage and bootstrap, now have a solid story for inclusion in your projects using CMake and FetchContent as easily as this:

FetchContent_Declare(
    Boost
    URL https://github.com/boostorg/boost/releases/download/boost-1.85.0/boost-1.85.0-cmake.tar.xz
    URL_MD5 badea970931766604d4d5f8f4090b176
    DOWNLOAD_EXTRACT_TIMESTAMP ON
)

set(BOOST_INCLUDE_LIBRARIES program_options property_tree asio)
set(BOOST_RUNTIME_LINK static)
set(BOOST_ENABLE_MPI OFF)
set(BOOST_ENABLE_PYTHON OFF)
FetchContent_MakeAvailable(Boost)

Properly factoring your project Link to heading

Most of your opinions or company conventions should live in an accessible easily shared module. It’s the easiest way to normalize build outputs, common properties, or detect and manage platform specific setups while keeping the declaration of targets relatively clear of the noise. By declaring your desired conventions into a common module you are discouraging disjointed or sprawling settings and definitions throughout your build.

Important Extras Link to heading

These aren’t really part of CMake, but I consider them important enough that I usually only consider CMake as a triumvirate:

       cmake
        / \
       /   \
      /     \
ccache ----- ninja

Ccache Link to heading

Unlike build systems such as FastBUILD, Bazel, or Zig, CMake doesn’t have a built-in cache. It’s important to get caching going from the outset of your project, and Ccache is useful and easy to integrate for most of the CMake generators.

Tip
On Fedora if you install ccache, then it will be automatically set up as the system cc and c++.

Ninja Link to heading

Ninja is fast, simple, and light. I prefer it to essentially any other generator that CMake offers. It’s worth your time to make this the default.

Bonus: Clangd Link to heading

I almost didn’t include this, but it’s honestly hard to over estimate how useful this is. When using Sublime, Emacs, VScode, Helix, NeoVim, or any editor that supports LSP, clangd provides the great introspection and linting you expect in a modern development environment. And because it also embeds clang-tidy and clang-format, there is no reason to avoid taking the 5 or 10 minutes to configure these and include them in your project repo.

A Minimal Example Link to heading

Originally when I started writing I figured I’d just outline a couple of my existing projects and how my approach to CMake lined up with each. But now, I feel like it’s more useful to walk through a very minimal example and save some deep dives for other posts.

So, let’s begin with the most basic “hello world” example and progressively introduce these pillars. I’m working on Fedora 40 (KDE spin because I prefer actual desktop environments!) and have already installed most essentials for building software.

Note
Rather than list the packages I installed I’ll just say that the very minimum you need would be some compiler, cmake, and ninja. If you can build hello world then we’re starting at the right place, but eventually we’ll be adding SDL which may end up requiring some development packages added for your window system if you’re on Linux.

Hello World Link to heading

The simplest Cmake project in the world consists of two files:

CMakeLists.txt:

# At the time of writing, CMake stable is 3.30
cmake_minimum_required(VERSION 3.28)

project(contemporary-cmake C)

add_executable(hello main.c)

main.c:

#include <stdio.h>

int main(int argc, char* argv[]) {
    printf("Hello! ... world.\n");
    return 0;
}

Configuring and building this masterpiece is as easy as:

cmake -B build -GNinja .
cmake --build build

OK, so this was a silly start, but it’s the baseline we build up from.

Adding CMakePresets.json Link to heading

We can start by getting the ground work for our presets established. Using presets we encode our projects expected layout and workflow.

CMakePresets.json

{
    "version": 6,
    "cmakeMinimumRequired": {
        "major": 3,
        "minor": 28,
        "patch": 0
    },
    "include": [],
    "configurePresets": [
        {
            "name": "default",
            "displayName": "Default Configuration",
            "generator": "Ninja",
            "binaryDir": "${sourceDir}/build",
            "cacheVariables": {
                "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
            }
        }
    ],
    "buildPresets": [],
    "testPresets": [],
    "workflowPresets": []
}

First question you might have is “How do I know what version of the preset format to use?” and that is an excellent question. At the time of writing Cmake 3.30 supports up to version 8. But CLion for example only supports up to version 6. Other IDEs might have different capabilities so I don’t really have a recommendation for what to use since it will depend on your style of working and preferred environment.

Now you can configure the project: cmake --preset=default but you still have to build it as before.

Note

CMake presets come in a variety of flavors, but at the root of them all must be a configuration preset. Unlike a build system like Bazel where you can ask to run a test and Bazel will build all targets required for this test, with CMake presets you’re primarily still configuring, building, and testing as separate distinct steps.

The workflow presets are the exception here as they specifically encode how to run a full workflow from start to finish. They are quite nice for CI.

Adding build presets for different CMAKE_BUILD_TYPES Link to heading

First change the generator in the default preset to Ninja Multi-Config so that we can use build presets to determine whether we build in Debug or Release with a single configuration preset.

Add the following to your buildPresets: [] list:

{
    "name": "default-build",
    "hidden": true,
    "configurePreset": "default",
    "inheritConfigureEnvironment": true
},
{
    "name": "debug",
    "inherits": [
        "default-build"
    ],
    "configuration": "Debug"
},
{
    "name": "release",
    "inherits": [
        "default-build"
    ],
    "configuration": "Release"
}

OK, let’s briefly unpack this. We’ve added 3 new presets and 1 is hidden. The other two inherit this hidden preset. A “hidden” preset is just one that isn’t displayed when you query the presets available (Ex: cmake --build --list-presets). By inheriting from a base preset we can avoid repeating ourselves excessively when we only want to vary the “configuration” we build. This inheritance pattern is the key to making presets do their best work for you.

So now, after we configure the build cmake --preset=default, we can build the debug or release version of our app using cmake --build --preset=debug or cmake --build --preset=release.

Adding a workflow preset to make our build a one-liner Link to heading

Skipping test presets for the moment (because we don’t have any tests!) let’s add a “build all” workflow preset. Add the following to your workflowPresets: [] list:

{
    "name": "build-all",
    "displayName": "Build all targets for Debug and Release configurations.",
    "steps": [
        {
            "type": "configure",
            "name": "default"
        },
        {
            "type": "build",
            "name": "debug"
        },
        {
            "type": "build",
            "name": "release"
        }
    ]
}

And now from a fresh clone you can configure and build everything with one line: cmake --workflow --preset=build-all.

Pulling in dependencies with FetchContent Link to heading

Now that we have scaffolded a simple set of presets we should proceed by using the FetchContent module to pull in SDL3 and GoogleTest to our build.

I should say ahead of time, that I tend to only use FetchContent for a couple of specific dependencies and do prefer to just vendor some libraries as a git subtree (or by whatever similar mechanism is available in other VCSs).

Create a new file cmake/RemoteDeps.cmake:

include(FetchContent)

FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/6dae7eb4a5c3a169f3e298392bff4680224aa94a.zip
)
FetchContent_Declare(
    SDL3
    URL https://github.com/libsdl-org/SDL/archive/1f8b9a320cc97cd62dfcb18848e2551153f29e36.zip
)
FetchContent_MakeAvailable(googletest SDL3)

Then you can include it in the main CMake script right after the first project call: include(cmake/RemoteDeps.cmake)

The next time you configure or run the build, it should download the two archives and include them in the build.

We can make sure this worked by linking our executable to SDL:

target_link_libraries(hello SDL3-shared)

If we re-run our workflow preset we’ll see that SDL3 and googletest are built for each configuration.

Note
In case it isn’t clear, FetchContent is specific to each cmake configuration you use. This is perfectly fine and dandy for some situations but may not be ideal for every dependency you might need. Ccache can help out a bit for dependencies you’re building from source, and you might otherwise find that directly vendoring certain libraries in the repo (using add_subdirectory instead of the FetchContent module) is ultimately the better choice for your project. For large binaries (compiler toolchains for example) that would be used by more than a single configuration this might require another mechanism to be the most efficient. This is also a strong reason for using the ‘Ninja Multi-Config’ generator so that you can rely on FetchContent for toolchains and not have two copies for Debug and Release builds.

Supplying build options for dependencies Link to heading

If you want to pass options to the build of the projects now included in your build the good news is that you just need to set them sometime before the call to FetchContent_MakeAvailable. Since they are just a part of your build, you can hard-code them in the CMakeLists.txt if you like, but I like the idea of using presets for this.

Let’s update our configure presets to allow us to build with a shared or a static SDL3. For our default preset we will update our binary directory to include the preset name: "binaryDir": "${sourceDir}/build/${presetName}",. After that we can create a second preset that inherits from default:

{
    "name": "default-static",
    "displayName": "Default Configuration - Static SDL3",
    "inherits": ["default"],
    "cacheVariables": {
        "SDL_STATIC": "ON",
        "SDL_SHARED": "OFF"
    }
}

So now we should just be able to configure and build like before:

cmake --preset=default-static
ninja -C build/default-static

OOPS…

Error
chipc@rabe:~/workspaces/slowgames/cmake-example$ ninja -C build/default-static/
ninja: Entering directory `build/default-static/'
[24/266] Linking C executable Debug/hello
FAILED: Debug/hello
: && /usr/lib64/ccache/cc -g  CMakeFiles/hello.dir/Debug/main.c.o -o Debug/hello  -lSDL3-shared && :
/usr/bin/ld: cannot find -lSDL3-shared: No such file or directory
collect2: error: ld returned 1 exit status

Riiight, we were linking with a thing using an internal detail to that build. I found the target name by running ninja -C build help previously, and when I do that again I see that we should now be using SDL3-static.

So, how should we work with this? Maybe in our CMakeLists.txt:

set(_sdl_lib SDL3-shared)
if (SDL_STATIC)
    set(_sdl_lib SDL3-static)
endif()

target_link_libraries(hello PRIVATE _sdl_lib)

That’s a solution to the problem. And it will work. But there is a better way.

If you read SDLs CMakeLists.txt you’ll see that it’s defining proper ALIASes for its targets and indeed will correctly alias the static library if you do not build the shared library at the same time. Have a look here.

Tip
ALIAS targets are great, and you should get in the habit of providing them for your public targets. I’d like to talk more about them, with examples from actual work projects, in another post.

So, the ALIAS we should actually be using for our application is SDL3::SDL3:

target_link_libraries(hello PRIVATE SDL3::SDL3)

Now our program links correctly for both presets. Sure we have to grep around the other projects CMakeLists.txt for any ALIAS targets, and there might actually be a better way to query that but as far as I know, even in systems like vcpkg the recipe author just lists what ALIAS targets you should be using.

Presets! Presets everywhere! Link to heading

OK, remember how I mentioned the combinatorial explosion of presets? This is the first taste, because we definitely want to replicate our build and workflow presets for this new default-static configuration preset.

There is no easy way to say it, it’s a noisy copy-paste type of thing. However, smarter people than me once said something along the lines of “it’s pointless to optimize the part of the process you do once at the start when you’ll spend 99% of the time working with the other stuff.”, and I agree.

So we need 3 new build presets and 1 new workflow preset, and they all that to be uniquely named. I’m going with:

  • default-build-static
  • debug-static
  • release-static
  • build-all-static

Our new build presets look like:

{
    "name": "default-build-static",
    "hidden": true,
    "configurePreset": "default-static",
    "inheritConfigureEnvironment": true
},
{
    "name": "debug-static",
    "inherits": [
        "default-build-static"
    ],
    "configuration": "Debug"
},
{
    "name": "release-static",
    "inherits": [
        "default-build-static"
    ],
    "configuration": "Release"
}

And our new workflow preset looks like:

{
    "name": "build-all-static",
    "displayName": "Build all targets for Debug and Release configurations.",
    "steps": [
        {
            "type": "configure",
            "name": "default-static"
        },
        {
            "type": "build",
            "name": "debug-static"
        },
        {
            "type": "build",
            "name": "release-static"
        }
    ]
}

We now have symmetry and our hypothetical CI system builds the universe with two lines:

cmake --workflow --preset=build-all
cmake --workflow --preset=build-all-static

Update our program to actually use SDL Link to heading

Not much to say about this. We just want to open a window. main.c now looks like this:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>

#include <SDL3/SDL.h>


int main(int argc, char* argv[]) {

    if (!SDL_Init(SDL_INIT_VIDEO)) {
        fprintf(stderr, "Failed to initialize SDL: %s\n", SDL_GetError());
        return EXIT_FAILURE;
    }

    SDL_Window* window = SDL_CreateWindow("Hello!", 1024, 768, SDL_WINDOW_HIGH_PIXEL_DENSITY);
    if (window == NULL) {
        fprintf(stderr, "Failed to create window: %s\n", SDL_GetError());
        return EXIT_FAILURE;
    }

    bool running = true;
    SDL_Event event;
    while(running) {
        while(SDL_PollEvent(&event)) {
            switch(event.type) {
            case SDL_EVENT_QUIT: {
                running = false;
                break;
            }
            default: break;
            }
        }

        SDL_Delay(100);
    }

    SDL_Quit();

    return EXIT_SUCCESS;
}

Now when you run build/default/Debug/hello you should be greeted with a canvas of infinite possibility:

hello

Applying some conventions Link to heading

At this point we might want to update our build to use a specific C standard version, or place our targets into a specific location other than the default build tree. We might also want to ensure that our targets are aliased following some convention.

So we start with our very simple hello target:

add_executable(hello main.c)
target_link_libraries(hello PRIVATE SDL3::SDL3)

and we update it like so:

set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)

set(_debug_bin "${CMAKE_BINARY_DIR}/out/Debug/bin")
set(_debug_lib "${CMAKE_BINARY_DIR}/out/Debug/lib")

set(_release_bin "${CMAKE_BINARY_DIR}/out/Release/bin")
set(_release_lib "${CMAKE_BINARY_DIR}/out/Release/lib")


set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG   ${_debug_bin})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${_release_bin})
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_DEBUG   ${_debug_lib})
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${_release_lib})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG   $<IF:$<STREQUAL:$<PLATFORM_ID>,Windows>,${_debug_bin},  ${_debug_lib})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE $<IF:$<STREQUAL:$<PLATFORM_ID>,Windows>,${_release_bin},${_release_lib})

add_executable(hello main.c)
target_link_libraries(hello PRIVATE SDL3::SDL3)

This is clearly not the cleanest or most elegant approach. It’s applying certain settings from the current directory and below and perhaps you don’t want to do that. We can update this to be specific for the hello target, but what happens once we add another target or two or three and so on. And not to mention, what about when we want to have this setup applied to another project?

That’s where a simple module comes in, as it can be easily shared, can provide a minimum set of macros, functions and variables, and can help keep your primary scripts focused on declaring their targets.

Note
At this point I’m going to start prefixing certain things with SG_ which is just my thing. Your prefix can be whatever is clearly yours or relevant to your project.

Let’s create a new file:

cmake/CommonProperties.cmake


# This allows you to override the output root in your presets
if (NOT SG_OUTPUT_ROOT)
    set(SG_OUTPUT_ROOT ${CMAKE_BINARY_DIR}/out)
endif ()

message(STATUS "[SG] Build Output Path: ${SG_OUTPUT_ROOT}")

set(_debug_bin "${SG_OUTPUT_ROOT}/Debug/bin")
set(_debug_lib "${SG_OUTPUT_ROOT}/Debug/lib")

set(_release_bin "${SG_OUTPUT_ROOT}/Release/bin")
set(_release_lib "${SG_OUTPUT_ROOT}/Release/lib")

function(sg_common_properties TGT_NAME)
    set_target_properties(${TGT_NAME} PROPERTIES
        RUNTIME_OUTPUT_DIRECTORY_DEBUG ${_debug_bin}
        RUNTIME_OUTPUT_DIRECTORY_RELEASE ${_release_bin}
        ARCHIVE_OUTPUT_DIRECTORY_DEBUG ${_debug_lib}
        ARCHIVE_OUTPUT_DIRECTORY_RELEASE ${_release_lib}
        # When targeting Windows, we want DLLs to land next to executables
        LIBRARY_OUTPUT_DIRECTORY_DEBUG   $<IF:$<STREQUAL:$<PLATFORM_ID>,Windows>, ${_debug_bin},   ${_debug_lib}>
        LIBRARY_OUTPUT_DIRECTORY_RELEASE $<IF:$<STREQUAL:$<PLATFORM_ID>,Windows>, ${_release_bin}, ${_release_lib}>
    )
endfunction()

macro(sg_executable TGT_NAME)
    add_executable(${TGT_NAME} ${ARGN})
    sg_common_properties(${TGT_NAME})
endmacro()

macro(sg_library TGT_NAME)
    add_library(${TGT_NAME} ${ARGN})
    sg_common_properties(${TGT_NAME})
endmacro()

Now our top level cmake script is still nice and simple:

# At the time of writing, CMake stable is 3.30
cmake_minimum_required(VERSION 3.28)

project(contemporary-cmake C)

set(CMAKE_C_STANDARD 23)
set(CMAKE_C_STANDARD_REQUIRED ON)

include(cmake/CommonProperties.cmake)
include(cmake/RemoteDeps.cmake)

sg_executable(hello main.c)
target_link_libraries(hello PRIVATE SDL3::SDL3)

Double check our updates have the desired effect Link to heading

Our example sets the C standard to C23, which means we can replace that use of NULL in our test program with nullptr. On my machine the default system toolchain is GCC 14 which has the broadest support for C23 according to cppreference.com, so there are perhaps other things we could do to test this but this post is about CMake and not C23 so let’s not dive into that.

Then we can easily check to make sure our output locations were setup correctly:

[chipc@rabe cmake-example]$ tree build/default/out/
build/default/out/
├── Debug
│   └── bin
│       └── hello
└── Release
    └── bin
        └── hello

5 directories, 2 files

Toolchains Everything Around Me Link to heading

You can go a long way with a Linux environment (or Visual Studio developer shell) and only use the techniques we’ve looked at so far. However, I haven’t worked on a single project that did not ultimately need a proper toolchain. When you look at newer declarative build systems such as Bazel, Buck2, or GN they only work by first requiring you configure at least one toolchain. Not to mention somewhat older style systems like GNUMake or even modern systems such as FastBUILD also require explicit toolchain configuration.

CMake comes from a different time and perhaps in order to help relegate autotools it knows how to locate and provide reasonable defaults for a wide variety of C and C++ compilers, for many platforms. Toolchain scripts are ostensibly a way to guide CMake into determining default compiler and linker locations and options. For many people the first time you find yourself needing one is likely to setup an embedded or cross-compiled project.

Let’s take a quick look at the worlds simplest toolchain:

set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)

That’s all there is to it. There is a lot of information in the CMake documentation regarding toolchains and what the common settings to adjust are. For example, when cross compiling you don’t want CMake to attempt to build an executable and run it so you need to adjust the settings that determine how try_compile works.

Cross-compilation with MingW Link to heading

Let’s have some fun and set up cross-compilation from Linux => Windows using mingw and a custom toolchain.

Fedora can provide a full featured totally badass MingW and Wine experience, but let’s attempt to use a self-contained toolchain first. The llvm-mingw project provides an easily acquired toolchain for multiple platforms.

First we download the llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64 build and unpack it so we can make sure we understand the layout:

[chipc@bucket llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64]$ ll
total 148K
drwxr-xr-x. 5 chipc chipc 4.0K Oct 15 14:03 aarch64-w64-mingw32
drwxr-xr-x. 5 chipc chipc 4.0K Oct 15 14:03 armv7-w64-mingw32
drwxr-xr-x. 2 chipc chipc  12K Oct 15 14:03 bin
drwxr-xr-x. 3 chipc chipc 4.0K Oct 15 14:03 generic-w64-mingw32
-rwxr-xr-x. 1 chipc chipc  84K Oct 24 17:50 hello.exe
drwxr-xr-x. 5 chipc chipc 4.0K Oct 15 14:03 i686-w64-mingw32
drwxr-xr-x. 2 chipc chipc 4.0K Oct 15 14:03 include
drwxr-xr-x. 5 chipc chipc 4.0K Oct 15 14:03 lib
-rw-r--r--. 1 chipc chipc  15K Oct 15 14:03 LICENSE.TXT
drwxr-xr-x. 8 chipc chipc 4.0K Oct 15 14:03 share
drwxr-xr-x. 5 chipc chipc 4.0K Oct 15 14:03 x86_64-w64-mingw32

Wow! OK, lots of targets available, but I’m going to focus on x86_64.

First we make sure we can build a hello world:

[chipc@bucket llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64]$ bin/x86_64-w64-mingw32-gcc main.c -o hello.exe
[chipc@bucket llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64]$ ls
aarch64-w64-mingw32  armv7-w64-mingw32  bin  generic-w64-mingw32  hello.exe  i686-w64-mingw32  include  lib  LICENSE.TXT  main.c  share  x86_64-w64-mingw32
[chipc@bucket llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64]$ ./hello.exe
002c:fixme:winediag:loader_init wine-staging 9.15 is a testing version containing experimental patches.
002c:fixme:winediag:loader_init Please mention your exact version when filing bug reports on winehq.org.
0070:err:winediag:is_broken_driver Broken NVIDIA RandR detected, falling back to RandR 1.0. Please consider using the Nouveau driver instead.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
008c:fixme:wineusb:query_id Unhandled ID query type 0x5.
0070:fixme:xrandr:xrandr10_get_current_mode Non-primary adapters are unsupported.
0070:fixme:xrandr:xrandr10_get_current_mode Non-primary adapters are unsupported.
0034:err:winediag:is_broken_driver Broken NVIDIA RandR detected, falling back to RandR 1.0. Please consider using the Nouveau driver instead.
002c:err:winediag:is_broken_driver Broken NVIDIA RandR detected, falling back to RandR 1.0. Please consider using the Nouveau driver instead.
0024:err:winediag:is_broken_driver Broken NVIDIA RandR detected, falling back to RandR 1.0. Please consider using the Nouveau driver instead.
Howdy!

Nice. So we will begin by unpacking the toolchain someplace and relying on our presets and the toolchain file to setup paths to each tool. It isn’t ideal as we’ll see, but as long as we getting a working Windows build we can figure out the rest afterwards.

First we create our toolchain: cmake/toolchain/llvm-mingw.cmake

include_guard()

set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_CROSS_COMPILING ON)

set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

message(STATUS "LLVM MingW: ${LLVM_MINGW_ROOT}")

set(_prefix x86_64-w64-mingw32)

set(CMAKE_C_COMPILER ${LLVM_MINGW_ROOT}/bin/${_prefix}-gcc)
set(CMAKE_CXX_COMPILER ${LLVM_MINGW_ROOT}/bin/${_prefix}-g++)
set(CMAKE_ASM_COMPILER ${LLVM_MINGW_ROOT}/bin/${_prefix}-as)
set(CMAKE_OBJDUMP ${LLVM_MINGW_ROOT}/bin/${_prefix}-objdump)
set(CMAKE_OBJCOPY ${LLVM_MINGW_ROOT}/bin/${_prefix}-objcopy)
set(CMAKE_SIZE ${LLVM_MINGW_ROOT}/bin/${_prefix}-size)

And we need to add the following configure preset:

{
    "name": "mingw-cross",
    "displayName": "MingW build for Linux hosts",
    "inherits": ["default-static"],
    "toolchainFile": "${sourceDir}/cmake/toolchain/llvm-mingw.cmake",
    "cacheVariables": {
        "LLVM_MINGW_ROOT": "/home/chipc/workspaces/slowgames/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64"
    }
}

Hopefully it’s clear what the issue is here. :D

Note
We are inheriting from the ‘default-static’ preset to avoid the issue of not being able to locate SDL3.dll at runtime or needing to copy it from some place in the build tree to another.

So now we can configure and build our test app for Windows:

chipc@rabe:~/workspaces/slowgames/cmake-example$ cmake --preset=mingw-cross
Preset CMake variables:

  CMAKE_EXPORT_COMPILE_COMMANDS="ON"
  CMAKE_TOOLCHAIN_FILE:FILEPATH="/home/chipc/workspaces/slowgames/cmake-example/cmake/toolchain/llvm-mingw.cmake"
  LLVM_MINGW_ROOT="/home/chipc/workspaces/slowgames/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64"
  SDL_SHARED="OFF"
  SDL_STATIC="ON"

-- LLVM MingW: /home/chipc/workspaces/slowgames/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64

... TOO MUCH OUTPUT TO INCLUDE HERE ...

--
-- SDL3 was configured with the following options:
--
-- Platform: Windows
-- 64-bit:   TRUE
-- Compiler: /home/chipc/workspaces/slowgames/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64/bin/x86_64-w64-mingw32-gcc
-- Revision: SDL3-2597973

... TOO MUCH OUTPUT TO INCLUDE HERE ...

chipc@rabe:~/workspaces/slowgames/cmake-example$ ninja -C build/mingw-cross/
ninja: Entering directory `build/mingw-cross/'
[268/268] Linking CXX static library lib/Debug/libgmock_main.a
chipc@rabe:~/workspaces/slowgames/cmake-example$ build/mingw-cross/out/Debug/bin/hello.exe
002c:fixme:winediag:loader_init wine-staging 9.15 is a testing version containing experimental patches.
002c:fixme:winediag:loader_init Please mention your exact version when filing bug reports on winehq.org.
0124:fixme:ntdll:NtQuerySystemInformation info_class SYSTEM_PERFORMANCE_INFORMATION
0124:fixme:system:NtUserQueryDisplayConfig flags 0x2, paths_count 0x11f534, paths 0x149e2e0, modes_count 0x11f530, modes 0x14d4b80, topology_id (nil) semi-stub
0124:fixme:dxgi:dxgi_output_GetDesc1 iface 000000000149EDB0, desc 000000000011F4B8 semi-stub!
0124:fixme:dxgi:dxgi_output_GetDesc1 iface 000000000149EF30, desc 000000000011F508 semi-stub!
0124:fixme:dwmapi:DwmSetWindowAttribute (0000000000040074, 14, 000000000011FB4C, 4) stub
0124:fixme:win:RegisterTouchWindow hwnd 0000000000040074, flags 0x3 stub!
0124:fixme:sync:SetWaitableTimerEx (00000000000000E0, 000000000011FD80, 0, 0000000000000000, 0000000000000000, 0000000000000000, 0) semi-stub

And our glorious blank window appears. It’s really quite cool how well Wine works these days.

Bootstrap the MingW toolchain Link to heading

So now we have a basic cmake toolchain file that can use an llvm-mingw release to cross compile our test app from Linux to Windows. But needing to download the toolchain into a path that’s so specifically hard-coded is a bit of a bummer. As it currently stands, anyone who wants to use this preset would actually have to create an inheriting preset in a CMakeUserPresets.json file with the path to where they downloaded the tools.

If we want to use FetchContent to grab the toolchain, we can’t do this in the toolchain script because it will end up downloading and unpacking the tools for every use of CMakes facilities to inspect compiler features. So let’s try fetching things before the first project call:

include(FetchContent)

FetchContent_Declare(
    llvm-mingw
    URL https://github.com/mstorsjo/llvm-mingw/releases/download/20241015/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64.tar.xz
)

message(STATUS "Downloading llvm-mingw")
FetchContent_MakeAvailable(llvm-mingw)

set(LLVM_MINGW_ROOT ${CMAKE_BINARY_DIR}/_deps/llvm-mingw-src)

And remove the cacheVariables from our mingw-cross preset:

{
    "name": "mingw-cross",
    "displayName": "MingW build for Linux hosts",
    "inherits": ["default-static"],
    "toolchainFile": "${sourceDir}/cmake/toolchain/llvm-mingw.cmake"
}

And this does work. We can see evidence in the output:

-- SDL3 was configured with the following options:
--
-- Platform: Windows
-- 64-bit:   TRUE
-- Compiler: /home/chipc/workspaces/slowgames/cmake-example/build/mingw-cross/_deps/llvm-mingw-src/bin/x86_64-w64-mingw32-gcc
-- Revision: SDL3-2597973

So now we have the obvious problem that we will be downloading the toolchain for every config and platform.

Note
And at this point it is easy enough to see why people tend to give up and use scripts, use a package manager such as Conan, or commit the ultimate sin of requiring Docker to cross-compile. But I still think we can work through it at the very least for educational purposes.

The egg and the chicken and the egg problem Link to heading

We need to fetch llvm-mingw before our first project call or before we enable our first language. We have a couple of options then on how to approach this.

  • Create a file ‘Prelude.cmake’ and include it directly before doing anything else.
  • Try and have the toolchain manage the bootstrap.

To my eyes and estimation, I think the Prelude.cmake option is a lot simpler and more explicit while the second option lets us continue to keep certain details of our build in the presets and driven principally from there. The main drawback of the second approach is that we have to jump through some hoops to avoid having CMake download llvm-mingw every time try_compile is used in the build (which is used a lot by SDL).

Creating our new bootstrap script: cmake/toolchain/MingwBoostrap.cmake:

include_guard()

if (NOT DEFINED ENV{LLVM_MINGW_ROOT})
    message(FATAL_ERROR "You must provide a path for LLVM_MINGW_ROOT.")
endif()

if (EXISTS $ENV{LLVM_MINGW_ROOT})
    return()
endif()

include(FetchContent)

FetchContent_Declare(
    llvm-mingw
    URL https://github.com/mstorsjo/llvm-mingw/releases/download/20241015/llvm-mingw-20241015-ucrt-ubuntu-20.04-x86_64.tar.xz
)

message(STATUS "Downloading llvm-mingw")
FetchContent_MakeAvailable(llvm-mingw)

Notice we’re using $ENV{LLVM_MINGW_ROOT}. This is important, because cache variables are not propagated to the sub builds performed by the try_compile mechanisms. The environment should be though.

Next we update our mingw-cross preset with the following item, and it’s clear that this approach has a bit of a maintenance liability here because there isn’t a ${binaryDir} preset macro we can use, so we have to incorporate a priori knowledge about an ancestor preset here in order to work.

"environment": {
    "LLVM_MINGW_ROOT": "${sourceDir}/build/${presetName}/_deps/llvm-mingw-src"
}

And the last step is to update the toolchain script:

include_guard()

include(${CMAKE_CURRENT_LIST_DIR}/MingwBootstrap.cmake)

set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_CROSS_COMPILING ON)

set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

message(STATUS "LLVM MingW: ${LLVM_MINGW_ROOT}")

set(_prefix x86_64-w64-mingw32)

set(CMAKE_C_COMPILER $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-gcc)
set(CMAKE_CXX_COMPILER $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-g++)
set(CMAKE_ASM_COMPILER $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-as)
set(CMAKE_OBJDUMP $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-objdump)
set(CMAKE_OBJCOPY $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-objcopy)
set(CMAKE_SIZE $ENV{LLVM_MINGW_ROOT}/bin/${_prefix}-size)

And heck yes, all presets build correctly and llvm-mingw isn’t duplicated needlessly. In case you’re thinking to yourself “This is a bit wasteful. I have 13 projects and I only need llvm-mingw once to provide a toolchain for each of them.”, you are absolutely correct. The goal here is a demonstration, actual projects will have to make determinations differently than I have here. As a monorepo advocate and practitioner, I do indeed tend to work within a big private git repo I call the “depot” and do my best to avoid fracturing my tools and sources. If that doesn’t sound like your jam, then you definitely want to take a different approach.

A last splash of presets Link to heading

So we can build with a ‘default’ configuration preset, a ‘default-static’ configuration preset, and a ‘mingw-cross’ configuration preset. So far these have all been intended to be used on a Linux system, but the ‘default’ preset should also work on Windows from a Visual Studio environment or Msys2 environment as well as MacOS with the Xcode command line tools installed. Our mingw-cross preset however, is Linux only at the moment.

We could update our bootstrap process to work on every platform, and rename our preset to simply “mingw”. But this post is long enough already and it is then perhaps best to save that for a future post.

For our final act though, let’s add missing build and workflow presets and conditionally enable the ‘mingw-cross’ only for Linux systems.

Only enable mingw-cross on Linux Link to heading

CMake presets have a field called “condition” which will determine whether they can be used by a supplied expression. The simplest of which is to check if we’re running on a specific platform. When a preset is disabled by a condition it won’t appear when using the ‘–list-presets’ argument and you won’t be able to run it. I won’t make excuses about how weird these look because json leads to crimes. But it does the thing.

Add the following to the mingw-cross preset:

"condition": {
    "type": "equals",
    "lhs": "${hostSystemName}",
    "rhs": "Linux"
}

Adding the remaining presets Link to heading

Same as when we added the ‘default-static’ preset, we’ll add 3 new build presets, and 1 new workflow preset.

Build presets:

{
    "name": "mingw-cross-build",
    "hidden": true,
    "configurePreset": "mingw-cross",
    "inheritConfigureEnvironment": true
},
{
    "name": "debug-mingw-cross",
    "inherits": [
        "mingw-cross-build"
    ],
    "configuration": "Debug"
},
{
    "name": "release-mingw-cross",
    "inherits": [
        "mingw-cross-build"
    ],
    "configuration": "Release"
}

Workflow presets:

{
    "name": "build-all-mingw-cross",
    "displayName": "Build all targets for Debug and Release configurations. (MingW)",
    "steps": [
        {
            "type": "configure",
            "name": "mingw-cross"
        },
        {
            "type": "build",
            "name": "debug-mingw-cross"
        },
        {
            "type": "build",
            "name": "release-mingw-cross"
        }
    ]
}

And now we can just make sure everything shows up correctly:

chipc@rabe:~/workspaces/slowgames/cmake-example$ cmake --list-presets
Available configure presets:

  "default"        - Default Configuration
  "default-static" - Default Configuration - Static SDL3
  "mingw-cross"    - MingW build for Linux hosts


chipc@rabe:~/workspaces/slowgames/cmake-example$ cmake --build --list-presets
Available build presets:

  "debug"
  "release"
  "debug-static"
  "release-static"
  "debug-mingw-cross"
  "release-mingw-cross"


chipc@rabe:~/workspaces/slowgames/cmake-example$ cmake --workflow --list-presets
Available workflow presets:

  "build-all"             - Build all targets for Debug and Release configurations.
  "build-all-static"      - Build all targets for Debug and Release configurations. (Static SDL3)
  "build-all-mingw-cross" - Build all targets for Debug and Release configurations. (MingW)

Wrapping up Link to heading

This is a big post. It is important to me that I assembled something that worked on it’s own because it seems to me that a lot of information regarding CMakes most useful features are often presented in a disjointed manner. So by starting from scratch and building up from there I hope it is more clear how these things can work together and what advantages there might be in getting familiar with them yourself.

Sure, it seems like a ton of ceremony and madness just to build something that displays an empty window, but remember that over a span of years this is just a drop in the bucket and will provide a really solid foundation as you move into more interesting territory.

I created an example repository while writing this up and you can find it here. I’d like to continue this series and make some posts that focus on specific topics that we’ve talked about here likely starting with preset factoring. I don’t have a schedule or plan for this, but in either case feedback on this post is always welcome so feel free to reach out on mastodon.