Bottom-up CMake introduction

Published on

If you want to learn CMake, but do not have time to go through all the resources on the internet, then this article is for you. I will cover essentials you’ll need to start:

  • targets
  • commands
  • variables
  • functions
  • macros

In the next few minutes, we will reimplement some CMake’s builtin functionality using the CMake itself.

Disclaimer: there are several very inaccurate statements about CMake in this article. Most of them are here on purpose: the goal is to build an intuition of how CMake works, not to be 100% correct.

What is CMake?

CMake is not a build system as many think of it. CMake is a build system generator. Basically, you can see it as a compiler that compiles CMake scripts into Makefiles. Or several other build systems including Ninja and Xcode, Eclipse, and Visual Studio projects.

The typical workflow is as follows: you create a CMakeLists.txt (can be empty), you generate the build system, you build something. Here is the code:

> touch CMakeLists.txt
> cmake .
> make help

This is the bare minimum you need to start. Now let’s learn some CMake concepts.

Targets

All the work in CMake is organized around targets. A target is something you can build or call.

Target calls

Create a CMakeLists.txt with the following content:

add_custom_target(hello-target
                  COMMAND cmake -E echo "Hello, CMake World")

And run the following commands:

> cmake .
< truncated >
-- Configuring done
-- Generating done

> make hello-target
Scanning dependencies of target hello-target
Hello, CMake World
Built target hello-target

Here, the make tells us that it has built the target hello-target, even though it just called echo command and did not produce any artifacts.

Build targets

Let’s fix that and actually build some simple program. Create the following files:

// main.c

extern void hello_world();

int main() {
  hello_world();
  return 0;
}
// hello.c
extern int printf(const char *, ...);

void hello_world() {
  printf("Hello, CMake world\n");
}

Replace the custom target COMMAND:

add_custom_target(hello-target
                  COMMAND gcc main.c hello.c -o hello)

And re-run make:

> make hello-target
< truncated >
-- Configuring done
-- Generating done
Built target hello-target

As you can see make detected the change and reconfigured CMake. If everything is right, you should have the hello executable:

> ./hello
Hello, CMake world

Great success!!!

Commands

In fact, you can describe the whole build process using the custom target as we did above. The problem is, however, that the command will re-run every time whenever you run make hello-target: the hello program will be re-compiled completely even when nothing has changed.

Let’s use separate commands to solve this problem. The new version of CMakeLists.txt:

add_custom_command(OUTPUT hello.o
                   COMMAND gcc -c hello.c
                   DEPENDS hello.c)
add_custom_command(OUTPUT main.o
                   COMMAND gcc -c main.c
                   DEPENDS main.c)

add_custom_target(hello-target
                  COMMAND gcc main.o hello.o -o hello
                  DEPENDS main.o hello.o)

The important point here is the DEPENDS. This construct describes the build process in the form of a (direct acyclic) graph: A depends on B, B depends on C and D, and so forth. Then, a change of D or C means that B is changed, which means that A is also changed, and therefore, all the changed items should be re-created.

Now try the following: build the program, add some change to one of the files, re-run the build twice, you should see something like this:

> make hello-target
[ 50%] Generating hello.o
[100%] Generating main.o
[100%] Built target hello-target
# Add small change to hello.c
> make hello-target
[ 50%] Generating hello.o
[100%] Built target hello-target
> make hello-target
[100%] Built target hello-target

We’ve just got incremental compilation, yay!

Variables

It is time to do some refactoring: I’m more of a clang person than gcc, and therefore I want an easier way to change the compiler. Let’s extract it into a separate variable.

Definition of a variable is as easy as set (FOO bar) call, that defines a variable FOO with value bar. The usage is also straightforward: ${FOO} becomes bar when executed.

Here is how a better version of CMakeLists.txt looks like:

set (C_COMPILER gcc)

add_custom_command(OUTPUT hello.o
                   COMMAND ${C_COMPILER} -c hello.c
                   DEPENDS hello.c)
add_custom_command(OUTPUT main.o
                   COMMAND ${C_COMPILER} -c main.c
                   DEPENDS main.c)

add_custom_target(hello-target
                  COMMAND ${C_COMPILER} main.o hello.o -o hello
                  DEPENDS main.o hello.o)

The variable definition can be recursive. Try to add the following code to the CMake script:

set (NUMBERS 1)
message(${NUMBERS})
set (NUMBERS ${NUMBERS} 2)
message(${NUMBERS})
set (NUMBERS ${NUMBERS} 3)
message(${NUMBERS})
set (NUMBERS 0 ${NUMBERS})
message(${NUMBERS})

And re-run cmake . to see this in action:

> cmake .
1
12
123
0123

Functions

In CMake, everything is a function!

set (FOO bar)

set is a function that takes two arguments.

if(CMAKE_SYSTEM_NAME STREQUAL Linux)
  # ...
else()
  # ...
endif()

