What you'll learn
A serious C project must build predictably, expose a deliberate public interface, survive multiple toolchains, reject hostile or malformed input, and prove its quality continuously. This module connects those responsibilities into one release-ready workflow.
By the end, you'll be able to:
- Automate incremental builds with Make and portable builds with CMake
- Design libraries around small, stable public headers
- Write deliberately portable C against a chosen language standard
- Apply secure input, arithmetic, memory, and error-handling habits
- Profile real workloads and run quality gates in continuous integration
The build pipeline
Each .c file becomes a translation unit after preprocessing, then compiles to an object file. The linker resolves references between those objects and libraries. Build automation records this dependency graph so only affected work is repeated.
# Compile translation units separately
cc -std=c17 -Wall -Wextra -Iinclude -c src/main.c -o build/main.o
cc -std=c17 -Wall -Wextra -Iinclude -c src/catalog.c -o build/catalog.o
# Link object files into one executable
cc build/main.o build/catalog.o -o build/catalog_app
# Only a changed translation unit needs recompilation.Key idea
Make
Make evaluates targets, prerequisites, and recipes. A target is rebuilt when it is missing or older than one of its prerequisites. Variables keep compiler policy centralized.
CC ?= cc
CPPFLAGS := -Iinclude
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -Wconversion -g
LDLIBS :=
APP := build/catalog_app
OBJECTS := build/main.o build/catalog.o
.PHONY: all test clean
all: $(APP)
$(APP): $(OBJECTS)
$(CC) $(OBJECTS) $(LDLIBS) -o $@
build/%.o: src/%.c
@mkdir -p build
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
test: $(APP)
./$(APP) --self-test
clean:
rm -f $(OBJECTS) $(APP)Note
-MMD -MP—so editing a header rebuilds every affected source file.makebuilds the default targetmake testbuilds prerequisites before running checks$@names the target and$<the first prerequisite- Phony targets name actions rather than real output files
CMake
CMake generates native build systems for different platforms and IDEs. Describe targets and their requirements; avoid placing every option in global flags that leak across unrelated targets.
cmake_minimum_required(VERSION 3.20)
project(catalog VERSION 1.0 LANGUAGES C)
add_library(catalog src/catalog.c)
target_include_directories(catalog PUBLIC include)
target_compile_features(catalog PUBLIC c_std_17)
add_executable(catalog_app src/main.c)
target_link_libraries(catalog_app PRIVATE catalog)
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(catalog PRIVATE
-Wall -Wextra -Wpedantic -Wconversion)
endif()
enable_testing()
add_executable(catalog_tests tests/test_catalog.c)
target_link_libraries(catalog_tests PRIVATE catalog)
add_test(NAME catalog_tests COMMAND catalog_tests)cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failureTip
build/, which can be removed or recreated without touching source code.Libraries & interfaces
A library's public headers are contracts. Expose the types and operations clients need, keep private helpers static inside implementation files, and hide changeable representation behind opaque types where appropriate.
/* include/counter.h — the stable public interface */
#ifndef COUNTER_H
#define COUNTER_H
#include <stddef.h>
typedef struct Counter Counter; // opaque: clients cannot access fields
Counter *counter_create(void);
void counter_destroy(Counter *counter);
int counter_increment(Counter *counter, const char *key);
size_t counter_value(const Counter *counter, const char *key);
#endif
/* src/counter.c owns the private representation. */
struct Counter {
/* allocation strategy and entries can change without changing the API */
};| Artifact | Typical form | Tradeoff |
|---|---|---|
| Static library | libname.a / name.lib | Copied into the executable at link time |
| Shared library | .so / .dylib / .dll | Loaded at runtime; versioning and search paths matter |
| Public header | include/name.h | Source-level API contract for clients |
| Implementation | src/name.c | Owns private representation and helper functions |
Watch out
Standards & portability
Choose and declare a language baseline such as C17. Portable code depends on standard guarantees, tests implementation assumptions, isolates platform-specific code, and builds on more than one compiler.
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
uint32_t rotate_left32(uint32_t value, unsigned distance) {
distance %= 32U;
if (distance == 0U) return value;
return (value << distance) | (value >> (32U - distance));
}
int main(void) {
uint32_t flags = UINT32_C(0x80000001);
printf("%08" PRIx32 "\n", rotate_left32(flags, 1U));
return 0;
}| Risk | Portable response |
|---|---|
| Integer widths differ | Use minimum-width types or uint32_t when exactly 32 bits are required |
| Byte order differs | Serialize fields explicitly; never dump structs as a file/network format |
| Struct padding differs | Encode each field and define the external format |
| Paths and line endings differ | Use platform APIs behind a narrow adapter; parse text deliberately |
| Compiler extensions differ | Keep extensions isolated and provide a standard fallback |
Note
uint32_t exist only when the implementation provides an exact matching type. Use sizeof, CHAR_BIT, and static assertions when a platform property is a real program requirement.Secure C habits
Security begins at every trust boundary. Treat command-line arguments, files, environment values, network bytes, and even corrupted internal state as data that must satisfy a clear contract before it controls memory or arithmetic.
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
int read_int(const char *prompt, int *result) {
char buffer[128];
fputs(prompt, stdout);
if (fgets(buffer, sizeof buffer, stdin) == NULL) return 0;
errno = 0;
char *end = NULL;
long value = strtol(buffer, &end, 10);
if (end == buffer || errno == ERANGE ||
value < INT_MIN || value > INT_MAX) {
return 0;
}
while (*end == ' ' || *end == '\t') end++;
if (*end != '\n' && *end != '\0') return 0;
*result = (int) value;
return 1;
}
int main(void) {
int age;
if (!read_int("Age: ", &age) || age < 0 || age > 130) {
fputs("Invalid age\n", stderr);
return EXIT_FAILURE;
}
printf("Accepted: %d\n", age);
return EXIT_SUCCESS;
}- Carry buffer capacity with every pointer and validate indexes before access
- Check size arithmetic before allocation: multiplication and addition can overflow
- Prefer bounded input plus explicit parsing over ambiguous convenience functions
- Check every operation that can fail and preserve useful error context
- Use least privilege, minimize exposed functionality, and never embed secrets in source
- Keep dependencies and compilers supported and respond to security advisories
Watch out
Profiling & optimization
Optimization is a measurement loop: define a representative workload, establish a baseline, locate the dominant cost, change one bottleneck, and confirm both speed and correctness.
# Always measure an optimized build with realistic input
cc -std=c17 -O2 -g src/*.c -o build/app
# Linux examples
perf stat ./build/app data/large-input.txt
perf record ./build/app data/large-input.txt
perf report
# Also compare end-to-end behavior and memory use
/usr/bin/time -v ./build/app data/large-input.txt- Choose a user-visible metric and realistic input distribution
- Measure an optimized build under controlled conditions
- Use a profiler to find hot functions, allocations, cache misses, or I/O waits
- Improve the algorithm or data layout before micro-optimizing instructions
- Run tests and sanitizers, then compare against the baseline
Key idea
Continuous integration
Continuous integration rebuilds and verifies each change in clean environments. A compiler and operating-system matrix exposes accidental dependencies that one developer machine cannot reveal.
name: C quality gates
on: [push, pull_request]
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
compiler: [gcc, clang]
exclude:
- os: windows-latest
compiler: gcc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build --config Debug
- name: Test
run: ctest --test-dir build -C Debug --output-on-failureTip
Recap & quick check
Key takeaways
- Build tools encode the dependency graph and make compilation reproducible.
- CMake models portable targets; Make provides direct incremental build rules.
- Small public headers and opaque types protect library boundaries.
- Portability comes from explicit guarantees, isolated platform code, and multiple toolchains.
- Secure C validates boundaries, checks arithmetic and failures, and continuously verifies changes.
- Profile representative optimized builds before deciding what to optimize.
Quick check
1. What does Make use to decide whether a target needs rebuilding?
2. Why use an opaque library type?
3. How should a binary file format store a multi-byte integer portably?
4. What is the first requirement for meaningful optimization?
Phase 5 complete. Next: Module 20 — C Capstone Projects, where you'll apply the full course through a graduated portfolio of complete programs.