Skip to content
academia.sh

Lesson 01 / 13

The Place of C

In C, a source turning into a running program is not a single step: it passes through four stages, and each stage looks at a separate question. A three-file measurement core shows these four stages in a single run and writes 4 of fourteen observations to the preprocessor, 4 to the compiler, 3 to the linker, and 3 to runtime.

Published Updated

In this lesson

  • Defines and distinguishes the four compilation stages (preprocessor, compiler, linker, runtime)
  • Verifies the distribution of fourteen observations across the stages by running the three-file measurement core
  • Shows through examples at which stage the same kind of decision is made in the source
Contents

The Rust curriculum closed with five courses, and its last course asked a single question: where does the guarantee the compiler gives end (M10/K05). In C there is a question that comes before that one. How many times does a source file change hands before it becomes a running program? The answer is not one step but four — and the single question asked throughout this course is which hand a decision is made in.

In Rust, a question of this kind was asked in exactly one place: does a line written in the source produce a value (M10/K01). In C the same kind of question is asked four times, because the source passes through four separate hands before it becomes a program, and each hand makes its own decision by looking only at what it knows. This lesson names those four hands and shows the difference between them by compiling and running a single program.

The Four Stages

As a C source turns into a program, it passes through four hands in sequence. These four names do not change throughout the course:

Class Definition
preprocessor Settled at the text level, before the compiler ever sees the source
compiler Settled while a single unit is translated, from its types and constants
linker Settled when units come together and it becomes clear which definition is used
runtime Settled only once the program runs, by looking at data

What these four names share is that each answers the same question differently: what information does it take to make this decision? The preprocessor looks only at text and knows nothing called a type. The compiler sees a single translation unit, but knows nothing outside that unit. The linker brings multiple units together, but does not interpret the content of any of them — it only matches names. Runtime looks only at data; it no longer even has the source itself in hand.

This measure is not confused with the question Build, Testing and the Ecosystem (M08/K05) asked — which classpath a type must be found on — or the question Production Go: Testing, Tooling and Performance (M09/K05) asked — what a piece of information’s source is (source, tool, runtime). What was measured there was where a dependency is looked for, or whom a piece of information comes from; what is measured here is which stage a decision is made at, and the number of stages is four.

Why the Stages Are Visible

These four stages being visible is a consequence of the language’s closeness to hardware. C hides none of the steps on the path from source to a running binary from the programmer: the text the preprocessor produces is a readable intermediate output, the object file the compiler produces can be examined, and which definition the linker chose can be traced. This visibility is not a shortcoming but the language’s own design decision — and it will come up again and again through the rest of the course.

The C standard (C17) divides the path from source to binary into eight translation phases. This course groups those eight phases into three classes — preprocessor, compiler, linker — and adds runtime on top as a fourth class; runtime is not among the standard’s translation phases, it is defined separately, as the execution environment. The decisions the programmer makes are gathered at these four points. The distinctions among the standard’s eight phases — for instance, decoding the text encoding and joining line endings are separate phases — do not affect the decisions this course measures; one name is enough for all of them: preprocessor.

The Measurement Core

The core used through the rest of the course is three files. Since measuring the linker class requires more than one unit, the core is multi-unit from the start: one header file, two source files. The first file carries only declarations — it contains no definitions.

// stage.h — declarations of names defined in other units
#ifndef STAGE_H
#define STAGE_H

/* These two functions are defined in another unit: their calls are resolved at link time. */
int identity(void);
const char *unit_name(void);

/* This variable is also defined in another unit. */
extern int unit_counter;

#endif

The second file carries what these declarations correspond to. choice.c is a unit that can be compiled on its own and knows nothing of any line in stage.c.

// choice.c — the second unit: definitions live here, calls resolve at link time
#include "stage.h"

int unit_counter = 2;

int identity(void) {
    return 7;
}

const char *unit_name(void) {
    return "choice";
}

The third file carries the actual measurement. It writes fourteen separate observations to a table, adding beside each row which stage settled it.

// stage.c — the core that measures at which stage a decision is made
#include <stdio.h>
#include <string.h>
#include "stage.h"

#define FACTOR 3
#define PASTE(a, b) a##b

enum Color { RED = 1, GREEN = 2, BLUE = 4 };

static int PASTE(add, _three)(int x) {
    return x + 3;
}

static void observe(const char *name, const char *stage, const char *evidence) {
    printf("%-30s%-14s%s\n", name, stage, evidence);
}

