Why Structure Matters
Part 9 of 16 — GNU Make Mastery Series. Everything so far has lived in one directory. Real projects span dozens of directories, libraries, and subsystems. This part covers two fundamentally different approaches and when to use each.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, $?, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
You Are Here
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Canonical Project Layout
myproject/
├── Makefile # top-level entry point
├── include/ # public headers
│ └── myproject/
├── src/
│ ├── core/ # subsystem: core
│ │ ├── Module.mk # non-recursive: per-dir fragment
│ │ ├── core.c
│ │ └── core.h
│ ├── net/ # subsystem: networking
│ │ ├── Module.mk
│ │ └── net.c
│ └── main.c
├── lib/ # produced .a / .so files
├── build/ # all object and dep files (out-of-source)
└── tests/
└── Makefile
Sample Source Files
Self-contained examples: Multi-module project examples reference main.c, net.c, and logger.c. Drop these files into the module subdirectories shown in the project-tree snippets and the Makefiles will build cleanly.
main.c
/* main.c — entry point */
#include <stdio.h>
#include "logger.h"
int main(void) {
log_info("Application started");
return 0;
}
logger.h
/* logger.h */
#ifndef LOGGER_H
#define LOGGER_H
void log_info(const char *msg);
void log_error(const char *msg);
#endif
logger.c
/* logger.c — simple logging helpers */
#include <stdio.h>
#include "logger.h"
void log_info(const char *msg) { printf("[INFO] %s\n", msg); }
void log_error(const char *msg) { fprintf(stderr, "[ERROR] %s\n", msg); }
net.c
/* net.c — minimal networking stub */
#include <stdio.h>
void net_connect(const char *addr, int port) {
printf("Connecting to %s:%d\n", addr, port);
}
void net_disconnect(void) { printf("Disconnected\n"); }
Recursive Make
$(MAKE) -C Pattern
$(MAKE) -C dir changes into dir and invokes a new Make process there. Always use $(MAKE) (not make) so the same Make binary and jobserver options propagate.
# Top-level Makefile (recursive approach)
SUBDIRS := src/core src/net tests
.PHONY: all clean $(SUBDIRS)
all: $(SUBDIRS)
# Each subdir target triggers a sub-make in that directory
$(SUBDIRS):
$(MAKE) -C $@ # -C changes to subdir; $(MAKE) propagates jobserver
# Pass build config down to each sub-make
$(SUBDIRS): BUILD ?= release
$(SUBDIRS):
$(MAKE) -C $@ BUILD=$(BUILD)
clean:
for dir in $(SUBDIRS); do $(MAKE) -C $$dir clean; done # $$dir: escape $ for shell
Classic Pitfalls
Pitfalls
- Broken parallelism:
make -j8 at the top level doesn't parallelize across subdirs without the jobserver. Use $(MAKE) -C (not a shell loop) so Make can coordinate jobs.
- Stale inter-directory deps: If
src/net depends on a header rebuilt by src/core, recursive Make may not see the dependency. Express it explicitly.
- Ordering: Use prerequisite chaining to enforce order:
src/net: src/core
Passing Variables to Sub-makes
# Method 1: export (makes variable visible to all child processes)
export CC := arm-linux-gnueabihf-gcc
export CFLAGS := -Wall -O2
# Method 2: pass on command line (overrides child Makefile vars)
$(MAKE) -C src/core CC=$(CC) CFLAGS="$(CFLAGS)"
# Method 3: export all variables (use sparingly — can cause surprises)
export
Non-Recursive Make
In the non-recursive approach, a single top-level Makefile includes per-directory fragments (Module.mk) that append to global variable lists. Make has a single, complete dependency graph — enabling correct parallelism and cross-directory deps.
include Module.mk Pattern
# src/core/Module.mk — note use of $(d) for current dir prefix
d := src/core
CORE_SRCS := $(d)/allocator.c $(d)/logger.c $(d)/config.c
CORE_OBJS := $(patsubst $(d)/%.c,build/$(d)/%.o,$(CORE_SRCS))
# Append to global lists
SRCS += $(CORE_SRCS)
OBJS += $(CORE_OBJS)
# Module-specific compile flags
$(CORE_OBJS): CFLAGS += -DCORE_MODULE
build/$(d)/%.o: $(d)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# Top-level Makefile (non-recursive: single Make process)
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -O2
BUILDDIR := build
TARGET := myapp
# Include all module fragments — they append to SRCS and OBJS
include src/core/Module.mk
include src/net/Module.mk
DEPS := $(OBJS:.o=.d) # .o → .d for dependency tracking
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^ # single link step sees all objects
-include $(DEPS) # pull in auto-generated .d files
clean:
rm -rf $(BUILDDIR) $(TARGET)
Advantages & Trade-offs
Comparison
| Recursive | Non-Recursive |
| Cross-dir deps | Error-prone to express | Natural — single graph |
| Parallel builds | Needs jobserver tuning | Full -j exploitation |
| Module independence | Each subdir self-contained | All fragments share namespace |
| Incremental builds | Per-subdir Makefiles re-evaluated | Single Make database |
| Best for | Large, loosely-coupled monorepos | Tightly-coupled libraries |
Out-of-Source Builds
Never pollute your source tree with build artifacts. Keep all .o, .d, and output files in a separate build/ tree, mirroring the source structure:
SRCDIR := src
BUILDDIR := build
# Mirror src/ tree under build/ so source stays clean
# e.g. src/core/allocator.c → build/src/core/allocator.o
SRCS := $(shell find $(SRCDIR) -name '*.c') # recursive file discovery
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS)) # remap paths to build/
$(BUILDDIR)/%.o: $(SRCDIR)/%.c
@mkdir -p $(dir $@) # create matching subdirectory under build/
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
Multiple build flavors: Parameterize the build directory by config so debug and release artifacts never collide:
BUILD ?= release
BUILDDIR := build/$(BUILD)
ifeq ($(BUILD),debug)
CFLAGS += -g3 -O0 -DDEBUG
else
CFLAGS += -O2 -DNDEBUG
endif
# Now: build/debug/src/core/allocator.o
# build/release/src/core/allocator.o
Complete Non-Recursive Project Example
# =============================================================
# Top-level Makefile — non-recursive, out-of-source
# =============================================================
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -Iinclude
BUILD ?= release
BUILDDIR := build/$(BUILD)
TARGET := $(BUILDDIR)/myapp
ifeq ($(BUILD),debug)
CFLAGS += -g3 -O0 -DDEBUG
else
CFLAGS += -O2 -DNDEBUG
endif
# Accumulator variables — populated by Module.mk files
SRCS :=
OBJS :=
# Include per-directory build fragments
include src/core/Module.mk
include src/net/Module.mk
DEPS := $(OBJS:.o=.d)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(LDFLAGS) -o $@ $^
@echo "Built: $@"
$(BUILDDIR)/%.o: src/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
-include $(DEPS)
clean:
rm -rf build $(TARGET)
# ── Variable Inspector ──────────────────────────────────────
.PHONY: info
info:
@echo "CC = $(CC)"
@echo "CFLAGS = $(CFLAGS)"
@echo "BUILD = $(BUILD)"
@echo "BUILDDIR = $(BUILDDIR)"
@echo "TARGET = $(TARGET)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
@echo "DEPS = $(DEPS)"
Create Source & Module Files
The Makefile uses non-recursive include src/*/Module.mk fragments that append to accumulator variables. Create the full directory layout:
mkdir -p src/core src/net include
cat > include/app.h << 'EOF'
#ifndef APP_H
#define APP_H
void core_init(void);
void net_connect(const char *host);
#endif
EOF
cat > src/core/main.c << 'EOF'
#include <stdio.h>
#include "app.h"
int main(void) {
core_init();
net_connect("localhost");
return 0;
}
EOF
cat > src/core/init.c << 'EOF'
#include <stdio.h>
#include "app.h"
void core_init(void) { printf("Core initialized\n"); }
EOF
# Module.mk — appends sources to accumulator variable
cat > src/core/Module.mk << 'EOF'
SRCS += src/core/main.c src/core/init.c
OBJS += $(BUILDDIR)/core/main.o $(BUILDDIR)/core/init.o
EOF
cat > src/net/client.c << 'EOF'
#include <stdio.h>
#include "app.h"
void net_connect(const char *host) { printf("Connecting to %s\n", host); }
EOF
cat > src/net/Module.mk << 'EOF'
SRCS += src/net/client.c
OBJS += $(BUILDDIR)/net/client.o
EOF
Try It
# Inspect accumulated variables
make info
make BUILD=debug info # different build dir
# Build the project
make
./build/release/myapp # "Core initialized" + "Connecting to localhost"
# Debug build
make BUILD=debug
./build/debug/myapp
make clean
Next in the Series
In Part 10: Cross-Compilation & Toolchains, we set CROSS_COMPILE prefixes, sysroot flags, and PKG_CONFIG_SYSROOT_DIR to build firmware for embedded ARM targets from an x86 Linux host.
Continue the Series
Part 10: Cross-Compilation & Toolchains
Configure CROSS_COMPILE, sysroots, and toolchain variables for ARM, RISC-V, and MIPS embedded targets.
Read Article
Part 8: Compilation Workflow & Libraries
Build static archives and shared objects, manage SONAME versioning, and handle link order for multi-library projects.
Read Article
Part 11: Parallel Builds & Performance
Maximize throughput with make -j, the jobserver protocol, and techniques for detecting and fixing race conditions.
Read Article