Step 63. The Compilation Process — Four Workers Passing the Baton

Step 63. The Compilation Process — Four Workers Passing the Baton

Level 1 — Programming and the Computer’s Inner Workings | Difficulty ★★★☆☆ | Estimated time: 3 hours

Prerequisites: Steps 56–62 complete. You can compile C programs with gcc.

  • What you need: a WSL Ubuntu terminal, gcc, nano (or cat). The verification environment is Ubuntu 24.04, gcc 13.3.0, x86-64.
  • Caution: today’s exercises are 100% safe. We only create and transform files — no system work that deletes or modifies anything.

So far we’ve compiled with a single line, gcc hello.c -o hello. Like a magic incantation. But behind that one line, four workers are passing a baton. Today we open the lid of that workbench. The first harvest is that your eyes for reading error messages change; the second is that knowing "how an executable is born" becomes the foundation for the later study of dissecting executables.


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Recite in order the four stages behind gcc hello.c -o hello (preprocessing, compilation, assembly, linking)
  • Reproduce each stage separately with the -E, -S, -c options and keep the artifacts (.i, .s, .o, executable)
  • Open each artifact and explain its role
  • Know which stage an "undefined reference" error speaks for, and prescribe the fix
  • Translate a two-file project piece by piece and link it at the end

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment C language, WSL Ubuntu bash, gcc 13.x (verified: 13.3.0, x86-64)
Today’s commands gcc -E (preprocess only), gcc -S (up to assembly), gcc -c (up to object), gcc -save-temps (keep intermediate artifacts), file (check a file’s identity), nm (see names inside a part), ldd (list libraries)
Concepts needed Preprocessing, compilation, assembly, linking, object files, libraries, the ELF format

2-1. The Four-Stage Workbench

The journey of C source code to an executable.

  1. Preprocessing: handles directives starting with #. #include unfolds the contents of that file right into place, and #define substitutes. The result is a .i file.
  2. Compilation: translates the unfolded C code into assembly (the human-readable edition of machine code). The result is a .s file. "Compilation" in the narrow sense refers to only this stage.
  3. Assembly: transcribes assembly into machine code (0s and 1s). The result is a .o (object) file. Machine code in parts condition, not yet runnable.
  4. Linking: combines several object files and libraries (other people’s parts like printf) into a runnable finished product. The stage that connects the "this function lives over there" links between parts.

2-2. Object Files — Machine Code as Parts

A .o file is a lump of machine code that’s finished translating but can’t run alone. The part with main, the part with add, the library part with printf — only when the linker bundles these into one lump does it become an executable.

This structure is why a large program can be split across hundreds of files.

2-3. Why Run It in Stages

Split into parts, you can retranslate only the files you fixed and reuse the rest. Retranslating a million-line program in one go every time would take all day.

And thanks to this structure, the act of "attaching a part someone else made (a library) to my program" becomes possible.

2-4. ELF — The Format of Linux Executables

Linux object files and executables follow a common format called ELF (Executable and Linkable Format). "Parts condition" and "finished product" are, so to speak, different states of the same format.

Today we confirm this format name ourselves with the file command.


3. Follow Along

3-1. Preparing the Experiment Source

Input (stage.c)

#include <stdio.h>
#define VERSION 2

int main(void) {
    printf("Stage-by-stage experiment, version %dn", VERSION);
    return 0;
}

How to read it: it contains both #include and #define. Experiment material for seeing what happens to both in the preprocessing stage.

3-2. Stage 1: Preprocess Only

gcc -E stage.c -o stage.i
wc -l stage.i
tail -5 stage.i
820 stage.i
# 4 "stage.c"
int main(void) {
    printf("Stage-by-stage experiment, version %dn", 2);
    return 0;
}

(Verified 2026-09-09. The line count varies by environment.)

New commands: gcc -E is "preprocess only and stop." wc -l is "count the lines," tail -5 is "show the last five lines."

How to read the output: our code was eight lines, but the artifact is 820 lines. Because stdio.h’s contents were all unfolded into it. And look at our main at the very bottom — a 2 is stamped where VERSION was: the #define was already substituted. Search with grep -c "define VERSION" stage.i and you get 0 hits. Once preprocessing is done, no # directives remain.

Why: the moment you confirm with hundreds of lines of output that #include was "copy-paste," the true identity of header files becomes clear.

3-3. Stage 2: Translate to Assembly

gcc -S stage.i -o stage.s
grep -nE "main:|call" stage.s
9:main:
22:	call	printf@PLT

(Verified 2026-09-09.)

How to read the output: -S is "translate only up to assembly." Our C changed into a list of instructions like mov and call. You don’t need to understand all of it. Just find this much: "ah, it says call printf. That’s the function we called." (@PLT is a marker of the gateway for calling library functions — for now, reading as far as "it calls printf" is enough.)

3-4. Stage 3: Making the Machine-Code Part

gcc -c stage.s -o stage.o
file stage.o
stage.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

(Verified 2026-09-09.)

