Programming in C

Chapter 9
Preprocessing, Libraries, Files, and Modular Builds

Bachelors Program in Electronics and Communication
Programming in C   •   Post Graduate

Preprocessing, Libraries, Files, and Modular Builds

Introduction and Context

A substantial C program is rarely written as one source file compiled by one command. Professional systems are assembled from headers, implementation files, libraries, generated configuration, and build rules. The C toolchain therefore performs several conceptually distinct transformations: preprocessing, compilation, assembly, linking, and loading. Understanding these stages is essential for embedded systems, communication software, operating systems, device drivers, scientific applications, and security-critical products.

The preprocessor handles textual directives before compilation. Libraries provide reusable interfaces and implementations. Files support persistent storage and interaction with devices. Modular builds organize these elements so that only affected components are rebuilt. These facilities are powerful, but they also introduce risks: macro side effects, incompatible declarations, undefined behavior in file handling, symbol collisions, stale object files, and non-reproducible builds.

Learning Objectives

By the end of this chapter, a learner should be able to:

  1. Explain the stages of the C translation and build process.
  2. Design safe macros and header files using conditional compilation.
  3. Distinguish static and dynamic libraries and explain symbol resolution.
  4. Use text and binary streams with appropriate error and resource handling.
  5. Organize a multi-file C project using interfaces, implementation hiding, and dependency-aware builds.
  6. Analyze portability, security, reproducibility, and maintainability trade-offs.

1. The C Translation Model

A useful abstraction of a build is:

source files → preprocessing → translation units → compilation/assembly
             → object files → linking → executable or library

A translation unit is a source file after all included headers and preprocessing directives have been processed. Each implementation source file is normally compiled independently. The linker subsequently combines object files and libraries.

The ISO C standard describes translation phases, including character mapping, removal of comments, preprocessing, conversion of preprocessing tokens, string-literal formation, and compilation. In practice, compilers may combine internal phases, but the conceptual separation remains important.

A declaration tells the compiler that an entity exists and specifies its type; a definition allocates storage or supplies an implementation. A declaration such as extern int sample_count; does not create the object. A definition such as int sample_count; does. Multiple incompatible definitions or missing definitions commonly produce linker failures or, worse, undefined behavior.

External, internal, and no linkage

An identifier with external linkage can refer to an entity defined in another translation unit. An identifier declared with static at file scope has internal linkage and is visible only within that translation unit. Local variables normally have no linkage. Encapsulation is improved when implementation details use internal linkage and only the required interface is exposed through a header.

2. The Preprocessor

The preprocessor operates on preprocessing tokens rather than on the full semantic C language. It performs file inclusion, macro replacement, conditional compilation, and selected diagnostics. It does not understand function types, scope in the C sense, or whether a macro expansion is logically safe.

2.1 Object-like and function-like macros

An object-like macro substitutes a token sequence:

#define UART_BUFFER_SIZE 256
#define PROJECT_VERSION "2.4"

A function-like macro accepts arguments:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

The parentheses protect many operator-precedence cases, but the macro is still unsafe for arguments with side effects. In MAX(i++, j++), the selected argument may be evaluated more than once. An inline function is generally preferable when type-specific behavior is acceptable:

static inline int max_int(int a, int b) {
    return a > b ? a : b;
}

A macro can be useful when genericity, compile-time substitution, stringification, token pasting, or conditional compilation is required. It should not be used merely to avoid writing a short function.

The stringification operator # converts a macro argument into a string, while ## concatenates tokens. Both require careful documentation because expansion rules can surprise maintainers. A macro intended as a constant should be written in uppercase by convention, although naming convention does not provide safety.

2.2 Conditional compilation

Conditional directives include #if, #ifdef, #ifndef, #elif, #else, and #endif:

#if defined(TRACE_ENABLED)
    fprintf(stderr, "packet received\\n");
#endif

They are valuable for platform selection, feature configuration, diagnostics, and hardware variants. Excessive conditional compilation, however, creates many configurations that must be tested. Configuration should be centralized where possible, and unsupported combinations should be rejected explicitly:

#if defined(USE_SPI) && defined(USE_I2C)
#error "Select only one transport"
#endif

2.3 Header files and include guards

A header normally contains declarations, type definitions, macros, and inline functions—not ordinary external definitions. An include guard prevents repeated inclusion within one translation unit:

#ifndef SENSOR_H
#define SENSOR_H

#include <stddef.h>

struct sensor;
struct sensor *sensor_create(void);
int sensor_read(struct sensor *, unsigned char *, size_t);
void sensor_destroy(struct sensor *);

#endif

