c-foundations Lesson 11 22 min read

How Do Header Files and Makefiles Organize C Projects?

From single-file programs to multi-file projects — the C build system

Reading: C Text: Ch. 12 §1–2 (pp. 687–710, Abstraction/Personal Libraries), §5 (pp. 720–725, Macros); Linux Text: Ch. 23 §4 (pp. 346–349, make)

After this lesson, you will be able to:

  • Split a C program into separate .h and .c files
  • Write include guards (#ifndef/#define/#endif) in every header file
  • Distinguish between #include <header.h> and #include "header.h"
  • Use gcc -c to compile individual source files and link them
  • Write a Makefile with targets, prerequisites, and recipes to automate building
  • Explain how make uses timestamps to selectively recompile only changed files

One File Isn’t Enough

Your programs are growing. Lab 4 was a few hundred lines. Lab 5 will be more. Eventually, putting everything in one file becomes unmanageable — you scroll endlessly looking for functions, and one typo forces recompilation of everything.

C’s solution is separate compilation: split your code into multiple .c files, each with a corresponding .h header file, and use a Makefile to build them automatically. This is C’s equivalent of Java packages and Gradle.


Headers, Source Files, and Make

What Goes in a Header File (.h)

A header file is an interface — it declares what a module provides without showing how:

/* math_utils.h */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

double calculate_area(double radius);
double calculate_circumference(double radius);

#define PI 3.14159265358979

#endif

Headers contain:

  • Function prototypes (declarations, not definitions)
  • #define constants
  • typedef declarations
  • struct definitions (later in Series 4)

Headers do NOT contain:

  • Function bodies (implementations)
  • Variable definitions (allocating memory)

Include Guards

Those #ifndef/#define/#endif lines are include guards:

#ifndef MATH_UTILS_H    // If MATH_UTILS_H is NOT defined...
#define MATH_UTILS_H    // ...define it (so this block runs only once)

// ... declarations ...

#endif                   // End the conditional

Key Insight: Include guards prevent a header from being processed twice. Without them, if A.h includes B.h and C.h also includes B.h, then a file that includes both A.h and C.h gets B.h’s contents twice — causing “duplicate definition” errors.

Why does this matter?

Include guards are boilerplate you’ll write in every header file for the rest of the course. They look tedious, but without them, any project with more than a couple of headers becomes impossible to compile. The convention is to use the filename in uppercase with underscores: MATH_UTILS_H for math_utils.h.

Source Files (.c)

The implementation goes in the .c file:

/* math_utils.c */
#include "math_utils.h"

double calculate_area(double radius)
{
    return PI * radius * radius;
}

double calculate_circumference(double radius)
{
    return 2.0 * PI * radius;
}

Note: #include "math_utils.h" uses quotes (for your own headers), not angle brackets (for system headers like <stdio.h>).

Using the Module

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

int main(void)
{
    double r = 5.0;
    printf("Area: %.2f\n", calculate_area(r));
    printf("Circumference: %.2f\n", calculate_circumference(r));
    return 0;
}

From Java: This is similar to Java’s package structure. A .h file is like a Java interface — it declares what’s available. A .c file is like the implementing class. #include "math_utils.h" is like import. The difference: C’s #include literally pastes the header’s text into your file, while Java’s import is a name lookup.

Check Your Understanding
What would happen if you put a function body (implementation) in a .h header file, and two different .c files both #include it?
A Nothing — the include guards prevent any problems
B The compiler picks the first definition and ignores the second
C The program compiles but crashes at runtime
D A "multiple definition" linker error — each .c file compiled a copy of the function
Answer: D. Include guards prevent a header from being included twice in the same .c file. But each .c file is compiled separately. Both .c files get their own copy of the function body, and when the linker tries to combine them, it finds two definitions of the same function. That's why headers contain only prototypes (declarations), not implementations.

Separate Compilation

Compile each .c file to an object file (.o), then link them together:

gcc -Wall -c math_utils.c        # Produces math_utils.o
gcc -Wall -c main.c               # Produces main.o
gcc -o calculator math_utils.o main.o   # Link into executable

The -c flag means “compile only, don’t link.” Benefits:

  • Change main.c? Only recompile main.c, not math_utils.c
  • On large projects, this saves significant build time

Makefiles

A Makefile automates the build process:

CC = gcc
CFLAGS = -Wall -Wextra -std=c17

calculator: main.o math_utils.o
	$(CC) $(CFLAGS) -o calculator main.o math_utils.o

main.o: main.c math_utils.h
	$(CC) $(CFLAGS) -c main.c

math_utils.o: math_utils.c math_utils.h
	$(CC) $(CFLAGS) -c math_utils.c

clean:
	rm -f calculator *.o

Each rule has three parts:

target: prerequisites
	recipe (MUST use TAB, not spaces!)

Common Pitfall: Makefiles require a TAB character before each recipe line, not spaces. This is the single most common Makefile error. If you see “missing separator” errors, check your tabs.

Run it:

make                   # Builds the first target (calculator)
make clean             # Runs the clean target (deletes build files)

make is smart: it checks file timestamps and only recompiles files that changed.

Check Your Understanding
You edit math_utils.c but don't touch main.c. When you run make, what gets recompiled?
A Both math_utils.c and main.cmake always recompiles everything
B Only math_utils.c, then the final link step reruns
C Only math_utils.c — no relinking needed
D Nothing — make only recompiles when you change header files
Answer: B. make checks timestamps. Since math_utils.c is newer than math_utils.o, it recompiles that file. Since main.c hasn't changed (and neither has the header it depends on), main.o is still up to date. But the final executable depends on both .o files, and one changed, so the link step reruns. This is the whole point of separate compilation — only rebuild what changed.
Why does this matter?

On a two-file project, recompiling everything takes a fraction of a second. On a project with hundreds of source files (like the Linux kernel), selective recompilation saves minutes or hours. The habit of writing correct Makefile dependencies pays off as your projects grow.

Conditional Compilation

Use #ifdef to toggle debug output:

#ifdef DEBUG
    printf("Debug: x = %d\n", x);
#endif

Compile with debug output: gcc -Wall -DDEBUG -o program program.c

Without -DDEBUG, the debug printf is completely removed from the compiled code.

The Trick: Use #ifdef DEBUG to add debug output that you can toggle on and off at compile time without editing source code.

Check Your Understanding
Your Makefile recipe uses spaces instead of a TAB before the command. What happens when you run make?
A It works fine — make treats spaces and tabs identically
B It compiles with warnings but produces the correct output
C It silently skips the recipe and produces no output file
D You get a "missing separator" error and nothing builds
Answer: D. make requires a literal TAB character to identify recipe lines. Spaces look identical to the human eye but make doesn't recognize them as recipe markers. The "missing separator" error is one of the most common (and frustrating) Makefile bugs. Most editors can be configured to insert tabs in Makefiles.
Quick Check: Why do header files need include guards?

Without include guards, a header can be included multiple times (through chains of includes), causing duplicate declaration errors. The guards (#ifndef/#define/#endif) ensure the header’s content is only processed once per compilation unit.

Quick Check: What's the difference between #include <stdio.h> and #include "math_utils.h"?

Angle brackets (<>) search system include directories for standard library headers. Quotes ("") search the current directory first, then system directories. Use quotes for your own headers, angle brackets for system headers.

Quick Check: Why does the Makefile recipe line require a TAB?

It’s a design decision from 1976 that stuck. The make utility uses TABs to distinguish recipe lines (commands to run) from other lines. Spaces look identical but are treated as regular text, causing “missing separator” errors.


You Can Build Real Projects Now

With headers and Makefiles, you can organize C projects just like professional software. Each module has a clean interface (.h) and implementation (.c), and make handles the build process automatically.

Next: arrays. C arrays are fundamentally different from Java arrays — no bounds checking, no .length property, and they decay to pointers when passed to functions. Understanding these differences is critical for avoiding buffer overflows.

Big Picture: The header/source/Makefile pattern scales from two-file assignments to million-line projects. The Linux kernel uses this exact pattern — thousands of .c files with corresponding .h headers, built by make. You’re learning the same tools used to build operating systems.