Hello! Welcome to the first lesson of Module 3: The Build Process and Program Execution.
In our last lesson, you mastered the art of creating robust, type-safe macros for hardware register manipulation, even exploring advanced techniques like X-Macros. We saw how the C preprocessor can be a powerful tool for automation and abstraction. Now, we'll take a step back to see where that preprocessing step fits into the bigger picture.
This lesson addresses the learning outcome: Trace C source code through all compilation stages: preprocessing, compilation, assembly, and linking. Understanding this fundamental toolchain pipeline is crucial for debugging complex issues, optimizing performance, and grasping how large-scale embedded projects are constructed from many source files. For interviews at companies like Qualcomm or Nvidia, this knowledge is considered foundational.
The Big Picture: From Source to Executable
Before we dive into the details, let's look at a high-level map of the journey our code takes.
As you can see, turning a simple .c file into a program that can run on a processor isn't a single step. It's a multi-stage process orchestrated by a compiler driver program like gcc. The driver calls a sequence of other tools to get the job done.
Let's watch a short video that provides an excellent overview of these stages from an embedded systems perspective.
ARM - M: Build process, role of linker and Linker scripts! | Embedded Systems podcast, in Pyjama!
The video 'ARM - M: Build process, role of linker and Linker scripts!' from the 'inpyjama' channel gives a great step-by-step breakdown of the C compilation pipeline. It clearly explains what happens at each stage and what kind of file is produced.
Please watch from 03:43 to 11:03. Focus on the four main stages discussed: pre-processing, compilation, assembly, and linking. Note the file extensions associated with the output of each stage (e.g., .i, .s, .o).
Now, we will trace this process step-by-step, showing the commands you can use to stop the build at each stage and inspect the intermediate files. We will use a simple main.c file for our demonstration.
// main.c
#include <stdio.h>
#define GREETING "Hello, world!"
int main() {
printf("%s\n", GREETING);
return 0;
}
Stage 1: Preprocessing
- Input: C source file (
main.c) - Tool: C Preprocessor (
cpp) - Action: Handles all preprocessor directives (lines starting with
#). This includes:- Expanding
#includedirectives by literally pasting the content of the header file (stdio.h) into the source. - Replacing macros defined with
#define(likeGREETING) with their values. - Handling conditional compilation (
#if,#ifdef, etc.).
- Expanding
- Output: An intermediate, "pure" C file, typically with a
.iextension.
This is the stage where the advanced macros you created in the last lesson are expanded into standard C code before the compiler proper ever sees them.
To see the preprocessor's output, you can use the -E flag with gcc.
# The -E flag tells gcc to stop after the preprocessing stage.
# The -o flag specifies the output file name.
gcc -E main.c -o main.i
If you open main.i, you'll find it's a very large file. It contains the original main.c with GREETING replaced by "Hello, world!" and, importantly, the entire contents of stdio.h (and any files it includes) pasted at the top.
Decoding the Compiler: A Deep Dive into the Phases of C Code Compilation
The article 'Decoding the Compiler' provides a clear textual walk-through of this process, with helpful command examples.
Read the section 'The Preprocessing Phase: Setting the Stage for Code Magic'. It reinforces what the preprocessor does and the purpose of the -E flag.
Stage 2: Compilation
- Input: Preprocessed C file (
main.i) - Tool: C Compiler (e.g.,
cc1for GCC) - Action: The compiler parses the pure C code and translates it into architecture-specific assembly language. This is also where the bulk of code optimization (
-O1,-O2,-O3) occurs. The compiler checks for syntax errors during this phase. - Output: An assembly language file, typically with a
.sextension.
To stop the process after compilation, use the -S flag.
# The -S flag tells gcc to stop after the compilation stage.
# It will generate a main.s file automatically.
gcc -S main.i
If you inspect main.s, you will see the assembly instructions that correspond to your C code, tailored for your machine's CPU architecture (e.g., x86-64 or AArch64). You'll be able to spot the string "Hello, world!\n" and a call instruction to the printf function.
Stage 3: Assembly
- Input: Assembly file (
main.s) - Tool: Assembler (
as) - Action: The assembler translates the human-readable assembly instructions into machine code (binary 0s and 1s). It packages this code, along with metadata, into a relocatable object file.
- Output: An object file, typically with a
.oextension (or.objon Windows).
This file is called "relocatable" because the code and data within it don't have final memory addresses. For example, the call to printf is unresolved; the assembler knows printf is needed but doesn't know where it will be in the final program's memory. It leaves a placeholder for the linker to fill in.
To generate an object file, use the -c flag.
# The -c flag tells gcc to compile and assemble, but not link.
gcc -c main.s -o main.o
You cannot view main.o with a text editor as it's binary. However, you can use the file command to inspect it:
$ file main.o
main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
The key word here is relocatable.
Stage 4: Linking
- Input: One or more object files (
main.o) and system libraries. - Tool: Linker (
ld) - Action: This is the final stage. The linker takes all the necessary object files and libraries and combines them into a single, complete executable file. Its two primary jobs are:
- Symbol Resolution: It finds the definitions for all the unresolved symbols. For our example, it finds where the
printffunction's code is located (in the C standard library) and updates thecallinstruction inmain.oto point to it. - Relocation: It assigns final memory addresses to all the code and data from the input object files, creating a unified memory layout for the program.
- Symbol Resolution: It finds the definitions for all the unresolved symbols. For our example, it finds where the
- Output: An executable file (e.g.,
a.outby default on Linux, or a named file).
To complete the process, you simply run gcc on the object file. The driver will automatically invoke the linker.
# gcc will call the linker (ld) to link main.o with necessary C libraries.
gcc main.o -o my_program
Now, if you inspect the final executable with the file command:
$ file my_program
my_program: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, ...
Notice it's now an executable, not relocatable. The dynamically linked part indicates that it still has some dependencies that will be resolved at runtime by the OS loader, a topic we'll explore later.
For a comprehensive review of all four stages, with practical demonstrations of the commands, the following resource is highly recommended.
The webpage 'Linking' from the University of Pittsburgh provides a concise, academic summary of the entire build process. It's an excellent textual reference that reinforces what we've just demonstrated.
Read the section titled '1. Overview of compiler drivers'. It provides a structured list of the steps, the tools involved (cpp, ccl, as, ld), and the gcc flags to invoke them.
Conclusion
In this lesson, we've deconstructed the "black box" of compilation, tracing the path of a C source file through four distinct stages.
Key Takeaways:
- Preprocessing (
.c→.i): The C preprocessor (cpp) expands macros and includes, creating a plain C file. Usegcc -E. - Compilation (
.i→.s): The compiler (cc1) translates C into architecture-specific assembly language. Usegcc -S. - Assembly (
.s→.o): The assembler (as) converts assembly into machine code, producing a relocatable object file. Usegcc -c. - Linking (
.o→ executable): The linker (ld) combines object files and libraries, resolving symbols and relocating addresses to create a final executable. This is the default final step ofgcc.
Understanding this pipeline is not just academic; it's essential for debugging linker errors, optimizing build times in large projects using Makefiles, and understanding how your code is ultimately represented and executed on the hardware.
Preview of the Next Lesson:
We've briefly touched on the linker's role in "symbol resolution" and "relocation." In our next lesson, we will perform a deep dive into the linking process. We'll explore how the linker resolves symbols, what happens when it finds multiple definitions (strong vs. weak symbols), and how it modifies code to work at its final memory addresses.