1.2 Source Code to Executable
The phrase "compile the program" often refers to an entire build pipeline, not only the compiler executable. In a C-like system, source code may pass through preprocessing, compilation, assembly, linking, loading, dynamic linking, runtime initialization, and finally user code execution. Each step has different inputs, outputs, failure modes, and debugging tools. Advanced compiler work requires knowing where one tool's responsibility ends and the next tool's responsibility begins.
Consider a two-file program:
// main.c
#include "calc.h"
int main(void) {
return twice(21);
}
// calc.c
int twice(int x) {
return x * 2;
}When this becomes an executable, the compiler does not simply read both files as one magical whole. Each source file is typically compiled into a separate translation unit. The compiler can check syntax and generate object code for each translation unit, but it may not know the final address of every function. The object files contain symbols and relocation records so the linker can connect pieces later.
Preprocessing and Translation Units
In languages with a preprocessor, preprocessing transforms source text before normal compilation. It expands includes, replaces macros, and evaluates conditional compilation directives. This is powerful but dangerous because the compiler's parser does not see exactly the file you wrote; it sees the preprocessed translation unit.
For example:
#define SIZE 4
int data[SIZE];The parser effectively sees:
int data[4];That means preprocessing can affect diagnostics, build performance, dependency tracking, and reproducibility. Header inclusion can duplicate large amounts of text across translation units. Conditional compilation can create build configurations that are rarely tested. Macro expansion can produce errors at locations that are not obvious from the original source. Many modern language designs avoid textual preprocessing for these reasons, but understanding it is essential for systems languages and build tools.
Compiler, Assembler, Linker, Loader
A simplified native build pipeline looks like this:
main.c
-> preprocessor
main.i
-> compiler frontend/middle/backend
main.s
-> assembler
main.o
-> linker
app
-> loader
running processThe compiler proper performs language understanding and target-code generation. It may emit assembly text, object code, bytecode, or another IR. The assembler turns assembly into an object file. The object file contains sections such as code and data, plus symbol and relocation information. The linker combines object files and libraries, resolves external references, lays out sections, and writes an executable or shared library. The loader is part of the operating system runtime path; it maps the executable into memory, loads required dynamic libraries, applies relocations when needed, prepares the initial stack, and transfers control to the program entry point.
These boundaries matter when debugging. A syntax error is not a linker error. An undefined symbol is not a parser failure. A missing shared library at program start is not an optimizer bug. If you can identify the failing phase, you can choose the right tool: preprocessed output, compiler diagnostics, assembly listing, nm, objdump, linker map files, loader tracing, or runtime logs.
Symbols and Relocations
When one object file refers to a function defined in another object file, the compiler cannot always fill in the final address. Instead, the object file says, in effect: "there is a reference here to the symbol twice; please fix it when the final layout is known." That request is represented by relocation information.
Object files have symbol tables. Some symbols are defined by the object file; others are unresolved imports. The linker collects definitions and uses them to satisfy imports. If an import has no definition, the linker reports an undefined reference. If multiple strong definitions claim the same symbol, the linker may report a duplicate symbol error. Static libraries complicate this because the linker may pull only the object files needed to satisfy currently unresolved symbols.
This is also where build order can matter in some toolchains. A library placed before the object file that needs it may not be searched again. Modern build systems hide some of this detail, but the underlying model still shapes error messages and linking behavior.
Optimization Levels and Debug Builds
Build commands often include flags such as -O0, -O2, -g, -Wall, -march, or --target. These flags change compiler behavior. Optimization level affects how aggressively the compiler rewrites IR and machine code. Debug flags preserve information that maps generated code back to source-level concepts. Target flags affect instruction selection and ABI assumptions.
The same source program can produce different machine code under different flags while still preserving the language-defined behavior. This is not a contradiction. Source code describes semantics; machine code is one implementation of those semantics. Optimizers are allowed to change implementation details when they can prove the observable behavior stays the same. This proof obligation is why undefined behavior in languages like C and C++ is so important: if the language gives no defined meaning to a program, the optimizer has fewer constraints.
Following One Symbol Across Build Artifacts
Suppose main.mini calls printTotal defined in another file. Compilation of the caller can emit a call instruction before it knows the final address. Its object file records a symbol reference and a relocation: “patch these instruction bits with the address of printTotal.” The defining object records an exported symbol. The linker matches the reference to the definition, lays out sections, applies relocations, and may create a dynamic-linking stub instead of embedding a final address. At load time, the OS maps segments, resolves remaining dynamic symbols, prepares initial stack/process state, and transfers control to the entry point.
This explains common errors precisely: an undefined reference is usually link-time name resolution, not a parser failure; a missing shared library is load-time configuration; a crash in a static initializer is runtime. Build tools should retain each artifact long enough to inspect it: preprocessed source, object files, symbol tables, relocations, final executable, and debug metadata.
Build Hygiene
- Rebuild when an input actually changes, but make the dependency graph complete: generated headers, grammar files, compiler flags, and tool versions are inputs too.
- Prefer deterministic builds. Stable paths, timestamps, environment variables, and dependency versions make a binary reproducible and make compiler regressions diagnosable.
- Treat debug information as a first-class artifact. Optimized code without reliable source mapping is difficult to trust in production.