Project 10: Modular Program Architecture

A modular C application with clean interfaces, stable APIs, and a reproducible build system.

Quick Reference

Attribute Value
Difficulty Level 3 - Advanced
Time Estimate 1-2 weeks
Main Programming Language C
Alternative Programming Languages None
Coolness Level Level 3 - Genuinely Clever
Business Potential Level 3 - Service & Support
Prerequisites C basics, header usage, Makefiles
Key Topics Translation units, linkage, build systems, ABI

1. Learning Objectives

By completing this project, you will:

  1. Design clean module boundaries with headers and source files.
  2. Control symbol visibility and linkage with static/extern.
  3. Build a reproducible build system using Make or CMake.
  4. Manage API/ABI compatibility across versions.
  5. Produce documentation that makes modules easy to reuse.

2. All Theory Needed (Per-Concept Breakdown)

Concept 1: Translation Units, Linkage, and Symbol Visibility

Fundamentals

C programs are built from translation units: each .c file plus the headers it includes. The compiler compiles each translation unit separately, then the linker resolves symbols across object files. Linkage determines whether a symbol is visible outside its translation unit (extern) or restricted to it (static). Understanding this model is essential for building modular programs that avoid symbol collisions and expose clean APIs.

Deep Dive into the concept

A translation unit is the result of preprocessing a .c file. It contains all the declarations and definitions visible at that point. When you compile a translation unit, you get an object file with symbols (functions, globals) that can be resolved by the linker. If two object files define the same global symbol without static, the linker will report a multiple-definition error. This is why you use static for internal symbols and reserve extern for public API functions.

Header files are not magical; they are simply text included in translation units. A header should contain declarations (function prototypes, type definitions) but not definitions of objects with external linkage, otherwise every translation unit will define the same symbol. Inline functions and static functions are exceptions because they have internal linkage. Include guards or #pragma once prevent duplicate inclusion within a single translation unit.

Symbol visibility also impacts ABI. If you expose too many symbols, you increase the surface area for breakage. A modular architecture defines clear interfaces in headers and hides internal details in source files. This is often called the “opaque pointer” or “pimpl” pattern in C: you define a struct in the .c file and expose only a forward declaration in the header. This allows you to change the internal representation without breaking users.

The linker resolves symbols based on name and linkage. Understanding how the linker searches libraries (static vs shared) and how it resolves weak/strong symbols is part of professional C. This project will make you write multiple modules, expose only intended APIs, and verify symbol tables using tools like nm and objdump. The goal is to make you comfortable with the compile-link pipeline and the visibility rules that enforce modular design.

A deeper practical detail is how inline functions behave in C. static inline functions in headers are safe because each translation unit gets its own copy, but extern inline has compiler-specific semantics in some modes, which can cause missing symbols or duplicate definitions. Many projects avoid extern inline entirely to keep behavior consistent across compilers. Symbol visibility flags such as -fvisibility=hidden can reduce exported symbols by default, improving load times and reducing ABI surface. You can then explicitly mark public APIs with attributes like visibility default or compiler-specific declspecs on Windows. This approach makes modularity explicit and is common in large C libraries. Including a small section on symbol visibility and inline functions will make your architecture guide more realistic and actionable.

To operationalize this concept in a real codebase, create a short checklist of invariants and a set of micro-experiments. Start with a minimal, deterministic test that isolates one rule or behavior, then vary a single parameter at a time (inputs, flags, platform, or data layout) and record the outcome. Keep a table of assumptions and validate them with assertions or static checks so violations are caught early. Whenever the concept touches the compiler or OS, capture tool output such as assembly, warnings, or system call traces and attach it to your lab notes. Finally, define explicit failure modes: what does a violation look like at runtime, and how would you detect it in logs or tests? This turns abstract theory into repeatable engineering practice and makes results comparable across machines and compiler versions.

How this fits on projects

Definitions & key terms

  • Translation unit: Preprocessed source file compiled into an object file.
  • Linkage: Determines symbol visibility across translation units.
  • External linkage: Symbol visible to other translation units.
  • Internal linkage: Symbol visible only within its translation unit (static).
  • Include guard: Prevents multiple inclusion of headers.

Mental model diagram (ASCII)

file.c + headers -> translation unit -> object.o
object.o + object2.o -> linker -> executable

How it works (step-by-step, with invariants and failure modes)

  1. Preprocess each .c file into a translation unit.
  2. Compile into .o with symbol table.
  3. Link symbols across object files.
  4. Resolve external references and produce binary.

