CC Blog Design Solutions Research & Design Hub

CMake the Most of Software Development

Written by Jacob Beningo

Modern Build Systems Support Scalability

Discover how modernizing your build system with CMake can revolutionize embedded software development, offering unparalleled flexibility and efficiency. Dive into this article to explore the transformative benefits of CMake’s cross-platform capabilities and how it integrates seamlessly with contemporary development practices.

  • What limitations exist in traditional microcontroller vendor IDE build systems?
  • How does a modern build system support multiple targets like debug, release, test, simulation, and analysis?
  • Why is CMake an effective tool for cross-platform embedded development?
  • How do toolchain files in CMake enable flexible cross-compilation?
  • In what ways can build scripts simplify managing complex embedded projects?
  • CMake Build System
  • Embedded Software Development
  • Modern Development Practices
  • Companies Mentioned:
    Make | www.Make.com

Many embedded software developers today cannot take advantage of modern development techniques like DevOps, Test-Driven Development, and simulation because they write software using their microcontroller vendor’s IDE. While these integrated environments are convenient for getting a project started quickly, they don’t lend themselves well to building multiple configurations; they often build slowly and can create productivity headaches.

Embedded software development doesn’t need to be that way. There’s a better way that will allow you to leverage modern development techniques and practices while still having the benefits of an integrated IDE: create your own modern CMake build system. I know this sounds time-consuming and difficult, but I will show you how easy it can be.

Those who want to jump straight to the solution can download a completed version at a link in the article resources. [1]

WHAT’S A BUILD SYSTEM?

A build system is a set of tools and processes used to automate converting source code into executable programs. It includes:

  • Cross-compilation
  • Configuration management
  • Automation
  • Integration with development tools
  • Resource handling

A typical build system will look like Figure 1.

FIGURE 1
The traditional build system has a single target that builds for the microcontroller.
FIGURE 1
The traditional build system has a single target that builds for the microcontroller.

The application comprises drivers, an operating system, some middleware, and the application itself. These are cross-compiled using Make [2] with the target compiler, possibly GCC, and then linked into a final binary that runs on the target microcontroller. There is typically just a single build configuration, which forces you to run the code directly on the target and only on the target.

The problem with the traditional build system is that it doesn’t scale well or provide a developer with the right tools to build a modern system. You need a build system that supports a modern workflow to succeed today. But what does that build system look like?

DESIGNING A BUILD SYSTEM

A modern build system doesn’t just support a single target; it supports a flexible and scalable architecture with multiple build targets. There are a minimum of five targets that the modern build system should support:

  • Debug
  • Release
  • Test
  • Simulation
  • Analysis

The debug and release builds are similar to the traditional, single-target builds. They build the software to run on your microcontroller in either an unoptimized (debug) or optimized (release) manner. Different compilation flags are applied to simplify debugging your software or optimize it for a production build.

The test build is all about enabling test automation and DevOps. Using an automated unit test harness is a critical technique to ensure you are writing robust, tested code. The test build will run unit tests, regression tests, and maybe even hardware-in-the-loop tests. The idea is that every time you commit your code, the test build will automatically run and make sure that the changes you made to the code didn’t break anything.

Today’s embedded systems have reached a level of complexity and sensor integration that lends itself well to using simulation. Simulation is a technique that allows developers to run application code off-target to prove that it works correctly without having to run it on hardware. It’s a powerful technique and one that every developer should be supporting.

Finally, the analysis build scans your code and performs metric measurements so that you can have a scoreboard to tell you how good or bad your software is. Cyclomatic Complexity is an example of a metric often checked in these builds. The idea is that the more complex your functions are, the greater the risk of a bug in that code. Minimizing complexity allows you to keep code simple, reducing the chances of bugs.

With this knowledge, you might design a modern build system that looks like Figure 2. As you can see, it supports all these builds along with a software architecture and build system tools that allow for flexibility and reuse of the developed code. Many developers today write code to be used in more than one product, and a build system like this enables them to reuse their code across multiple products. I often call this the development of a platform.

