Sitemap
Prashant Ajabe

Prashant Ajabe

Exploring CMake….!

14 min readMay 17, 2026

While studying the build system of the ESP32, I discovered the tools used by the ESP32 build system. It uses CMake and Ninja tools to build projects. Out of curiosity, I started exploring CMake. The main question was: what problem does CMake solve? Why don’t we just write the build files (Makefile/build.Ninja) for our project? In an earlier blog about the bare metal embedded systems, I wrote the basic LED blink code and Makefile to build the executable. I am using this project to explore the build automation using CMake. Below is the GitHub link for the supporting files for this blog:

https://github.com/prashantajabe09/Bare_Metal_Build_Automation_With_Cmake.git

  • The Problem with Manual Compilation

Here is what we do — we start with my bare metal project files, then manually run the commands to build the executable. We examine what problems this manual method has and how we can solve them (using Make tool and Makefile). Basically we automate this manual process of building. Then we explore what problems this automation process has and what solutions we have for it. It is at this point that we discuss CMake. We start with manual compilation, move to Make and Makefile, and finally arrive at CMake. Each step is motivated by the problems of the previous one. By doing this we are able to see the big picture of the build automation. Let’s Start!

We have 3 source files in our bare metal project main.c, Stm32_Startup.c, syscalls.c and linker script to link the project. To build the final executable (.elf) we need to run the below command :

arm-none-eabi-gcc \  # We will be using the arm gcc compiler to compile for our arm processor
-mcpu=cortex-m3 -mthumb -mfloat-abi=soft \ #Options passed to compiler to compile for the arm cortex-m3 processor.
-Wall -O0 -g \ #Options to configure the compiler to genrate all warnings, no optimization and inclusion of the debug symbols
--specs=nano.specs \ # Instruct compiler to include the lightweight library
-T Stm32_ls.ld -Wl,-Map=final.map \ # Instruct compiler to use the Stm32_ls linker script and genrate the map file
main.c Stm32_Startup.c syscalls.c \ #input files to genrate the final executable
-o final.elf # executable that we want to generate

arm-none-eabi-objcopy -O ihex bare_metal.elf final.hex #command to generate the hex file from elf

Ohhh! Very long command to build the executable. Now just imagine instead of 3 source files there are 100 of them what the command will look like. Scary! right? And other issue with this method is that every time you change some file you need to re-run the whole command and compilation time increases as the number of the source file increases. Lots of time wasted during development!

One way to tackle the issue of the re-compilation of all the files every time you change something, is to instruct the compiler to store the object files created during compilation. The next time you change a file, just run the command to compile that file and link all the files to generate the executable. GCC flag -c instructs the compiler to compile the source file into an object file and stop before the linking step. The -o flag then specifies the name of the output object file. This means you compile each file once, save the object file, and in future builds you only recompile files that changed — not the entire project. Below is the command to instruct the compiler to compile the main.c source file but do not link. Also the command to link the object files to build the final executable.

# command to compile main.c but do not link
arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -mfloat-abi=soft -Wall -O0 -g main.c -o main.o

# command to link the compiled source file to build the executable
arm-none-eabi-gcc --specs=nano.specs -T Stm32_ls.ld -Wl,-Map=final.map main.o Stm32_Startup.o syscalls.o -o final.elf

This method reduces the compilation time, but the bigger problem is that what if we have 100 files and we change some 5 files and forget to recompile one of those files. The final executable contains the old object code for that file. This is a serious Bug! So we need a solution that keeps track of the files that needs to be recompiled! and which runs the commands for us so that we don’t require to type these long commands. How to do it?

  • Introducing the Make Tool

Let’s take the example of the main file. If the timestamp of the main.c file is newer than the timestamp of main.o, then we need to recompile main.c. It is as simple as that! So we need a tool that will keep track of all files and compile only those files whose timestamp is newer than their object file. At this point, the Make tool comes into the picture.

The Make tool tracks files by their timestamp and compiles only when the timestamp of the input file is newer than that of the output file (in our case, the object file and executable file). The Make tool works with a Makefile. A Makefile contains the recipe to generate output files. A Recipe to generate a file looks like the following:

