Yasir Explains/Compiler Design/Structure of Compiler/Phases of a Compiler
Structure of Compiler

Phases of a Compiler

On this page

The Big PicturePhase 1 — Lexical AnalysisPhase 2 — Syntax AnalysisPhase 3 — Semantic AnalysisPhase 4 — IR GenerationPhase 5 — Code OptimizationPhase 6 — Code GenerationEnd-to-End: All Six Phases
Structure of Compiler

Phases of a Compiler

A compiler transforms source code through six sequential phases. Follow one line — int result = (a + 2) * b; — from raw text all the way to machine instructions.

The Big Picture

You write int result = (a + 2) * b;. The CPU understands only binary. A compiler bridges that gap through six sequential phases — each with one specific job, producing one specific output, and passing it to the next.

Two helpers accompany every phase: the Symbol Table (a dictionary of every name in the program) and the Error Handler (which collects mistakes at each phase).

Source Codeint result = (a + 2) * b;PHASE 1Lexical AnalysisToken StreamPHASE 2Syntax AnalysisAbstract Syntax TreePHASE 3Semantic AnalysisAnnotated ASTPHASE 4IR GenerationThree-Address CodePHASE 5Code OptimizationOptimized IRPHASE 6Code GenerationMachine Coderead / writeSTSymbol Tablename → type, scope, location↔ all 6 phaseserrorsEHError Handlercollects errors at each phase↔ phases 1 – 3

We will trace the same example line through every phase.

Phase 1 — Lexical Analysis

The lexer reads source code character by character and groups them into tokens — the smallest meaningful units of the language.

A token has two parts:

  • Lexeme — the actual text from the source code (e.g. int, result, +)
  • Token Type — the category it belongs to (e.g. KEYWORD, IDENTIFIER, ARITH_OP)

Whitespace and comments are discarded — they are not tokens and carry no meaning to later phases.


Token Type Reference

Token TypeMeaningExamples
KEYWORDReserved word with a fixed meaningint, if, while, return
IDENTIFIERName chosen by the programmerx, result, total
INTEGERWhole-number literal0, 5, 42
FLOATDecimal-number literal3.14, 0.5
ASSIGN_OPAssignment symbol=
ARITH_OPArithmetic operator+, -, *, /
RELATIONAL_OPComparison operator>, <, >=, <=, ==, !=
LEFT_PARENOpening parenthesis(
RIGHT_PARENClosing parenthesis)
LEFT_BRACEOpening curly brace{
RIGHT_BRACEClosing curly brace}
SEMICOLONStatement terminator;

What Gets Discarded

Example
int x = 5 + 2 ; ← source with spaces
↑ ↑ ↑ ← spaces are stripped, not tokens
int x = 5 + 2 ; ← lexer processes only the characters
Example
// this is a comment ← entire line discarded, no tokens produced
x = x + 1; ← only this line generates tokens

Exam Example 1 — Simple Declaration

Example
Q. Perform lexical analysis on the following statement
and identify all tokens.
int x = 5;
Ans.
Token No. Lexeme Token Type
--------- ----------- --------------------
1 int KEYWORD
2 x IDENTIFIER
3 = ASSIGN_OP
4 5 INTEGER
5 ; SEMICOLON
--------- ----------- --------------------
Total Tokens: 5
Note: Spaces between tokens are discarded by the lexer.

Exam Example 2 — Arithmetic Expression

Example
Q. Perform lexical analysis on the following statement
and identify all tokens.
int result = (a + 2) * b;
Ans.
Token No. Lexeme Token Type
--------- ----------- --------------------
1 int KEYWORD
2 result IDENTIFIER
3 = ASSIGN_OP
4 ( LEFT_PAREN
5 a IDENTIFIER
6 + ARITH_OP
7 2 INTEGER
8 ) RIGHT_PAREN
9 * ARITH_OP
10 b IDENTIFIER
11 ; SEMICOLON
--------- ----------- --------------------
Total Tokens: 11
intKEYWORD
resultID
=ASSIGN
(LPAREN
aID
+PLUS
2INTEGER
)RPAREN
*STAR
bID
;SEMI

