CMake

Page Contents

References / useful Links

  • https://github.com/onqtam/awesome-cmake
  • https://www.jetbrains.com/help/clion/quick-cmake-tutorial.html
  • https://cgold.readthedocs.io/en/latest/overview.html
  • https://github.com/toeb/moderncmake/blob/master/Modern%20CMake.pdf
  • https://stackoverflow.com/questions/8934295/add-source-in-a-subdirectory-to-a-cmake-project
  • https://stackoverflow.com/questions/17653738/recursive-cmake-search-for-header-and-source-files
  • https://github.com/ruslo/sugar/wiki/Collecting-sources
  • https://crascit.com/2015/02/02/cmake-target-dependencies/
  • http://floooh.github.io/2016/01/12/cmake-dependency-juggling.html
  • http://crascit.com/2016/01/31/enhanced-source-file-handling-with-target_sources/
  • https://cmake.org/pipermail/cmake/2016-May/063400.html
  • https://github.com/bilke/cmake-modules
http://derekmolloy.ie/hello-world-introductions-to-cmake/
https://mirkokiefer.com/cmake-by-example-f95eb47d45b1
https://www.udemy.com/introduction-to-cmake/
https://cgold.readthedocs.io/en/latest/overview/cmake-can.html
https://crascit.com/professional-cmake/
https://pabloariasal.github.io/2018/02/19/its-time-to-do-cmake-right/

Install From Source

sudo apt install build-essential checkinstall zlib1g-dev libssl-dev
wget https://github.com/Kitware/CMake/archive/refs/tags/v3.28.0-rc4.tar.gz
tar -zxvf v3.28.0-rc4.tar.gz
( cd CMake-3.28.0-rc4
    ./bootstrap && make && sudo make install
)
rm -fr CMake-3.28.0-rc4 

Intro

Some commonly used commands:

  • message: prints given message.
  • cmake_minimum_required: sets minimum version of cmake to be used.
  • add_executable: adds executable target with given name.
  • add_library: adds a library target to be build from listed source files.
  • add_subdirectory: adds a subdirectory to build. CMake will enter this directory, find the CMakeLists.txt and build using that.

Intro

CMake orchastrates the build process but requires actual build tools like make or ninja, for example, to do the actual building.

cmake -B <build-tree> -S <source-tree>
cmake --build <build-tree>

CMake works in three stages:

  1. Configuration
  2. Generation
  3. Build

Configuration Stage

  1. Read project details from a source tree and prepare the output directory, a.k.a. the build tree for the generation stage.
  2. Collects environment data: architecture, available compilers, linkers etc and checks compilation environment is sane.
  3. Parse and execute CMakeLists.txt. Store collected information in the build tree as CMakeCache.txt.
  4. CMakeCache.txt can be prepopulated using cmake -C <initial-cache-script> ....
  5. Can be important to set CMAKE_BUILD_TYPE to, for example "Debug" or "Release": cmake ... -DCMAKE_BUILD_TYPE=Debug ...

Generation Stage

  1. Generate a build system: configuration files for other build tools like make or ninja. A generator decides what build tool is used.
  2. Use cmake -G <generator-name> ... or the enironment variable CMAKE_GENERATOR to set a specific generator.
  3. Can still modify build configuration using generator expressions.
  4. Note: Configuration and generation stages are automatically executed one after the other unless specified otherwise.

Build Stage

  1. Runs the build tool. The build tools create targets

Language

Everything is a comment or a command invocation.

Comments

# This is a comment
#[=[
    This is a multiline
    comment
#]=]
    

Commands

Arguments to commands cannot be other commands. Arguments are just strings - everything is a string.

The types of arguments are bracketed, quoted and unquoted string arguments:

message([=[
This message can have
multiple lines and include sequences like [[me]]
because [=[ was used.
]=])

message("This is a quotes string argument: expands escape sequences and interpolates variables")
message("Quoted arguments
can also span multiple lines")

message(unquoted\ single\ argument)
message(four separate unquoted arguments)
message(four;separate;unquoted;arguments)
message(${SOME_LIST}) # unquoted arguments with lists AUTOMATICALLY UNPACK LISTS

Variables

set(MYVARIABLE "Some value")
unset(MYVARIABLE)

Variable are evaluated using ${MYVARIABLE}: the scope stack is traversed for find the first matching variable name, or an empty string if not found. I.e. check current scope, then the cache.

The variable expansion of ${outer_${inner}}, expands ${inner} first and then expands the resulting ${outer_xxx}.

Use ${} to reference normal or cache, $ENV{} for environment variables, $CACHE{} for cached variables.

ENV variables will be interpolated during generation and are therefore cemented into the build tree. Change the environment after this will not change the build unless it is regenerated.

CACHE variables are persistent and stored in CMakeCache.txt in the build tree.

CMake has 2 scopes: function scope and directory scope.

Function scope: When custom functions defined with function() are run, variables defined in funciton have scope local to function.

Directory scope: When a CMakeLists.txt file is in a subdirectory and run using add_subdirectory().

When either scope is created as a nested scope, the current scope values are copied into it. When nested scope exited its copies are destroyed. Therefore, if a nested scope shadows a variable in the outer scope, it will not effect the outerscope variable as one would expect. Also, unsetting a variable in the nested scope, won't unset it in the parent scope and so on.

To change variables in the parent scope need to use set(VarName "Value" PARENT_SCOPE), but note whilst this effects the parent scope it doesn't also change the local scope!

A variable defined in one scope (e.g., a directory or a function) is not automatically inherited by targets created within that scope. It must be explicitly passed or set to meke it releveant to a target, e.g., using target_compile_definitions and so on.

As such, although, when including subdirectories using add_subdirectory, variables defined in the parent directory are available to the subdirectory, but targets defined in the subdirectory do not automatically inherit properties or variables from the parent.

Conditionals

NOTE: CMake will evalulated unquoted arguments as it they are variable references:

set(V1 FALSE)
if(V1) # Is evaluated as if(FALSE), where FALSE is an unquoted string

# .. and ..

set(V2 "V1")
if(${V2}) # Is evaluated as if(V1), which is evaluated as if(FALSE)!!!

Strings a TRUE only if they equal one of "ON", "Y", "YES" or "TRUE" (case insensitive), or a non-zero number.

BUT NOTE for unquoted strings, if() will only evaluate a string to FALSE if it is one of "OFF", "NO", "N", "FALSE", "IGNORE", "NOTFOUND", "", "0", or ends with "-NOTFOUND" or is undefined.

Check if a variables is defined using if(DEFINED <name>).

Targets And Properties

CMake organises the world into targets and each of those targets has a set of properties that are either used to build the target, or describe what a consumer of the target needs to use the target.

Targets

Targets build according to their own build specification in combination with usage requirements propagated from their link dependencies. [[Ref]](https://cmake.org/cmake/help/latest/manual/cmake-buildsystem.7.html#build-specification-and-usage-requirements)

Some of the more useful target commands include the following.

  1. target_compile_definitions(): passed to the compiler with -D flags, in an unspecified order.
  2. target_compile_options(): options for compiling sources, passed to compiler as flags, in the order of appearance. Automatically escaped for the shell.
  3. target_compile_features(): use to specify features compiler must support, e.g. cxx_constexpr.
  4. target_include_directories(): include directories for compiling, passed to compiler with -I flag.
  5. target_sources()
  6. target_link_libraries()
  7. target_link_directories()
  8. target_link_options(): List of link options, passed to linker as flags, in the order of appearance. Automatically escaped for the shell.

Each command can specify a scope:

PUBLIC: Populates both properties for building and properties for using a target.

PRIVATE: Populates only properties for building a target.

INTERFACE: Populates only properties for using a target.

Build Requirements vs Usage Requirements Permalink

Target properties are defined in one of two scopes: INTERFACE and PRIVATE. Private properties are used internally to build the target, while interface properties are used externally by users of the target. In other words, interface properties model usage requirements, whereas private properties model build requirements of targets.

Interface properties have the prefix, wait for it, INTERFACE_ prepended to them.

For example, the property COMPILE_OPTIONS encodes a list of options to be passed to the compiler when building the target. If a target must be built with all warnings enabled, for instance, this list should contain the option -Wall. This is a private property used only when building the target and won’t affect its users in any way.

On the other hand, the property INTERFACE_COMPILE_FEATURES stores which features must be supported by the compiler when building users of the target. For instance, if the public header of a library contains a variadic function template, this property should contain the feature cxx_variadic_templates. This instructs CMake that applications including this header will have to be built by a compiler that understands variadic templates.

Properties can also be specified as PUBLIC. Public properties are defined in both PRIVATE and INTERFACE scopes.

--[It's Time To Do CMake Right](https://pabloariasal.github.io/2018/02/19/its-time-to-do-cmake-right/)

This is otherwise known as Transative Usage requirements.

  • Usage: one target depends on another because it uses that target.

  • Requirements: The used target's requirements that must be met by the using target. Everywhere else we call these requirements properties or dependencies.

  • Transitive: CMake appends some properties/requirements of used targets to properties of targets using them. This can happen all the way down the dependency chain (see diagram below)

  • This has been discussed previously, but this is the formal name for it.

Executable targets are defined/created using:

        add_executable(<name> file1.cpp ...)
    

CMake knows how to compile to object files... don't need a rule for this, just chuck source files at CMake and let it do its thing.

Library targets are created using:

        add_library(<name> file1.cpp ...)
    
Custom Targets

Custom targets are created using:

add_custom_target(<name> [All] 
        [command1 [args1...]]
        [command2 [args2...] ...]
        [DEPENDS ... ]
        [BYPRODUCTS ...]
        ... see docs lol ...
    )

Custom targets specify user-defined command line executions which don't check whether the produced output is up to date.

Custom Commands

Custom targets always build, every time. To have something only build when necessary use add_custom_command. It creates dependencies between the customer command and its dependencies so that it is only run if something it depends on is newer than it.

add_custom_command(OUTPUT output1 [output2 ...]
            COMMAND command1 [ARGS] [args1...]
            [COMMAND command2 [ARGS] [args2...] ...]
            [MAIN_DEPENDENCY depend]
            [DEPENDS [depends...]]
            [BYPRODUCTS [files...]]
            [IMPLICIT_DEPENDS <lang1> depend1
                            [<lang2> depend2] ...]
            [WORKING_DIRECTORY dir]
            [COMMENT comment]
            [DEPFILE depfile]
            [JOB_POOL job_pool]
            [JOB_SERVER_AWARE ]
            [VERBATIM] [APPEND] [USES_TERMINAL]
            [CODEGEN]
            [COMMAND_EXPAND_LISTS]
            [DEPENDS_EXPLICIT_ONLY])

If DEPENDS is not specified, the command will run whenever the OUTPUT is missing; if the command does not actually create the OUTPUT, the rule will always run.

As A Generator

A custom command can be used as, for example, a generator. For example, it could compile Protobuf definitions into code:

add_custom_command(OUTPUT myProtoBuf.pb.h myProtoBuf.pb.c
    COMMAND protoc ARGS myProtoBuf.proto
    DEPENDS myProtoBuf.proto)
...
...
    add_executable(MyExecutable file1.c ... myProtoBuf.pb.c)

This has told CMake how to create the files myProtoBuf.pb.[ch] and what they depend on. It has also made MyExecutable depend on those files.

So, if myProtoBuf.proto changes, CMake knows it needs to run the command that outputs myProtoBuf.pb.[ch] and therefore also rebuild the targer MyExecutable.

As A Target Hook

Execute commands pre or post build of a target.

add_custom_command(TARGET 
            PRE_BUILD | PRE_LINK | POST_BUILD
            COMMAND command1 [ARGS] [args1...]
            [COMMAND command2 [ARGS] [args2...] ...]
            [BYPRODUCTS [files...]]
            [WORKING_DIRECTORY dir]
            [COMMENT comment]
            [VERBATIM]
            [COMMAND_EXPAND_LISTS]
            [USES_TERMINAL])

Don't use PRE_BUILD unless you're using a VSCode generator.

PRE_LINK: Run after sources have been compiled but before linking the binary or running the librarian or archiver tool of a static library. This is not defined for targets created by the add_custom_target() command.

POST_BUILD: Run after all other rules within the target have been executed.

Properties

Query and set target properties:

get_target_property(<var> <target> <prop-name>
set_target_property(<target1> ... PROPERTIES <prop-name1> <prop-value1> ... )

Properties not limited to targets: directory, global, source, etc supported as well.

Best to use as many high-level commands as possible rather than fettling with property variables directly. E.g., use add_dependencies(...) rather than felling with the target property MANUALLY_ADDED_DEPENDENCIES.

Get Operating System (OS) Information

Check the CMAKE_SYSTEM_NAME variable to discover the OS:

if(CMAKE_SYSTEM_NAME STREQAL "Linux")
    ....
endif()

See also cmake_host_system_information(RESULT <VARIABLE> QUERY <KEY>). The key can be things like HOSTNAME, FQDN, TOTAL_PHYSICAL_MEMORY, OS_NAME, OS_RELEASE, OS_PLATFORM and more.

To test whether host is 32-bit or 64-bit one can use:

if(CMAKE_SIZEOF_VOID_P EQUAL 8)
    message(STATUS "Target is 64-bits")
endif()
    

Tool Chain Config

Set C++ Standard

# To set the standard
set_property(TARGET <target> PROPERTY CXX_STANDARD (98|11|14|17|20|23))

# But to abort if compiler doesn't support that standard need to...
set(CXMAKE_CXX_STANDARD_REQUIRED ON)

# And to stop the default gnu extensions, e.g., -std=gnu++14 instead of -std=c++14
set(CMAKE_CXX_EXTENSIONS OFF)

Inter proceedural Optimization

include(CheckIPOSupported)
# Must check its supported first!
check_ipo_supported(RESULT ipo_supported)
if(ipo_supported)
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION True)
endif()

Compiler Features Check

list(FIND CMAKE_CXX_COMPILE_FEATURES cxx_variable_templates result)
if(resultg EQUAL -1)
    message(FATAL_ERROR "Variable template support required!")
endif()

Testing for each feature needed is likely to be onerous so just check for high level stuff like cxx_std_23 and so on.

Build And Use A Library

In your main CMakeLists.txt add:

add_subdirectory(...)
...            
target_link_libraries(project-name library-name)

The command target_link_libraries links artifacts from the subdirectory to the main project. It connects libraries with executables.

In your library subdirectory add another CMakeLists.txt:

add_library(library-name SHARED|STATIC ...sources...)
target_include_directories(library-name PUBLIC .)
        

add_library produces a globally visible target, library-name.
target_include_directories adds the library's directory to its public include path, which is what will allow the parent project C files to see the library (public) includes without providing a relative path.

This is because PUBLIC and INTERFACE includes populate properties for using a target [Scope](https://cmake.org/cmake/help/latest/manual/cmake-buildsystem.7.html#target-command-scope). The [usage requirements](https://cmake.org/cmake/help/latest/manual/cmake-buildsystem.7.html#target-usage-requirements) of a target are settings that propagate to consumers, which link to the target via target_link_libraries(), in order to correctly compile and link with it.

Dependency Graph

To visualise dependencies use:

cmake --graphviz=<output filename.dot> .

Note: Won't show custom targets. Can be done, needs further trickery not written down here.

Functions And Macros

Functions introduce new scope, macros do not - they're a little like a C preprocessor macro. Neither can return in the sense that a normally programming language returns a value. Returning is done via an output parameter.

Generically:

function(function-name arg1 arg2 ...)
    set(function-name-output-parameter ... PARENT_SCOPE)
endfunction()

macro(macro-name arg1 arg2)
    set(macro-name-output-parameter ...)    
endmacro()

Function arguments that are specified in the definition are required, but the function can accept any number of parameters using automatically and scoped vairables ARGV, ARGC, ARGn, where n is the index of the argument. See also cmake_parse_arguments().

Embedded C Project Template

# Project Configuration
cmake_minimum_required(VERSION 3.16)

# Note, project type set to 'NONE' deliberately so that CMake does not search for C compiler before the toolchain
# is configured.
project(my-project-name NONE)

set(CMAKE_VERBOSE_MAKEFILE ON)

# Toolchain
set(CMAKE_SYSTEM_NAME      "Generic")
set(CMAKE_SYSTEM_PROCESSOR "arm")
set(CMAKE_CROSSCOMPILING   true)

# Skip link step during toolchain validation.
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

set(TOOLCHAIN_TUPLE "arm-none-eabi")
find_program(CMAKE_C_COMPILER   "${TOOLCHAIN_TUPLE}-gcc")
find_program(CMAKE_ASM_COMPILER "${CMAKE_C_COMPILER}")
find_program(CMAKE_OBJCOPY      "${TOOLCHAIN_TUPLE}-objcopy")
find_program(CMAKE_OBJDUMP      "${TOOLCHAIN_TUPLE}-objdump")
find_program(CMAKE_SIZE         "${TOOLCHAIN_TUPLE}-size")

# Search only under *target* paths.
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

# Programming Languages - C and Assembly
enable_language(C ASM)

# Set the suffix for executables on this platform.
set (CMAKE_EXECUTABLE_SUFFIX ".elf")

# Target Parameters
set(TARGET_MCU       "STM32...")
set(TARGET_FAMILY    "STM32...")

# Target Compiler/Linker Flags
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE "Debug")
    message(STATUS "Build type not specified, defaulting to 'Debug'")
endif()

# See https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html
set(TARGET_WARNING_FLAGS
        "-Werror"                   # Make all warnings into errors.
        "-Wall"                     # Enable all the warnings about constructions that some users consider questionable, and that are easy to avoid etc. (See GCC docs for list).
        "-Wextra"                   # Enable some extra warning flags not enabled by -Wall. (See GCC docs for list).
        "-Wshadow"                  # Warn whenever a local variable or type declaration shadows another variable, parameter, type etc.
        "-Wcast-align=strict"       # Warn whenever a pointer is cast such that the required alignment of the target is increased regardless of the target machine.
        "-Wpointer-arith"           # Warn about anything that depends on the “size of” a function type or of void.
        "-pedantic"                 # Issue all the warnings demanded by strict ISO C.
        "-Wconversion"              # Warn for implicit conversions that may alter a value.
        "-Wsign-conversion"         # Warn for implicit conversions that may change the sign of an integer value.
        "-Wfloat-conversion"        # Warn for implicit conversions that reduce the precision of a real value.
        "-Wfloat-equal"             # Warn when floating-point values are used in equality expressions.
        "-Wcast-qual"               # Warn whenever a pointer is cast so as to remove a type qualifier from the target type.
        "-Wduplicated-branches"     # Warn when an if-else has identical branches.
        "-Wduplicated-cond"         # Warn about duplicated conditions in an if-else-if chain.
        "-Wswitch-default"          # Warn whenever a switch statement does not have a default case.
        "-Wwrite-strings"           # When compiling C, give string constants the type const char[length] so that copying the address of one into a non-const char * pointer produces a warning.
        "-Wunused-macros"           # Warn about macros defined in the main file that are unused.
)
if ("${DEBUG_OUTPUT_METHOD}" STREQUAL "RTT")
    # The RTT libraries generate the following warning: supress it when using RTT.
    list(APPEND TARGET_WARNING_FLAGS -Wno-cast-align)
endif()
STRING(REPLACE ";" " " TARGET_WARNING_FLAGS "${TARGET_WARNING_FLAGS}")

# Common flags and defines - things like "-mcpu=xxx", "-mlittle-endian", "-mthumb" etc
set(TARGET_COMMON_FLAGS   ...flags appropriate to your target...
)
STRING(REPLACE ";" " " TARGET_COMMON_FLAGS "${TARGET_COMMON_FLAGS}")

set(TARGET_DEFINITIONS
    "-DCORE_CM..."
    "-D${TARGET_FAMILY}"
    "-DEVAL_BOARD"
)
STRING(REPLACE ";" " " TARGET_DEFINITIONS "${TARGET_DEFINITIONS}")

# Segger RTT
# Add `-DDEBUG_OUTPUT_METHOD=RTT` to CMake options in CMake profile to use.
if ("${DEBUG_OUTPUT_METHOD}" STREQUAL "RTT")
    message(STATUS "Using SEGGER RTT protocol for debug output")
    include_directories("${CMAKE_SOURCE_DIR}/path-to-segger-rtt-source-files")
endif ()

set(TARGET_COMPILER_FLAGS "-ffunction-sections -fdata-sections")
set(TARGET_C_FLAGS "-std=c11")
set(TARGET_LD_FLAGS "-Wl,--gc-sections")

# Set language-wide flags for C language, used when building for all configurations.
set(CMAKE_C_FLAGS "${TARGET_WARNING_FLAGS} ${TARGET_COMMON_FLAGS} ${TARGET_DEFINITIONS} ${TARGET_COMPILER_FLAGS} ${TARGET_C_FLAGS}")

# Set linker flags to be used to create executables. These flags will be used by the linker when creating an executable.
set(CMAKE_EXE_LINKER_FLAGS "${TARGET_COMMON_FLAGS} ${TARGET_LD_FLAGS}")

# Flags for Debug build type or configuration.
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS} -DTARGET_DEBUG  -DAPP_DEBUG -Og -ggdb")
set(CMAKE_EXE_LINKER_FLAGS_DEBUG "${TARGET_COMMON_FLAGS} ${TARGET_LD_FLAGS}")

# Flags for Release build type or configuration.
set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS} -Os -DNDEBUG")
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${TARGET_COMMON_FLAGS} ${TARGET_LD_FLAGS}")

# Project Layout
set(CMAKE_INCLUDE_CURRENT_DIR true)
set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

include_directories("${CMAKE_SOURCE_DIR}/...your include dirs ...")

# Set output directory into which runtime target files should be built.
SET(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR})

# Add sources to executable target...
# CMake recommends not using globbing and listing explicitly. With globbing it warns that you can compile files that
# you don't mean to and that the addition/removal of a file is not present in your build system diff, so tracking down
# "what did you change?" in debugging reported problems can be harder since there's no evidence of accidentally
# added/removed files in a normal Git diff output.
add_executable(application
    // List c files
)

if ("${DEBUG_OUTPUT_METHOD}" STREQUAL "RTT")
    # Only add SEGGER RTT sources if compiling with RTT enabled
    target_sources(application
            PUBLIC "${CMAKE_SOURCE_DIR}/path-to-segger/SEGGER_RTT.c"
    )
endif ()

# Set linker flags.
set(MY_LINK_FLAGS
        -T'${CMAKE_CURRENT_SOURCE_DIR}/path-to-LD-file'
        -mcpu=cortex-...
        -mthumb
        -Wl,-gc-sections,--print-memory-usage,-Map=${PROJECT_BINARY_DIR}/${PROJECT_NAME}.map
)
STRING(REPLACE ";" " " MY_LINK_FLAGS "${MY_LINK_FLAGS}")
set_target_properties(${PROJECT_NAME} PROPERTIES LINK_FLAGS "${MY_LINK_FLAGS}")

# Build HEX and BIN files from the ELF target
set(HEX_FILE ${PROJECT_BINARY_DIR}/${PROJECT_NAME}.hex)
set(BIN_FILE ${PROJECT_BINARY_DIR}/${PROJECT_NAME}.bin)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
        COMMAND ${CMAKE_OBJCOPY} -Oihex $<TARGET_FILE:${PROJECT_NAME}> ${HEX_FILE}
        COMMAND ${CMAKE_OBJCOPY} -Obinary $<TARGET_FILE:${PROJECT_NAME}> ${BIN_FILE}
        COMMENT "Building ${HEX_FILE}
Building ${BIN_FILE}")
    

TARGET_COMMON_FLAGS For Cortex M0

# The target is an ARM Cortex-M0, which implies the thumb instruction set. It is little endian and has no
# hardware FP support.
set(TARGET_COMMON_FLAGS   "-mlittle-endian"
                            "-mcpu=cortex-m0"
                            "-mthumb"
                            "-mfloat-abi=soft"
)
STRING(REPLACE ";" " " TARGET_COMMON_FLAGS "${TARGET_COMMON_FLAGS}")    
        

One thing I did want to do was to check my GCC compiler version. CMake should support macros to do this (CMAKE_CXX_COMPILER_ID and CMAKE_CXX_COMPILER_VERSION) but they didn't work for me so I ended up writing the following:

if (CMAKE_C_COMPILER STREQUAL "CMAKE_C_COMPILER-NOTFOUND")
            message(FATAL_ERROR "Compiler not found")
        else()
            execute_process(
                    COMMAND "${CMAKE_C_COMPILER}" "--version"
                    COMMAND "grep" "-Eo" "[0-9]+\\.[0-9]+\\.[0-9]+"
                    RESULT_VARIABLE COMPILER_VERSION_ERROR
                    OUTPUT_VARIABLE COMPILER_VERSION_OUTPUT
                    ERROR_VARIABLE COMPILER_VERSION_OUTPUT_ERROR
                    OUTPUT_STRIP_TRAILING_WHITESPACE
                    ERROR_STRIP_TRAILING_WHITESPACE)
        
            if (NOT ${COMPILER_VERSION_ERROR} EQUAL 0)
                message(FATAL_ERROR "Failed to query compiler version.\n\t\tError code or reason: ${COMPILER_VERSION_ERROR}.\n\t\tSTDERR: ${COMPILER_VERSION_OUTPUT_ERROR}")
            endif()
        
            if(${COMPILER_VERSION_OUTPUT} VERSION_LESS "13.2")
                message(FATAL_ERROR "Required GCC version 13.2 or later. Found ${COMPILER_VERSION_OUTPUT}.")
            endif()
        endif()

-nostdlib and -ffreestanding

For really space constrained firmware you might not want to link against the standard library and will want to stop GCC from assuming it can optimise certain functions based on the behaviour of the standard C library. For this you need the -nostdlib and -ffreestanding directives -- [Ref].

In freestanding mode, the only available standard header files are: <float.h>, <iso646.h>, <limits.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, and <stdint.h> (C99 standard 4.6) -- [Ref].

# In addition this project will not link against the standard C library (-nostdlib and -ffreestanding)
#		-nostdlib      controls which libraries to link against
# 		-ffreestanding controls if you are compiling freestanding C (which is part of the C standard).
# 		 			   CAUTION: Even though freestanding is used it seems GCC still requires the freestanding
#                               environment provide memcpy, memmove, memset and memcmp
#                               [https://gcc.gnu.org/onlinedocs/gcc/Standards.html]
set(TARGET_COMMON_FLAGS ...)
list(APPEND TARGET_COMMON_FLAGS "-nostdlib" "-ffreestanding")
STRING(REPLACE ";" " " TARGET_COMMON_FLAGS "${TARGET_COMMON_FLAGS}")
        

Include CPPCheck In Your Projects

if (${USE_CPPCHECK})
    find_program(CMAKE_CXX_CPPCHECK NAMES cppcheck)
    if (CMAKE_CXX_CPPCHECK)
        MESSAGE(STATUS "Found CPPCheck: `${CMAKE_CXX_CPPCHECK}`")

        get_target_property(CPPCHECK_SOURCES ${PROJECT_NAME}${CMAKE_EXECUTABLE_SUFFIX} SOURCES)

        # Filter the source set if you need too.
        list(FILTER CPP_SOURCES INCLUDE REGEX ...)

        # CPP Check needs the include directories and the compile definitions that the
        # project uses. To get these use a generator expression:
        #     Generator expressions are evaluated during build system generation to produce
        #     information specific to each build configuration.
        set(CPPCHECK_INCS "$<TARGET_PROPERTY:${PROJECT_NAME}.elf,INCLUDE_DIRECTORIES>")
        set(CPPCHECK_DEFS "$<TARGET_PROPERTY:${PROJECT_NAME}.elf,COMPILE_DEFINITIONS>")

        # When using add_custom_command() or add_custom_target(), use the VERBATIM and 
        # COMMAND_EXPAND_LISTS options to obtain robust argument splitting and quoting.
        add_custom_target(
                ${PROJECT_NAME}_cppcheck
                ALL #< Adds to default target, remove to require distinct target to be made
                COMMAND ${CMAKE_CXX_CPPCHECK}
                ${CPPCHECK_SOURCES}
                "-I;$<JOIN:${CPPCHECK_INCS},;-I;>" #< Must be quoted so it is recognised as a generator expression
                "-D$<JOIN:${CPPCHECK_DEFS},;-D>"   #< Must be quoted so it is recognised as a generator expression
                --force
                --enable=all
                --suppress=missingIncludeSystem
                COMMAND_EXPAND_LISTS                     #< Lists in COMMAND arguments will be expanded, including those created with generator expressions
                VERBATIM                                 #< All arguments to the commands will be escaped properly for the build tool 
                USES_TERMINAL
            )
        
            # Or, apparently could use the following, which looks better:
            # "$<LIST:TRANSFORM,$<TARGET_PROPERTY:tgt,INCLUDE_DIRECTORIES>,PREPEND,-I>"
    else()
        MESSAGE(WARNING "Could not find CPPCHECK")
    endif()
endif()

Include DoxyGen In Your Projects

if (${USE_DOXYGEN})
    find_package(Doxygen)
    if (${DOXYGEN_FOUND})
        MESSAGE(STATUS "Doxygen found.")

        set(DOXYGEN_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/...choose a dir name...")
        set(DOXYGEN_IMAGE_PATH "${PROJECT_SOURCE_DIR}/...insert paths here...")
        set(DOXYGEN_RECURSIVE "YES")
        set(DOXYGEN_WARN_NO_PARAMDOC "YES")
        set(DOXYGEN_EXTRACT_ALL "YES")
        set(DOXY_INPUT "${PROJECT_SOURCE_DIR}/...list dirs here...")
        set(DOXYGEN_FILE_PATTERNS "*.c" "*.h" ...)
        set(DOXYGEN_EXCLUDE_PATTERNS ...anything you'd like to ignore...)
        doxygen_add_docs(
                ${PROJECT_NAME}_doxygen
                ALL #< Adds to default target, remove to require distinct target to be made
                ${DOXY_INPUT}
                COMMENT "Generate Doxygen content"
        )
    else ()
        MESSAGE(WARNING "Doxygen not found. Doxygen output will not be created.")
    endif ()
endif()
    

Misc

See search path used: if you want to see which directories CMake is search in your case just call

cmake -D CMAKE_FIND_DEBUG_MODE=ON

Dump out all variables and their values:

get_cmake_property(_variableNames VARIABLES)
foreach (_variableName ${_variableNames})
    message(STATUS "${_variableName}=${${_variableName}}")
endforeach()