target: dependencies
commad

A target is the output file we want to generate (a .o file in our case) and dependencies are the files on which the target depends. For example, the target main.o depends on main.c. The command is run by the Make tool to generate the target from its dependencies. The target, dependencies and command together form the recipe.

  • Writing Our Makefile

Let’s convert the command to build main.o from the main.c into a recipe. Write the recipe in the Makefile and save the file in the same folder with the name makefile. On a terminal, run the command make main.o — this command generates the main.o file in the folder. During the next build if the timestamp of main.c is newer than that of main.o , the Make executes the command otherwise it skips it. This approach solves our major headache of tracking files.

main.o:main.c
arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -mfloat-abi=soft -Wall -O0 -g main.c -o main.o

Let’s now write recipes to generate the Stm32_Startup.o and syscalls.o files and a recipe to generate the executable.

Stm32_Startup.o:Stm32_Startup.c
arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -mfloat-abi=soft -Wall -O0 -g Stm32_Startup.c -o Stm32_Startup.o

syscalls.o:syscalls.c
arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -mfloat-abi=soft -Wall -O0 -g syscalls.c -o syscalls.o

final.elf:main.o Stm32_Startup.o syscalls.o
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -mfloat-abi=soft --specs=nano.specs -T Stm32_ls.ld -Wl,-Map=final.map main.o Stm32_Startup.o syscalls.o -o final.elf

When we run the commands, we get the object files and the final executable. After you write a Makefile, you don’t have to remember which file to re-compile, as that is taken care of by the Make. Any time you want to build the executable just run the make command, that’s it.

Let’s write Makefile using variables to remove a repetitive part, as below:

CC= arm-none-eabi-gcc
MACHINE= cortex-m3
CFLAGS= -c -mcpu=$(MACHINE) -mthumb -mfloat-abi=soft -Wall -O0 -g
LDFLAGS= -mcpu=$(MACHINE) -mthumb -mfloat-abi=soft --specs=nano.specs -T Stm32_ls.ld -Wl,-Map=final.map
OBJCOPY= arm-none-eabi-objcopy

main.o:main.c
$(CC) $(CFLAGS) main.c -o main.o

Stm32_Startup.o:Stm32_Startup.c
$(CC) $(CFLAGS) Stm32_Startup.c -o Stm32_Startup.o

syscalls.o:syscalls.c
$(CC) $(CFLAGS) syscalls.c -o syscalls.o

final.elf:main.o Stm32_Startup.o syscalls.o
$(CC) $(LDFLAGS) main.o Stm32_Startup.o syscalls.o -o final.elf

final.hex:final.elf
$(OBJCOPY) -O ihex final.elf final.hex

final.bin:final.elf
$(OBJCOPY) -O binary final.elf final.bin
  • Where Makefile Falls Short

The Makefile syntax itself is largely portable, but the shell commands inside the recipes — such as rm, mkdir, and cp — are Unix-specific and do not work on Windows natively. This means the Makefile needs to be rewritten for each different operating system. Also, as your project grows, you must manually add every new source file to the Makefile. There is no automatic file discovery — if you add a new .c file to your project and forget to add it to the Makefile, it will silently be excluded from the build with no error or warning.

  • Enter CMake
Press enter or click to view image in full size
CMake Configuration Stage Part 1
Press enter or click to view image in full size
CMake Configuration Stage Part 2

So here comes the CMake tool.The CMake tool is platform independent. CMake operates on a file called CMakeLists.txt, which does not depend on a particular system.The CMakeLists.txt file describes the project. In the CMakeLists.txt file, we tell CMake what we want to build and which files it depends on. We also instruct CMake what compiler to use and how to configure the compiler for our requirement. CMake reads the CMakeLists.txt file and finds out what system it is running on, what compilers are available, and generates the build files (build files for the Make tool in our case). On any particular system, there might be different build system tools (for example, Make, Ninja etc.). To generate the build files for a particular build tool, CMake uses the generator of that particular tool. Each build tool has its own generator. When we ask CMake to generate the build files for a specific build tool, it uses the respective generator. If you don’t specify which generator to use, CMake uses the default one. To check the available generators on the system use cmake --help command and look at the generator section. To force a generator on CMake, use cmake -B build -G “generator name”.