Exam Example 3 — If Statement

Example
Q. Perform lexical analysis on the following statement
and identify all tokens.
if (x > 0) y = x * 2;
Ans.
Token No. Lexeme Token Type
--------- ----------- --------------------
1 if KEYWORD
2 ( LEFT_PAREN
3 x IDENTIFIER
4 > RELATIONAL_OP
5 0 INTEGER
6 ) RIGHT_PAREN
7 y IDENTIFIER
8 = ASSIGN_OP
9 x IDENTIFIER
10 * ARITH_OP
11 2 INTEGER
12 ; SEMICOLON
--------- ----------- --------------------
Total Tokens: 12
Note: 'x' appears at Token 3 and Token 9. Each occurrence
is a separate token. The lexer does not check whether
a variable is declared — that is Phase 3's job.

Exam Example 4 — While Loop

Example
Q. Perform lexical analysis on the following statement
and identify all tokens.
while (i < n) { i = i + 1; }
Ans.
Token No. Lexeme Token Type
--------- ----------- --------------------
1 while KEYWORD
2 ( LEFT_PAREN
3 i IDENTIFIER
4 < RELATIONAL_OP
5 n IDENTIFIER
6 ) RIGHT_PAREN
7 { LEFT_BRACE
8 i IDENTIFIER
9 = ASSIGN_OP
10 i IDENTIFIER
11 + ARITH_OP
12 1 INTEGER
13 ; SEMICOLON
14 } RIGHT_BRACE
--------- ----------- --------------------
Total Tokens: 14
Note: 'i' appears at Tokens 3, 8, and 10 — one token
per occurrence, regardless of repetition.

Errors the Lexer Catches

Example
Error Type Example Problem
-------------------- ----------------- ----------------------------
Invalid character int x = 5 @ 3; '@' is not part of the language
Unclosed string "hello No closing '"' found
Malformed number 3.14.15 Two decimal points in one number
Invalid identifier 2abc = 5; Identifier cannot start with a digit

Phase 2 — Syntax Analysis

The parser takes the token stream and checks whether the tokens are arranged in a valid order according to the language's grammar rules. If valid, it builds an Abstract Syntax Tree (AST) — a tree that captures the structure of the code without punctuation.


What is a Grammar Rule?

A grammar rule is a recipe that says: "this sequence of tokens counts as valid code."

Two symbols to know:

  • → means "can be written as"
  • | means "or"

For example, the rule for a variable declaration:

Example
statement → type identifier "=" expression ";"

Read it as: "A valid statement is: a type, then a name, then =, then an expression, then ;."

Watch how our line matches it token by token:

Example
int result = (a + 2) * b ;
↑ ↑ ↑ ↑ ↑
type ident. = expression ;

Every slot is filled — the statement is valid.


Expression Rules and Operator Precedence

An expression can be simple (5) or complex ((a + 2) * b). Three rules handle all cases:

Example
expression → expression "+" term | term
term → term "*" factor | factor
factor → "(" expression ")" | identifier | integer

Why three separate rules? To encode operator precedence — the rule that * is computed before +.

RuleHandlesPriority
expression+ and -Lowest — evaluated last
term* and /Higher — evaluated before +
factornumbers, names, ()Highest — the atoms

Because * lives in term, which is inside expression, multiplication always binds tighter than addition. The tree reflects this: whichever operation is deeper in the tree runs first.


Example 1 — Simple Assignment: int x = 5;

The simplest case — just a type, a name, and a single integer.

Declaration
type · int
name · x
Integer 5

Example 2 — Operator Precedence: a + 2 * b

No parentheses — the grammar rule forces * to sit deeper in the tree, so it is evaluated first.

BinaryOp +
Identifier a
BinaryOp ×
Integer 2
Identifier b

The × node is lower in the tree → it is evaluated first → result is a + (2 * b).


Example 3 — Parentheses Override: (a + 2) * b

Adding parentheses flips the tree. Now + is deeper — so it runs first.

