Preprocessor Directives and Separate Compilation
A C project becomes maintainable when each source file has one responsibility and its boundary is explicit. The preprocessor assembles the source seen by the compiler; separate compilation turns each source file into an object file; the linker proves that declarations and definitions agree across the whole program. Learn that pipeline and you can diagnose most multi-file build errors instead of papering over them.
One command hides four distinct stages
Resolve #include, macro expansion, and #if branches into a translation unit.
Turn each translation unit into assembly or an object file while checking C syntax and types.
Produce a relocatable object file such as scores.o.
Resolve names across objects and libraries into an executable.
# Ask the compiler to stop after each stage.
cc -E -Iinclude src/main.c > build/main.i # preprocessed C text
cc -std=c17 -Wall -Wextra -Iinclude -c src/main.c -o build/main.o
cc build/main.o build/scores.o -o build/score-reportA translation unit is one .c file after all of its includes and preprocessor directives are handled. The compiler does not magically see another .c file because it is nearby. It only sees the declarations available in this one translation unit; the linker later matches its unresolved external names with definitions in other object files.
#include copies an interface; it does not compile a module
#include "scores.h" asks the preprocessor to locate and insert your project header. #include <stdio.h> expresses that you want a system or toolchain header. Exact search paths vary by compiler, but the convention communicates intent: quotes for project headers, angle brackets for provided libraries.
/* include/scores.h — public contract, not implementation */
#ifndef SCORES_H
#define SCORES_H
#include <stddef.h>
double scores_average(const int values[], size_t count);
int scores_max(const int values[], size_t count, int *out_max);
#endif /* SCORES_H */Place declarations, public types, and documented constants in a header. Put ordinary function bodies and privately useful helpers in a .c file. Never solve a missing-symbol error by including a .c file from another .c file; that duplicates definitions when the project is compiled normally.
Header guards make repeated inclusion harmless
Headers naturally include other headers. Without a guard, two include paths can expose the same declaration or type definition twice inside one translation unit. A conventional unique macro turns the header into an idempotent operation: the first inclusion defines the guard; later ones skip the contents.
#ifndef PROJECT_SCORES_H
#define PROJECT_SCORES_H
struct ScoreSummary {
int minimum;
int maximum;
double average;
};
int scores_summarize(const int values[], size_t count,
struct ScoreSummary *out_summary);
#endif /* PROJECT_SCORES_H */Declaration promises; definition provides the program's one body
A function declaration tells a caller its name, parameters, and return type. A function definition provides its body. Public declarations can appear in every translation unit that needs them. A non-static function or global object definition must appear once in the linked program.
/* src/scores.c */
#include "scores.h"
static int is_valid_input(const int values[], size_t count) {
return values != NULL && count > 0;
}
double scores_average(const int values[], size_t count) {
if (!is_valid_input(values, count)) return 0.0;
long total = 0;
for (size_t index = 0; index < count; index++) total += values[index];
return (double)total / (double)count;
}Here scores_average has external linkage because callers in other files use it through the header. is_valid_input is an implementation detail, so file-scope static gives it internal linkage. This avoids accidental name clashes and advertises the smallest supported API.
Declaration mismatch
Header says double scores_average(...); source defines a different signature. Include the header in its own implementation so the compiler catches the disagreement.
Undefined reference
A caller saw the declaration, but no supplied object or library defines the name. Add the correct object to the link command.
Multiple definition
Two object files define the same external function or variable. Keep exactly one definition; headers normally contain declarations.
Macros transform tokens, so treat them as sharp tools
An object-like macro is a textual name replacement. A function-like macro substitutes its arguments before the compiler reasons about types. Macros are useful for include guards, conditional compilation, and occasionally carefully constrained compile-time constants. They do not have type checking, scope, or one-time evaluation.
#define SQUARE(value) value * value
int result = SQUARE(2 + 3); /* becomes 2 + 3 * 2 + 3: wrong */
int next = SQUARE(index++); /* index may be incremented twice: wrong */
/* If a macro is genuinely needed, parenthesize every use. */
#define SQUARE_SAFE(value) ((value) * (value))
/* Prefer this when one evaluation and a type are important. */
static inline int square_int(int value) {
return value * value;
}Parentheses repair operator precedence, but they cannot repair a repeated side effect. Prefer a typed function or static inline helper for behavior. Prefer an enum constant or a const object when you need a named value. If a multi-statement macro is unavoidable, wrap it in do { ... } while (0) so it acts as one statement at a call site.
Conditional compilation is build-time selection
#if, #ifdef, #elif, and #error choose which source text reaches the compiler. They are appropriate for platform adapters, optional diagnostics, feature switches, and rejecting an unsupported build configuration. They are not substitutes for an ordinary if driven by user input at runtime.
/* Compile with: cc -DDEBUG=1 ... */
#if defined(DEBUG) && DEBUG
#define LOG(message) fprintf(stderr, "[debug] %s\n", (message))
#else
#define LOG(message) ((void)0)
#endif
#if !defined(_WIN32) && !defined(__linux__) && !defined(__APPLE__)
#error "This tutorial adapter needs a supported platform implementation"
#endifBuild a small report as independent modules
Use this layout for the score-report program. It separates an application entry point from a reusable calculation module and keeps public contracts under an explicit include directory.
score-report/
├── include/
│ └── scores.h # public declarations
├── src/
│ ├── main.c # CLI and presentation
│ └── scores.c # calculation implementation
├── tests/
│ └── scores_test.c # module tests
└── build/ # generated objects; do not hand-edit/* src/main.c */
#include <stdio.h>
#include "scores.h"
int main(void) {
const int scores[] = {84, 91, 76, 98};
const size_t count = sizeof scores / sizeof scores[0];
printf("Average: %.1f\n", scores_average(scores, count));
return 0;
}
# Compile each translation unit, then link them together:
cc -std=c17 -Wall -Wextra -Wpedantic -Iinclude -c src/main.c -o build/main.o
cc -std=c17 -Wall -Wextra -Wpedantic -Iinclude -c src/scores.c -o build/scores.o
cc build/main.o build/scores.o -o build/score-report-Iinclude makes your public headers findable without hard-coding a relative path from every source directory. -c stops before linking. The final command must mention every object that provides a symbol your program uses; when calling a library function, link its required library too, often with a flag such as -lm.
External state is a shared dependency, not free convenience
A file-scope definition such as int report_count = 0; has external linkage by default. Other files can refer to it with extern int report_count;, but that hides a dependency and lets any module mutate shared state. Prefer passing state through function parameters or putting it in an owning structure. If a file-scope object truly belongs only to one implementation, make it static.
/* scores.c: private implementation cache, if one is genuinely needed */
static unsigned long calculation_count;
/* scores.h: do not define a mutable global here. */
/* extern unsigned long calculation_count; // avoid unless it is intentional API */A header change invalidates dependent object files
An object file records the source after the headers it used were preprocessed. If scores.h changes, every source file that includes it needs recompilation before linking. For two files you can run the commands manually. As the project grows, a dependency-aware build tool automates this exact rule—next lesson's focus.
main.c, scores.c, and tests that include the changed header.Independent lab: split the inventory parser into an API
Take your command-line inventory program and divide it into inventory.h, inventory.c, and main.c. The header should expose only the types and functions the CLI needs. Keep parsing helpers, implementation counters, and validation details static in inventory.c.
-c for each source and deliberately omit one object once to read the linker's undefined-reference diagnostic.DEBUG log macro and compile once with -DDEBUG=1 and once without it.Lesson review
- The preprocessor transforms directives into the source text for one translation unit; it does not link modules.
- Headers publish declarations and contracts. Put normal external definitions in one
.cfile. - Include guards prevent repeated processing of a header in one translation unit.
- Prefer typed functions and constants over macros when evaluation, type checking, or scope matters.
- Use
#iffor build-time source selection; use normaliffor runtime decisions. - Use file-scope
staticfor private helpers and avoid mutable external global state unless it is intentional API. - Compile each source to an object, link the complete object set, and rebuild sources affected by changed headers.