CMake Tutorial for C Beginners – From Makefile to Modern Builds

Make is the classic way to build C — but write Makefiles long enough and you meet its sharp edges: header dependencies you must list by hand, tab-vs-space landmines, and a file that only works on Unix. CMake is the industry’s answer, and it’s what you’ll meet in almost every serious C/C++ codebase today. This tutorial takes the exact same three-file project from our Makefile tutorial and rebuilds it the CMake way, with every command’s actual output shown (CMake 3.28, gcc 13.3, Ubuntu 24.04).

What CMake Actually Is (Not a Build Tool)

The one idea that makes CMake click: CMake doesn’t build anything. It’s a generator — it reads your project description (CMakeLists.txt) and generates a native build system: a Makefile on Linux, an Xcode project on macOS, a Visual Studio solution on Windows. You describe the project once; CMake produces the right build files for wherever you are. That’s why it won: one description, every platform.

Step 1 — Install CMake

sudo apt install cmake

(macOS: brew install cmake. Windows: winget install Kitware.CMake or the installer from cmake.org.) Verify:

cmake --version
cmake version 3.28.3

The Project — Same Calculator, New Build

The same three files from the Makefile tutorial — utils.h declaring add() and multiply(), utils.c defining them, main.c calling them:

/* main.c */
#include <stdio.h>
#include "utils.h"

int main(void)
{
    printf("3 + 4 = %d\n", add(3, 4));
    printf("3 * 4 = %d\n", multiply(3, 4));
    return 0;
}

Next to them, create a file named exactly CMakeLists.txt (capital C, capital M, capital L — CMake is picky about the name). The whole thing is five lines:

cmake_minimum_required(VERSION 3.16)
project(calc C)

add_executable(calc main.c utils.c)
target_compile_options(calc PRIVATE -Wall -Wextra)

Line by line:

  • cmake_minimum_required — the oldest CMake version this file supports. Every CMakeLists.txt must start with it.
  • project(calc C) — names the project and says it’s C (CMake assumes C++ otherwise, and will demand a C++ compiler).
  • add_executable(calc main.c utils.c) — the heart of it: build an executable called calc from these sources. Notice utils.h isn’t listed — hold that thought.
  • target_compile_options(calc PRIVATE -Wall -Wextra) — warnings on, always. PRIVATE means the flags apply to this target only, not to anything that later links against it.

Step 2 — Configure, Then Build

CMake is a two-step tool, and the modern commands are symmetrical:

cmake -B build        # configure: generate the build system into ./build
cmake --build build   # build: run it

The configure step inspects your machine and writes everything into a build/ directory:

$ cmake -B build
-- The C compiler identification is GNU 13.3.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Configuring done (0.1s)
-- Generating done (0.0s)
-- Build files have been written to: /work/calc/build

This is called an out-of-source build: every generated file lands in build/, your source directory stays clean, and “start over” is just rm -rf build. Peek inside and you’ll find CMake kept its promise about being a generator:

$ ls build
CMakeCache.txt
CMakeFiles
Makefile
calc
cmake_install.cmake

There’s a real Makefile in there — on Linux, CMake generated one for you (you can even cd build && make and it works). Now build and run:

$ cmake --build build
[ 33%] Building C object CMakeFiles/calc.dir/main.c.o
[ 66%] Building C object CMakeFiles/calc.dir/utils.c.o
[100%] Linking C executable calc
$ ./build/calc
3 + 4 = 7
3 * 4 = 12

Step 3 — The Feature That Beats Hand-Written Makefiles

Rebuild with nothing changed — CMake does nothing, like Make:

$ cmake --build build
[100%] Built target calc

Touch one source file — only that file recompiles, like Make:

$ touch utils.c
$ cmake --build build
[ 33%] Building C object CMakeFiles/calc.dir/utils.c.o
[ 66%] Linking C executable calc
[100%] Built target calc

Now the payoff. In the Makefile tutorial, we had to manually list utils.h as a prerequisite — and “edited a header but nothing rebuilt” is the classic bug when you forget. Our CMakeLists.txt never mentions utils.h anywhere. Touch it anyway:

$ touch utils.h
$ cmake --build build
[ 33%] Building C object CMakeFiles/calc.dir/main.c.o
[ 66%] Building C object CMakeFiles/calc.dir/utils.c.o
[100%] Built target calc

Both files that #include "utils.h" recompiled — automatically. CMake asks the compiler which headers each source actually includes and tracks them for you. An entire category of stale-build bugs, gone. This alone is why large projects moved off hand-written Makefiles.

Step 4 — Debug and Release Builds

With Make you’d edit CFLAGS by hand. With CMake you configure two build directories with different build types and they coexist:

cmake -B build-debug   -DCMAKE_BUILD_TYPE=Debug
cmake -B build-release -DCMAKE_BUILD_TYPE=Release

Run the builds with --verbose and you can see exactly what each type adds to the compile line. Debug gets -g (debugger-ready — see our GDB tutorial):

/usr/bin/cc   -g -Wall -Wextra ... -c /work/calc/main.c

Release gets full optimization and strips assert()s:

/usr/bin/cc   -O3 -DNDEBUG -Wall -Wextra ... -c /work/calc/main.c

One caveat worth knowing early: the default build type is empty — neither -g nor -O. Always pass -DCMAKE_BUILD_TYPE explicitly.

Step 5 — Growing Up: Targets and Libraries

CMake’s model is targets — executables and libraries — connected by target_link_libraries. Here’s the same project restructured the way real codebases do it, with the utilities as a reusable static library:

cmake_minimum_required(VERSION 3.16)
project(calc C)

add_library(utils STATIC utils.c)
target_include_directories(utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

add_executable(calc main.c)
target_link_libraries(calc PRIVATE utils)
target_compile_options(calc PRIVATE -Wall -Wextra)
$ cmake -B build && cmake --build build
[ 25%] Building C object CMakeFiles/utils.dir/utils.c.o
[ 50%] Linking C static library libutils.a
[ 50%] Built target utils
[ 75%] Building C object CMakeFiles/calc.dir/main.c.o
[100%] Linking C executable calc
$ ./build/calc
3 + 4 = 7
3 * 4 = 12

Two things to notice. First, CMake built a genuine libutils.a and linked it — the same artifact you’d hand-craft with ar. Second, the PUBLIC on target_include_directories means “anyone who links utils also gets its include path” — the library carries its own requirements with it. That’s the idea (“modern CMake”) that scales from this toy to projects with hundreds of targets.

Common Issues

Problem Fix
Error: could not load cache You ran cmake --build . in the source directory — point it at the build dir: cmake --build build
CMake Error: The source directory ... does not appear to contain CMakeLists.txt File must be named CMakeLists.txt exactly, in the directory you pass to cmake
No CMAKE_CXX_COMPILER could be found on a C-only project Declare the language: project(name C) — without it CMake also demands a C++ compiler
Generated files polluting your source directory You ran cmake . (in-source build) — delete CMakeCache.txt and CMakeFiles/, then use cmake -B build
Changed CMakeLists.txt — do I reconfigure? No — cmake --build build notices and re-runs the configure step automatically
Binary has no debug info / runs slow Default build type is empty — configure with -DCMAKE_BUILD_TYPE=Debug (or Release)

Make or CMake — Which Should You Learn?

Both, in that order. Make teaches you what a build is — targets, dependencies, timestamps — in a file you can read in one sitting (start with the Makefile tutorial). CMake is what you’ll actually use on multi-platform and multi-library projects, and on Linux it literally generates the Makefile for you. For a single-directory personal project, a Makefile is still perfectly fine; the moment you add a second platform, a third-party library, or a teammate on Windows, reach for CMake.

What’s Next

Your Debug build already compiles with -g, so it’s ready for the debugger — How to Use GDB to Debug C Programs picks up exactly there, and Valgrind completes the workflow by catching the leaks your tests miss.

New to the Linux C toolchain? Start from installing GCC on Ubuntu or the complete C environment setup guide.


As an Amazon Associate we earn from qualifying purchases.

Recommended Book

Build systems exist to compile well-structured programs — and program structure is what The C Programming Language by Kernighan & Ritchie (Amazon.com) teaches better than any book since. Chapter 4 on functions and program structure is the “why” behind every multi-file build.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>