FIGURE 2
A modern build system is flexible, scalable, and can support multiple build targets, including test, simulation, and code analysis.
FIGURE 2
A modern build system is flexible, scalable, and can support multiple build targets, including test, simulation, and code analysis.

Now that we know what a modern build system should look like, let’s discuss how to implement the design using CMake.

BUILD A SYSTEM GENERATOR

CMake is a cross-platform, open-source build system generator. It simplifies the process of managing build processes in a compiler-independent manner, making it ideal for projects that need to operate across different systems. It brings:

  • Cross-platform support
  • Configurability
  • Modularity
  • Extensive language support
  • Toolchain integration

Instead of writing a bunch of Makefile scripts, which can get complicated quite quickly, using CMake provides a modular programming language to configure how you want your build system to be generated. Behind the scenes, CMake can take what you provide and generate Make or Ninja files that are then executed to build the final targets. Figure 3 shows an example how you might use CMake with Ninja to compile an embedded application. You can see that CMake’s job is to generate the build system files that are used by Ninja to compile the final binaries.

FIGURE 3
CMake uses CMakelists.txt files and toolchain files to generate the build system files that are used by other tools to compile the software into the target binary.
FIGURE 3
CMake uses CMakelists.txt files and toolchain files to generate the build system files that are used by other tools to compile the software into the target binary.

How you use CMake will depend on the size and complexity of the project that you are building. If you are working on a simple project that is relatively self-contained, then you would create a single top-level CMakelists.txt file to manage the build system generation. CMakelists.txt contains all the commands, variables, and conditional logic to create the build system files.

In a more complex, larger project where a lot of reuse will occur, a single top-level CMake file won’t provide enough flexibility. Instead, a CMakelists.txt file is provided in all the projects subdirectories. A high-level CMakelists.txt file is then used to set global settings, directories to search, etc. When each directory is traversed, the local CMakelists.txt file is read for the detailed settings on how to compile the source in that directory. Figure 4 shows an example how a project might look in this configuration.

FIGURE 4
In complex or large projects, CMakelists.txt files are included in nearly all directories to inform CMake of all the compiler details and settings for that specific set of software.
FIGURE 4
In complex or large projects, CMakelists.txt files are included in nearly all directories to inform CMake of all the compiler details and settings for that specific set of software.
UNDERSTANDING “CMakelists.txt files”

At first exposure, the CMakelistsl.txt file can seem a little overwhelming, but once you look closely you’ll see that the syntax is relatively straight forward. For example, in Listing 1, you can see the start of a CMakelists. Every CMakelists.txt file starts with cmake_minimum_required to tell the build system what the minimum version of CMake required to generate the build system. CMake, like any other piece of software, evolves with time and is constantly adding new features and easier ways to do things.

The line project (controller C CXX ASM) is also important. It’s a command that gives our project the name ‘controller’ and tells CMake what programming languages we’ll be using. In this example, you can see that we are naming the project controller and that we’ll be creating a mixed language project that uses C, C++, and assembly language.

You can also see in Listing 1 that we use the command set to create variables that hold a list of the files we want to compile. We will add to those files later, along with the desgination of where we want to generate our final output file.

LISTING 1
A CMakelists.txt file contains commands, variables, and conditional logic required to prepare the build system files for compiling the application. 

cmake_minimum_required(VERSION 3.10)project(controller C CXX ASM)# Set targetset(TARGET controller)# Set the output directory for the executablesset(EXE_DIR ${CMAKE_BINARY_DIR}/bin)set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${EXE_DIR})# Initialize lists for sources and include directoriesset(ALL_C_SOURCES “”)set(ALL_CPP_SOURCES “”)set(ALL_ASM_SOURCES “”)set(ALL_INCLUDE_DIRS “”)# Add subdirectoriesadd_subdirectory(firmware/app)add_subdirectory(firmware/bsp)add_subdirectory(firmware/drv)add_subdirectory(firmware/hal)add_subdirectory(firmware/lib)
CREATING A BUILD SYSTEM