int main(void) {
    char buffer[64];
    int preprocessor = 0, compiler = 0, linker = 0, runtime = 0;

    printf("%-30s%-14s%s\n", "observation", "stage", "evidence");

    /* --- preprocessor --- */

    snprintf(buffer, sizeof buffer, "FACTOR placed as the text %d", FACTOR);
    observe("macro value", "preprocessor", buffer);
    preprocessor++;

#ifdef VERBOSE
    observe("conditional body", "preprocessor", "the verbose body entered compilation");
#else
    observe("conditional body", "preprocessor", "the short body entered compilation");
#endif
    preprocessor++;

    snprintf(buffer, sizeof buffer, "the generated name returned %d", PASTE(add, _three)(4));
    observe("token pasting", "preprocessor", buffer);
    preprocessor++;

    observe("header text", "preprocessor", "declarations copied into the source as text");
    preprocessor++;

    /* --- compiler --- */

    snprintf(buffer, sizeof buffer, "sizeof(char) == %d", (int)sizeof(char));
    observe("type size unit", "compiler", buffer);
    compiler++;

    int constant_array[2 * 3 + 1];
    snprintf(buffer, sizeof buffer, "element count %d", (int)(sizeof constant_array / sizeof constant_array[0]));
    observe("constant folding", "compiler", buffer);
    compiler++;

    snprintf(buffer, sizeof buffer, "BLUE constant %d", (int)BLUE);
    observe("enumeration constant", "compiler", buffer);
    compiler++;

    int whole = 7 / 2;
    double fractional = 7 / 2.0;
    snprintf(buffer, sizeof buffer, "whole %d fractional %.1f", whole, fractional);
    observe("operator choice by type", "compiler", buffer);
    compiler++;

    /* --- linker --- */

    snprintf(buffer, sizeof buffer, "the call returned %d", identity());
    observe("function in another unit", "linker", buffer);
    linker++;

    snprintf(buffer, sizeof buffer, "unit declared as %s", unit_name());
    observe("name in another unit", "linker", buffer);
    linker++;

    snprintf(buffer, sizeof buffer, "counter %d", unit_counter);
    observe("variable in another unit", "linker", buffer);
    linker++;

    /* --- runtime --- */

    int data[] = {4, 8, 15, 16};
    int sum = 0;
    for (size_t i = 0; i < sizeof data / sizeof data[0]; i++) {
        sum += data[i];
    }
    snprintf(buffer, sizeof buffer, "sum %d", sum);
    observe("value computed from data", "runtime", buffer);
    runtime++;

    const char *text = "hello, world!";
    snprintf(buffer, sizeof buffer, "length %d", (int)strlen(text));
    observe("string length", "runtime", buffer);
    runtime++;

    const char *verdict = (sum > 40) ? "large" : "small";
    observe("data-dependent branch", "runtime", verdict);
    runtime++;

    printf("\n%-14s%d\n", "preprocessor", preprocessor);
    printf("%-14s%d\n", "compiler", compiler);
    printf("%-14s%d\n", "linker", linker);
    printf("%-14s%d\n", "runtime", runtime);
    printf("total observations: %d\n", preprocessor + compiler + linker + runtime);
    return 0;
}
observation                   stage         evidence
macro value                   preprocessor  FACTOR placed as the text 3
conditional body              preprocessor  the short body entered compilation
token pasting                 preprocessor  the generated name returned 7
header text                   preprocessor  declarations copied into the source as text
type size unit                compiler      sizeof(char) == 1
constant folding              compiler      element count 7
enumeration constant          compiler      BLUE constant 4
operator choice by type       compiler      whole 3 fractional 3.5
function in another unit      linker        the call returned 7
name in another unit          linker        unit declared as choice
variable in another unit      linker        counter 2
value computed from data      runtime       sum 43
string length                 runtime       length 13
data-dependent branch         runtime       large

preprocessor  4
compiler      4
linker        3
runtime       3
total observations: 14

Reading the Measurement

The table’s four sections show four separate points of view. The four rows in the preprocessor section know no types, only text: the macro value was placed into the source as text, which conditional body would enter compilation was settled before the compiler ever saw the source, a new name was produced from two pieces, and the header file’s declarations were copied into the source. The four rows in the compiler section decide by looking at only a single unit: the unit of a type’s size, the folding of a constant expression, the value of an enumeration constant, and which meaning the division operator carries were all resolved inside stage.c itself — none of them looked at choice.c.

The three rows in the linker section show the exact opposite: all three call a name defined in choice.c, and all three only take on a meaning once the two units come together. stage.c on its own can compile these three rows, but it cannot run them — the missing definitions are completed only at the linking stage. The three rows in the runtime section look only at data: the sum, the string length, and the data-dependent branch — none of them sits in the source as a fixed number, all three are computed while main runs. If the same source were run with a different data array, the result of these three rows would change; that is not possible in the previous three sections, because their result is already known from the source itself — before it ever runs.

