Splitting my first C program into files felt almost too easy—until the compiler accepted every file and the linker still refused to build the executable. That is the moment C’s build model becomes clear: each .c file is compiled in isolation, while the linker is responsible for resolving the external names those compiled units promise to provide one another.

Project layout

Directory treetext
multi-file-demo/
├── include/
│   └── math_ops.h
├── src/
│   ├── main.c
│   └── math_ops.c
└── Makefile

Source and interface have separate homes

  • include/math_ops.h is the public contract shared by callers and the implementation.

  • Each .c file becomes its own translation unit after preprocessing.

  • Generated objects and dependency files will go under build/, keeping source directories clean.

  • This layout is small enough to understand while scaling to more modules.

Declare the interface once

include/math_ops.hc
#ifndef MULTI_FILE_DEMO_MATH_OPS_H
#define MULTI_FILE_DEMO_MATH_OPS_H
 
int add_ints(int left, int right);
int multiply_ints(int left, int right);
 
#endif

A header declares; it usually does not allocate

  • The include guard prevents duplicate contents within one translation unit.

  • The macro avoids identifiers beginning with double underscores, which are reserved to the implementation.

  • Function prototypes let the compiler check argument and return types at each call.

  • A declaration says a symbol exists; exactly one linked translation unit must normally define each external function.

  • Include the same header in both caller and implementation so incompatible signatures are caught early.

Define the functions in one source file

src/math_ops.cc
#include "math_ops.h"
 
int add_ints(int left, int right)
{
    return left + right;
}
 
int multiply_ints(int left, int right)
{
    return left * right;
}

These definitions create external symbols

  • Including the header makes the definitions subject to the published prototypes.

  • Neither function is static, so each has external linkage and can satisfy references from main.o.

  • One source file owns each definition, avoiding a multiple-definition error.

  • Real arithmetic may overflow for large signed integers; this teaching example assumes results are representable.

Call the module from main

src/main.cc
#include <stdio.h>
 
#include "math_ops.h"
 
int main(void)
{
    const int left = 6;
    const int right = 7;
 
    printf("sum=%d\n", add_ints(left, right));
    printf("product=%d\n", multiply_ints(left, right));
    return 0;
}

The caller contains references, not copies

  • Quoted includes search paths supplied by -Iinclude for the project header.

  • main(void) explicitly states that main accepts no parameters.

  • The compiler records unresolved references to the two functions in main.o.

  • The printf reference is later satisfied through the C runtime/library selected by the GCC driver.

  • Returning zero communicates successful process termination to the environment.

Compile each source into an object file

multi-file-demobash
mkdir -p build
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Iinclude -c src/main.c -o build/main.o
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Iinclude -c src/math_ops.c -o build/math_ops.o

No executable exists yet

  • -std=c17 selects a known language edition; choose the project’s supported standard deliberately.

  • The warning flags expose suspicious conversions and non-portable constructs without hiding diagnostics.

  • -Iinclude adds the project header directory to preprocessing lookup.

  • -c stops before linking, producing one relocatable .o for each source file.

  • -o makes every generated path explicit.

  • Compilation succeeds even though main.o still has unresolved external references—that is normal for an object file.

Inspect which object defines each name

multi-file-demobash
nm --undefined-only build/main.o
nm --defined-only build/math_ops.o
                 U add_ints
                 U multiply_ints
                 U printf
0000000000000000 T add_ints
0000000000000014 T multiply_ints
  • U marks an undefined reference that another object or library must satisfy.

  • T normally indicates a global symbol defined in the object’s executable text section.

  • Addresses vary by architecture, compiler, optimization, and object format.

  • printf is not in math_ops.o; the GCC link driver adds the normal runtime dependencies.

  • nm is a diagnosis tool, not a substitute for compatible C declarations across translation units.

multi-file-demobash
gcc build/main.o build/math_ops.o -o build/calculator
./build/calculator
sum=13
product=42
  • Without -c, GCC recognizes object inputs and invokes the platform linker.

  • The output option names the final executable build/calculator.

  • Invoking gcc rather than raw ld supplies the platform’s normal startup objects, library paths, and C runtime conventions.

  • For plain object files, their order normally does not affect mutual resolution; archive-library ordering is different.

  • The shell receives the executable’s zero exit status after both calls complete.

The one-command equivalent

multi-file-demobash
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Iinclude \
    src/main.c src/math_ops.c -o build/calculator-direct
./build/calculator-direct
sum=13
product=42

Convenient does not mean a different build model

  • GCC still preprocesses, compiles, and assembles each source before linking the results.

  • This form is excellent for a tiny experiment and reproducible bug report.

  • Separate object files enable incremental builds: changing one source need not recompile every module.

  • A build system becomes worthwhile once flags, generated code, libraries, configurations, or many translation units appear.

Automate correct incremental builds with Make

Makefilemakefile
CC ?= cc
CPPFLAGS := -Iinclude
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -Wconversion -MMD -MP
LDFLAGS :=
LDLIBS :=
 
