[C Series 19] Learning Build Automation and Project Structure

한국어 버전

After sharpening your low-level instincts with bitwise operations, it's time to split real projects into multiple files and automate the build process. Instead of typing clang main.c utils.c -o app every time, a Makefile (a file that automates your build commands) recompiles only the files that changed and shortens your command history.

New Terms in This Lesson

  1. Makefile: The script of build rules read by make
  2. Target: The thing you want to build (executables, object files, etc.)
  3. Dependency: The inputs required to build a target
  4. Rule: The set of commands that produce a target

Key Ideas

Study Notes

  • Estimated time: Around 70 minutes
  • Prereqs: Experience splitting headers/sources and running clang or gcc from a terminal
  • Goal: Design a simple project tree, write a Makefile, and understand incremental builds

As projects grow, splitting files by responsibility and minimizing repetitive work becomes crucial. Using directories like src, include, and build keeps collaboration organized, and a Makefile remembers how to rebuild only the necessary pieces.

Think of the workflow like this:

  1. Manually run clang main.c stats.c -o app once.
  2. Get tired of repeating that command.
  3. Encode the steps in a Makefile so make runs them for you.

We'll walk through:

  • Designing a baseline folder structure (src, include, build)
  • Compiling individual .c files into object files before linking
  • Understanding Makefile target/dependency/command syntax
  • Using phony targets and a clean rule to tidy up

Code Walkthrough

Sample Project Layout

project/
├── include/
│   └── stats.h
├── src/
│   ├── main.c
│   └── stats.c
└── Makefile

stats.h keeps declarations and structs, stats.c holds the implementation, and main.c includes the header to use the functions.

include/stats.h

#ifndef STATS_H
#define STATS_H

#include <stddef.h>

typedef struct {
    double min;
    double max;
    double average;
} Stats;

Stats calculate_stats(const int *values, size_t length);

#endif

src/stats.c

#include "stats.h"

Stats calculate_stats(const int *values, size_t length) {
    Stats result = {0};
    size_t i;
    int sum = 0;

    if (length <= 0) {
        return result;
    }

    result.min = values[0];
    result.max = values[0];

    for (i = 0; i < length; i++) {
        if (values[i] < result.min) {
            result.min = values[i];
        }
        if (values[i] > result.max) {
            result.max = values[i];
        }
        sum += values[i];
    }

    result.average = (double)sum / length;
    return result;
}

src/main.c

#include <stdio.h>
#include "stats.h"

int main(void) {
    int data[5] = {10, 40, 30, 25, 60};
    Stats stats = calculate_stats(data, 5);

    printf("min=%.0f max=%.0f avg=%.2f\n",
           stats.min, stats.max, stats.average);

    return 0;
}

Writing the Makefile

Makefile

CC = clang
CFLAGS = -Wall -Wextra -std=c11 -Iinclude
BUILD_DIR = build
SRCS = src/main.c src/stats.c
OBJS = $(SRCS:src/%.c=$(BUILD_DIR)/%.o)
TARGET = $(BUILD_DIR)/stats-app

.PHONY: all clean run

all: $(TARGET)

$(TARGET): $(OBJS)
	@mkdir -p $(BUILD_DIR)
	$(CC) $(OBJS) -o $(TARGET)

$(BUILD_DIR)/%.o: src/%.c include/stats.h | $(BUILD_DIR)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD_DIR):
	@mkdir -p $(BUILD_DIR)

run: $(TARGET)
	./$(TARGET)

clean:
	rm -rf $(BUILD_DIR)

SRCS:src/%.c=$(BUILD_DIR)/%.o rewrites src/foo.c into build/foo.o. This kind of % rule is called a pattern rule: it describes a reusable shape instead of one file at a time. The | $(BUILD_DIR) syntax states that the build directory must exist before the recipe runs, but it does not trigger rebuilds when the directory changes.

Whenever you see target: dependencies, read it as “To make this result, I need those files.”

Observing Incremental Builds

  1. Running make compiles src/main.c and src/stats.c into build/main.o and build/stats.o, then links them into build/stats-app.
  2. If you edit only src/main.c and rerun make, it recompiles just main.o and reuses stats.o.
  3. make run builds (if necessary) and executes the binary.
  4. make clean deletes the entire build directory.

Why It Matters

  • Separating files by role improves reuse and testing.
  • Make tracks dependencies and rebuilds only what's needed, saving time.
  • Variables like CC and CFLAGS keep the build configurable.
  • A disciplined structure prepares you for the capstone-sized example in the next part.

On real projects, you may later add -MMD -MP for automatic header dependency tracking. For now, it's enough to internalize that "Makefile remembers the build order."

Practice in CodeSandbox

The sandbox below uses CodeSandbox's Universal starter. For C, the key learning loop is still compile and run in the terminal, so recreate the lesson code as a source file and repeat that cycle directly.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. Fork the starter and create a practice file such as hello.c
  2. Paste in the lesson code and compile it if clang or gcc is available
  3. Edit the code, rebuild it, and compare the new output

For C, the terminal build loop matters more than a browser preview. Compiler availability can vary by environment, so first confirm that clang or gcc is present in the Universal starter.

Practice

  • Follow along: Create the exact tree above and run make and make run to verify the output.
  • Extend: Add a standard deviation function in stats.c, append it to SRCS, and rebuild.
  • Debug: Remove header dependencies from the pattern rule and observe how make fails to rebuild when you change stats.h.
  • Completion check: Manage a project with at least two .c files using make for build/run/clean.

Wrap-Up

Build automation is more than convenience—it shrinks the feedback loop between editing code and seeing results. Next up, we'll combine pointers, structs, files, and build tooling into a text-based system utility.

💬 댓글

이 글에 대한 의견을 남겨주세요