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.