The C Compiler

C is a compiled language1, meaning that its source code must be translated into machine code for a target architecture before execution. In the previous section, Clang performed that transformation: clang hello.c -o hello

The command saves considerable effort by concealing the many operations required to transform source code into an executable. That abstraction is enormously useful, but beneath it lies a remarkably elegant process that deserves to be understood. This section begins by examining that process.

From Source to Executable

Like the source code of nearly all mainstream programming languages2, C source code is nothing more than text adhering to the language’s syntax and stored in a plain-text file; hello.c is simply a text file whose contents are interpreted as C source code by the compiler.

That source is not magically transformed into an executable file. Instead, it passes through several distinct transformations, each producing an observable representation:

    flowchart LR
        A["hello.c<br/>C source"] -->|Preprocessing| B["hello.i<br/>Preprocessed source"]
        B -->|Compilation| C["hello.s<br/>Assembly source"]
        C -->|Assembly| D["hello.o<br/>Object file"]
        D -->|Linking| E["hello<br/>Executable"]

When clang hello.c -o hello executes, Clang coordinates this entire process.3 That convenience is useful, but it can obscure what is actually happening. Fortunately, that convenience does not preclude inspection of the intermediate results. Under normal circumstances, those results are retained internally rather than written to disk; however, Clang can generate and expose each one independently through command-line options.

Each stage, along with the options used to control it, is examined in detail in the following sections; for now, the intermediate representations are summarized below:

  • hello.c is the C source code.
  • hello.i contains the source text after preprocessor directives have been applied.
  • hello.s contains assembly language generated by the compiler.
  • hello.o is an object file containing machine code and other information needed for linking.
  • hello is the final executable program.

For additional details about Clang and its command-line interface, see the Clang Compiler User’s Manual.

Preprocessing

Preprocessing is the first stage in the C compilation process. Essentially, it is a controlled text-substitution step that modifies the source before compilation and C syntax checking. Preprocessing is controlled through preprocessor directives, which begin with the # character and describe how the source text should be transformed.

Common operations include file inclusion, macro expansion, and conditional compilation. Their detailed use is beyond the scope of this section. A cursory examination of file inclusion is sufficient for the purposes at hand; the remaining preprocessor directives and their applications are left for later study.

To demonstrate, create a new file named message.txt containing:

Hello from another file!

Next, create a file named preprocessor.c containing:

#include "message.txt"

Run the following command to invoke the preprocessing stage.

clang -E preprocessor.c -o preprocessor.i

The -E option instructs Clang to stop after preprocessing and write the intermediate result to a file named preprocessor.i.

Open preprocessor.i in a text editor and compare it with preprocessor.c. Notice that the #include "message.txt" directive has been replaced with the text contained in message.txt.4 The output also contains lines beginning with # that Clang uses to preserve information about the original source locations as preprocessing combines text from multiple sources. Their detailed format is not important here; notice instead where processing enters message.txt, incorporates its contents, and then returns to preprocessor.c.

Notice also that neither message.txt nor the resulting preprocessor.i need constitute a valid C program for preprocessing to occur. At this stage, the source has not yet been parsed according to the syntax of C; ordinary C syntax errors are therefore irrelevant to the preprocessing operation.

Test this for yourself. Add arbitrary text to message.txt, then run the preprocessing command again and inspect preprocessor.i. As long as the preprocessor directives themselves remain valid, Clang will continue to produce the transformed source text.

For additional details about Clang’s preprocessor implementation, see the Clang Internals Manual.

Compilation

After preprocessing comes the compilation stage. Its purpose is to analyze the preprocessed source according to the rules of C and translate valid source into assembly language for the target architecture. Syntax errors and other violations detected at this stage prevent compilation from proceeding and produce compiler diagnostics5.

Compilers commonly divide their work into two broad regions.

The frontend understands the source language. For C, it parses the source, checks its syntax and meaning, constructs an internal representation of the program, and reports language-level diagnostics.

The backend takes that internal representation and generates code for a specific target architecture. This process includes target-specific work such as instruction selection, register allocation, and machine-code generation, while LLVM also performs optimization on the intermediate representation.

In the Clang/LLVM toolchain, Clang provides the C language frontend, while LLVM provides much of the optimization and target-specific code-generation machinery.

flowchart LR
  A["C source"] --> B["Clang frontend"]
  B --> C["LLVM intermediate<br/>representation"]
  C --> D["LLVM optimization<br/>and backend"]
  D --> E["Target-specific<br/>machine code"]

