In the last article about CMake I’ve explained how to use CMake for embedded software development and how to use, for example, an external toolchain.

This time, I’m going to write about how can you automate CMake for your own needs. The CMake Presets is a very powerful — and readable — tool for CMake.

Creating a minimal CMake Preset from a simple CMake example

I’m using the same application code I’ve used before. In short, it has a main.c and a library named test_lib with a respective test_lib.c and test_lib.h.

The root CMakeLists.txt is written as following:

cmake_minimum_required(VERSION 3.23)
project(
  test_c
  VERSION 2.0
  DESCRIPTION "Another example project with CMake"
  LANGUAGES C
)
 
add_executable(test ${CMAKE_SOURCE_DIR}/src/test.c)
 
add_subdirectory(test_lib)
target_link_libraries(test PRIVATE test_lib)

The executable target is named test.

The test_lib library CMakeLists.txt is written as following:

set(TEST_LIB_PATH ${CMAKE_CURRENT_SOURCE_DIR})
 
add_library(test_lib ${TEST_LIB_PATH}/test_lib.c)
target_include_directories(test_lib PUBLIC ${TEST_LIB_PATH})

A CMake preset can be simply added by creating the file CMakePresets.json in the root directory of the project.

A minimal CMakePresets.json might look like this:

{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 23,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "default",
      "displayName": "Default Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
    }
  ],
  "buildPresets": [
    {
      "name": "default",
      "displayName": "Default Build Preset",
      "configurePreset": "default",
      "jobs": 8,
      "targets": "test"
    }
  ]
}

There is a default configure preset and a default build preset. Essentially, this is minimum required to take advantage from the CMake preset feature.

I’ve decided to define the target inside the preset, but it can also be done as usual.

Test your CMake Preset

To run a configure preset, run on the shell:

cmake --preset default

This will create the Makefiles inside the /build directory, defined by the field binaryDir.

To run a build preset, do the same as above but add the keyword --build before the --preset:

cmake --build --preset default

This will automatically build the binary and store it in /build.

Different presets for different build types

The most popular build types are debug and release. To have a preset with a pre-defined build type, change CMAKE_BUILD_TYPE inside the cacheVariables configure preset variable to match your desired build type. The configuration inside the build preset should also match the build type.

{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 23,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "default",
      "displayName": "Default Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "default",
      "displayName": "Default Build Preset",
      "configurePreset": "default",
      "configuration": "Debug",
      "jobs": 8,
      "targets": "test"
    }
  ]
}

The preset above will configure and build with the build type Debug.

Furthermore, to support the release build type, you only need to include two more presets:

{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 23,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "debug",
      "displayName": "Debug Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug"
      }
    },
    {
      "name": "release",
      "displayName": "Release Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "debug",
      "displayName": "Default Build Preset",
      "configurePreset": "debug",
      "configuration": "Debug",
      "jobs": 8,
      "targets": "test"
    },
    {
      "name": "release",
      "displayName": "Default Build Preset",
      "configurePreset": "release",
      "configuration": "Release",
      "jobs": 8,
      "targets": "test"
    }
  ]
}

The preset file above includes two configure presets and two build presets, each one for a different build type.

Info

Note that the field configurePreset was also changed and it should match a existing configure preset.

You can try configuring and building for the two build types by running:

cmake --preset debug
cmake --build --preset debug

Or for release:

cmake --preset release
cmake --build --preset release

Include a workflow

The next step of your CMake preset file is to include a workflow preset. A workflow is basically a bunch of steps you want CMake to execute. Since you already configured and built, you might want to group that into ONE only command — that’s where the workflow shines.

A workflow preset might look like this:

{
  "version": 6,
  "cmakeMinimumRequired": {
    "major": 3,
    "minor": 23,
    "patch": 0
  },
  "configurePresets": [
    {
      "name": "debug",
      "displayName": "Debug Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug"
      }
    },
    {
      "name": "release",
      "displayName": "Release Configure Preset",
      "generator": "Unix Makefiles",
      "binaryDir": "${sourceDir}/build",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "debug",
      "displayName": "Default Build Preset",
      "configurePreset": "debug",
      "configuration": "Debug",
      "jobs": 8,
      "targets": "test"
    },
    {
      "name": "release",
      "displayName": "Default Build Preset",
      "configurePreset": "release",
      "configuration": "Release",
      "jobs": 8,
      "targets": "test"
    }
  ],
  "workflowPresets": [
    {
      "name": "debug",
      "displayName": "Debug Workflow",
      "steps": [
        {
          "type": "configure",
          "name": "debug"
        },
        {
          "type": "build",
          "name": "debug"
        }
      ]
    },
    {
      "name": "release",
      "displayName": "Release Workflow",
      "steps": [
        {
          "type": "configure",
          "name": "release"
        },
        {
          "type": "build",
          "name": "release"
        }
      ]
    }
  ]
}

Try runing the workflow by typing:

cmake --workflow --preset debug

It will configure and build for you. Crazy good! 😁

Sprinkle CMake with a simple Command Line Interface

If you are like me, you can’t be satisfied with the previous result. Even though the commands work as expected, the commands are still too long and most of it could be easily abstracted.

With the help of Python and its libraries, it is possible to create a CLI with the following methods:

  • init: generates the Makefiles according to a preset
  • build: builds the code according to a preset
  • workflow: executes the workflow steps according to a preset
  • run: executes the binary
  • reset: deletes the /build directory

Create a file (do not include the extension .py) in the root project directory. I’ve named it example.

My example file contains:

#!/usr/bin/env python3
 
import argparse
import subprocess
 
parser = argparse.ArgumentParser()
parser.add_argument("action", help="The action to perform, e.g. init, build, workflow, run, reset")
parser.add_argument("type", nargs='?', const="debug", default="debug", help="The build type to perform, e.g. debug, release")
args = parser.parse_args()
 
if (args.action == 'init'):
  subprocess.run(["cmake", "--preset", args.type])
elif (args.action == 'build'):
  subprocess.run(["cmake", "--build", "--preset", args.type])
elif (args.action == 'workflow'):
  subprocess.run(["cmake", "--workflow", "--preset", args.type])
elif (args.action == 'run'):
  subprocess.run(["./build/" + args.type + "/test"], shell=True)
elif(args.action == 'reset'):
  subprocess.run(["rm", "-rf", "build"])

I’ve also added the example path into the $PATH with:

export PATH=<your/path/here>:$PATH
source ~/.zshrc

And finally, to run any command inside the example, all you have to do is:

example <command>

If you want to execute your workflow preset, you simply run:

example workflow

Info

The build type is Debug as default.

In case it is a release build:

example workflow release

That’s all folks! Not only you ended up with a 2 words command to compile your code, but the entire code necessary to reach this result is also easy to understand and to go through. The JSON preset is readable and quite flexible: only a few preset options are necesary to make it work.