Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 17

C Compilation Process

Recommended resources

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.

Embedded System Build Process Flow
A flowchart illustrating the typical build process in an embedded system. C source files (`.c`) and assembly files (`.s`) are processed by a toolchain (preprocessor, compiler, assembler, linker) to produce a final executable file.

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 #include directives by literally pasting the content of the header file (stdio.h) into the source.
    • Replacing macros defined with #define (like GREETING) with their values.
    • Handling conditional compilation (#if, #ifdef, etc.).
  • Output: An intermediate, "pure" C file, typically with a .i extension.

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., cc1 for 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 .s extension.

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 .o extension (or .obj on 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:
    1. Symbol Resolution: It finds the definitions for all the unresolved symbols. For our example, it finds where the printf function's code is located (in the C standard library) and updates the call instruction in main.o to point to it.
    2. 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.
  • Output: An executable file (e.g., a.out by 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.

Linking

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. Use gcc -E.
  • Compilation (.i → .s): The compiler (cc1) translates C into architecture-specific assembly language. Use gcc -S.
  • Assembly (.s → .o): The assembler (as) converts assembly into machine code, producing a relocatable object file. Use gcc -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 of gcc.

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.