The intermediate representation separates knowledge of the source language from many details of the target machine. The frontend determines what the C source means; the backend determines how that meaning should be implemented for a particular architecture.

Compilation therefore spans the transition from the frontend’s analysis of the C source to the backend’s generation of target-specific code.

Assembly language uses symbolic instruction names, labels, and other human-readable notation to represent machine instructions. Unlike C, it is specific to a target instruction set architecture, such as x86-64 or ARM64. Clang can target multiple architectures. By default, it generates code for the architecture and platform of the machine on which it is running unless another target is explicitly specified.

A Brief History of Assembly Language

Early computers were programmed directly in machine code, requiring programmers to work with numeric instruction encodings and memory addresses. Symbolic assembly languages began to emerge during the late 1940s and early 1950s as a more intelligible representation of those instructions. Kathleen Booth, working with early computers at Birkbeck College in London, developed one of the earliest assembly languages during this period.

Assembly replaced numeric opcodes and addresses with mnemonic instruction names, labels, and other human-readable notation. It remains important today for low-level systems work, embedded development, performance analysis, reverse engineering, and understanding the machine code generated by compilers.

As with preprocessing, the compilation stage produces an observable artifact. To demonstrate, create a new file named compile.c containing:

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

The following command invokes Clang, which first preprocesses compile.c and passes the resulting source to the compilation stage. The -S option instructs Clang to stop after compilation and write the generated assembly to compile.s:

clang -S compile.c -o compile.s

Open compile.s in a text editor and compare it with compile.c. The contents look very different from the C source. Depending on the target architecture, compiler version, and other options, the exact assembly varies; however, you should be able to identify the add function and instructions corresponding to the addition and return operation.

The important observation is not the precise assembly syntax, but the transformation that has occurred. The compiler translates the C source into assembly language for the selected target architecture.

C expresses operations at a considerably higher level than the instructions represented by assembly language. Consequently, there is no requirement for a one-to-one relationship between C statements and generated instructions. A single C statement may produce several instructions, multiple statements may be combined, and compiler optimizations may transform or eliminate operations altogether.

compile.s is assembly source and therefore accepts direct modification before being passed to the assembler. Returning to the original C source is not required. This makes the boundary between compilation and assembly directly observable.

Detailed examination of assembly language is beyond the scope of this section. For now, it is sufficient to recognize compile.s as the observable intermediate representation produced by the compilation stage.

For additional details about controlling Clang’s compilation stages, see the Clang Command Guide.

Assembly

After compilation comes the assembly stage. Its purpose is to translate the assembly source into machine code—the binary instructions that the target processor can execute—and package that code, along with other information required by later stages, into an object file.

Representation Note: Assembly source is still text; the resulting object file is binary.

To demonstrate, create a new file named main.c containing:

#include <stdio.h>

int add(int a, int b);

int main(void)
{
  printf("%d\n", add(2,3));
}

Compile and assemble the file into an object file:

clang -c main.c -o main.o

The -c option instructs Clang to continue through preprocessing, compilation, and assembly, but stop before the next stage. The resulting object file is written to main.o.

Open main.o in a text editor. Unlike the C and assembly source encountered in the previous stages, its contents are no longer meaningfully readable as ordinary text. The assembler has converted the symbolic assembly instructions into machine-code bytes and stored them within an object-file format.

Notice that main.c contains a reference to add, but no definition of that function. That does not prevent Clang from producing main.o because the declaration int add(int a, int b); provides the compiler with the information needed to validate the call. This function prototype specifies that add accepts two int arguments and returns an int; the function’s implementation may reside elsewhere. The resulting object file therefore records an unresolved reference to add for later resolution.

Test this distinction by temporarily removing:

int add(int a, int b);

and compile main.c again:

clang -c main.c -o main.o

Compilation now fails because the compiler no longer has a declaration describing add. Restore the function prototype before continuing.

Next, create a second source file named math.c containing:

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

Compile and assemble it independently:

clang -c math.c -o math.o

You should now have two object files:

main.o
math.o

Each source file has passed independently through preprocessing, compilation, and assembly. main.o contains the machine code generated from main.c along with an unresolved reference to add, while math.o contains the machine code generated from math.c, including the definition of add. Consequently, a program divided among several source files produces a corresponding set of object files before those relationships are reconciled.

At this stage, Clang has no mechanism for combining the independently produced object files into a single program. Assembly operates on each input separately; relationships among those object files remain unresolved until the next stage.