if, else, and endif are functions.

add_custom_target and add_custom_command are also functions.

Let’s create our own function and hide all the intricacies of our CMake script:

set (C_COMPILER gcc)

function(create_executable name)
  add_custom_command(OUTPUT hello.o
                     COMMAND ${C_COMPILER} -c hello.c
                     DEPENDS hello.c)
  add_custom_command(OUTPUT main.o
                     COMMAND ${C_COMPILER} -c main.c
                     DEPENDS main.c)

  add_custom_target(${name}
                    COMMAND ${C_COMPILER} main.o hello.o -o hello
                    DEPENDS main.o hello.o)
endfunction()

create_executable(hello-target)

Fun fact: function and endfunction are also functions.

The function is now reusable, but quite useless since the source files are hardcoded. Let’s go a bit deeper and fix this issue.

Macros

In CMake, everything is a function! Except for macros.

Macros are like functions, with one exception: they are inlined whenever they called. We can extract compilation into the macro:

macro(compile source_file)
  get_filename_component(output_file ${source_file} NAME_WE)
  set (output_file ${output_file}.o)
  add_custom_command(OUTPUT ${output_file}
                     COMMAND ${C_COMPILER} -c ${source_file}
                     DEPENDS ${source_file})
endmacro()

The macro uses get_filename_component to cut the extension from the input source file and constructs the output file name: main.c -> main.o.

Now we can use this macro:

function(create_executable name)
  compile(hello.c)
  set (output_files ${output_file})

  compile(main.c)
  set (output_files ${output_files} ${output_file})

  add_custom_target(${name}
                    COMMAND ${C_COMPILER} ${output_files} -o hello
                    DEPENDS ${output_files})
endfunction()

The code looks a bit cleaner now, but there is at least one part that may look confusing: set (output_files ${output_file}). Since the body of a macro is inlined, we can rewrite this function like this (just for illustration):

function(create_executable name)
  get_filename_component(output_file hello.c NAME_WE)
  set (output_file ${output_file}.o)
  add_custom_command(OUTPUT ${output_file}
                     COMMAND ${C_COMPILER} -c hello.c
                     DEPENDS hello.c)
  set (output_files ${output_file})

  get_filename_component(output_file main.c NAME_WE)
  set (output_file ${output_file}.o)
  add_custom_command(OUTPUT ${output_file}
                     COMMAND ${C_COMPILER} -c main.c
                     DEPENDS main.c)
  set (output_files ${output_files} ${output_file})

  add_custom_target(${name}
                    COMMAND ${C_COMPILER} ${output_files} -o hello
                    DEPENDS ${output_files})
endfunction()

So basically, we reuse the variable output_file. We can use it to construct the list of object files for the custom target. I hope it is clearer now.

Loops

It obviously follows (c) that we can use a loop to handle a variable amount of source files passed to this function:

function(create_executable name)
  foreach(file ${ARGN})
    compile(${file})
    set (output_files ${output_files} ${output_file})
  endforeach()

  add_custom_target(${name}
                    COMMAND ${C_COMPILER} ${output_files} -o hello
                    DEPENDS ${output_files})
endfunction()

create_executable(hello-target main.c hello.c)

Here we iterate over passed source files (main.c, hello.c) stored in the ARGN variable, and accumulate all the intermediate files in output_files.

Final touches

I added three more things to the final version:

  • I added another variable C_FLAGS that stores some additional compile flags one may need
  • the name of the executable passed as a separate argument
  • extracted the linking phase into a separate command
set (C_COMPILER gcc)
set (C_FLAGS -g -O0)

macro(compile source_file)
  get_filename_component(output_file ${source_file} NAME_WE)
  set (output_file ${output_file}.o)
  add_custom_command(OUTPUT ${output_file}
                     COMMAND ${C_COMPILER} ${C_FLAGS} -c ${source_file}
                     DEPENDS ${source_file})
endmacro()

function(create_executable name exe)
  foreach(file ${ARGN})
    compile(${file})
    set (output_files ${output_files} ${output_file})
  endforeach()

  add_custom_command(OUTPUT ${exe}
                     COMMAND ${C_COMPILER} ${output_files} -o ${exe}
                     DEPENDS ${output_files})

  add_custom_target(${name} DEPENDS ${exe})
endfunction()

create_executable(hello-target hello main.c hello.c)

Give it another try:

> cmake .
> make hello-target
> ./hello
Hello, CMake world!

Conclusion

We’ve just replicated (limited) version of CMake’s add_executable functionality.

Here is the version you would use if you didn’t know how to build the thing on your own:

set (CMAKE_C_COMPILER gcc)
set (CMAKE_C_FLAGS -g -O0)

add_executable(hello main.c hello.c)

What’s next?

Go and learn about other CMake functions (that are confusingly called commands), you are ready now!

I would highly recommend learning about the following concepts:


Drop me a line or ping me on twitter or Mastodon if you have questions!