The #pragma once directive is widely supported but is not specified by older ISO C standards. Include guards are maximally portable. Headers should be self-sufficient: a source file that includes a header should not depend on another header having been included first.

Use angle brackets for implementation or system headers, and quoted inclusion for project headers according to the compiler's include-search policy. Avoid defining ordinary global objects in headers. If a shared object is needed, place extern declaration in the header and exactly one definition in a source file.

2.4 Predefined macros and diagnostics

Compilers commonly provide macros such as __FILE__, __LINE__, __DATE__, and __TIME__; the standard also defines __STDC_VERSION__ in conforming implementations. These can support diagnostics, but embedding build timestamps harms reproducibility. Prefer an explicit version supplied by the build system.

The preprocessing operator _Pragma can carry compiler directives through macros, but compiler-specific extensions should be isolated and documented. Compiler options such as -DNAME=value should be treated as part of the build interface, not as invisible assumptions.

3. Libraries and Symbol Management

A library is a reusable collection of code and data exposed through an interface. The standard C library supplies facilities such as input/output, strings, memory allocation, time, and mathematical functions. A project library may package domain functions such as modulation, packet framing, sensor calibration, or error logging.

3.1 Interface and implementation

A strong library separates:

  • Public contract: declarations, documented ownership, valid ranges, error conventions, and thread-safety guarantees.
  • Private implementation: data representation, helper functions, algorithms, and internal state.
  • Build artifact: a static archive or shared object plus headers and possibly metadata.

Opaque structures are a central C technique. The header declares struct sensor; without defining its fields. The source file defines the structure, so clients cannot depend on its representation. This permits internal changes without recompiling client logic, although clients must still relink when the binary interface changes.

3.2 Static libraries

A static library is commonly an archive of object files, such as libsignal.a. During linking, required members are copied into the executable. Advantages include simple deployment and predictable runtime dependencies. Disadvantages include larger executables, duplicated library code across processes, and the need to relink applications after library updates.

Archive order can matter with traditional linkers. If library A calls symbols in library B, a command commonly places A before B:

gcc main.o -lsignal -lm -o analyzer

The exact behavior depends on the linker and options, so build rules should state dependencies explicitly rather than relying on accidental ordering.

3.3 Shared libraries

A shared library, such as libsignal.so on many Unix-like systems or a DLL on Windows, is loaded or mapped at runtime. It can reduce disk and memory duplication and permit independent library updates. It also introduces deployment, ABI compatibility, search-path, versioning, and security issues.

An API is the source-level interface. An ABI includes binary calling conventions, object layout, symbol names, alignment, and compatibility rules. Changing a public structure's layout or function calling convention can break ABI compatibility even when source declarations appear similar. Libraries should version incompatible interfaces and avoid exposing unstable representation details.

3.4 Linking and symbol resolution

The compiler checks each translation unit using declarations. The linker resolves references to definitions. Typical failures include:

  • Undefined reference: a required definition was not supplied.
  • Multiple definition: incompatible or duplicate external definitions were supplied.
  • Unresolved import at runtime: a shared-library dependency is unavailable or incompatible.

Visibility controls reduce accidental coupling. File-scope static functions and data are private to a translation unit. Some toolchains also provide symbol-visibility attributes; these are useful but non-portable and should be isolated behind project macros.

4. Files and Streams

C represents file input and output through streams. A FILE * identifies a stream managed by the standard I/O library. A stream may represent a regular file, terminal, pipe, or other implementation-defined device.

4.1 Opening and closing

FILE *fp = fopen("measurements.txt", "r");
if (fp == NULL) {
    perror("measurements.txt");
    return EXIT_FAILURE;
}

/* use fp */

if (fclose(fp) == EOF) {
    perror("close");
    return EXIT_FAILURE;
}

Common modes include r for reading, w for truncating or creating a file, and a for appending. Adding b requests binary mode, which matters on systems where text and binary streams differ. Modes such as r+, w+, and a+ permit updating, but transitions between reading and writing require the positioning or flushing operations specified by the standard.

A robust program checks both fopen and fclose. A successful write to a buffered stream does not necessarily mean data has reached stable storage. fflush transfers buffered data to the host environment; it is not, by itself, a universal guarantee of physical storage durability. Platform-specific synchronization may be required for that stronger property.

4.2 Text files

Line-oriented text input can use fgets, which limits the number of bytes written and retains the newline when one is read. It is usually safer than unbounded input functions. Formatted functions such as fscanf require careful return-value checking because conversion may fail or stop early.