CMake operates in two phases — the configuration phase and the build phase. In the configuration phase, CMake reads the CMakeLists.txt file and detects the OS on which it is running, detects the compiler, detects the build tool to use, and decides which generator to use. It then generates the build files using the generator. While doing all this, CMake also generates a cache file where it stores information related to the compiler used and the generator it used. In the build phase, CMake checks which generator it used to generate the build files from the cache file and accordingly invokes the build tool. The build tool then operates on the build files generated by CMake during the configuration phase.

  • Writing The CMakeLists File

Okay, Enough Theory! Let’s write the CMakeLists.txt file and generate the build files.

While writing the CMakeLists.txt file, include the below two commands at the start of the file:

cmake_minimum_required(CMAKE VERSION)
project()

cmake_minimum_required() specifies the CMake version that must be used for this particular project. If the version available on your system is older than the requested one, CMake generates an error of below type. Here I deliberately pass the version as 3.23 whereas my system has 3.22.1 version.

CMake 3.23 or higher is required.  You are running version 3.22.1

project() declares and defines basic information about a project. It is an introduction of the project that you give to CMake. We can add more information about the project, such as a version, description and languages . CMake sets up variables using this information, which can be used in the CMakeLists.txt . CMake detects and sets up a compiler by looking at the languages. Place project() right after cmake_minimum_required() . If the language is not mentioned, then C and C++ are assumed. After project(), CMake checks for various things such as the compiler available and tools available.

Get Prashant Ajabe’s stories in your inbox

Join Medium for free to get updates from this writer.

add_executable(): tells CMake to take the source files and create an executable from them. It defines what to build and from what files.

add_executable(executable_name source_file_1.c source_file_2.c ....)

An executable name and the project name do not have to be the same. Multiple executables are allowed — we can create more than one executable in the same project. Every .c source file that is part of the executable must be listed. add_executable() tells CMake to create an executable named “executable_name” , by compiling source_file_1….. source files. add_executable() defines what you are building and from what files.

target_link_options(<target> <PRIVATE|PUBLIC|INTERFACE> <options...>)

target_link_options() tells CMake when linking a target, it should add options to a linker command. target_link_options() command adds linker flags to a specific target. Cmake reads target_link_options() command and adds the options to the linker command in a link.txt file that CMake generates at the end of the configuration stage.

add_custom_command(TARGET <target_name>
POST_BUILD
COMMAND <command_to_run>
COMMENT <message_to_show>
)

add_custom_command(): tells CMake to run the COMMAND as part of the build process. There are two types of add_custom_command() — POST_BUILD and OUTPUT. We use POST_BUILD for our bare metal project, as we want to generate a hex file and a bin file. To generate the target (executable), the COMMAND in add_custom_command() is executed. The main objective of the add_custom_command() is to generate additional output files.

set_target_properties(<targets> ...
PROPERTIES <prop1> <value1>
[<prop2> <value2>] ...)

set_target_properties(): By default CMake does not add a file extension to the executable on Linux. set_target_properties() forces the output file to be named BareMetal.elf instead of just BareMetal, as can be seen in the bare metal project’s CMakeLists.txt file.

Let’s first write simple Hello World C program and a CMakeLists.txt file to check what CMake detect on normal build process (For WSL Linux). Below is the simple CMakeLists.txt file written for the Hello World C program. When we execute the cmake -B build configuration command, CMake reads the CMakeLists.txt file and starts detecting the compiler, the system and sets internal variables for future use. For example, CMake sets CMAKE_C_COMPILER variable to the compiler it found and sets the OS it is running on to CMAKE_SYSTEM_NAME. You can check what variables CMake sets during the configuration process by running the cmake -B build --trace command, or you can check the CMakeCCompiler.cmake and CMakeSystem files. When you check these files, you can observe the system, the processor and the OS that CMake detects. The Build files are generated based on these system parameters. I am pasting the CMakeSystem file below for reference. After the configuration step completes, you can invoke the build by running the cmake --build build command.

