What IR is and why your compiler needs it
Intermediate representation (IR) is the middle language your compiler creates after reading your source code but before turning it into machine instructions. Think of it as a bridge: your compiler reads Python or C or Java on one side, converts it to IR in the middle, then converts that IR to the actual binary code your processor runs on the other side.
IR exists because it solves a real problem. If you built a compiler that went straight from source code to machine instructions, you would have to write the entire translation process over again for every combination of programming language and processor type. With IR in the middle, you write one translator from your language to IR, then one translator from IR to each processor you want to support. That is far fewer translators to maintain.
IR is also where most compiler optimizations happen. Once your code is in IR form, it is easier to spot patterns — unused variables, loops that can run in parallel, expressions that are computed the same way twice — and fix them before generating the final machine code.
Key Takeaways
- IR is a simplified, processor-independent language that sits between your source code and machine code, making compilers easier to build and maintain.
- The process of generating IR involves parsing your source code into an abstract syntax tree, then walking that tree and emitting IR instructions.
- Most compilers use a three-address code format for IR, where each instruction does one straightforward operation on at most three values.
- LLVM IR and Java bytecode are real-world examples of IR that you can study to understand how production compilers handle this step.
- Testing your IR generator means checking that the IR you produce actually runs and produces the same output as the original source code.
The basic steps to generate IR from source code
Generating IR happens in stages, and each stage takes the output of the previous one. First, your compiler reads the source code and builds an abstract syntax tree (AST) — a tree-shaped representation of the code's structure. A straightforward expression like x = 5 + 3 becomes a tree with an assignment node at the top, a variable node for x on the left, and an addition node on the right with leaf nodes for 5 and 3.
Once you have the AST, you walk through it and emit IR instructions. This is called code generation. For each node in the tree, you decide what IR instruction or instructions to output. For the assignment example above, you might emit an instruction that says "add the values in registers 1 and 2, store the result in register 3, then move register 3 into the memory location for variable x."
The IR you generate is not tied to any real processor. It uses abstract registers (register 1, register 2, etc.) and abstract memory locations. Later, a separate part of your compiler called the backend will take this abstract IR and convert it to real machine code for a specific processor like x86 or ARM, assigning real registers and memory addresses as it goes.
Three-address code: the most common IR format
Most compilers use a format called three-address code for their IR. The name comes from the fact that each instruction typically has three operands: two inputs and one output. An instruction might look like t1 = a + b, which means "add the values in a and b, store the result in temporary variable t1."
Three-address code is popular because it is straightforward to generate and straightforward to optimize. Each instruction does exactly one operation, so you can look at any instruction in isolation and understand what it does. Complex expressions get broken down into many small instructions. The expression x = (a + b) * (c - d) becomes something like:
- t1 = a + b
- t2 = c - d
- t3 = t1 * t2
- x = t3
Each temporary variable (t1, t2, t3) is a new register or memory location. When you generate this IR, you need to keep track of which temporary variables you have created so far, so you do not reuse a name and lose a value you still need.
Walking the AST and emitting instructions
The actual process of generating IR is a recursive walk through your AST. You write a function for each type of node in the tree. When you visit a node, you recursively visit its children first, then emit IR instructions based on what the node represents and what values its children produced.
Here is a simplified example. Suppose you have an addition node with two children (the left and right operands). Your code-generation function for addition would: (1) recursively generate IR for the left child and remember what temporary variable holds its result, (2) recursively generate IR for the right child and remember what temporary variable holds its result, (3) emit a new IR instruction that adds those two temporaries together, (4) return the name of the temporary variable that now holds the sum.
The tricky part is managing temporary variables. You need a counter that increments every time you create a new temporary, so you never accidentally reuse a name. You also need to keep track of which variables in your source code map to which memory locations, so that when you see the variable name x in the source, you know where to store its value in the IR.
Real-world examples: LLVM IR and Java bytecode
LLVM IR is the intermediate representation used by the LLVM compiler framework, which powers Clang (the C and C++ compiler), Swift, and many other languages. LLVM IR looks similar to assembly language but is processor-independent. A straightforward function in LLVM IR might look like:
define i32 @add(i32 %a, i32 %b) { %result = add i32 %a, %b ret i32 %result }
The % prefix marks temporary variables, i32 means a 32-bit integer, and each instruction is one line. This is close to three-address code but with more explicit type information.
Java bytecode is another example of IR. When you compile a Java source file, the Java compiler does not generate machine code — it generates bytecode, which is IR designed to run on the Java Virtual Machine. The JVM then interprets or just-in-time compiles that bytecode to machine code at runtime. You can see Java bytecode by running the javap tool on a compiled .class file.
Testing your IR generator
Once you have written code to generate IR, you need to test it. The simplest test is to write a small program in your source language, generate IR from it, then run that IR through your backend (or through an existing IR interpreter) and check that the output is correct.
Start with very straightforward programs: a single variable assignment, a straightforward arithmetic expression, a function with one parameter. For each test, write down what the output should be, generate IR, run it, and compare. If the output is wrong, print out the IR you generated and trace through it by hand to find the mistake.
A common mistake is forgetting to handle a particular type of AST node. If your source code contains an if statement but you never wrote a code-generation function for if nodes, your IR generator will crash or produce garbage. Build your compiler incrementally: add support for one language feature at a time, test it, then move on to the next feature.
Frequently Asked Questions
Do I have to use three-address code for my IR?
No. Three-address code is common because it is straightforward, but other formats exist. Some compilers use a graph-based IR where instructions are nodes and data flows between them. Others use a stack-based format where instructions push and pop values on a stack. Choose whatever format makes sense for your compiler and your target processors.
What is the difference between IR and assembly language?
Assembly language is specific to a real processor — x86 assembly is different from ARM assembly. IR is processor-independent. IR also tends to be more abstract: it uses unlimited temporary variables, while real assembly has a fixed number of registers. IR is what your compiler works with internally; assembly is what gets written to a file or fed to an assembler.
Can I skip IR and go straight from the AST to machine code?
Technically yes, but it is a bad idea for anything larger than a toy compiler. Without IR, you have to write the entire code-generation and optimization process separately for each processor you want to support. With IR, you write it once and reuse it. IR also makes it easier to add optimizations later without rewriting your whole backend.
How do I handle control flow like loops and if statements in IR?
Most IR formats use labels and conditional jumps. An if statement becomes a conditional jump instruction that either jumps to a label marking the false branch or falls through to the true branch. Loops become a label at the top of the loop body and a conditional jump at the bottom that jumps back to the label if the loop condition is still true.
What tools can I use to learn more about how real compilers generate IR?
Read the LLVM documentation and look at LLVM IR output from real code. You can see what IR a C program generates by running clang -emit-llvm -S myprogram.c. For Java, compile a straightforward program and run javap -c MyClass to see the bytecode. These real examples show you how production compilers handle features you will need to support.