advanced-c Lesson 5 20 min read

How Do I Organize Multi-File C Projects?

Separating interface from implementation — headers, source files, and advanced Makefiles

Reading: C Text: Ch. 12 §1–2 (pp. 687–710, Abstraction, Personal Libraries)

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 .o target lists its header file dependencies. If you change student.h, make knows to recompile everything that includes it. If you only change main.c, only main.o is recompiled — the other object files are reused.

Check Your Understanding
You add a new field to the Student struct in student.h. Which files need to be recompiled?
A Every .c file that includes student.h
B Only student.c and main.c
C Only student.c
D None — header changes don't require recompilation
Answer: A. The struct definition in the header determines the memory layout every file uses. If you change it but don't recompile a file that includes it, that file uses the old layout — fields are at wrong offsets, and you get silent data corruption. That's why the Makefile lists 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 .c file)
  • $@ = the target (the .o file)
  • % = 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:

  1. Read the .h file to understand the struct and function signatures
  2. Look at how main.c calls the functions
  3. Implement each function in the .c file
  4. 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.

Check Your Understanding
What is the purpose of the #ifndef STUDENT_H / #define STUDENT_H / #endif pattern in header files?
A It makes the code compile faster by skipping comments
B It hides the header contents from other files
C It converts the header into a source file during compilation
D It prevents the header from being included more than once, avoiding duplicate definitions
Answer: D. This is called an include guard. If 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 printf without 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.