char line[128];
while (fgets(line, sizeof line, fp) != NULL) {
    char code[16];
    double value;
    if (sscanf(line, "%15s %lf", code, &value) == 2) {
        /* process one valid record */
    }
}
if (ferror(fp)) {
    /* distinguish an I/O error from ordinary end of file */
}

The field width in %15s prevents writing beyond a 16-byte array, including space for the terminating null character. Input validation must still check ranges, formats, and semantic constraints.

4.3 Binary files

Binary I/O treats objects as sequences of bytes:

uint32_t value = 0x12345678u;
if (fwrite(&value, sizeof value, 1, fp) != 1) {
    /* handle failure */
}

Writing a structure directly is not automatically portable. Padding, alignment, byte order, type sizes, floating-point representation, and compiler ABI can differ. A portable file format specifies field widths, byte order, encoding, version, and integrity checks. For example, a protocol may serialize a 32-bit unsigned value explicitly in big-endian order rather than copying its in-memory representation.

4.4 Positioning and large files

fseek, ftell, rewind, and fgetpos support positioning, subject to text-stream restrictions and implementation limits. For large data sets, use interfaces and types appropriate to the target platform; do not assume that long or int can represent every file size. A file offset should be checked for failure, and arithmetic involving offsets should be protected from overflow.

4.5 Resource ownership and security

Every successful open creates an ownership obligation: the responsible code path must close the stream. In error-heavy functions, a cleanup path reduces leaks:

int process(const char *name) {
    int status = -1;
    FILE *fp = fopen(name, "rb");
    if (fp == NULL) return -1;

    /* processing; set status = 0 only on success */

    if (fclose(fp) != 0) status = -1;
    return status;
}

Never construct privileged paths by blindly concatenating untrusted input. Validate path components, avoid unintended symbolic-link traversal where relevant, use least privilege, and consider atomic temporary-file replacement for configuration updates. File names, permissions, race conditions, and disclosure of diagnostic data are governance and security concerns, not merely programming details.

5. Modular Program Design

A modular C project normally assigns one coherent responsibility to each module. A module consists of a public header and one or more implementation files. The header specifies the contract; the implementation owns private state and algorithms.

A useful dependency direction is:

application → domain modules → platform abstraction → operating system

Lower-level modules should not depend unnecessarily on application details. Cyclic dependencies often indicate a missing abstraction or an excessively broad interface.

Example project layout

telemetry/
├── include/
│   ├── packet.h
│   └── storage.h
├── src/
│   ├── packet.c
│   ├── storage.c
│   └── main.c
├── tests/
│   └── test_packet.c
└── Makefile

packet.h may expose packet creation and serialization, while packet.c hides buffer management. storage.h may define functions for appending records without exposing whether the implementation uses a text file, binary log, or remote sink.

5.1 Separate compilation

A typical build sequence is:

gcc -std=c17 -Wall -Wextra -Iinclude -c src/packet.c -o build/packet.o
gcc -std=c17 -Wall -Wextra -Iinclude -c src/storage.c -o build/storage.o
gcc -std=c17 -Wall -Wextra -Iinclude -c src/main.c -o build/main.o
gcc build/main.o build/packet.o build/storage.o -o build/telemetry

The -c option stops after producing an object file. Header changes may require recompilation of every source file that includes the header, directly or indirectly. Source changes normally require recompilation only of the corresponding source file, followed by relinking.

5.2 Dependency-aware builds

A build system records targets and prerequisites. A simplified Makefile is:

CC      = gcc
CFLAGS  = -std=c17 -Wall -Wextra -Iinclude
OBJ     = build/main.o build/packet.o build/storage.o

build/telemetry: $(OBJ)
	$(CC) $(OBJ) -o $@

build/%.o: src/%.c
	@mkdir -p build
	$(CC) $(CFLAGS) -MMD -MP -c $< -o $@

-include $(OBJ:.o=.d)

.PHONY: clean
clean:
	rm -rf build

Automatic dependency generation reduces stale-header problems. The build directory keeps generated artifacts separate from source. In larger projects, CMake, Meson, Ninja, or another build orchestrator may generate platform-specific rules, but the underlying concepts remain dependency graphs and reproducible commands.

5.3 Reproducibility and quality

A reproducible build aims to produce equivalent artifacts from the same declared inputs. Important practices include pinning toolchain versions, recording compiler and linker flags, avoiding timestamps and absolute paths in binaries, controlling locale and environment variables, and isolating generated files. Continuous integration should build from a clean checkout, run tests, enable warnings, and exercise more than one supported configuration.

Warnings are not proof of correctness, but treating important warnings as errors removes many defects early. Sanitizers can detect classes of memory and undefined-behavior errors during testing. Static analyzers and code review complement, rather than replace, runtime tests.

