We’re Officially Live β€” Lifetime Founding Membership Available for a Limited Time
Free preview

The C Compilation Pipeline

What gcc actually does between your .c file and a flashable binary, and why 'undefined reference' and 'implicit declaration' are different bugs at different stages.

20 min read

In this lesson:


One command, four stages

When you run gcc main.c -o main, it looks like one step. It's actually four tools run back to back, each transforming the previous one's output:

flowchart LR
  A[main.c<br/>source] -->|preprocessor<br/>cpp| B[main.i<br/>expanded source]
  B -->|compiler<br/>cc1| C[main.s<br/>assembly]
  C -->|assembler<br/>as| D[main.o<br/>object file]
  D -->|linker<br/>ld| E[main<br/>executable / ELF]

You can stop after any stage and inspect the intermediate output:

gcc -E main.c -o main.i      # stop after preprocessing
gcc -S main.c -o main.s      # stop after compiling (emit assembly)
gcc -c main.c -o main.o      # stop after assembling (object file, not linked)
gcc    main.c -o main        # all the way through to an executable

Stage 1, the preprocessor

The preprocessor is a text tool. It doesn't understand C; it manipulates characters. It handles every line starting with #:

The output (main.i) is pure C with no # directives left, typically thousands of lines, because a single #include <stdio.h> drags in all its declarations.

#include <stdint.h>
#define LED_PIN 5
 
int main(void) {
    uint32_t mask = 1u << LED_PIN;   // after preprocessing: 1u << 5
    return mask;
}

A bug at this stage looks like fatal error: stdio.h: No such file or directory (missing include path) or a macro expanding wrong.


Stage 2, the compiler

The compiler proper takes the expanded source and translates it into assembly for your target architecture, x86-64, ARM Thumb, RISC-V, whatever you're building for. This is the stage that:

This is where most language errors surface: type mismatches, syntax errors, and the warning implicit declaration of function 'foo', meaning you called foo but the compiler never saw a declaration (you forgot to #include its header). The compiler guesses a signature and marches on, which is a bug waiting to bite.


Stage 3, the assembler

The assembler is a near-mechanical translation from assembly mnemonics to binary machine-code object files (.o). An object file contains:

That last point is the key to understanding the linker.


Stage 4, the linker

Each .c file is compiled independently into its own .o, this is a translation unit. The linker's job is to combine all the object files (plus libraries) into one executable, and to resolve symbols: match every "I need printf" reference to the one place printf is defined.

main.o            uart.o            libc.a
─────             ──────            ──────
defines: main     defines: uart_tx  defines: printf, malloc, ...
needs:   uart_tx  needs:   printf
         printf
            β”‚         β”‚                  β”‚
            └────── linker β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
                  resolves every "needs" to a "defines"
                       β”‚
                   main (ELF executable)

If a needed symbol has no definition anywhere, you get the classic undefined reference to 'uart_tx', a linker error, not a compiler error. The code compiled fine; there was just nothing to point the call at.


Declaration vs definition, the distinction the linker cares about

The compiler is happy with just a declaration, it trusts the promise. The linker is the one that demands a definition exist. This split is exactly why the same missing function gives you a compiler warning (implicit declaration, if you also forgot to declare it) versus a linker error (undefined reference, if you declared it but never defined it).

Symptom Stage Cause
No such file or directory preprocessor bad #include / missing -I path
implicit declaration of function compiler called a function with no declaration in scope
conflicting types for compiler declaration and use/definition disagree
undefined reference to linker declared & called, but never defined (or not linked)
multiple definition of linker the same symbol defined in two translation units

The embedded twist

On a desktop, the linker produces an executable the OS loads. On a microcontroller there is no OS loader, so two extra things matter:

A linker script tells the linker the chip's memory map: put .text and .rodata in flash (e.g. 0x08000000 on STM32), put .data/.bss in RAM (0x20000000). On a PC the linker uses a default script; on bare metal you supply your own.

An objcopy step converts the ELF into a raw image the flashing tool understands:

arm-none-eabi-gcc ... -T stm32f4.ld -o firmware.elf   # link with the chip's script
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin  # strip ELF metadata β†’ raw flash image

The ELF still carries debug info and section headers for your debugger; the .bin/.hex is the stripped-down bytes that actually get written to flash.


Gotchas


TL;DR

This is just the start

Sign up free to track progress on this lesson, get coding problems with the Run button, and unlock the full interview Q&A.

More in C Programming

Pointers & AddressesPointer Arithmetic & Arrays
Dynamic Memory: malloc & free
Stack vs Heap
Structs, Unions & Bitfields
Bit Manipulation & Masks