Software Dev Notes

Home › Systems Programming

How a C Program Goes From Source File to Executable

A C program passes through preprocessing, compilation, assembly and linking before it runs. Learn each stage, the files produced and the gcc commands.

2026-10-19 · 6 min read

When you run gcc or clang on a C file, the command looks like a single step, but it hides four distinct stages. Each stage reads one kind of file and produces another, and most build errors belong to exactly one of them. A missing header, a type mismatch, a bad assembly instruction and an unresolved symbol each point to a different stage, so knowing the pipeline makes errors much easier to read.

This article walks through preprocessing, compilation, assembly and linking. It shows the commands that run each stage, the files each stage creates, and how static and dynamic linking differ. The examples use gcc, and clang behaves the same way for these options.

The four stages at a glance

The pipeline turns human-readable C source into machine code that a processor can execute. Each stage has a clear input and output, and you can stop the pipeline after any stage to inspect its result.

Stage Input Output Typical command option
Preprocessing .c source Expanded .i text -E
Compilation .i text Assembly .s -S
Assembly .s text Object file .o -c
Linking One or more .o files and libraries Executable none, or -o

The rest of this article follows a small program through each stage.

A small example program

Consider a program split across two files. The first file holds the main function, and the second holds a helper that computes a square.

/* main.c: entry point; declares the helper so the compiler knows its signature */
#include <stdio.h>
#include "square.h"

int main(void) {
    printf("%d\n", square(7));   /* calls the function defined in square.c */
    return 0;                    /* zero tells the shell the program succeeded */
}
/* square.c: the definition of square(); compiled separately and linked in later */
#include "square.h"

int square(int x) {
    return x * x;   /* plain integer multiplication */
}
/* square.h: the declaration shared by both files */
#ifndef SQUARE_H
#define SQUARE_H
int square(int x);   /* promise that some object file defines square(int) */
#endif

The include guard in the header stops a file from being processed twice if it is included more than once through different paths.

Stage one: preprocessing

The preprocessor works on text before the compiler sees any C grammar. It handles every line that starts with a hash mark. It pastes in the contents of each #include file, replaces macros with their values, and removes comments.

You can see the result with the -E option. Running gcc -E main.c -o main.i produces a large file. Most of it comes from the standard header stdio.h, which contains declarations for printf and many other functions. The line that calls square survives unchanged, because the preprocessor does not know what square does.

Preprocessing errors include missing header files and macros used with the wrong number of arguments. A message such as "No such file or directory" that names a header belongs to this stage, because the compiler never reached the code that uses the header.

Stage two: compilation

The compiler reads the preprocessed text, parses it into a syntax tree, checks types and then generates assembly for the target processor. The -S option stops after this step and writes assembly text, so gcc -S main.i -o main.s produces a readable file.

Type errors, undeclared identifiers and syntax mistakes are caught here. The compiler also applies optimizations in this stage when they are enabled, such as -O2. Each source file is compiled independently, so the compiler for main.c does not know the body of square. It trusts the declaration in square.h and emits a call to a named function.

The assembly file contains instructions for the target, such as x86-64 or ARM64, and labels for functions and data. Reading it is a useful habit when you want to check what the optimizer produced.

Stage three: assembly

The assembler translates the text assembly into machine code and packs it into an object file. The -c option stops after this step and produces a .o file, which is the usual way build systems compile each source file. Running gcc -c square.c -o square.o creates an object file without needing the other source files.

An object file is not runnable yet. It contains machine code, data and a symbol table. Symbols are names that the file defines or needs. The file square.o defines the symbol square, and main.o needs the symbol square but does not define it. The nm command lists these entries, and the letter T marks a symbol defined in the text, or code, section while U marks an undefined symbol that must be found elsewhere.

Stage four: linking

The linker combines object files into one executable. It arranges the code and data sections, assigns final addresses and resolves each undefined symbol to a definition in another object file or in a library.

When you run gcc main.o square.o -o app, the linker sees that main.o needs square and finds it defined in square.o. It connects the call in main to the function's address. The program can then run.

If a symbol is never defined, the linker stops with an error such as "undefined reference to square". That message means the compiler accepted the declaration but no object file or library supplied the body. Common causes are forgetting to pass a source file to the build, mismatching a function name, or failing to link a library with -l.

Static and dynamic linking

A static library is an archive of object files, usually with the extension .a. The linker copies the needed code into the executable, so the program carries its own copy. The result has no runtime dependency on that library, but each program holds its own copy of the code.

A shared library, with the extension .so on Linux or .dylib on macOS, is not copied into the executable. The executable records which library it needs, and the dynamic loader maps that library into memory when the program starts. Several programs can share one copy in RAM, and updating the library fixes all of them at once, provided the interface stays compatible.

Property Static linking Dynamic linking
Library code location Inside the executable Separate shared file
Startup cost Lower, no loader work for that library Small loader cost at startup
Memory use across programs Copied per program One copy shared in memory
Deployment Single self-contained file Needs the library present at runtime
Updating the library Rebuild the executable Replace the shared file

Neither choice is always better. Tools meant for easy distribution often prefer static linking, while system software usually relies on shared libraries.

Reading the pipeline in practice

Build systems such as make and CMake run these stages automatically, but you can run them by hand to isolate a problem. If the preprocessed file looks wrong, the problem is in macros or includes. If the assembly looks wrong, the compiler or its flags are responsible. If the object file has an unexpected undefined symbol, the source set or link order is wrong.

Link order also matters with static libraries. The linker scans libraries in the order they appear on the command line, so a library that depends on another should appear before the library it depends on. Many confusing link errors come from reversing that order.

Key takeaways

A C build runs four stages. Preprocessing expands directives into plain C text, compilation turns that text into assembly, assembly produces object files of machine code and symbols, and linking resolves those symbols into one executable.

Each stage can be run alone with the -E, -S and -c options. Knowing which stage produced an error tells you where to look, and that single habit saves a great deal of time when a build fails.

More in Systems Programming

Systems

How Virtual Memory Works in Modern Operating Systems

Virtual memory gives each process its own address space, backed by pages and page tables. Learn page faults, the TLB and why it protects programs.

6 min read