The build process of a C/C++ program happens in four distinct stages. Each stage takes the output of the previous one and transforms it. Understanding these stages is directly relevant to reverse engineering, because when you analyze a binary in Ghidra, you are looking at the final output of all four stages combined.
What it does: The preprocessor is a text substitution engine. It does not understand C syntax; it just manipulates text.
Input:.c source file
Output: Expanded .i file (still C source, but with all preprocessor directives resolved)
What happens at this stage
Directive
What the preprocessor does
#include <stdio.h>
Copies the entire contents of stdio.h into your file
#define MAX 100
Replaces every occurrence of MAX with 100
#ifdef DEBUG
Includes or excludes code blocks based on whether DEBUG is defined
#pragma once
Passes a hint to the compiler (preprocessor marks the file)
#define SQUARE(x) ((x)*(x))
Macro expansion: replaces SQUARE(5) with ((5)*(5))
After preprocessing (simplified: the actual stdio.h expansion is hundreds of lines):
// ... hundreds of lines from stdio.h ...int main() { printf("Hello"); return 0;}
Why this matters for reverse engineering
Macros disappear. By the time you have a binary, MAX and SQUARE(x) are gone: they were replaced with their values during preprocessing. In Ghidra, you will see 100 or ((5)*(5)) in the decompiler, not MAX.
#include bloats the file. A 10-line C file can become 10,000+ lines after including headers. This is why object files contain so much code you didn’t write.
Conditional compilation hides code. Code inside #ifdef DEBUG is completely removed if DEBUG is not defined: it never reaches the compiler, never appears in the binary, and you will never see it in Ghidra.
2. Compilation
What it does: The compiler translates preprocessed C code into assembly language for the target CPU. This is where the real “translation” happens.
Input: Expanded .i file
Output: Assembly file (.s)
What happens at this stage
The compiler performs:
Lexical analysis: breaks the source into tokens (keywords, identifiers, operators)
Syntax analysis: builds an abstract syntax tree (AST) to check grammar
Semantic analysis: checks types, scopes, and meaning
Optimization: rewrites the code to be faster or smaller (e.g., replacing x * 2 with x << 1)
Code generation: emits assembly instructions for the target architecture
Example
Input (C):
int add(int a, int b) { return a + b;}
Output (x86 assembly, unoptimized):
_add: push ebp mov ebp, esp mov eax, [ebp+8] ; load a add eax, [ebp+12] ; add b pop ebp ret
Output (x86 assembly, optimized -O2):
_add: lea eax, [ecx+edx] ; a + b in one instruction ret
Why this matters for reverse engineering
The compiler decides everything. Whether a loop becomes a for, while, or do-while in assembly is the compiler’s choice, not the programmer’s. In Ghidra, you will see the compiler’s output, which may not match the original source structure.
Optimization changes the code. At -O0, you see straightforward, readable assembly. At -O2 or -O3, the compiler inlines functions, reorders instructions, and eliminates variables, making the decompiler output harder to read.
Different compilers produce different assembly. MSVC, GCC, and Clang compile the same C code differently. This is why malware compiled with MSVC looks different from malware compiled with MinGW, and why imphash and opcode signatures differ between builds.
Compiler fingerprints are IOCs. The specific patterns a compiler emits (function prologues, stack layouts, exception handling) can tell you which compiler was used, which is valuable attribution intelligence (e.g., “this was built with Delphi” or “this is MSVC 2019”).
3. Assembly
What it does: The assembler translates assembly language into machine code (raw bytes) and packages it into an object file.
Input:.s assembly file
Output:.o (Linux) or .obj (Windows) object file
Resolves local labels: jumps within the same file get their offsets calculated
Creates a symbol table: records function names and their offsets
Generates relocation entries: placeholders for addresses that are not known yet (external functions, global variables)
Packages into object file format: ELF (Linux), COFF (Windows), or Mach-O (macOS)
Example
Input (assembly):
_add: lea eax, [ecx+edx] ret
Output (machine code):
8D 04 11 C3
And the object file contains:
The .text section (code bytes)
The .data section (initialized data)
The .bss section (uninitialized data)
A symbol table listing _add at offset 0
Relocation entries for anything that is not resolved yet
Why this matters for reverse engineering
Object files are incomplete. They contain unresolved references: calls to printf, VirtualAlloc, or InternetOpenA are placeholders. The actual addresses are not filled in yet. This is why an object file is not directly runnable.
The symbol table is gold. If the binary is not stripped, the symbol table tells you function names. This is why stripped malware is harder to analyze: the names are gone.
This is where opcodes live. When you see 8D 04 11 C3 in a hex editor or Ghidra’s listing, you are looking at the assembler’s output. This is the level yarGen’s --opcodes operates on.
4. Linking
What it does: The linker combines multiple object files and libraries into a single, runnable executable. It resolves all the unresolved references from the assembly stage.
Input: One or more .o / .obj files + static libraries (.a / .lib)
Output: Executable (.exe / .elf / .so / .dll)
What happens at this stage
Combines sections: merges .text from all object files into one .text, .data into one .data, etc.
Resolves symbols: fills in the actual addresses for every function and variable
Links external libraries: either:
Statically: copies the library code into the executable (larger file, no dependencies)
Dynamically: creates an import table pointing to DLLs/shared libraries (smaller file, requires the library at runtime)
Sets the entry point: where execution begins (e.g., main or WinMain)
Produces the final PE/ELF header: the file format the OS loader understands
Example
You have two object files:
main.o:
_main: call _printf ; unresolved, where is printf? call _add ; unresolved, where is add? ret
add.o:
_add: lea eax, [ecx+edx] ret
The linker:
Finds _add in add.o→ fills in its address
Finds _printf in libc.a (static) or msvcrt.dll (dynamic) → fills in its address or creates an import entry
Combines both .text sections into one
Produces program.exe
Why this matters for reverse engineering
The import table is created here. When you open a binary in Ghidra and see Imports → kernel32.dll → CreateFileA, that table was built by the linker. This is exactly what imphash hashes.
Static vs. dynamic linking changes everything.
Static linking: The binary contains all the library code. A “hello world” can be 1.6 MB. The binary is self-contained but bloated. floss will find lots of library strings.
Dynamic linking: The binary only contains stubs that jump to DLL functions. A “hello world” is 10 KB. The binary depends on DLLs being present at runtime.
The entry point is set here. In Ghidra, the entry function is the linker’s chosen start address, not necessarily main. The real main is called from the CRT startup code that the linker inserted.
Packing happens after linking. UPX and other packers operate on the final linked executable. They compress the .text and .data sections, add a decompression stub, and change the entry point. This is why packed binaries have UPX0/UPX1 sections: the linker created the original sections, and UPX restructured them.