Invariant: Each external symbol is defined exactly once. Failure mode: Multiple definitions or unresolved references.

Minimal concrete example

// module.c
static int internal_helper(void) { return 1; }
int public_api(void) { return internal_helper(); }

Common misconceptions

  • “Headers compile separately.” → They are included into translation units.
  • static is just an optimization.” → It controls linkage/visibility.
  • “Global variables are fine in headers.” → They cause multiple definitions.

Check-your-understanding questions

  1. What is a translation unit?
  2. Why do headers need include guards?
  3. What does static do for a function?
  4. How does the linker resolve symbols?
  5. Why hide struct definitions in .c files?

Check-your-understanding answers

  1. A .c file plus its included headers after preprocessing.
  2. To avoid duplicate declarations/definitions in the same unit.
  3. It gives internal linkage (file-local visibility).
  4. It matches undefined references to definitions in object files/libraries.
  5. To keep the ABI stable while changing internals.

Real-world applications

  • Building reusable libraries with stable APIs.
  • Avoiding symbol collisions in large projects.

Where you’ll apply it

References

  • “Linkers and Loaders” — Levine
  • “The Linux Programming Interface” — compile/link chapters

Key insights

Modularity in C is enforced by translation units and linkage rules.

Summary

Understanding translation units and linkage is essential for modular C design. It allows you to expose clean APIs, avoid symbol collisions, and maintain ABI stability.

Homework/Exercises to practice the concept

  1. Create a module with one public and two private functions.
  2. Use nm to inspect symbol visibility.
  3. Move a global variable into a .c file and expose accessors.

Solutions to the homework/exercises

  1. Mark private functions as static.
  2. nm should show local (t) vs global (T) symbols.
  3. Expose getter/setter functions in the header.

Concept 2: Build Systems, Dependencies, and ABI Stability

Fundamentals

A build system orchestrates compilation and linking, ensuring that changes rebuild the right files. In C, Make or CMake are common. Dependencies between headers and sources must be tracked to avoid stale builds. ABI stability refers to keeping binary compatibility across versions, which requires careful control of exported symbols and struct layouts.

Deep Dive into the concept

A robust build system tracks dependencies: if a header changes, all translation units that include it must be recompiled. make uses dependency files (.d) generated by the compiler to track these relationships. Without proper dependency tracking, you can get confusing bugs caused by stale objects. CMake automates much of this but still requires careful configuration to separate targets and manage include paths.

ABI stability matters when you distribute libraries. If you change the layout of a public struct or the signature of an exported function, you break binary compatibility. Even if your code compiles, existing binaries may fail at runtime. The safest approach is to hide struct definitions behind opaque pointers and expose only functions. If you need to evolve the ABI, you can use versioned symbols or bump major versions. Your project will document an API versioning strategy and include a compatibility checklist.

Build systems also control flags: warnings, sanitizers, optimization levels, and feature macros. Professional C projects maintain a standard set of flags (-Wall -Wextra -Werror) and separate debug/release configurations. Your project will implement a build that produces multiple configurations and demonstrates how flags affect behavior, tying back to earlier projects on compiler behavior.

To operationalize this concept in a real codebase, create a short checklist of invariants and a set of micro-experiments. Start with a minimal, deterministic test that isolates one rule or behavior, then vary a single parameter at a time (inputs, flags, platform, or data layout) and record the outcome. Keep a table of assumptions and validate them with assertions or static checks so violations are caught early. Whenever the concept touches the compiler or OS, capture tool output such as assembly, warnings, or system call traces and attach it to your lab notes. Finally, define explicit failure modes: what does a violation look like at runtime, and how would you detect it in logs or tests? This turns abstract theory into repeatable engineering practice and makes results comparable across machines and compiler versions.

Another way to deepen understanding is to map the concept to a small decision table: list inputs, expected outcomes, and the assumptions that must hold. Create at least one negative test that violates an assumption and observe the failure mode, then document how you would detect it in production. Add a short trade-off note: what you gain by following the rule and what you pay in complexity or performance. Where possible, instrument the implementation with debug-only checks so violations are caught early without affecting release builds. If the concept admits multiple approaches, implement two and compare them; the act of measuring and documenting the difference is part of professional practice. This habit turns theoretical understanding into an engineering decision framework you can reuse across projects.

Finally, keep a short field guide that lists typical symptoms when this concept is violated and the quickest diagnostic tool to use. For example, link errors, sanitizer crashes, or unexpected timing. This makes the concept actionable when things break. Repeat the exercise for at least one cross-platform variant so you see which parts are portable and which are not. Over time, this builds intuition that is hard to gain from theory alone.

