C fundamentals · lesson 11

Building a project

A single <code>main.c</code> file works for toy programs. Real programs split across many files. The toolchain that stitches them together is Make — and the rules it follows are simpler than they look.

in progress
15 min

Why splitting files matters

When you compile a C program, the compiler processes one translation unit at a time. Each .c file is a translation unit. They are compiled independently into object files (.o), then linked together by the linker into a final executable.

This means you can change one .c file and recompile only that file — not the entire program. On a project with 200 source files, this is the difference between a 2-second rebuild and a 3-minute rebuild.

shell
# compile each .c to a .o (object file) gcc -c main.c -o main.o gcc -c utils.c -o utils.o # link the object files into an executable gcc main.o utils.o -o myprogram # run it ./myprogram

Headers — the interface contract

A header file (.h) declares what a module exposes. It lists function signatures and type definitions — but no function bodies. The .c file contains the actual implementations.

Any .c file that wants to call a function from utils.c includes utils.h. The compiler sees the declarations and knows the types. The linker resolves the actual addresses at link time.

c
/* utils.h — declarations only */ #ifndef UTILS_H #define UTILS_H int add(int a, int b); void print_banner(const char *msg); #endif
c
/* utils.c — implementations */ #include <stdio.h> #include "utils.h" int add(int a, int b) { return a + b; } void print_banner(const char *msg) { printf("=== %s ===\n", msg); }
Every header needs an include guard. Without #ifndef / #define / #endif, including the same header twice (which happens easily through transitive includes) causes duplicate definition errors. Alternatively, #pragma once works in all modern compilers and is less boilerplate.

static and extern — controlling visibility

By default, a function or global variable defined in a .c file is visible to all other translation units — the linker can see it. This is called external linkage.

static at file scope restricts a symbol to its own translation unit. Other files cannot see it. It's private to that module.

extern tells the compiler: "this symbol exists, but it's defined in another file. Don't allocate storage here — just trust it exists."

c
/* counter.c */ static int count = 0; // private — only this file can see it void increment(void) { count++; } int get_count(void) { return count; } /* other.c — using extern to reference a global from config.c */ extern int max_connections; // defined in config.c, declared here
💡
static on a local variable means something different: the variable persists across function calls (stored in the data segment, not the stack). Same keyword, different context, different meaning.

Makefiles

Typing the compile commands by hand gets old fast. make reads a Makefile and figures out which files need rebuilding based on timestamps. If utils.c changed but main.c didn't, only utils.o is recompiled.

A Makefile is a list of rules. Each rule says: "to build target, run these commands, and it depends on these prerequisites."

makefile
CC = gcc CFLAGS = -Wall -Wextra -O2 # final target depends on both object files myprogram: main.o utils.o $(CC) main.o utils.o -o myprogram # each .o depends on its .c and the shared header main.o: main.c utils.h $(CC) $(CFLAGS) -c main.c -o main.o utils.o: utils.c utils.h $(CC) $(CFLAGS) -c utils.c -o utils.o # phony target: not a real file, just a command name .PHONY: clean clean: rm -f *.o myprogram
⚠️
Makefiles require tabs, not spaces. The indented recipe lines must start with a real tab character. Spaces look identical but break make with a cryptic error. If your Makefile isn't working, check for space-vs-tab confusion first.

A minimal project layout

A real project separates source files, headers, and build artifacts. This keeps things navigable and prevents the root directory from filling up with .o files.

shell
myproject/ ├── Makefile ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ └── utils.c └── build/ # .o files go here — gitignored
one-line takeaway

Split into .c + .h pairs, use static to hide internals, and let Make handle what to recompile.