/* CMakelsts.txt file for Hello World C Program */

cmake_minimum_required(VERSION 3.22)

project(HEllo
DESCRIPTION "TEST 1"
VERSION 1.0.1
LANGUAGES C
)

add_executable(Hello
main.c
)
/* CMakeSystem file for the Hello World C program Configured to Run on my laptop.*/

set(CMAKE_HOST_SYSTEM "Linux-5.10.102.1-microsoft-standard-WSL2")
set(CMAKE_HOST_SYSTEM_NAME "Linux")
set(CMAKE_HOST_SYSTEM_VERSION "5.10.102.1-microsoft-standard-WSL2")
set(CMAKE_HOST_SYSTEM_PROCESSOR "x86_64")



set(CMAKE_SYSTEM "Linux-5.10.102.1-microsoft-standard-WSL2")
set(CMAKE_SYSTEM_NAME "Linux")
set(CMAKE_SYSTEM_VERSION "5.10.102.1-microsoft-standard-WSL2")
set(CMAKE_SYSTEM_PROCESSOR "x86_64")

set(CMAKE_CROSSCOMPILING "FALSE")

set(CMAKE_SYSTEM_LOADED 1)
  • Writing the ToolChain For Bare Metal Project

Looking at the variables in the CMake generated files, you will notice that CMake uses the default compiler on the system and generates commands to build an executable for an x86_64 processor. However, we want the build files for a cortex-M3 processor , which does not have an OS. For this, I explicitly set the variables to the compiler that I want to use (arm-none-eabi-gcc) , along with the processor of my microcontroller, and pass compiler and linker flags to modify the compiler behavior. We write this information in a separate toolchain file and pass that file to CMake during the configuration phase. CMake uses the information in the toolchain file to set its variables instead of auto-detecting the compiler and the system. Below is the toolchain file. The variables set in the toolchain file have higher precedence over the variables set during auto-detection.

# TOOLCHAIN FILE to SET CMAKE variable as per our Project requirement.

# Bare Metal System without the OS
set(CMAKE_SYSTEM_NAME Generic)

# Target Processor is cortex-M3
set(CMAKE_SYSTEM_PROCESSOR cortex-m3)

# Specify cross compiler
set(CMAKE_C_COMPILER arm-none-eabi-gcc)

#specify the ASM Compiler
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)

# TO genrate the extra hex and bin
set(CMAKE_OBJCOPY arm-none-eabi-objcopy)

#To output the size of the Executable
set(CMAKE_SIZE_UTIL arm-none-eabi-size)

# Prevent CMake from trying to run compiled binaries
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# CPU + architecture flags
set(MCU_FLAGS "-mcpu=cortex-m3 -mthumb -mfloat-abi=soft")

# Compiler flags
set(CMAKE_C_FLAGS "${MCU_FLAGS} -Wall -O0 ")

# Linker flags
set(CMAKE_EXE_LINKER_FLAGS "${MCU_FLAGS} --specs=nano.specs")

Let’s explain the toolchain variables briefly:

  1. CMAKE_SYSTEM_NAME is set to Generic , which means the target processor for which we want to generate the executable does not have an OS.
  2. CMAKE_SYSTEM_PROCESSOR is set to Cortex-M3, which tells CMake that the target processor is Cortex-M3.
  3. CMAKE_C_COMPILER and CMAKE_ASM_COMPILER are set to arm-none-eabi-gcc , which tells CMake not to use the default available compiler , but instead use arm-none-eabi-gcc compiler.
  4. CMAKE_OBJCOPY is set to arm-none-eabi-objcopy, which is used to generate .hex and .bin files.
  5. CMAKE_TRY_COMPILE_TARGET_TYPE is set to STATIC_LIBRARY , which tells CMake to skip the compiler validation step. Normally CMake compiles and runs a small test program to verify the compiler works. Since we are cross-compiling for a Cortex-M3 with no OS, that test program cannot run on the host machine and would always fail — so we skip it.
  6. MCU_FLAGS, CMAKE_C_FLAGS and CMAKE_EXE_LINKER_FLAGS are set to options that are used to modify the compilation behavior. This is discussed in more detail in my earlier blog.