How to read the output: -c is "up to the object only." The file command tells you this file’s identity. ELF (2-4’s format), relocatable (a parts-condition file whose addresses aren’t fixed yet). It’s machine code but a half-finished product that can’t run alone. Open this file and you’ll only see garbled characters — it’s not for humans.

3-5. Stage 4: Completing with the Link

gcc stage.o -o stage
./stage
Stage-by-stage experiment, version 2

(Verified 2026-09-09.)

How to read the output: we linked one object file into an executable and ran it. Here our part merged with the standard library part where printf lives. We ran all four stages by hand. The single line gcc stage.c -o stage was these four called in sequence.

3-6. Splitting into Two Files — Linking’s Reason to Exist

Input (math_ops.c)

int add(int a, int b) {
    return a + b;
}

Input (main2.c)

#include <stdio.h>

int add(int a, int b);

int main(void) {
    printf("40 + 2 = %dn", add(40, 2));
    return 0;
}

Compile and run

gcc -c math_ops.c -o math_ops.o
gcc -c main2.c -o main2.o
gcc main2.o math_ops.o -o calc
./calc
40 + 2 = 42

(Verified 2026-09-09.)

How to read the output: we made each of the two sources into a part (each with -c), then linked the two together at the end. The int add(int a, int b); at the top of main2.c is a declaration saying "a function of this shape exists somewhere." main2.o was translated trusting those words, and the linker found that somewhere in math_ops.o and connected it.

Predict: what error appears if you leave out math_ops.o in the final link and run only gcc main2.o -o calc? Predict, then check yourself. This is Wall 1’s true identity.

Why: this is how large programs work. Hundreds of files are each translated into .o files and linked all at once at the end.

3-7. One-Step Confirmation — All Four at Once with -save-temps

If running the four stages one by one is tedious, gcc has an option to leave all intermediate artifacts behind.

gcc -save-temps stage.c -o stage2
ls stage2*
stage2  stage2-stage.i  stage2-stage.o  stage2-stage.s

(Verified 2026-09-09. Attaching -o stage2 makes the artifact names take the form stage2-stage.*. Without -o, just gcc -save-temps stage.c leaves stage.i, stage.s, stage.o.)

How to read the output: along with the executable, the .i, .s, and .o all remain. The four workers each left their artifacts on the workbench. Now say to yourself which stage each file belongs to. If you can say it, you’ve understood today.

Why: knowing a tool’s shortcut and knowing the process are different things. Today we learned the process first; the shortcut comes after. Reverse the order and the shortcut becomes magic.

3-8. The Parts Box Called a Library

Where did printf come from? We never wrote it, yet it works once linked. The answer: "a giant parts box called the standard C library is already installed, and the linker found the printf part there and attached it."

nm stage.o
ldd stage
0000000000000000 T main
                 U printf
	linux-vdso.so.1 (0x00007fd6fd414000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd6fd000000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fd6fd416000)

(Verified 2026-09-09. ldd’s address values differ on every run.)

How to read the output:

  • nm shows the list of names inside a part. T main means "a part called main is in here (T = defined)"; U printf means "printf is not here and must be fetched from somewhere (U = undefined)."
  • ldd shows "which parts boxes this executable uses." See libc.so.6? The standard library where printf lives.

Just as Python’s import finds tools at run time, a C executable carries a list of the boxes it needs. This is the true meaning of the word "link" — not merging but connecting.


4. Missions & Exercises

Mission — A Four-Stage Reproduction Report

  1. Make your own source (including two functions, e.g., int square(int n) and main) as report.c
  2. Reproduce each of the four stages with commands, and keep all four artifacts (.i, .s, .o, executable)
  3. Open the .s file and find and mark your function’s name and the call instruction
  4. On purpose, link with one part left out, like gcc main2.o -o calc2, to reproduce an "undefined reference" error, and copy the full error into your notes
  5. Answer in the report: for each of the four stages, (a) the input, (b) the artifact, (c) what it does — one line each

Exercises

Problem 1. In 3-2, an eight-line source became an 820-line .i. What came in, and why is the #define VERSION 2 line not left in the .i?

Problem 2. Up to which stage do gcc -E, gcc -S, and gcc -c each go, and what are their artifacts’ extensions?

Problem 3. In nm stage.o, printf had a U beside it. What does this mean, and at which stage is that printf ultimately fetched from where?

Problem 4. The error "undefined reference to ‘add’" — which of the four workers speaks it? And give two representative causes.


5. Model Answers & Completion Criteria

Mission Model Answer

An example of the reproduction commands:

gcc -E report.c -o report.i     # stage 1: preprocess
gcc -S report.i -o report.s     # stage 2: translate to assembly
gcc -c report.s -o report.o     # stage 3: object part
gcc report.o -o report          # stage 4: link
./report

The full text of the deliberately caused error (verified 2026-09-09):