BinaryOp ×
BinaryOp +
Identifier a
Integer 2
Identifier b

The + node is now lower → it runs first → result is (a + 2) * b.

Compare Example 2 and 3 side by side: the only change is parentheses, but the entire tree structure flips. The parentheses themselves never appear in the tree — their effect is already captured by which node sits deeper.


Full Declaration AST

Wrapping Example 3 in the full declaration int result = (a + 2) * b;:

Declaration
type · int
name · result
BinaryOp ×
BinaryOp +
Identifier a
Integer 2
Identifier b

The semicolon is gone. The type and name sit beside the value. The expression is now a sub-tree — structure replaces punctuation.


Errors the Parser Catches

ErrorExampleWhy it fails
Missing semicolonint x = 5Statement rule requires ; at the end
Unmatched bracketint x = (5 + 3;factor rule requires closing )
Missing operandint x = 5 +;expression rule requires a term after +
Wrong order= int x 5;Tokens don't match the statement rule pattern

Phase 3 — Semantic Analysis

Syntax checks structure. Semantics checks meaning. The semantic analyser walks the AST and verifies that the program actually makes sense.

"Colourless green ideas sleep furiously" is grammatically perfect but semantically nonsense. Similarly, int x = "hello"; is syntactically valid C but semantically wrong.


What It Checks

  • Type compatibility — are operand types compatible? (int + int is fine; int + string is not)
  • Declaration before use — is every variable declared before it is used?
  • Scope rules — is this identifier visible in the current scope?
  • Function calls — correct number and types of arguments?

All checks consult the Symbol Table, which maps every name to its type, scope, and memory location.


Our Example — Step by Step

Example
Step 1 — Declare "result"
Not yet in symbol table ✓
Add: { name: "result", type: int, scope: local }
Step 2 — Analyse right-hand side (a + 2) * b
"a" → found in symbol table, type = int ✓
"2" → integer literal, type = int ✓
a + 2 → int + int → int ✓
"b" → found in symbol table, type = int ✓
(...)*b → int * int → int ✓
Step 3 — Check assignment
right-hand side type (int) == declared type (int) ✓
Result: AST annotated with type=int at every node ✓

Semantic Errors

int x = "hello"; // type mismatch int y = z + 1; // z never declared int r = add(1, 2, 3); // add() takes 2 arguments

Phase 4 — IR Generation

The compiler does not jump straight from the AST to machine code. It first produces a simplified, machine-independent version of your program called Intermediate Representation (IR).

Think of it as a first draft. It is simpler than the AST and simpler to optimise. Once the draft is good, you translate it to the final target.

The key benefit: the same IR works for any CPU. Write the front-end once, then generate x86, ARM, or WebAssembly by swapping only the last step.

Source Code

C++, Java, Rust…

→

IR

machine-independent

→

x86

→

ARM

→

WebAssembly


Three-Address Code

The most common IR format is Three-Address Code (TAC). The rule is simple:

Each instruction does exactly one operation and stores the result in a temporary variable.

Example
destination = source1 op source2

If an expression needs multiple operations, you break it into steps. Temporaries t1, t2, … hold the intermediate results.


How the AST Maps to IR

The compiler walks the AST bottom-up — children before parents. Each operator node becomes one TAC instruction.

Our expression: (a + 2) * b