How this fits on projects

Definitions & key terms

  • Dependency tracking: Ensuring changed headers trigger recompilation.
  • ABI: Application Binary Interface (binary-level contract).
  • Versioning: Semantic or ABI versioning of interfaces.
  • Target: A build artifact (library or executable).

Mental model diagram (ASCII)

src/*.c -> object files -> library.a -> app
headers -> dependency graph -> rebuild triggers

How it works (step-by-step, with invariants and failure modes)

  1. Detect changed files and headers.
  2. Rebuild affected objects.
  3. Link into libraries/executables.
  4. Ensure ABI compatibility for public symbols.

Invariant: Public API changes must preserve ABI unless version bumped. Failure mode: Changing struct layouts breaks binary compatibility.

Minimal concrete example

%.o: %.c
	$(CC) -MMD -c $< -o $@

Common misconceptions

  • “If it compiles, ABI is fine.” → ABI can break silently.
  • “Make always knows dependencies.” → It needs .d files.
  • “Headers are safe to change.” → They trigger rebuilds and ABI changes.

Check-your-understanding questions

  1. Why are .d files important?
  2. What breaks ABI compatibility?
  3. Why use opaque structs in headers?
  4. How do you build debug vs release modes?
  5. What is semantic versioning?

Check-your-understanding answers

  1. They track header dependencies for rebuilds.
  2. Changing exported function signatures or struct layouts.
  3. To hide implementation details and allow changes without ABI break.
  4. Use different flags and targets in the build system.
  5. Versioning that signals breaking vs non-breaking changes.

Real-world applications

  • Building shared libraries for OS distributions.
  • Maintaining stable APIs in SDKs.

Where you’ll apply it

References

  • “GNU Make Manual”
  • “Linkers and Loaders” — Levine

Key insights

A modular program is only as reliable as its build system and ABI discipline.

Summary

Build systems enforce dependencies and configuration, while ABI stability ensures binary compatibility. Together they make modular C programs maintainable and safe to evolve.

Homework/Exercises to practice the concept

  1. Add dependency generation to a Makefile.
  2. Convert a public struct to an opaque pointer.
  3. Create debug and release build targets.

Solutions to the homework/exercises

  1. Use -MMD and include .d files.
  2. Move struct definition to .c, expose typedef struct X X;.
  3. Add CFLAGS_DEBUG and CFLAGS_RELEASE.

3. Project Specification

3.1 What You Will Build

A modular C application (or library) composed of multiple well-defined modules with clean public headers, a stable API/ABI, and a reproducible build system with debug/release targets.

3.2 Functional Requirements

  1. Module boundaries: At least 4 modules with public headers.
  2. Symbol control: Internal symbols hidden via static or -fvisibility.
  3. Build system: Make or CMake with dependency tracking.
  4. Versioning: API/ABI version documented.
  5. Documentation: Module README explaining responsibilities.

3.3 Non-Functional Requirements

  • Performance: Build completes under 10 seconds.
  • Reliability: No duplicate symbol or unresolved reference errors.
  • Usability: Public API is clearly documented.

3.4 Example Usage / Output

#include "module.h"
int main(void) { return module_run(); }

3.5 Data Formats / Schemas / Protocols

Version header:

#define MODULE_API_VERSION 1

3.6 Edge Cases

  • Circular dependencies between modules.
  • Duplicate symbol names.
  • ABI changes without version bump.

3.7 Real World Outcome

What you will see:

  1. A multi-module C project with clean headers.
  2. A build system that rebuilds only what changed.
  3. An API versioning guide.

3.7.1 How to Run (Copy/Paste)

make
./app

3.7.2 Golden Path Demo (Deterministic)

Build and run the app; output matches expected demo string.

3.7.3 If CLI: exact terminal transcript

$ ./app
module system ok
Exit: 0

Failure demo (deterministic):

$ make broken
ERROR: multiple definition of `module_run`
Exit: 2

4. Solution Architecture

4.1 High-Level Design

+--------------+   +--------------+   +--------------+
| module A     |-->| module B     |-->| module C     |
+--------------+   +--------------+   +--------------+
         |               |
         v               v
         public API headers

4.2 Key Components

| Component | Responsibility | Key Decisions | |———–|—————-|—————-| | Public headers | Expose API | Use opaque structs | | Internal modules | Implement logic | Hide symbols | | Build system | Compile/link | Dependency tracking |

4.3 Data Structures (No Full Code)

typedef struct module module_t; // opaque

4.4 Algorithm Overview

  1. Define module responsibilities and interfaces.
  2. Implement each module with internal helpers.
  3. Link modules into final executable or library.

Complexity Analysis:

  • Build time: O(files)
  • Runtime: depends on module logic

5. Implementation Guide

5.1 Development Environment Setup

clang -std=c23 -Wall -Wextra -Werror -g

5.2 Project Structure

modular-app/
├── include/
│   ├── module_a.h
│   └── module_b.h
├── src/
│   ├── module_a.c
│   └── module_b.c
├── build/
└── Makefile

5.3 The Core Question You’re Answering

“How do I structure a C program so it scales in size without becoming fragile?”

5.4 Concepts You Must Understand First

  1. Translation units and linkage.
  2. Dependency tracking in builds.
  3. ABI stability and versioning.

5.5 Questions to Guide Your Design

  1. What should be public vs private?
  2. How will you version your API?
  3. How will your build handle header changes?

5.6 Thinking Exercise

Design a module interface that hides its internal struct.

5.7 The Interview Questions They’ll Ask

  1. What is the difference between static and extern?
  2. How do you avoid multiple definition errors?
  3. Why use opaque pointers?

5.8 Hints in Layers

  • Hint 1: Start with two modules and a small main.
  • Hint 2: Add dependency tracking (-MMD).
  • Hint 3: Add API versioning macros.

5.9 Books That Will Help

| Topic | Book | Chapter | |——-|——|———| | Linking | “Linkers and Loaders” — Levine | Ch. 1-3 |

5.10 Implementation Phases

Phase 1: Foundation (3-4 days)

  • Create module headers and sources.
  • Checkpoint: Build succeeds with two modules.

Phase 2: Core Functionality (4-5 days)

  • Add additional modules and dependencies.
  • Checkpoint: Build handles header changes.

Phase 3: Polish & Edge Cases (2-3 days)

  • Add versioning and ABI notes.
  • Checkpoint: API docs complete.

5.11 Key Implementation Decisions

| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Build tool | Make, CMake | Make | Transparent and educational | | API exposure | structs vs opaque | opaque | ABI stability |


6. Testing Strategy

6.1 Test Categories

| Category | Purpose | Examples | |———|———|———-| | Unit tests | Module-level correctness | module_a tests | | Integration tests | Full app behavior | end-to-end run | | Edge case tests | Build failures | missing header |

6.2 Critical Test Cases

  1. Ensure headers compile independently.
  2. Verify no duplicate symbols in nm output.
  3. Build debug and release targets.

6.3 Test Data

Expected output: "module system ok"

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

| Pitfall | Symptom | Solution | |——–|———|———-| | Defining globals in headers | Link errors | Use extern declarations | | Missing include guards | Redefinition errors | Add guards | | ABI break without version bump | Runtime crashes | Version carefully |

7.2 Debugging Strategies

  • Use nm to inspect symbol visibility.
  • Use objdump -t to see symbol tables.

7.3 Performance Traps

Over-modularization can add function call overhead; profile if needed.


8. Extensions & Challenges

8.1 Beginner Extensions

  • Add a CLI interface for module selection.

8.2 Intermediate Extensions

  • Build shared and static library variants.

8.3 Advanced Extensions

  • Add symbol versioning for ABI compatibility.

9. Real-World Connections

9.1 Industry Applications

  • Large-scale C codebases in OS and embedded systems.
  • SDKs distributed as binary libraries.
  • OpenSSL modular layout.
  • SQLite build system.

9.3 Interview Relevance

  • Linkage and build questions are common in systems interviews.

10. Resources

10.1 Essential Reading

  • “Linkers and Loaders” — Levine
  • GNU Make manual

10.2 Video Resources

  • Build systems and linker talks

10.3 Tools & Documentation

  • nm, objdump, readelf

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain translation units and linkage.
  • I can describe how build dependencies work.
  • I can define API vs ABI stability.

11.2 Implementation

  • Modules compile and link cleanly.
  • Build system handles changes correctly.
  • Public APIs are documented.

11.3 Growth

  • I can design modular C systems confidently.
  • I can evolve APIs without breaking ABI.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Multi-module project with clean headers.
  • Build system with dependency tracking.
  • API documentation.

Full Completion:

  • All minimum criteria plus:
  • Debug/release builds and ABI notes.

Excellence (Going Above & Beyond):

  • Shared library versioning and ABI tests.