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
multi-file-demo/
├── include/
│ └── math_ops.h
├── src/
│ ├── main.c
│ └── math_ops.c
└── MakefileSource and interface have separate homes
include/math_ops.his the public contract shared by callers and the implementation.Each
.cfile 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
#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);
#endifA 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
#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 frommain.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
#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
-Iincludefor 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
printfreference 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
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.oNo executable exists yet
-std=c17selects a known language edition; choose the project’s supported standard deliberately.The warning flags expose suspicious conversions and non-portable constructs without hiding diagnostics.
-Iincludeadds the project header directory to preprocessing lookup.-cstops before linking, producing one relocatable.ofor each source file.-omakes every generated path explicit.Compilation succeeds even though
main.ostill has unresolved external references—that is normal for an object file.
Inspect which object defines each name
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_intsThe symbol table predicts the link
Umarks an undefined reference that another object or library must satisfy.Tnormally indicates a global symbol defined in the object’s executable text section.Addresses vary by architecture, compiler, optimization, and object format.
printfis not inmath_ops.o; the GCC link driver adds the normal runtime dependencies.nmis a diagnosis tool, not a substitute for compatible C declarations across translation units.
Link the object files
gcc build/main.o build/math_ops.o -o build/calculator
./build/calculatorsum=13
product=42Use the compiler driver for the final link
Without
-c, GCC recognizes object inputs and invokes the platform linker.The output option names the final executable
build/calculator.Invoking
gccrather than rawldsupplies 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
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Iinclude \
src/main.c src/math_ops.c -o build/calculator-direct
./build/calculator-directsum=13
product=42Convenient 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
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
CPPFLAGSholds preprocessor options,CFLAGScompile options,LDFLAGSlink options, andLDLIBSlibraries.$^expands to all target prerequisites,$<to the first prerequisite, and$@to the current target.The order-only
buildprerequisite creates the directory without rebuilding objects merely because its timestamp changed.-MMD -MPemits.dfiles so a header edit recompiles dependent objects and removed headers do not leave a brittle rule.The leading minus on
-includepermits 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
make
make
./build/calculatorcc ... -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=42Incrementality 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.crebuildsmath_ops.oand relinks without recompilingmain.c.Editing
math_ops.hrebuilds 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
.cfile and leave anexterndeclaration 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
/* 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_counterandnext_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 oneint 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
-O2only at a normal final link cannot recover missed compile-time optimization.With link-time optimization, compile and link consistently with
-fltoand 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 -vor add-Wl,-Map,build/calculator.mapwhen 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 -Ewhen 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.
Comments and corrections