TARGET := build/calculator
SOURCES := src/main.c src/math_ops.c
OBJECTS := $(SOURCES:src/%.c=build/%.o)
DEPS := $(OBJECTS:.o=.d)
 
.PHONY: all clean
all: $(TARGET)
 
$(TARGET): $(OBJECTS)
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
 
build/%.o: src/%.c | build
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
 
build:
	mkdir -p $@
 
clean:
	$(RM) -r build
 
-include $(DEPS)

Dependencies encode when work is stale

  • CPPFLAGS holds preprocessor options, CFLAGS compile options, LDFLAGS link options, and LDLIBS libraries.

  • $^ expands to all target prerequisites, $< to the first prerequisite, and $@ to the current target.

  • The order-only build prerequisite creates the directory without rebuilding objects merely because its timestamp changed.

  • -MMD -MP emits .d files so a header edit recompiles dependent objects and removed headers do not leave a brittle rule.

  • The leading minus on -include permits the first build before dependency files exist.

  • Recipe lines require a real tab in a Makefile.

  • The clean target removes generated artifacts and is intentionally separate from the default build.

Build, change one file, and observe

multi-file-demobash
make
make
./build/calculator
cc ... -c src/main.c -o build/main.o
cc ... -c src/math_ops.c -o build/math_ops.o
cc ... build/main.o build/math_ops.o ... -o build/calculator
make: Nothing to be done for 'all'.
sum=13
product=42

Incrementality is the payoff

  • The first run creates objects, dependency files, and the executable.

  • The second run compares timestamps and performs no recipe because every target is current.

  • Editing math_ops.c rebuilds math_ops.o and relinks without recompiling main.c.

  • Editing math_ops.h rebuilds both objects because generated dependency files record both includes.

  • Run builds in CI from a clean checkout too; incremental success can conceal undeclared dependencies.

Understand the two classic linker errors

  • Undefined reference: a used external symbol has no selected definition. Confirm spelling, exact signature/linkage, the owning object on the link command, conditional compilation, and required library.

  • Multiple definition: more than one linked object defines the same external symbol. Move the definition into one .c file and leave an extern declaration in the header when appropriate.

  • A compile-time “implicit declaration” warning means the caller lacked a visible prototype; do not suppress it and hope linking makes the call safe.

  • A linker can find a symbol whose C types disagree across translation units; that mismatch invokes undefined behavior even if the link succeeds.

  • C and C++ symbol conventions differ; a C header used by C++ may need a guarded extern "C" interface.

Why definitions in headers cause trouble

Incorrect public headerc
/* Do not put an ordinary external definition in a shared header. */
int global_counter = 0;
 
int next_value(void)
{
    return ++global_counter;
}

Every includer receives another definition

  • If two source files include this header, both objects define global_counter and next_value.

  • Modern GCC defaults make duplicate tentative global definitions easier to expose rather than silently merge.

  • Put extern int global_counter; in the header and exactly one int global_counter = 0; definition in a source file when shared mutable state is truly needed.

  • Small header functions may use carefully designed static inline, which gives each translation unit internal-linkage behavior; inline semantics deserve their own design review.

  • Prefer encapsulated state and functions over writable global variables.

Object order versus static-library order

GNU-style linkers normally process an archive where it appears and pull members that satisfy unresolved symbols already seen. That is why main.o -lmath can work while -lmath main.o may not for a static archive. Put objects that need symbols before the libraries that provide them, or use an explicit linker group for genuine circular archive dependencies after fixing the design where possible.

Build flags must agree across stages

  • Compile every translation unit with compatible ABI-affecting macros, target architecture, structure packing, and feature options.

  • Use optimization and debug flags when compiling objects; adding -O2 only at a normal final link cannot recover missed compile-time optimization.

  • With link-time optimization, compile and link consistently with -flto and use the compiler driver/plugin-aware toolchain.

  • Pass required thread or sanitizer flags during both compilation and linking when the toolchain documentation requires it.

  • Never mix stale objects built from incompatible headers or configurations; separate build directories by target/configuration.

Troubleshooting checklist

  • Run gcc -v or add -Wl,-Map,build/calculator.map when you need toolchain and link-map detail.

  • Use nm, readelf -Ws, or the platform equivalent to locate definitions and unresolved references.

  • Check that file names and case match on case-sensitive systems.

  • Inspect preprocessor output with gcc -E when macros hide a declaration or definition.

  • Delete only the project’s generated build directory when stale artifacts are suspected; do not use broad wildcard deletion.

  • Confirm all linked objects target the same architecture and compatible object format.

  • Place external libraries after the objects that reference them on GNU-like linkers.

  • Read the first diagnostic and the named symbol carefully; later messages are often consequences.

Official toolchain references

  • GCC overall options describes preprocessing, compilation, assembly, linking, and -c.

  • GCC link options documents object inputs, libraries, runtime selection, and link-stage flags.

  • GNU ld options explains object/archive inputs, symbol resolution, library paths, and archive ordering.

  • GNU Make manual documents targets, prerequisites, implicit rules, and automatic variables.