/usr/bin/ld: main2.o: in function `main':
main2.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status

How to verify: ① all four artifacts must remain in the folder. ② grep -nE "main:|call" report.s must find the function name and the call line. ③ The error text must show ld (the linker) and undefined reference. ④ Filling in the four-stage table: preprocessing — input .c, artifact .i, job "handle directives (unfold, substitute)" / compilation — input .i, artifact .s, job "translate to assembly" / assembly — input .s, artifact .o, job "turn into a machine-code part" / linking — input .o files + libraries, artifact executable, job "connect the parts."

Exercise Answers

Problem 1 answer. #include <stdio.h> unfolded that file’s entire contents into place. The #define was already substituted (VERSION → 2) in the preprocessing stage, so the directive itself doesn’t remain in the artifact.

Problem 2 answer. -E goes up to preprocessing (artifact .i), -S up to assembly translation (.s), and -c up to the object part (.o). The final linking stage has no separate option.

Problem 3 answer. U means "undefined — not inside this part, must be fetched from somewhere." printf is fetched and connected from the parts box called the standard C library (libc.so.6) at the linking stage. You can confirm that box with ldd.

Problem 4 answer. It’s the linker’s words. It translated believing "this function must be somewhere," and at link time it couldn’t find that definition. Representative causes: ① you left out an .o part that should have been passed along; ② the function name’s spelling differs among the three places — declaration, definition, and call.

Completion Criteria Checklist

  • [ ] I can recite the four compilation stages (preprocessing, compilation, assembly, linking) in order
  • [ ] I reproduced each stage with -E, -S, -c, and no option, and kept the artifacts
  • [ ] I can find substitution results in a .i and call instructions in a .s
  • [ ] I can check the identity of parts and executables with file, nm, ldd
  • [ ] I can explain the stage (linking) and fix for an "undefined reference" error
  • [ ] Mission: I completed the four-stage reproduction report and the error reproduction record

6. Common Pitfalls & Fixes

Wall 1. undefined reference to ‘function name’

Symptom: linking fails with an error like this (verified 2026-09-09):

main2.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status

Cause: at the linking stage, the function’s actual definition couldn’t be found. You left out a part, or the function name differs between declaration and definition.
Fix: check "did I pass all the .o files together," and "is the function name spelled the same in all three places — declaration, definition, call."

Wall 2. Flustered Because the Artifact Isn’t Human Text

Symptom: you open a .o file and are startled by heaps of garbled characters.
Cause: a .o is machine code. It’s not for humans.
Fix: that’s normal. The way to read it is with tools like file (identity check) and nm (see the function names inside).

Wall 3. Confusing the -E, -S, -c Options

Symptom: you mix up which option goes up to which stage.
Cause: the three options look alike.
Fix: remember by the artifacts. -E is .i (unfold), -S is .s (assembly), -c is .o (part). Not alphabetical order — the journey’s order. "Unfold (E), translate (S), make a part (c), and bundle at the end (no option)."

Wall 4. Missing Header File Error

Symptom: fatal error: stdio.h: No such file or directory appears (in a Korean-locale environment the message may appear translated).
Cause: an environment without the development header files installed (happens on minimal Linux installs). A preprocessing-stage error.
Fix: install the development tool bundle with sudo apt install build-essential -y. gcc and the essential headers come together.

Wall 5. -save-temps Artifact Names Differ from Expectations

Symptom: you expected stage2.i but got stage2-stage.i (verified 2026-09-09).
Cause: with -o stage2 attached, gcc derives the artifact names from the output file name.
Fix: without -o, just gcc -save-temps stage.c leaves stage.i, stage.s, stage.o. Both are normal — check the actual names with ls.


7. Summary

Today’s Concepts

Concept One-line explanation
Preprocessing The stage that unfolds #include and substitutes #define → .i
Compilation (narrow sense) The stage that translates C into assembly → .s
Assembly The stage that transcribes assembly into machine-code parts → .o
Linking The stage that connects parts and libraries into a finished product → executable
Object file Machine code in parts condition, unable to run alone (relocatable)
Library A box containing other people’s parts like printf (libc)
ELF The common format of Linux objects and executables

Today’s Commands

Command What it does
gcc -E source.c -o result.i Preprocess only (unfold headers)
gcc -S result.i -o result.s Up to assembly
gcc -c result.s -o result.o Up to object (part)
gcc result.o -o executable Link into the finished product
gcc -save-temps source.c Keep all intermediate artifacts
file file Check a file’s identity
nm part.o List names inside a part (T = defined, U = undefined)
ldd executable List required libraries

A Sense More Important Than Commands

From today, when you see a C error message, the first question is "which worker is speaking." "File not found (stdio.h)" is preprocessing speaking, "expected ;" is compilation speaking, "undefined reference" is the linker speaking. Know the worker and the prescription diverges. Errors are long and in English, but once you know their affiliation, the part you must read shrinks dramatically.

One more thing. When files number in the dozens, you can’t type 3-6’s list of commands every time. So there’s an automation tool, make, that "retranslates only what changed and links it too." For now, just know it exists — the day your files pass five, you’ll seek it out yourself.


Once every box is checked, Step 63 is complete.