Example
Step 1 — process the + node (leaf, computed first):
t1 = a + 2
Step 2 — process the × node (uses step 1's result):
t2 = t1 * b
Step 3 — assignment:
result = t2

Full IR output:

Example
t1 = a + 2 ← bottom of tree, evaluated first
t2 = t1 * b ← parent of +, uses t1
result = t2 ← root assignment

Example 2 — Two sub-expressions: x = (a + b) * (a - b)

Both sides of the * are computed independently and stored in temporaries before the multiply happens.

Example
t1 = a + b ← left sub-expression
t2 = a - b ← right sub-expression
t3 = t1 * t2 ← combine them
x = t3

Example 3 — If / Else

if (score >= 60) grade = "pass"; else grade = "fail";

In TAC there are no nested blocks — conditionals flatten into labels and goto instructions:

Example
t1 = score >= 60 ← evaluate condition (true or false)
if t1 goto L_pass ← jump if true
grade = "fail" ← else branch runs when t1 is false
goto L_end
L_pass:
grade = "pass" ← then branch
L_end:

Example 4 — Loop

for (i = 0; i < n; i++) sum = sum + i;

Every loop becomes: initialise → label → check condition → body → increment → jump back:

Example
i = 0
L_start:
t1 = i < n
if_false t1 goto L_end ← exit loop when condition fails
t2 = sum + i
sum = t2
i = i + 1
goto L_start
L_end:

Phase 5 — Code Optimization

The optimizer rewrites IR to make the program faster or smaller while guaranteeing the output stays identical. Think of it as a smart editor who simplifies your draft without changing the meaning.

This phase is technically optional, but a good optimizer can make code 5–10× faster in practice. Below are the five most important techniques.


Technique 1 — Constant Folding

What: If both operands are known constants, compute the result at compile time.

Example
Before:
t1 = 2 + 3 ← both sides are constants
t2 = t1 * 10 ← t1 is now a known constant (5)
After:
t2 = 50 ← compiler did the arithmetic; no runtime cost

Technique 2 — Constant Propagation

What: If a variable is always the same constant value, replace every use of it with that constant directly. This often unlocks folding.

Example
Before:
x = 5
y = x + 3 ← x is always 5
z = x * 2 ← x is always 5
After substitution:
y = 5 + 3 → y = 8 (fold)
z = 5 * 2 → z = 10 (fold)

Propagation and folding work together: propagation substitutes the value, then folding evaluates the result.


Technique 3 — Dead Code Elimination

What: Remove code whose result is never used, or code that can never be reached.

Case A — unused variable:

Example
Before:
t1 = a * b ← t1 is computed but never read anywhere
t2 = c + d
return t2
After:
t2 = c + d ← t1 line removed; it changed nothing
return t2

Case B — unreachable branch:

Example
Before:
DEBUG = false
if DEBUG goto L_log ← condition is always false
...
L_log:
print("debug info") ← this line can never execute
After:
... ← entire branch deleted

Technique 4 — Common Subexpression Elimination (CSE)

What: If the same expression is computed more than once with the same inputs, compute it once and reuse the result.

Example
Before:
t1 = a + b ← first time
t2 = c * d
t3 = a + b ← computed again! same a, same b
t4 = t3 - t2
After:
t1 = a + b ← computed once
t2 = c * d
t4 = t1 - t2 ← reuse t1; the second a+b is gone

Technique 5 — Loop Invariant Code Motion (LICM)

What: If a computation inside a loop produces the same result on every iteration (because it doesn't depend on the loop variable), move it outside.

Example
Before:
loop i from 0 to N:
t1 = width * height ← width and height never change in the loop
arr[i] = i * t1
After:
t1 = width * height ← computed once, before the loop starts
loop i from 0 to N:
arr[i] = i * t1 ← loop body is now cheaper

If N = 1,000,000 — this removes 999,999 multiplications.


Our Example — All Techniques Applied

Starting IR from Phase 4:

Example
t1 = a + 2
t2 = t1 * b
result = t2

Suppose a = 3 and b = 4 are known at compile time:

Example
Step 1 — constant propagation (substitute a=3, b=4):
t1 = 3 + 2
t2 = t1 * 4
Step 2 — constant folding (evaluate 3+2):
t1 = 5
t2 = 5 * 4
Step 3 — constant folding again (evaluate 5*4):
t2 = 20
Step 4 — propagate t2 into result, eliminate t1 and t2:
result = 20
Final optimized IR:
result = 20 ← zero arithmetic at runtime; the answer was pre-computed

Phase 6 — Code Generation

The code generator translates optimized IR into real CPU instructions. Abstract names like t1 and a are replaced with actual registers and memory addresses.

Three main jobs:

  1. Instruction Selection — pick the right CPU instruction for each IR operation
  2. Register Allocation — decide which variables live in fast registers vs. slow memory
  3. Instruction Scheduling — reorder instructions to avoid CPU pipeline stalls

Registers vs. Memory

Registers are tiny, ultra-fast storage slots inside the CPU (e.g. R1, R2, R3). Memory is large but much slower. The allocator tries to keep every "live" variable in a register for as long as possible.

A variable is live between its first assignment and its last use. Two variables that are never live at the same time can share a register.

Example
t1 = a + 2 ← t1 born here
t2 = t1 * b ← t1 dies here (last use), t2 born here
result = t2 ← t2 dies here
Lifetime map:
t1: ───────────
t2: ────────────
t1 and t2 are never alive at the same time → both can use R1.

Example 1 — Arithmetic: result = (a + 2) * b

Example
IR:
t1 = a + 2
t2 = t1 * b
result = t2
Assembly:
LOAD R1, a ; R1 = a
ADD R1, R1, 2 ; R1 = a + 2 (t1 → R1)
LOAD R2, b ; R2 = b
MUL R1, R1, R2 ; R1 = R1 × R2 (t2 → R1, reusing it)
STORE result, R1 ; memory[result] = R1

R1 is reused for both t1 and t2 because t1 is dead the moment t2 is computed.


Example 2 — If / Else

if (score >= 60) grade = "pass"; else grade = "fail";
Example
IR:
t1 = score >= 60
if t1 goto L_pass
grade = "fail"
goto L_end
L_pass:
grade = "pass"
L_end:
Assembly:
LOAD R1, score ; R1 = score
CMP R1, 60 ; set CPU flags by comparing R1 and 60
JGE L_pass ; jump to L_pass if R1 >= 60
STORE grade, "fail" ; else branch
JMP L_end
L_pass:
STORE grade, "pass" ; then branch
L_end:

Key idea: the IR temporary t1 = score >= 60 disappears. The comparison result is held in the CPU's flags register, not in a general-purpose register. The conditional jump JGE reads those flags directly.


Example 3 — Loop

for (i = 0; i < n; i++) sum = sum + i;
Example
IR:
i = 0
L_start:
t1 = i < n
if_false t1 goto L_end
t2 = sum + i
sum = t2
i = i + 1
goto L_start
L_end:
Assembly:
MOV R1, 0 ; R1 = i = 0
LOAD R2, sum ; R2 = sum (keep in register for the loop)
LOAD R3, n ; R3 = n (read once; doesn't change)
L_start:
CMP R1, R3 ; compare i and n
JGE L_end ; exit if i >= n (i.e. NOT i < n)
ADD R2, R2, R1 ; R2 = sum + i
ADD R1, R1, 1 ; i = i + 1
JMP L_start ; loop back
L_end:
STORE sum, R2 ; write final value of sum back to memory

sum and n are loaded into registers before the loop so the CPU does not hit memory on every iteration. sum is stored back only once after the loop ends.


Example 4 — Function Call

result = add(a, b);
Example
IR:
PARAM a ← first argument
PARAM b ← second argument
CALL add, 2 ← call with 2 args; return value goes to RETVAL
result = RETVAL
Assembly:
LOAD R1, a ; R1 = a (first argument → R1 by convention)
LOAD R2, b ; R2 = b (second argument → R2)
CALL add ; jump to add; CPU saves the return address
; inside add: R0 = return value
STORE result, R0 ; result = whatever add returned

A calling convention decides which registers carry arguments and which register holds the return value. Here we use: args in R1, R2, …; return value in R0.


Instruction Selection

For the same IR operation there can be several valid instructions. A good code generator picks the cheapest one:

Example
IR: t1 = i * 2
Option A: MUL R1, R1, 2 ; general multiply — slowest
Option B: ADD R1, R1, R1 ; add to itself = ×2 — faster
Option C: SHL R1, R1, 1 ; left-shift 1 bit = ×2 — fastest on most CPUs

All three produce the same result; Option C avoids the multiply unit entirely.


Register Spilling

A CPU has only 8–16 general-purpose registers. When more variables are live at the same time than there are registers, the allocator spills the least-needed variable to the stack:

Example
; Too many live variables — spill R3 (currently holding x) to the stack:
STORE [sp+4], R3 ; save x to stack slot sp+4
... use R3 for something else ...
LOAD R3, [sp+4] ; reload x when needed again

Every spill/reload is a slow memory access, so minimising spills is a key goal of register allocation.

End-to-End: All Six Phases

Trace the following program through all six phases of a compiler.

int a = 3; int b = 4; int result = (a + 2) * b;

Phase 1 — Lexical Analysis

Example
Token No. Lexeme Token Type
--------- ----------- --------------------
1 int KEYWORD
2 a IDENTIFIER
3 = ASSIGN_OP
4 3 INTEGER
5 ; SEMICOLON
6 int KEYWORD
7 b IDENTIFIER
8 = ASSIGN_OP
9 4 INTEGER
10 ; SEMICOLON
11 int KEYWORD
12 result IDENTIFIER
13 = ASSIGN_OP
14 ( LEFT_PAREN
15 a IDENTIFIER
16 + ARITH_OP
17 2 INTEGER
18 ) RIGHT_PAREN
19 * ARITH_OP
20 b IDENTIFIER
21 ; SEMICOLON
--------- ----------- --------------------
Total Tokens: 21
Note: All whitespace and newlines are discarded.

Phase 2 — Syntax Analysis (Abstract Syntax Tree)

Program
Decl: int a = 3
type · int
name · a
Integer 3
Decl: int b = 4
type · int
name · b
Integer 4
Decl: int result
type · int
name · result
BinaryOp ×
BinaryOp +
Identifier a
Integer 2
Identifier b

Parentheses and semicolons do not appear in the tree — the structure itself encodes their meaning.


Phase 3 — Semantic Analysis

Example
Symbol Table:
Name Type Scope Value
-------- ----- ------- -----
a int global 3
b int global 4
result int global —
Type Checking:
a → int ✓
2 → int (literal) ✓
a + 2 → int + int = int ✓
b → int ✓
(a + 2) * b → int × int = int ✓
result = (a+2)*b → int = int (compatible) ✓
Output: Annotated AST — every node labelled with type = int

Phase 4 — IR Generation (Three-Address Code)

Example
a = 3
b = 4
t1 = a + 2 ← BinaryOp(+) node, evaluated first
t2 = t1 * b ← BinaryOp(×) node, uses t1
result = t2 ← final assignment

Phase 5 — Code Optimization

Example
Step 1 — Constant Propagation (substitute a = 3, b = 4):
t1 = 3 + 2
t2 = t1 * 4
Step 2 — Constant Folding (evaluate 3 + 2):
t1 = 5
t2 = 5 * 4
Step 3 — Constant Folding (evaluate 5 * 4):
t2 = 20
Step 4 — Dead Code Elimination (t1 and t2 never read again):
result = 20
Optimized IR:
result = 20 ← entire expression resolved at compile time

Phase 6 — Code Generation (Assembly)

Example
Without optimization (from Phase 4 IR):
LOAD R1, a ; R1 = a (= 3)
ADD R1, R1, 2 ; R1 = 3 + 2 = 5 (t1 → R1)
LOAD R2, b ; R2 = b (= 4)
MUL R1, R1, R2 ; R1 = 5 × 4 = 20 (t2 → R1)
STORE result, R1 ; memory[result] = 20
With optimization applied:
MOV result, 20 ; result = 20 — single instruction, no arithmetic

Summary

Example
Phase Name Input Output
----- ------------------ ---------------- ----------------------
1 Lexical Analysis Source code Token stream (21 tokens)
2 Syntax Analysis Token stream Abstract Syntax Tree
3 Semantic Analysis AST Annotated AST
4 IR Generation Annotated AST Three-address code
5 Code Optimization Three-address code result = 20
6 Code Generation Optimized IR MOV result, 20