How Do I Organize Multi-File C Projects?
Separating interface from implementation — headers, source files, and advanced Makefiles
After this lesson, you will be able to:
- Split a project into header and source files appropriately
- Write include guards to prevent duplicate definitions
- Write a Makefile with separate compilation and header dependencies
- Identify and fix common multi-file errors (undefined reference, multiple definition)
The Problem: One File Isn’t Cutting It
Your lab programs are growing — struct definitions, sort functions, file I/O, menu logic. When everything lives in one file, finding anything means scrolling through hundreds of lines. And changing one function recompiles the entire file.
In Lesson 2.11, you learned the basics of headers and Makefiles. Now let’s apply those patterns to a real multi-file project with structs, multiple source files, and a production-style Makefile.
Building a Multi-File Project
Project Layout
A well-organized project separates concerns:
project/
├── student.h ← Struct definition + function prototypes
├── student.c ← Implementation of student functions
├── io.c ← File I/O and display functions
├── main.c ← Driver (main, menu logic)
└── Makefile ← Build automation
The Header File (Interface)
student.h declares what the module provides:
#ifndef STUDENT_H
#define STUDENT_H
#include <stdio.h>
typedef struct
{
char name[50];
int id;
double gpa;
} Student;
/* Function prototypes */
void print_student(const Student *s);
void sort_by_name(Student roster[], int size);
void sort_by_gpa(Student roster[], int size);
int read_roster(const char *filename, Student roster[], int max);
#endif
The Implementation Files
student.c implements the prototypes from student.h:
#include "student.h"
#include <string.h>
void print_student(const Student *s)
{
printf("%-20s %5d %.2f\n", s->name, s->id, s->gpa);
}
void sort_by_name(Student roster[], int size)
{
// Selection sort using strcmp on name field
// ... (implementation from Lesson 4.4)
}
io.c handles file reading:
#include "student.h"
#include <string.h>
int read_roster(const char *filename, Student roster[], int max)
{
FILE *fp = fopen(filename, "r");
if (fp == NULL) return -1;
// ... read students ...
fclose(fp);
return count;
}
main.c is the driver:
#include "student.h"
int main(void)
{
Student roster[100];
int count = read_roster("students.txt", roster, 100);
// ... menu logic ...
return 0;
}
The Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -g
program: main.o student.o io.o
$(CC) $(CFLAGS) -o program main.o student.o io.o
main.o: main.c student.h
$(CC) $(CFLAGS) -c main.c
student.o: student.c student.h
$(CC) $(CFLAGS) -c student.c
io.o: io.c student.h
$(CC) $(CFLAGS) -c io.c
clean:
rm -f program *.o
Key Insight: Each
.otarget lists its header file dependencies. If you changestudent.h,makeknows to recompile everything that includes it. If you only changemain.c, onlymain.ois recompiled — the other object files are reused.
Student struct in student.h. Which files need to be recompiled?student.h as a dependency for every .o target that includes it.
Deep dive: The compilation pipeline — what make actually does
When you run make, here’s what happens for a three-file project:
Step 1: Compile each .c file to a .o (object) file
gcc -c main.c → main.o (machine code, unlinked)
gcc -c student.c → student.o
gcc -c io.c → io.o
Step 2: Link all .o files into one executable
gcc -o program main.o student.o io.o
The -c flag means “compile only, don’t link.” Each .o file contains machine code for that file’s functions, but function calls to other files are left as placeholders (like “call sort_by_name at address ???”). The linker resolves these placeholders by connecting the caller to the actual function code.
This is why you get “undefined reference” errors: if a function is declared in a header but never implemented in any .c file, the linker can’t find it. The compiler was fine (it saw the prototype), but the linker fails because there’s no actual code.
| Error message | Cause | Fix |
|————–|——-|—–|
| undefined reference to 'sort_by_name' | Function declared in .h but not implemented in any .c | Add implementation to the correct .c file |
| multiple definition of 'Student' | Struct defined in .c instead of .h, or missing include guards | Move definition to .h with include guards |
| implicit declaration of function | Missing #include for the header | Add #include "student.h" |
| file not found: student.h | Wrong include path or filename | Check spelling, use "student.h" not <student.h> for local headers |
Deep dive: Makefile patterns you'll see in later courses
Automatic variables — shortcuts for common values:
%.o: %.c student.h
$(CC) $(CFLAGS) -c $<
$<= the first prerequisite (the.cfile)$@= the target (the.ofile)%= pattern match (any name)
This single rule replaces all three .o rules. You’ll see this in CSCD 340 and 350.
PHONY targets — targets that aren’t files:
.PHONY: clean all
all: program
clean:
rm -f program *.o
Without .PHONY, if a file named clean existed, make clean would say “already up to date” and do nothing.
Valgrind target — for quick memory checking:
valgrind: program
valgrind --leak-check=full ./program input.txt
Now make valgrind compiles and runs Valgrind in one step.
Reading Provided Code
In labs, you’ll receive header files that define the interface. Your job is to implement the functions declared there. This is a critical skill:
- Read the
.hfile to understand the struct and function signatures - Look at how
main.ccalls the functions - Implement each function in the
.cfile - Test incrementally — implement one function, compile, test, repeat
The Trick: When given a lab with provided code, start by reading the header file and understanding the struct. Then implement the simplest function first (usually print). Get it compiling and tested before moving to harder functions. Building incrementally prevents being stuck with 10 errors at once.
#ifndef STUDENT_H / #define STUDENT_H / #endif pattern in header files?student.h is included by both main.c and io.c, and main.c also includes io.h which includes student.h, the struct would be defined twice — a compile error. The guard ensures the header contents are processed only once per translation unit.
Why does this matter?
Multi-file projects are how all real C software is organized. The header-as-interface pattern separates what a module does from how it does it. In labs, you’ll receive headers that define your contract — read them carefully, because the struct layout and function signatures tell you exactly what to implement.
Quick Check: What goes in a header file vs. a source file?
Header (.h): struct definitions, function prototypes, #define constants, typedefs. Source (.c): function implementations, variable definitions, #include of the corresponding header. The header is the interface; the source is the implementation.
Quick Check: Why does the Makefile list student.h as a dependency for main.o?
Because main.c includes student.h. If student.h changes (e.g., a struct field is added), main.o must be recompiled. Without the dependency, make would use the stale main.o and you’d get subtle bugs.
Quick Check: What's the benefit of separate compilation?
Speed: changing one source file only recompiles that file, not the entire project. On large projects with hundreds of files, this saves minutes per build. It also enables team work — different developers work on different source files.
Big Picture: Separating interface from implementation is one of the most important ideas in software engineering. It’s why you can use
printfwithout reading glibc source code, and why teams of hundreds can work on the same codebase. Headers define contracts; source files fulfill them. This pattern scales from two-file lab assignments to million-line projects.
What’s Next
Your projects are organized. Your structs are defined. Your sort functions work. But those sort functions are nearly identical — the only difference is the comparison. In the next lesson, you’ll learn function pointers, which let you write one sort function that accepts any comparison.