LC1. The observe function, the buffer, and the four counters (preprocessor, compiler, linker, runtime) are the core’s own design decision, not a mechanism of C. C itself has no function that asks “which stage does this line belong to”; what decides which class a line falls into is the line’s position and form in the source, and the observe call only writes this decision onto a line and makes the fourteen observations comparable in a single run.

LC2. All four counters are ordinary int variables and are incremented by hand. C’s runtime carries no record of which stage a line belonged to while it was compiled — that information is already in the past by the time runtime starts. The only way to count the distribution is to add the right counter by hand next to each observation; the counting therefore lives in the core’s source, in the text the preprocessor and compiler will see.

Four of the fourteen observations were settled at the preprocessor, four at the compiler, three at the linker, three at runtime, and the output prints this distribution directly. These numbers are binding for the rest of the course: no lesson changes this distribution, each only takes its own slice of it.

The Same Decision, Four Places

The core’s fourteen lines were not chosen at random; each answers the same kind of question at a different stage: what a value is. The value of the FACTOR constant is settled at the preprocessor, because #define is a text-replacement rule applied before the compiler ever sees the source. The value of the BLUE constant is settled at the compiler, because an enumeration (enum) member is a compile-time constant resolved by looking at a single unit. The value of the unit_counter variable is settled at the linker, because the variable is defined in choice.c and stage.c reaches it only through an extern declaration — the two share the same address only once linked. The value of the sum variable is settled at runtime, because it cannot be computed without running a loop over an array’s elements.

LC3. Giving the enum Color members explicit values of 1, 2, 4 is also a decision made in the source, not something the language requires. Had no value been given, C would have incremented the members one by one starting from zero (0, 1, 2); here the members were deliberately chosen as powers of two so that each could represent a separate bit. This concerns not the four stages but a single decision — a constant’s numeric value — and that decision, too, is made at the compiler stage.

All four answer the question “what is a value,” but all four answer it in a different place. What decides which stage answers each time you ask the same question is how you write that value in the source: write a #define and the preprocessor answers, write an enum and the compiler answers, use a name defined in another unit and the linker answers, walk an array and runtime answers. The programmer can choose among these four; the language itself imposes none of them.

This pattern will come up again in other forms in later lessons of the course. A computation that does the same work can be written as a macro in The Preprocessor lesson, or as a declared call the linker resolves in the Functions lesson; both answer the question “what is this value,” but one at the text level, the other once units come together. The question asked throughout the course does not change: which hand is this decision, written in the source, made in?

The Bounding Measurement

The existence of four stages is not a complexity but four separate points of inspection. The same kind of mistake can surface in four separate places, and all four do not carry the same weight. If the wrong number is written into the FACTOR macro, the text is produced wrong and this spreads silently into the rest of the source — the preprocessor performs no inspection, it only substitutes. If the BLUE constant is given a wrong value, the compiler can see it, because it is an inconsistency that stays inside a single unit. If a definition in choice.c is deleted, the error surfaces not at the compiler but at the linker, because stage.c continues to compile on its own — what is missing becomes visible only once the units come together. If a wrong number is added to the data array, no stage catches it; the result only surfaces once it is run with that data.

All four can be called a “wrong value,” but where each of the four is caught differs, and none carries the same force. A preprocessor or compiler error can stop the program from compiling; a linker error can stop the program from being linked; a runtime error becomes visible only once that data is encountered, and none of the previous three stages can see it. The abundance of stages is therefore not a burden but an advantage: where the same class of error gets caught depends on how you write the source.

This bound should also be read as a warning for the rest of the course: something one stage does not inspect does not mean the next stage will. An error the preprocessor lets through is not left for the compiler; an error the compiler lets through is not left for the linker; an error the linker lets through is not left for runtime — each stage looks only at its own input and does not question what the one before it did. The sum of the four stages is therefore not a chain of inspection that completes itself, but four separate viewpoints, each working within its own bounds.

Summary

  • A C source passes through four stages on its way to becoming a program: preprocessor, compiler, linker, runtime; these four names do not change throughout the course.
  • The three-file measurement core classified fourteen observations in a single run: 4 preprocessor, 4 compiler, 3 linker, 3 runtime.
  • The same kind of decision (what a value is) can be made at any of the four stages; which one is chosen depends on how the source is written.
  • The existence of four stages is not complexity but a multiplicity of inspection points: the same kind of error can be caught in four separate places, with four separate weights.

Next Step

This lesson named the four stages and showed them side by side in a single run. The next lesson returns to the same core, but this time takes up the four classes one at a time: which input each stage looks at when it makes its decision, and why the stages run in an order with no way back.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close