Setting the programming language and setting some directories is not enough to create a build system. We need to set the target, the target properties and where the final target will be placed when the compilation process is complete. Listing 2 shows how we do all those things in CMake. Once again, we use a few CMake commands and variables to tell CMake what we need it to do.

LISTING 2
Configuring the project output using CMake.

# Add the executable targetadd_executable(${PROJECT_NAME} ${ALL_C_SOURCES} ${ALL_CPP_SOURCES} ${ALL_ASM_SOURCES})set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME ${PROJECT_NAME}.elf)# Set the include directories for the targettarget_include_directories(${PROJECT_NAME} PRIVATE ${ALL_INCLUDE_DIRS})

One final thing we need to get a basic, modern build system up and running using CMake is to set our linker options. Listing 3 shows a few additional statements that we add to CMakelists.txt that pull in our linker options.

LISTING 3
CMake commands to set the toolchain linker and library options. 

# Linker options from the toolchain filetarget_link_options(controller PRIVATE ${TOOLCHAIN_LINKER_OPTIONS})# Libraries from the toolchain filetarget_link_libraries(${TARGET} ${TOOLCHAIN_LIBRARIES})

If you’ve been looking carefully, you might realize that this CMakelists.txt file seems to use variables to reference values that aren’t defined in CMakelists.txt! How does CMake know what settings to use?

To answer that question, we need to talk about toolchain files.

CUSTOMIZING TARGETS AND TOOLCHAINS

A toolchain file in CMake is a crucial configuration file that specifies the compiler and tools to be used for compiling and cross-compiling various projects. The toolchain file clearly specifies the target system, such as Linux, Darwin, Windows, Arm, and so forth. It also specifies which compiler is used and the exact path to the compiler. For example, if you are going to build your application for a specific simulation build, you might have a toolchain file similar to Listing 4. You can see that in the simulation toolchain file, we want to build specifically for Darwin (MacOS) and use the GCC compiler.

The toolchain file can contain any build-specific information that might change from one target to the next. For example, if I’m building for an Arm Cortex-M microcontroller, I would specifically use the arm-none-eabi-gcc compiler. However, as you saw, for my simulation build, I would simply use the local machine’s gcc compiler.

Toolchain files provide you with the flexibility to change build settings from one target build to the next. They are extremely powerful. You can see from Listing 4 that we can even have different compiler flags, defines, linker settings, and so forth. It’s completely customizable allowing us to support multiple targets and builds without having to change our source code. You simply build with a different toolchain file and those settings are substituted into our CMakelists.txt file during the build.

LISTING 4
An example of a tool chain file for developers building and application for a simulation build.

# Use the system’s default C and C++ compilersset(CMAKE_SYSTEM_NAME Darwin) # Adjust this as necessary (e.g., Windows, Darwin for macOS, Linux)# Optionally specify the compiler (GCC/Clang) if the default selection needs to be overriddenset(CMAKE_C_COMPILER gcc)set(CMAKE_CXX_COMPILER g++)# Add any simulation-specific preprocessor definitionsadd_compile_definitions(SIMULATION)# Specify any host-specific compiler flags if necessaryset(COMMON_FLAGS “-O2 -g”)set(CMAKE_C_FLAGS “${CMAKE_C_FLAGS} ${COMMON_FLAGS}” CACHE STRING “” FORCE)set(CMAKE_CXX_FLAGS “${CMAKE_CXX_FLAGS} ${COMMON_FLAGS}” CACHE STRING “” FORCE)# Set the C standard and C++ standard that your project requiresset(CMAKE_C_STANDARD 17)set(CMAKE_CXX_STANDARD 17)
MODERN SYSTEM BUILDING

