Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
C: Foundations for Systems Programming Preprocessor Directives and Separate Compilation
This device
Course contentsPreprocessor Directives and Separate Compilation · 15 titles

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
C: Foundations for Systems Programming15 complete lessons

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
PREVIOUS LESSONDynamic Memory Allocation
NEXT LESSONErrors, Debugging, and Undefined Behavior
Memory, tooling, and practice 255 min

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.

What you will leave with

You will know the difference between preprocessing, compiling, and linking; publish a narrow header interface; prevent repeated inclusion; choose constants, functions, or macros deliberately; use conditional compilation for build-time choices; control external and internal linkage; and compile a small project from independent object files.

One command hides four distinct stages

  • 01
    Preprocess

    Resolve #include, macro expansion, and #if branches into a translation unit.

  • 02
    Compile

    Turn each translation unit into assembly or an object file while checking C syntax and types.

  • 03
    Assemble

    Produce a relocatable object file such as scores.o.

  • 04
    Link

    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-report

    A 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 */
    Use a project-specific guard name

    SCORES_H is readable for this tutorial, but a real project should avoid collisions with generic names, for example SOVRANCODE_SCORES_H. Many compilers support #pragma once; include guards remain the portable C baseline and make the mechanism visible.

    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.

    COMPILER ERROR

    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.

    LINKER ERROR

    Undefined reference

    A caller saw the declaration, but no supplied object or library defines the name. Add the correct object to the link command.

    LINKER ERROR

    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"
    #endif
    Test both compiled variants

    A #if branch excluded from your normal build is not type-checked by that build. Your CI or local test plan should compile every supported flag and platform configuration, not merely exercise the default executable.

    Build 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.

    EDITChange the public headerFor example, add a parameter or change a structure. This affects callers and the implementation.
    REBUILDCompile affected sourcesRecompile main.c, scores.c, and tests that include the changed header.
    LINKCombine fresh objectsA link of stale objects can succeed yet not represent the source you think you ran.
    TESTExercise the contractTest the public header through a separate test translation unit, not by calling private helpers.

    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.

    01Design the header firstWrite one declaration and a short ownership/error contract for every public function.
    02Protect it with a guardInclude the header twice through an extra test header; compilation should remain clean.
    03Compile separatelyUse -c for each source and deliberately omit one object once to read the linker's undefined-reference diagnostic.
    04Test a build flagAdd a 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 .c file.
    • 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 #if for build-time source selection; use normal if for runtime decisions.
    • Use file-scope static for 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.
    KNOWLEDGE CHECK

    Protect the interface; make the build explainable

    Check your mental model of translation units, API boundaries, macros, linkage, and the compiler/linker handoff.

    01What does the preprocessor do with #include "scores.h" before compilation?
    02What belongs in a public header for an ordinary function?
    03Why do headers need include guards?
    04Which macro call exposes the classic side-effect problem?
    05What does static on a file-scope function normally communicate?
    06What is the safe compilation sequence for several .c files?
    07When should #if / #ifdef usually be used?
    08A header changed but the executable still acts as before. What build rule is missing?
    PREVIOUS LESSONDynamic Memory Allocation
    NEXT LESSONErrors, Debugging, and Undefined Behavior
    ON THIS PAGEPreprocessor Directives and Separate CompilationTranslation pipeline#include and headersInclude guardsInterface and linkageMacrosConditional compilationMulti-file projectGlobal stateDependenciesIndependent labLesson reviewKnowledge check
    Course contents