Skip to content

From Source to Executable

Running gcc hello.c -o hello produces a program that can be run directly, and the process looks like a single step — but gcc is really just a unified front-end command that calls four independent tool stages in sequence behind the scenes. Understanding these four stages makes it much easier to see exactly where a compilation error or a linker error is actually coming from.

  1. Preprocessing: handles preprocessor directives in the source code that start with # — expanding an #included header file’s contents in place, or substituting a #defined macro with its value. The output at this stage is still text-based code, just with all preprocessor directives resolved
  2. Compilation: translates the preprocessed code into assembly — an intermediate representation tightly coupled to the specific CPU architecture, but still human-readable
  3. Assembly: translates the assembly code into an object file (a .o file), containing machine code that still can’t be run directly — references to external functions and variables inside it are still placeholders, not yet filled in with actual addresses
  4. Linking: combines one or more object files together with the library files they depend on, resolves and fills in the actual addresses for every cross-file function and variable reference, and produces the final, directly runnable executable

Inspecting the intermediate output at each stage

Section titled “Inspecting the intermediate output at each stage”
Terminal window
gcc -E hello.c -o hello.i # preprocessing only, output the expanded source
gcc -S hello.i -o hello.s # compilation only, output assembly code
gcc -c hello.s -o hello.o # assembly only, output an object file
gcc hello.o -o hello # linking only, output the final executable

Running gcc hello.c -o hello directly is equivalent to automatically running all four steps in sequence, without keeping the intermediate output on disk. Running each stage separately is mainly useful for pinpointing which stage a given problem is actually happening at — a “syntax error,” for instance, usually happens during compilation, while an “undefined reference” error happens during the final linking stage, and the two call for entirely different fixes.

What a header file is for: declaration, not implementation

Section titled “What a header file is for: declaration, not implementation”

A header file (a .h file) pulled in via #include typically contains only declarations of functions and variables — stating that a function exists, along with its parameter and return types — rather than their definitions, the actual code that runs inside the function body. The preprocessing stage simply inserts these declarations verbatim into the source, letting the compiler know “this function genuinely exists, and calls to it can be accepted for now.” The actual implementation lives in a separate source file, compiled into its own object file, and the two only get connected during the final linking stage.

Why understanding this pipeline is worth the effort

Section titled “Why understanding this pipeline is worth the effort”

Build tools encountered later (Make, covered in the next section), the difference between static and dynamic libraries, and topics like cross-platform compilation are all, at their core, further organization and optimization layered on top of these four stages. Without a grasp of this underlying pipeline, it’s easy to get stuck at “something errored and I don’t know where to even start looking.”

This topic is an important part of building a reliable Linux development workflow. Understanding it clearly will make later tasks easier, because it reduces guesswork and helps you recognize when a step is missing or misapplied.

  • Try the commands or configuration shown here in a safe test environment.
  • Compare how the concepts apply across different distributions or tools.
  • Keep a short note of what worked and what failed so you can diagnose future problems faster.
  • Revisit the related article in the series to deepen the connections between topics.
  • Skipping verification steps and assuming the system is configured correctly.
  • Copying commands without adapting paths, package names, or tool versions for your environment.
  • Treating this topic as an isolated tip rather than part of a larger workflow.

A common diagnostic workflow is to stop the compiler at an intermediate stage and inspect the result. For example, if a syntax error is reported during compilation, run gcc -E hello.c -o hello.i and examine the expanded source to see whether macro expansion caused an unexpected token. If a linker error happens instead, inspect hello.o with readelf -s hello.o or compare object files with nm to verify that the expected symbols are actually present.

Typical compiler flags to help diagnose problems

Section titled “Typical compiler flags to help diagnose problems”
  • -Wall -Wextra enables more warnings, catching potential bugs early.
  • -g keeps debug information in the object files, making runtime debugging possible.
  • -O0 disables optimization while debugging, so line numbers and variable values are easier to follow.
  • -fverbose-asm can be used with gcc -S to include comments in assembly output.

Understanding how source, assembly, object files, and linking fit together makes build failures much less mysterious. If you can map an error message to one of these four stages, you can choose the right fix instead of guessing.