Once you design and set up your modern build system, you’ll still need to build your project. There are several ways that you could compile a project. First, you can directly invoke CMake at the command line using a command like:

cmake -G Ninja -B /build -S . -DCMAKE_BUILD_TYPE=simulation -DCMAKE_TOOLCHAIN_FILE=toolchain-host.cmake

This command is telling CMake that we want to generate the necessary build system files for Ninja that are specifically for a simulation build using the toolchain-host.cmake file. The output from CMake will be conveniently placed in the /build directory.

Invoking CMake in this way will generate our build system files but it won’t compile our project. To do that, we also need to invoke Ninja using the command:

ninja -C /build

Once we do that, Ninja will read in the files output by CMake, invoke our compiler and generate the output binary for our build.

SCRIPTS SIMPLIFY

Using the command to build our various targets from the command line is a bit exhausting. I don’t know about you, but I don’t want to memorize that command string. I know that I can just use the command recall in my terminal to execute the command, but if I give my build system to a colleague, they won’t have access to it.

One solution to enable easy building and target selection is to use a build script. I like to create a build script name project.sh or devManager.sh to manage the various build targets. The build script can be broken up into several parts that you to:

1) Select the target.

2) Select whether you want to build or run that target.

There are quite a few details that can go into creating the script, but let’s just look at what the details of how we can simplify the build. You can see in Figure 5 a snippet from the project.sh script that manages the build string. You can see that instead of specifying the toolchain file, build directories, and build types, we instead replace them with a variable.

FIGURE 5
Using a script like project.sh or devManager.sh can help simplify selecting build targets and compiling your project.
FIGURE 5
Using a script like project.sh or devManager.sh can help simplify selecting build targets and compiling your project.

The variable is set dynamically based on the specific command that needs to be executed. For example, if I want to build specifically for simulation, at the terminal I might type:

./project.sh simulation build

That command will be taken by my script and the correct build strings generated. In this case, the following substitutions would occur:

  • $TOOLCHAIN_FILE => toolchain-host.cmake
  • $BUILD_DIR => /build/simulation/
  • $BUILD_TYPE => simulation

Using a script in this manner can make your build system far more flexible and help free up mental bandwidth for more important things like writing code.

CONCLUSIONS

There are a lot of advantages to using CMake as the foundation for your modern embedded build system. As you’ve seen, it’s flexible and can easily scale to include all the targets that a modern build system should support. More importantly, this flexibility allows you to create the build targets that make modern development techniques like DevOps, Test-Driven Development, and simulation available to your development cycle.

— ADVERTISMENT—

Advertise Here

It’s time to break the shackles of your existing build system and design or adopt your own. If you think that it’s too complicated, don’t worry. You can download the example for this article and use that as your baseline. Once you’ve reviewed and understand how it works, you can adapt it for your own project needs.

When you take this build system and use it with Visual Studio Code, you not only have a modern build system but also an integrated development environment that you can customize and scale for years into the future. I hope that you can now clearly see that this is a better, more flexible way to develop embedded software. 

[1] Modern embedded CMake build system download – https://www.beningo.com/embedded-software-cmake-build-system/
[2] Make – https://www.make.com/en

PUBLISHED IN CIRCUIT CELLAR MAGAZINE • FEBRUARY 2025 #415 – Get a PDF of the issue

Keep up-to-date with our FREE Weekly Newsletter!

Don't miss out on upcoming issues of Circuit Cellar.


Note: We’ve made the Dec 2022 issue of Circuit Cellar available as a free sample issue. In it, you’ll find a rich variety of the kinds of articles and information that exemplify a typical issue of the current magazine.

Would you like to write for Circuit Cellar? We are always accepting articles/posts from the technical community. Get in touch with us and let's discuss your ideas.

Sponsor this Article

Supporting Companies

Upcoming Events


Copyright © KCK Media Corp.
All Rights Reserved

Copyright © 2026 KCK Media Corp.

CMake the Most of Software Development

by Jacob Beningo time to read: 11 min