This behavior is important because it allows separate files to contain independently compiled portions of a program. One file may reference a function or variable defined in another without requiring that definition during compilation or assembly. The resulting object file records the unresolved dependency rather than attempting to satisfy it immediately.

That separation also makes it possible to reuse previously compiled code, including code supplied by libraries, without recompiling the original source for every program that uses it.

Although an object file contains machine instructions for the target architecture, it is not yet a complete executable or library. It remains an intermediate binary representation that may contain unresolved references and other information requiring further processing. More on this in the next section.

Detailed examination of object-file formats is beyond the scope of this section. For now, it is sufficient to recognize main.o and math.o as binary intermediate representations produced by the assembly stage.

For additional details about Clang’s assembler and toolchain behavior, see Assembling a Complete Toolchain.

Linking

After assembly comes the linking stage. At this point, each source file has been independently transformed into an object file containing machine instructions and other information required to construct the final program. Those object files may also contain references to functions or variables whose definitions reside elsewhere. The purpose of linking is to reconcile those references and combine the object files into a complete executable or library.

The previous section produced main.o and math.o. main.o contains a reference to add, whose declaration was available during compilation but whose definition was not. math.o contains that definition. Linking is the stage where that relationship is finally resolved.

The relationship is illustrated below:

    flowchart TD
        A["main.o<br/>references add"] --> C["Linker"]
        B["math.o<br/>defines add"] --> C
        C --> D["program<br/>Executable"]

Link the two object files:

clang main.o math.o -o program

Because both inputs are already object files, Clang proceeds directly to the linking stage. The linker finds the unresolved reference to add in main.o, matches it with the definition in math.o, and incorporates both into the resulting executable.6

Run the executable:

    ./program

The program writes 5 to standard output.

The role of linking becomes clearer when a required definition is missing. Attempt to link main.o by itself:

clang main.o -o program

This time linking fails. main.o still contains a reference to add, but no definition has been supplied to satisfy it.

This illustrates an important distinction: preprocessing, compilation, and assembly can all succeed even though the complete program cannot yet be constructed. References to definitions located elsewhere remain unresolved until linking.

Another salient distinction exists between compilation errors and linking errors. A compilation error prevents source from being translated successfully, while a linking error occurs after that translation has already succeeded and the required binary pieces cannot be reconciled into the final program.

Libraries participate in the same general mechanism. The printf function used by main.c, for example, is not defined in either main.o or math.o; its implementation is supplied by the C library.7

Linking involves considerably more than matching function names. Object files also contain symbols, relocation information, sections, and other metadata used to construct the final binary. Detailed examination of those mechanisms is beyond the scope of this section.

At the conclusion of linking, the transformation that began with source text has produced a complete executable or library.

For additional details about linking within the Clang toolchain, see Assembling a Complete Toolchain.

One Command, Several Operations

As noted earlier, the stages examined above are ordinarily coordinated by a single Clang invocation. For example:

clang hello.c -o hello

The command-line options used throughout this section simply stop that process at different boundaries and expose the corresponding intermediate result:

Option Stops After Result
-E preprocessing preprocessed source
-S compilation assembly source
-c assembly object file
none linking executable or library

Thus, the familiar command clang hello.c -o hello is not a single transformation, but a convenient invocation of the entire compilation pipeline.

For additional details about how Clang coordinates the individual compilation stages, see the Clang Driver Design & Internals.

Ask Clang

The compilation pipeline presented throughout this section is a conceptual model. Clang can expose additional details about how it implements that model.

Run:

clang -### hello.c -o hello

The -### option instructs the Clang driver to display the commands it would invoke without actually executing them. This exposes considerably more detail than the simplified preprocessing → compilation → assembly → linking model used throughout this section.

Do not attempt to understand every argument. Instead, compare the output with the stages examined above and identify the operations associated with compilation, assembly, and linking.

Not every conceptual stage necessarily appears as a separate command. Clang can combine stages internally—for example, preprocessing may occur within the compiler frontend rather than through a separate preprocessor executable. The stages remain useful conceptual boundaries even when their implementation does not correspond to separate processes.

Pro Tip: Abstractions are not false merely because they omit detail. A useful abstraction hides details that are unnecessary for the question currently being asked while preserving the relationships necessary to reason about it. When the abstraction becomes insufficient, descend another layer.

For additional details about the Clang driver and its compilation phases, see the Clang Driver Design & Internals.

Where Do We Go From Here?

