Make, CMake, and Cargo

No serious project compiles files by hand. Build systems track dependencies, recompile only what changed, and manage flags across platforms. This chapter covers the three build tools you will encounter most: Make for C, CMake for portable C/C++, and Cargo for Rust.

Make: The Foundation

Make reads a Makefile and builds targets based on dependency rules. The core idea is simple: if a target is older than its dependencies, run the recipe to rebuild it.

Anatomy of a Rule

target: dependencies
	recipe

The recipe line must start with a tab character, not spaces.

A Minimal Makefile

Given this project structure:

project/
  main.c
  mathlib.c
  mathlib.h
  Makefile
/* mathlib.h */
#ifndef MATHLIB_H
#define MATHLIB_H

int add(int a, int b);
int multiply(int a, int b);

#endif
/* mathlib.c */
#include "mathlib.h"

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}
/* main.c */
#include <stdio.h>
#include "mathlib.h"

int main(void) {
    printf("add(3,4) = %d\n", add(3, 4));
    printf("multiply(3,4) = %d\n", multiply(3, 4));
    return 0;
}
# Makefile
CC      = gcc
CFLAGS  = -Wall -Wextra -std=c11
LDFLAGS =

SRCS    = main.c mathlib.c
OBJS    = $(SRCS:.c=.o)
TARGET  = calculator

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^

%.o: %.c mathlib.h
	$(CC) $(CFLAGS) -c -o $@ $<

clean:
	rm -f $(OBJS) $(TARGET)

How it works:

  • $@ is the target name.
  • $^ is all dependencies.
  • $< is the first dependency.
  • %.o: %.c is a pattern rule: any .o depends on its corresponding .c.
  • .PHONY tells Make that all and clean are not real files.
make            # builds calculator
make clean      # removes build artifacts

Variables and Overrides

Override variables from the command line:

make CC=clang CFLAGS="-Wall -O2"

Automatic Dependency Generation

Manually listing header dependencies is fragile. Use GCC's -MMD flag:

# Makefile with auto-deps
CC      = gcc
CFLAGS  = -Wall -Wextra -std=c11 -MMD -MP
LDFLAGS =

SRCS    = main.c mathlib.c
OBJS    = $(SRCS:.c=.o)
DEPS    = $(OBJS:.o=.d)
TARGET  = calculator

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

-include $(DEPS)

clean:
	rm -f $(OBJS) $(DEPS) $(TARGET)

-MMD generates .d files listing each .c file's header dependencies. -include $(DEPS) pulls them in silently (the - suppresses errors on first build when .d files do not exist).

Try It: Add a new file utils.c / utils.h to the project. Update the SRCS variable and verify that make rebuilds correctly when you modify utils.h.

CMake: Portable Build Generation

Make works well for single-platform projects, but CMake generates build files for Make, Ninja, Visual Studio, Xcode, and more. CMake is the standard for cross-platform C and C++ projects.

CMakeLists.txt Basics

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(Calculator VERSION 1.0 LANGUAGES C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

add_executable(calculator
    main.c
    mathlib.c
)

target_compile_options(calculator PRIVATE -Wall -Wextra)

Out-of-Tree Build

CMake strongly recommends building outside the source directory:

mkdir build && cd build
cmake ..
make
./calculator

The source directory stays clean. All generated files live in build/.

Libraries in CMake

Split the math library into its own target:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(Calculator VERSION 1.0 LANGUAGES C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

# Build mathlib as a static library
add_library(mathlib STATIC mathlib.c)
target_include_directories(mathlib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# Build the executable and link against mathlib
add_executable(calculator main.c)
target_link_libraries(calculator PRIVATE mathlib)
target_compile_options(calculator PRIVATE -Wall -Wextra)

Change STATIC to SHARED to build a shared library instead.

Finding External Libraries

find_package(Threads REQUIRED)
target_link_libraries(calculator PRIVATE Threads::Threads)

find_package(ZLIB REQUIRED)
target_link_libraries(calculator PRIVATE ZLIB::ZLIB)

find_package searches standard system paths and produces imported targets you can link against.

CMake Build Types

cmake -DCMAKE_BUILD_TYPE=Debug ..     # -g, no optimization
cmake -DCMAKE_BUILD_TYPE=Release ..   # -O3, NDEBUG defined
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo ..  # -O2 -g

Try It: Create a CMake project with a SHARED library. Build it, then run ldd calculator to see that it links against the shared library.

Cargo: Rust's Build System and Package Manager

Cargo is to Rust what Make + a package manager is to C, but integrated into one tool. Every Rust project starts with:

cargo new myproject
cd myproject

This creates:

myproject/
  Cargo.toml
  src/
    main.rs

Cargo.toml

[package]
name = "myproject"
version = "0.1.0"
edition = "2021"

[dependencies]

Add a dependency:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
clap = "4"

Run:

cargo build    # downloads deps, compiles everything
cargo run      # build + run
cargo test     # build + run tests
cargo check    # type-check only, no codegen (fast)

A Complete Cargo Project

#![allow(unused)]
fn main() {
// src/mathlib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

pub fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(3, 4), 7);
    }

    #[test]
    fn test_multiply() {
        assert_eq!(multiply(3, 4), 12);
    }
}
}
// src/main.rs
mod mathlib;