Now let’s write a CMakeLists.txt file for our bare metal project. We mention the CMake version that we want to use, describe our project, define what we want to build (bin/hex file) and from which files (source files), sets the properties of an executable, passes the linker flags, and adds the custom commands to generate the bin or hex file. Below is the CMakeLists.txt file for our bare metal project.

cmake_minimum_required(VERSION 3.22)

project(BareMetal
VERSION 1.0.1
DESCRIPTION "Bare Metal Embedded Build USING CMAKE"
LANGUAGES C
)

add_executable(BareMetal
main.c
Stm32_Startup.c
syscalls.c
)

set_target_properties(BareMetal PROPERTIES SUFFIX ".elf")


target_link_options(BareMetal PRIVATE
-T${CMAKE_SOURCE_DIR}/Stm32_ls.ld
-Wl,-Map=final.map
)

add_custom_command(TARGET BareMetal POST_BUILD
COMMAND arm-none-eabi-objcopy -O binary BareMetal.elf BareMetal.bin
COMMAND arm-none-eabi-objcopy -O ihex BareMetal.elf BareMetal.hex
)

When we run the cmake -B build -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake configuration command, CMake generates many files, let’s discuss the files that are important for our understanding of how CMake works.

  • Files Generated During The Cmake Configuration Stage

In the configuration stage, based on the CMakeLists.txt and the toolchain file, CMake generates multiple Makefiles. The build/Makefile is the root Makefile from where the build starts. The build.make file actually contains the commands for building an executable. Along with these files, CMake also generates the CMakeCache file, which stores the paths to the compilers and the build tool generator that CMake detected during configuration. CMake generates the CMakeSystem file, which stores information about the system on which CMake is running and the cross-compilation information. You can find the compiler related variables set during the configuration stage in the CMakeCCompiler file. The flags.make file contains the flags we specified in the toolchain file. The link.txt file includes the link command, which is used in the build.make file. The compiler_depend.make file includes all dependencies from all the .d files generated during compilation. This file’s information is used for tracking the dependencies.

  • CMake Build Tool Command Execution Flow-chart

The flowchart below shows the full execution chain of commands when we run the cmake --build build command. Let’s walk through each step.

Press enter or click to view image in full size
CMake Build Flow-chart

While I was studying how CMake actually builds an executable after the configuration phase, I got confused, as there are multiple build files and complex commands that invoke the Make tool recursively from multiple files. So let’s simplify what happens when we run the cmake --build build command — how different commands are executed and what checks are made.

When we run the cmake --build build command, CMake starts with the root Makefile in the /build folder with the target set to all. CMake then executes cmake_check_build_system, which checks if any of the CMake dependencies (for example CMakeLists.txt,etc) have been updated. If any of the dependencies have been updated, CMake generates the latest build files and other required files. The build/CMakeFiles/Makefile.cmake file lists the files that CMake checks for any changes. If any of the files mentioned in the build/CMakeFiles/Makefile.cmake have been updated, CMake re-generates the build files. Once it is confirmed that CMake build files are up to date, CMake invokes Make with CMakeFiles/Makefile2 and the all target, which in turn invokes Make with bareMetal.dir/build.make and depend target and then
invokes Make with bareMetal.dir/build.make and build target. The depend target is a placeholder, as all the dependencies of the target (object files, executable) are figured out by the compiler and written into the dependency file. -MD, -MT and -MF flags are used in the compilation command to generate the .d dependency files. The data of all the .d files are copied to the compiler_depend.make file during second build command. In the build target, all the object files are compiled and an executable is generated. These multiple build files have many targets — here I have explained the flow of command execution to build an executable for our bare metal project.

That’s it! You now understand the full journey — from typing a single compilation command manually, to letting CMake generate and manage the entire build system for you. I hope it makes you curious to explore CMake further. Next you can explore how multiple CMakeLists.txt files connects, how CMake handles external libraries. Please find the link to the project below.

Github:https://github.com/prashantajabe09/Bare_Metal_Build_Automation_With_Cmake

--

--