This section followed C source through preprocessing, compilation, assembly, and linking until it became an executable file. The executable itself, however, remains largely opaque. Creating it does not cause it to run; something must load it, establish an execution environment, and begin executing its instructions.

Before examining that process, the next section introduces an instrument for observing a program while it executes.

Next: The Debugger

Exercises

For the following exercises, create three C source files:

  • main.c
  • math.c
  • convert.c

Define one function in math.c and a different function in convert.c. In main.c, declare both functions, call them, and print their return values.

Use this same three-file program for each exercise below.

  1. Compile each source file independently into an object file. Verify that three separate object files are produced.

  2. Link the three object files into a single executable. Run the executable and verify that it prints the values returned by both functions.

  3. Generate the assembly source for each of the three C source files. Locate the functions defined in math.c and convert.c within their corresponding assembly files.

  4. Change the calculation performed by the function in math.c. Recompile only math.c, then link the new object file with the existing object files produced from main.c and convert.c. Run the executable and verify that its output reflects the change.

  5. Starting again with only the three C source files, manually generate and inspect each intermediate representation in the compilation pipeline before producing the final executable.

  6. Use Clang to display the operations it would perform when building all three source files into a single executable. Compare that output with the preprocessing → compilation → assembly → linking model presented in this section.
    Answer

    The exact output varies by system, but the major operations should be recognizable.

    On the author's system, Clang reports the target as:

    x86_64-pc-linux-gnu

    This identifies the target architecture and platform for which Clang is generating code.

    On the author's system, the first major command invokes Clang's internal compiler frontend:

    /usr/lib/llvm-19/bin/clang -cc1 ... -emit-obj ... -o /tmp/hello-bdc1d4.o -x c hello.c

    This command performs the transformations required to produce an object file. Notice that preprocessing, compilation, and assembly do not appear as three separate commands. Clang combines those stages internally and emits the object file directly:

    /tmp/hello-bdc1d4.o

    The second major command invokes the system linker:

    /usr/bin/ld ... /tmp/hello-bdc1d4.o ... -lc ... -o hello

    This command combines the object file with startup code, libraries, and other platform components to produce the final executable:

    hello

    Several automatically supplied inputs are also visible. On the author's system, these include startup objects such as Scrt1.o, crti.o, crtbeginS.o, crtendS.o, and crtn.o.

    The linker command also includes -lc, which causes the C library to participate in the link. This is why functions such as printf can be resolved without explicitly naming the C library in the original command.

    Another notable argument is:

    -dynamic-linker /lib64/ld-linux-x86-64.so.2

    This identifies the dynamic linker expected by the resulting executable on the author's system.

    The important observation is that the preprocessing → compilation → assembly → linking model describes conceptual transformations. Those transformations do not necessarily correspond to four independently invoked programs.

  7. Consult the official Clang documentation and determine which command-line option instructs Clang to compile the three-file program using the C99 language standard. Rebuild the program using that option and verify that it still compiles and runs successfully.
  1. Languages are often described as either compiled or interpreted, but the distinction is not always absolute. A compiled implementation translates source code into another representation before execution, while an interpreted implementation executes the source or an intermediate representation through another program at runtime. Some languages support both approaches, and many modern runtimes combine compilation and interpretation. 

  2. Source code for nearly all mainstream programming languages is stored as plain text. Exceptions tend to arise in specialized visual, structured, or tool-specific programming environments where the primary representation may instead be a graph, database, serialized project format, or other non-textual structure. Compiled bytecode and machine code are not source code. The fact that they can sometimes be disassembled or decompiled does not change that distinction. 

  3. The clang command is more precisely a compiler driver. It coordinates multiple parts of the toolchain and invokes the components required for the requested operation. We will continue to use “Clang” and “compiler” in the conventional broader sense when the distinction is not important. 

  4. Included text is itself subject to preprocessing, so any preprocessor directives encountered in the included file are processed as well. This allows included files to include additional files or perform other preprocessing operations before the resulting source proceeds to compilation. 

  5. One reason the author prefers Clang is the quality of its diagnostics. Clang places considerable emphasis on producing useful error and warning messages, including precise source locations and contextual information intended to help explain what went wrong. 

  6. When Clang performs the final link step in this example, the compiler driver also supplies the standard startup and library components required by the platform. This is why printf can be resolved without explicitly naming the C library on the command line. 

  7. Libraries contain previously compiled code intended for reuse by other programs. Static and dynamic libraries participate in linking differently; those distinctions are beyond the present scope and are examined later when they become relevant.