C modular build pipeline .c files.h files Preprocesstranslation units Compileobject files Linkprogram/library Dependency-aware modular build Only affected objects are rebuilt; final linking combines resolved symbols.
C modular build pipeline
Figure: C modular build pipeline

Figure: Conceptual stages of a modular C build.

6. Detailed Applied Case: Embedded Telemetry Logger

Background

Consider an illustrative embedded telemetry logger that samples temperature and link-quality measurements, frames them into packets, and stores records when a communication link is unavailable.

Problem

An initial prototype has one 2,000-line source file. It uses macros for packet fields, writes structures directly to a binary file, and has platform-specific serial code mixed with parsing and storage. A small change to the packet format forces a full rebuild and has previously produced incompatible log files.

Analysis

The defects are architectural as well as syntactic. The packet representation is exposed, serialization is dependent on host byte order, file errors are ignored, and tests cannot substitute a simulated transport. The project also lacks explicit header dependencies and a clean-build verification step.

Approach and solution

The team divides the system into packet, transport, storage, and application modules. Each module has a narrow header. Packet serialization writes fixed-width fields in a documented byte order and includes a format version. Storage accepts a byte sequence and reports errors; it does not know how packets are constructed. Transport has a hardware implementation and a test double selected by a controlled build option.

Private helpers are declared static, and public state is replaced by opaque handles. A dependency-aware build generates .d files, uses separate debug and release configurations, and performs clean builds in continuous integration. Tests cover boundary lengths, truncated records, invalid versions, failed writes, and simulated transport timeouts.

Outcome

A packet-format change requires recompiling packet clients but does not expose internal buffer layout. Old logs can be rejected or migrated based on their version. The test double permits deterministic host testing, while the embedded build selects the hardware transport. Error paths close files and propagate status codes to the application.

Learning points

  1. A header is a contract, not a storage location for arbitrary definitions.
  2. Serialization must specify representation independently of host memory layout.
  3. Module boundaries improve testing and reduce change propagation.
  4. Build reproducibility and error handling are system-quality properties.

7. Limitations, Trade-offs, and Contemporary Practice

The preprocessor is not a type-safe metaprogramming system. Macro-heavy designs obscure control flow and complicate debugging. Static libraries simplify deployment but duplicate code and require relinking. Shared libraries improve reuse but create ABI and supply-chain concerns. Text files are inspectable but may be larger and slower; binary formats are compact but require explicit compatibility design.

Modern projects increasingly combine compiler diagnostics, static analysis, sanitizers, fuzz testing, dependency scanning, and software bills of materials. These practices are particularly relevant when a C library handles network data, firmware updates, or privileged files. AI-assisted code generation may accelerate boilerplate, but generated code must undergo compilation with strict diagnostics, review, tests, licensing checks, and security analysis. Plausible-looking C can still contain lifetime, bounds, integer-conversion, and concurrency defects.

Professional governance should define supported C language versions, compiler versions, warning policies, ownership conventions, API stability promises, file-format versioning, vulnerability response, and retention or privacy requirements for logged data. No single tool replaces a sound interface and an explicit engineering process.

Best-Practice Checklist

  • Keep headers self-contained and protect them with include guards.
  • Prefer functions or static inline functions over side-effect-prone macros.
  • Use static for private file-scope entities.
  • Place one definition of each externally linked object in one source file.
  • Check every file-open, read, write, conversion, seek, and close operation.
  • Define binary formats explicitly; do not assume structure layout is portable.
  • Separate platform-dependent code behind a narrow abstraction.
  • Generate and include header dependencies in modular builds.
  • Build from a clean checkout in automated integration.
  • Document API ownership, thread safety, error values, and ABI expectations.

Chapter Summary

C preprocessing performs token-level inclusion, substitution, and conditional selection before compilation; it is useful but lacks type safety. Headers should expose stable contracts, while implementation details remain private through internal linkage and opaque structures. Libraries package reusable interfaces and implementations as static or shared artifacts, with linking resolving symbols and ABI compatibility governing binary reuse. C file I/O is stream-based and requires disciplined mode selection, bounded input, return-value checking, resource cleanup, and explicit portable serialization. Modular builds use separate compilation and dependency graphs so that changes trigger the smallest correct rebuild. Reproducible builds, testing, diagnostics, security controls, and documented interfaces turn these mechanisms into dependable engineering practice.

Key Terms

Preprocessor: A token-processing stage that handles directives and macro expansion before compilation.

Macro: A preprocessor rule that substitutes a token sequence.

Translation unit: A source file after preprocessing, including included headers.

