Compiler DesignUnit 814 min read
Intermediate Code Generation: Three-Address Code, Stack Machines, DAGs
Unit 8 of Compiler Design covers how compilers generate intermediate representations (three-address code, stack machines, DAGs) that balance machine independence and efficiency, including their syntax, semantics, and translation from parse trees. This note explains each form, their trade-offs, and real-world applicatio
TAKEAWAYS:
- Intermediate code is a machine-independent, low-level representation that bridges syntax analysis and code generation.
- Three-address code (TAC) uses simple instructions with at most one operator and three operands, making it easy to optimize.
- Stack machines (e.g., Java bytecode) use a stack for operands, reducing register allocation complexity.
- Directed Acyclic Graphs (DAGs) eliminate redundant computations by merging common subexpressions.
- The choice of intermediate representation affects optimization opportunities and code generation efficiency.
- Real-world compilers (e.g., GCC, LLVM) use intermediate code to separate front-end (language-specific) and back-end (machine-specific) phases.
What is Intermediate Code?
Intermediate code (IR) is a low-level, machine-independent representation of a program generated by a compiler after parsing and semantic analysis. It serves as a bridge between:
- High-level source code (e.g., C, Java, Python)
- Machine-specific target code (e.g., x86 assembly, ARM machine code)
IR is designed to:
- Retain enough structure for optimizations (e.g., constant folding, dead code elimination).
- Be easy to translate into target code for different architectures.
- Support portability (write once, compile anywhere).
Why Not Generate Target Code Directly?
Generating target code directly from parse trees is hard because:
- Syntax trees lack low-level details (e.g., memory addresses, register usage).
- Different machines have different instruction sets (e.g., RISC vs. CISC).
- Optimizations (e.g., loop unrolling) are easier on a uniform IR.
IR solves these problems by providing a canonical form that all optimizations and back-ends can work with.
Types of Intermediate Representations
Three common forms are used in modern compilers:
| Type | Example | Key Feature | Used By |
|---|---|---|---|
| Three-Address Code | t1 = a + b |
Each instruction has at most one operator and three operands. | GCC (GIMPLE), LLVM (LLIL) |
| Stack Machines | LOAD a, LOAD b, ADD |
Uses a stack for operands; no explicit registers. | Java bytecode, Forth |
| Directed Acyclic Graph (DAG) | Nodes for ops, edges for dependencies | Eliminates redundant computations by merging common subexpressions. | Optimizing compilers |
1. Three-Address Code (TAC)
TAC is the most widely used IR in compilers like GCC and LLVM. It consists of quadruples or triples, where each instruction has:
- One operator (e.g.,
+,-,=). - Three operands (e.g.,
t1 = a + b).
Syntax of TAC
A typical TAC instruction looks like:
t = op a b
Where:
tis a temporary variable (e.g.,t1,t2).opis an operator (+,-,*,/,=).aandbare operands (variables, constants, or temporaries).
Example: TAC for x = (a + b) * (c - d)
Original expression:
x = (a + b) * (c - d);
Step-by-step TAC generation (from parse tree):
- Evaluate
(a + b):t1 = a + b - Evaluate
(c - d):t2 = c - d - Multiply results:
x = t1 * t2
Visualizing TAC Generation
flowchart TD
A["Parse Tree"] --> B["Post-order Traversal"]
B --> C["Generate TAC"]
C --> D["t1 = a + b"]
C --> E["t2 = c - d"]
C --> F["x = t1 * t2"]
D --> G["TAC: t1 = a + b"]
E --> H["TAC: t2 = c - d"]
F --> I["TAC: x = t1 * t2"]Advantages of TAC
- Simple: Easy to generate and optimize.
- Structured: Each instruction is straightforward.
- Portable: Can be translated to any target architecture.
Disadvantages of TAC
- Verbose: May generate many temporaries for complex expressions.
- Not always optimal: Some optimizations (e.g., strength reduction) require more advanced IRs.
2. Stack Machines
Stack machines use a stack to hold operands and intermediate results. Instructions typically:
- Push operands onto the stack.
- Pop operands for operations.
- Push results back onto the stack.
Example: Stack Machine Code for x = a + b
Original expression:
x = a + b;
Stack machine instructions:
LOAD a
LOAD b
ADD
STORE x
Stack state after each step:
| Instruction | Stack Before | Stack After |
|---|---|---|
LOAD a |
[] | [a] |
LOAD b |
[a] | [a, b] |
ADD |
[a, b] | [a + b] |
STORE x |
[a + b] | [] (x = a + b) |
Visualizing Stack Machine Execution
flowchart LR
A["LOAD a"] --> B["Stack: [a]"]
B --> C["LOAD b"] --> D["Stack: [a, b]"]
D --> E["ADD"] --> F["Stack: [a + b]"]
F --> G["STORE x"] --> H["Stack: [] (x = a + b)"]Real-World Example: Java Bytecode
Java compiles source code to bytecode, which runs on the Java Virtual Machine (JVM). The JVM is a stack machine:
int x = a + b;
Compiled to bytecode:
iconst_1 // Push 'a' (assuming a=1 for simplicity)
iconst_2 // Push 'b' (assuming b=2)
iadd // ADD
istore_0 // STORE x
Stack state:
| Bytecode | Stack Before | Stack After |
|---|---|---|
iconst_1 |
[] | [1] |
iconst_2 |
[1] | [1, 2] |
iadd |
[1, 2] | [3] |
istore_0 |
[3] | [] (x = 3) |
Advantages of Stack Machines
- No registers: Simplifies register allocation.
- Portable: JVM bytecode runs on any machine with a JVM.
- Efficient for some ops: Stack operations are fast for simple arithmetic.
Disadvantages of Stack Machines
- Harder to optimize: Harder to perform advanced optimizations like loop unrolling.
- Verbose for complex ops: Deep stacks can slow down execution.
3. Directed Acyclic Graphs (DAGs)
A DAG represents computations as a graph where:
- Nodes = operations or variables.
- Edges = dependencies between operations.
DAGs eliminate redundant computations by merging common subexpressions.
Example: DAG for x = a * b + a * b
Original expression:
x = a * b + a * b;
Naive TAC:
t1 = a * b
t2 = a * b
x = t1 + t2
DAG representation:
graph TD
A["a"] --> B["*"]
C["b"] --> B
B --> D["+"]
A --> E["*"]
C --> E
E --> D
D --> F["x"]Optimized TAG (Three-Address Code with DAG):
t1 = a * b
x = t1 + t1
Savings: One multiplication is eliminated.
How DAGs Work
- Build the DAG from the parse tree.
- Merge common subexpressions (e.g.,
a * bappears twice). - Generate code from the DAG (e.g., compute
a * bonce, reuse).
Real-World Example: Compiler Optimizations in LLVM
LLVM uses Static Single Assignment (SSA) form, which is a DAG-like representation where:
- Each variable is assigned exactly once.
- Optimizations like constant propagation and dead code elimination are easier.
Example:
int y = x + 5;
int z = y + 10;
SSA form (DAG-like):
%1 = add i32 %x, 5
%2 = add i32 %1, 10
Here, %1 and %2 are temporaries assigned once.
Advantages of DAGs
- Eliminates redundancy: Saves computation time.
- Better optimizations: Enables advanced techniques like common subexpression elimination (CSE).
- Clear dependencies: Makes control flow analysis easier.
Disadvantages of DAGs
- Complex to implement: Requires graph algorithms.
- Overhead: Building and traversing DAGs can be slow for simple programs.
Translation from Parse Trees to Intermediate Code
The process of generating IR from a parse tree involves:
- Traversing the parse tree (usually post-order).
- Generating IR instructions for each node.
- Managing temporaries (e.g.,
t1,t2).
Example: TAC Generation for if (a > b) x = a; else x = b;
Original code:
if (a > b) x = a; else x = b;
Parse tree:
IF
/ \
(a>b) ELSE
/ \ / \
a b x=a x=b
TAC generation:
- Generate condition:
t1 = a > b - Generate
thenbranch (x = a):t2 = a - Generate
elsebranch (x = b):t3 = b - Merge branches:
x = t1 ? t2 : t3
Final TAC:
t1 = a > b
t2 = a
t3 = b
x = t1 ? t2 : t3
Visualizing TAC for Conditional Statements
flowchart LR
A["Parse Tree: IF"] --> B["Generate t1 = a > b"]
B --> C["Then: t2 = a"]
B --> D["Else: t3 = b"]
C --> E["Merge: x = t1 ? t2 : t3"]Real-World Applications of Intermediate Code
1. Java Virtual Machine (JVM) and Bytecode
- What it uses: Stack machine (bytecode).
- How it works: Java source code is compiled to bytecode, which runs on any JVM (e.g., Windows, Linux, macOS).
- Example: When you run a Java program, the JVM translates bytecode to machine code on the fly (JIT compilation).
2. LLVM Compiler Infrastructure
- What it uses: Three-address code (LLVM IR) and DAGs (for optimizations).
- How it works: LLVM IR is a low-level, typed intermediate representation that allows optimizations before generating target code (e.g., x86, ARM).
- Example: Clang (the C/C++ frontend for LLVM) compiles source code to LLVM IR, which is then optimized and translated to machine code.
3. Python Bytecode
- What it uses: Stack-based bytecode (similar to JVM).
- How it works: Python source code is compiled to
.pycfiles (bytecode), which are executed by the Python interpreter. - Example: When you run
python script.py, the interpreter first compiles it to bytecode if not already done.
4. Nepali Context: eSewa and Digital Payments
- What it uses: Intermediate representations in backend services (e.g., Java/Kotlin for Android apps, Python for APIs).
- How it works: Payment processing involves:
- Validation: Check if the user has enough balance (IR for conditionals).
- Transaction: Deduct amount and update database (IR for arithmetic and I/O).
- Confirmation: Send SMS/email (IR for control flow).
- Example: When you pay a bill via eSewa, the backend compiler generates IR to validate your request, process the payment, and update records—all before generating machine code for execution.
5. Ncell and Mobile App Compilation
- What it uses: Intermediate code in Android apps (Java/Kotlin → Dalvik bytecode).
- How it works: Android apps are compiled to Dalvik bytecode, which runs on the Android Runtime (ART). ART uses JIT compilation to translate bytecode to machine code at runtime.
- Example: When you download an app from the Play Store, it’s pre-compiled to bytecode, which runs efficiently on your phone.
6. NEPSE Stock Market Software
- What it uses: High-performance IR (e.g., C++ → LLVM IR).
- How it works: Trading systems use compiled languages (C++, Java) that generate IR for:
- Real-time calculations (e.g., order matching).
- Database updates (e.g., portfolio changes).
- Example: When you buy/sell shares on NEPSE’s online platform, the backend compiler optimizes IR to execute trades in milliseconds.
Worked Example: TAC for a Loop
Original code:
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
Step-by-step TAC generation:
- Initialize
sumandi:sum = 0 i = 0 - Loop condition (
i < 10):t1 = i < 10 - Body of loop (
sum += i):t2 = sum + i sum = t2 - Increment
i:t3 = i + 1 i = t3 - Jump back to condition if
t1is true.
Final TAC:
sum = 0
i = 0
L1:
t1 = i < 10
IF t1 GOTO L2
GOTO L3
L2:
t2 = sum + i
sum = t2
t3 = i + 1
i = t3
GOTO L1
L3:
Visualizing Loop TAC
flowchart LR
A["sum = 0"] --> B["i = 0"]
B --> C["L1: t1 = i < 10"]
C --> D["IF t1 GOTO L2"]
D --> E["GOTO L3"]
C --> F["L2: t2 = sum + i"]
F --> G["sum = t2"]
G --> H["t3 = i + 1"]
H --> I["i = t3"]
I --> C
E --> J["L3: End"]Comparison of Intermediate Representations
| Feature | Three-Address Code | Stack Machines | DAGs |
|---|---|---|---|
| Readability | High | Medium | Low (graph-based) |
| Optimization Potential | Medium | Low | High |
| Implementation Complexity | Low | Medium | High |
| Used By | GCC, LLVM | JVM, Forth | LLVM (SSA), Research Compilers |
| Best For | General-purpose compilers | Portable bytecode | High-performance optimizations |
Exam Tip
What to Expect in the Exam
Definitions:
- Explain three-address code, stack machines, and DAGs with examples.
- Define temporary variables and their role in IR.
Generation:
- Given a parse tree or expression, generate TAC or stack machine code.
- Example: Convert
x = (a + b) * (c - d)to TAC.
Optimizations:
- Identify redundant computations in TAC and rewrite using DAGs.
- Example: Optimize
x = a * b + a * btox = 2 * (a * b).
Real-World Applications:
- Relate IR to JVM bytecode, LLVM, or Python bytecode.
- Example: Explain how Java’s
+operator is compiled to JVM bytecode.
Code Traces:
- Show stack states for stack machine instructions.
- Example: Trace the execution of
LOAD a; LOAD b; ADD; STORE x.
Common Mistakes to Avoid
- Forgetting temporaries: Always use
t1,t2, etc., for intermediate results. - Incorrect operator precedence: Evaluate expressions in the correct order (e.g.,
*before+). - Missing control flow: For
if-else, generate both branches and a merge. - Overlooking optimizations: In DAGs, always merge common subexpressions.
Sample Exam Question and Answer
Question: Generate three-address code for the following expression:
z = (a + b) * (c - d) + e;
Answer:
Break down the expression:
(a + b)→t1 = a + b(c - d)→t2 = c - d- Multiply results →
t3 = t1 * t2 - Add
e→z = t3 + e
Final TAC:
t1 = a + b t2 = c - d t3 = t1 * t2 z = t3 + e
Final Checklist for Full Marks
- Define IR and its purpose.
- Show TAC generation from a parse tree.
- Explain stack machines with stack traces.
- Draw a DAG and explain optimizations.
- Compare the three IR types in a table.
- Relate to real-world compilers (JVM, LLVM).
- Solve a worked example (loop, conditional, arithmetic).
Based on the PU BE Computer (PU) syllabus for Compiler Design (CMP360), unit 8.
Discussion
Loading…