fn main() {
    println!("add(3,4) = {}", mathlib::add(3, 4));
    println!("multiply(3,4) = {}", mathlib::multiply(3, 4));
}
cargo run
cargo test

Build Profiles

Cargo has two built-in profiles:

# These are the defaults -- you can override them in Cargo.toml

[profile.dev]
opt-level = 0
debug = true
overflow-checks = true

[profile.release]
opt-level = 3
debug = false
overflow-checks = false
lto = false
cargo build             # uses dev profile
cargo build --release   # uses release profile

The release binary goes to target/release/ instead of target/debug/.

Custom profiles are possible:

[profile.release-with-debug]
inherits = "release"
debug = true
cargo build --profile release-with-debug

Rust Note: Unlike C where you pass -O2 or -g to the compiler directly, Cargo manages optimization and debug info through profiles. This centralizes build configuration and makes it reproducible.

Workspaces

Large Rust projects split into multiple crates within a workspace:

# Cargo.toml (workspace root)
[workspace]
members = [
    "mathlib",
    "calculator",
]
# mathlib/Cargo.toml
[package]
name = "mathlib"
version = "0.1.0"
edition = "2021"
# calculator/Cargo.toml
[package]
name = "calculator"
version = "0.1.0"
edition = "2021"

[dependencies]
mathlib = { path = "../mathlib" }
cargo build              # builds all workspace members
cargo test -p mathlib    # test only the mathlib crate

Features

Cargo features enable conditional compilation:

# mathlib/Cargo.toml
[package]
name = "mathlib"
version = "0.1.0"
edition = "2021"

[features]
default = []
advanced = []
#![allow(unused)]
fn main() {
// mathlib/src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(feature = "advanced")]
pub fn power(base: i32, exp: u32) -> i32 {
    (0..exp).fold(1, |acc, _| acc * base)
}
}
cargo build                              # power() not compiled
cargo build --features advanced          # power() included

Comparing the Three

+------------------+-----------+----------------+----------------+
| Feature          | Make      | CMake          | Cargo          |
+------------------+-----------+----------------+----------------+
| Config file      | Makefile  | CMakeLists.txt | Cargo.toml     |
| Language         | Make DSL  | CMake DSL      | TOML + Rust    |
| Dep management   | Manual    | find_package   | crates.io      |
| Cross-platform   | Weak      | Strong         | Strong         |
| Incremental      | File-time | File-time      | Crate-internal |
| Parallel build   | make -j   | Inherited      | Built-in       |
+------------------+-----------+----------------+----------------+

Integrating C and Rust

Real projects often mix C and Rust. Three crates make this practical.

The cc Crate: Compiling C from Cargo

# Cargo.toml
[package]
name = "c-from-rust"
version = "0.1.0"
edition = "2021"

[build-dependencies]
cc = "1"
/* csrc/helper.c */
#include <stdint.h>

int32_t c_add(int32_t a, int32_t b) {
    return a + b;
}
// build.rs
fn main() {
    cc::Build::new()
        .file("csrc/helper.c")
        .compile("helper");
}
// src/main.rs
extern "C" {
    fn c_add(a: i32, b: i32) -> i32;
}

fn main() {
    let result = unsafe { c_add(10, 20) };
    println!("c_add(10, 20) = {}", result);
}
cargo run
# c_add(10, 20) = 30

The cc crate compiles the C file, produces a static library, and tells Cargo to link it. The build.rs script runs before compilation of the main crate.

bindgen and cbindgen

Writing extern "C" blocks by hand is error-prone. The bindgen crate reads a C header and auto-generates Rust FFI declarations. Add bindgen = "0.70" to [build-dependencies], call bindgen::Builder::default().header("mylib.h").generate() in build.rs, and use include!(concat!(env!("OUT_DIR"), "/bindings.rs")) in your Rust source. It handles structs, enums, typedefs, and function declarations.

The cbindgen crate does the reverse -- it reads Rust source with #[no_mangle] pub extern "C" functions and generates a C header file automatically. Add cbindgen = "0.27" to [build-dependencies] and call cbindgen::generate(crate_dir) from build.rs. The generated header contains proper C declarations matching your Rust exports.

Driver Prep: The Linux kernel build system uses a highly customized Kbuild system built on Make. Rust-for-Linux integrates with Kbuild to compile Rust kernel modules alongside C. Understanding both Make and Cargo is essential for this workflow.

Knowledge Check

  1. In a Makefile, what does $< expand to? What about $@ and $^?

  2. Why does CMake recommend out-of-tree builds?

  3. How does cargo build --release differ from cargo build in terms of optimization and debug info?

Common Pitfalls

  • Spaces instead of tabs in Makefiles. Make requires literal tab characters for recipe lines. Many editors silently convert tabs to spaces.

  • Forgetting -fPIC for shared libraries in CMake. Use set(CMAKE_POSITION_INDEPENDENT_CODE ON) or let CMake handle it with add_library(... SHARED ...).

  • Not running cargo clean when switching profiles. Stale artifacts in target/ can cause confusing behavior.

  • Linking order with static libraries in Make. The linker processes left to right. If main.o depends on libmath.a, write gcc main.o -lmath, not gcc -lmath main.o.

  • Forgetting build.rs in cc / bindgen workflows. The build script must exist and be referenced correctly in Cargo.toml.