Header guard: Conditional compilation used to prevent repeated header inclusion.

Declaration: A statement that specifies an entity’s type or existence.

Definition: A declaration that allocates storage or supplies an implementation.

Linkage: The rule determining whether declarations refer to the same entity across scopes or translation units.

Opaque structure: A structure whose representation is hidden from clients.

API: The source-level application programming interface of a component.

ABI: The binary-level application programming interface, including layout and calling conventions.

Static library: An archive of object files incorporated during linking.

Shared library: A library loaded or mapped separately at runtime.

Symbol: A named function, object, or other linkable entity.

Stream: A C abstraction for sequential input or output.

Serialization: Conversion of data into a defined external representation.

Include dependency: A relationship in which a source or header depends on another header.

Separate compilation: Compiling translation units independently before linking.

Reproducible build: A build whose declared inputs produce equivalent outputs across controlled environments.

Opaque handle: A client-visible reference to privately represented module state.

Review Questions

Short Answer Questions

  1. What is a translation unit, and how is it produced?
  2. Why can MAX(i++, j++) be unsafe when MAX is a macro?
  3. Distinguish an API from an ABI.
  4. Why should a program check both ferror(fp) and the result of fclose(fp) where appropriate?
  5. What is the purpose of generated dependency files in a modular build?

Long Answer Questions

  1. Explain the complete path from C source files to an executable, including preprocessing, compilation, assembly, and linking.
  2. Compare static and shared libraries with respect to deployment, memory use, update behavior, and compatibility.
  3. Describe how to design a portable binary file format for a telemetry application.
  4. Explain opaque structures, internal linkage, and header-file contracts as techniques for information hiding.
  5. Discuss reproducible builds and identify practices that improve build integrity in a professional project.

Analytical / Application Questions

  1. A project writes a C structure directly to a log file and later reads it on a processor with a different byte order. Analyze the failure modes and propose a portable replacement.
  2. A build sometimes succeeds after a header changes but uses stale object files. Diagnose the likely dependency problem and design a corrective build rule.
  3. Design module boundaries for a device logger that must support both real hardware and a simulator. Explain how interfaces, conditional compilation, tests, and error propagation should interact.

Multiple Choice Questions with Answers

Question 1

Which statement best describes a C translation unit?

A. A linked executable containing all libraries

B. A source file after preprocessing, including included headers

C. Only the tokens written in a .c file before preprocessing

D. A runtime process created by the operating system

Correct Answer: B. A source file after preprocessing, including included headers

Question 2

What is the principal problem with the macro MAX(i++, j++)?

A. The macro cannot compare integers

B. The preprocessor converts integers to floating point

C. An argument may be evaluated more than once

D. The macro always causes a linker error

Correct Answer: C. An argument may be evaluated more than once

Question 3

Which declaration normally prevents a file-scope function from being visible to other translation units?

A. extern

B. register

C. volatile

D. static

Correct Answer: D. static

Question 4

What is a major portability problem with writing a structure directly using fwrite?

A. fwrite always converts binary data to text

B. Structure padding and byte order may differ between systems

C. fwrite cannot write more than one byte

D. The linker removes all structures

Correct Answer: B. Structure padding and byte order may differ between systems

Question 5

Which operation is generally appropriate for bounded line input into a character array?

A. gets

B. fgets

C. atoi

D. fclose

Correct Answer: B. fgets

Question 6

What does a static library usually contain?

A. A collection of object files in an archive

B. Only preprocessor directives

C. A mandatory operating-system process

D. A text file containing compiler warnings

Correct Answer: A. A collection of object files in an archive

Question 7

If library A calls symbols in library B, which ordering is commonly required by traditional linkers?

A. B before A

B. A before B

C. The libraries must never appear in the command

D. Only the header for A is required

Correct Answer: B. A before B

Question 8

What is the main purpose of an opaque structure?

A. To expose every private field to clients

B. To prevent the compiler from checking types

C. To hide representation details behind a public interface

D. To force all functions to be macros

Correct Answer: C. To hide representation details behind a public interface

Question 9

Which event normally requires recompiling a source file that includes a changed header?

A. A change to an unrelated executable name

B. A change to the header’s declarations

C. Closing a file stream at runtime

D. Changing a local variable value

Correct Answer: B. A change to the header’s declarations

Question 10

Which practice most directly supports reproducible builds?

A. Embedding the current time in every binary

B. Relying on undeclared environment variables

C. Recording toolchain versions and controlling build inputs

D. Deleting all dependency information

Correct Answer: C. Recording toolchain versions and controlling build inputs

Programming in C
Chapter 9 of 9