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: %.cis a pattern rule: any.odepends on its corresponding.c..PHONYtells Make thatallandcleanare 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.hto the project. Update theSRCSvariable and verify thatmakerebuilds correctly when you modifyutils.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
SHAREDlibrary. Build it, then runldd calculatorto 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
-O2or-gto 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
-
In a Makefile, what does
$<expand to? What about$@and$^? -
Why does CMake recommend out-of-tree builds?
-
How does
cargo build --releasediffer fromcargo buildin 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
-fPICfor shared libraries in CMake. Useset(CMAKE_POSITION_INDEPENDENT_CODE ON)or let CMake handle it withadd_library(... SHARED ...). -
Not running
cargo cleanwhen switching profiles. Stale artifacts intarget/can cause confusing behavior. -
Linking order with static libraries in Make. The linker processes left to right. If
main.odepends onlibmath.a, writegcc main.o -lmath, notgcc -lmath main.o. -
Forgetting
build.rsincc/bindgenworkflows. The build script must exist and be referenced correctly inCargo.toml.