What IR is and why your compiler needs it

Intermediate representation (IR) is the middle layer between your source code and machine code. When your compiler reads code written in a language like C, Python, or Java, it does not jump straight to assembly or binary. Instead, it converts the source into IR — a simplified, standardized form that is easier to optimize and transform.

Think of IR as a translator's working notes. A human translator does not convert English to French word-for-word. They parse the English into meaning, then express that meaning in French. Your compiler does the same thing: it parses source code into IR, optimizes the IR, and then generates machine code from the optimized IR. This separation makes the compiler modular and lets you reuse the same IR layer across different target architectures.

Generating IR is the job of your compiler's frontend — the part that reads source code. The backend then takes that IR and turns it into executable instructions for a specific processor. If you are building a compiler from scratch or adding a new language to an existing compiler framework, you need to write code that produces IR.

Key Takeaways

  • IR sits between your source code and machine code, making it easier to optimize and retarget your compiler to different processors.
  • You generate IR by walking through your abstract syntax tree (AST) and emitting IR instructions for each node, usually in a single forward pass.
  • Most modern compilers use an existing IR format like LLVM IR, WebAssembly, or a custom bytecode rather than inventing one from scratch.
  • IR instructions should be simple and explicit — no implicit behavior — so the backend can reason about them and apply optimizations reliably.
  • Testing your IR generator means checking that the IR you produce actually runs and produces the same output as your source code would.

Choose an IR format or design your own

Before you write code to generate IR, you need to decide what form that IR will take. The easiest path is to use an existing IR that a compiler framework already supports. LLVM IR is the most common choice for compiled languages — it is text-based, well-documented, and LLVM's backend can turn it into machine code for almost any processor. If you are building a language that runs on the web, WebAssembly is the standard IR. If you are writing an interpreter or a bytecode-based virtual machine, you might use a custom bytecode format.

If you choose LLVM, you do not have to invent IR syntax or semantics — LLVM defines both. You write a frontend that reads your source language and emits LLVM IR text or uses LLVM's C++ API to build IR in memory. The LLVM project provides tutorials and examples for both approaches.

If you design your own IR, keep it simple. IR should be explicit and low-level enough that a backend can generate code from it without guessing. Avoid implicit behavior. For example, if your language has automatic memory management, your IR should have explicit allocation and deallocation instructions, not hidden garbage collection. This makes the backend's job tractable and makes optimization passes easier to reason about.

Build an abstract syntax tree first

You cannot generate IR from raw source code. You need to parse the source into an abstract syntax tree (AST) — a tree structure where each node represents a language construct like a function, a loop, or an assignment. Your parser builds the AST by reading tokens and grouping them according to your language's grammar rules.

The AST is the bridge between parsing and IR generation. Your parser does not need to know anything about IR; it just builds a tree. Then a separate pass walks the tree and generates IR. This separation makes both the parser and the IR generator simpler and easier to test independently.

For example, if your source code is x = 5 + 3, your parser builds an AST node for assignment, with a left child (the variable x) and a right child (a binary operation node for addition, with children for 5 and 3). Your IR generator then walks this tree and emits instructions to load the constants, add them, and store the result.

Walk the AST and emit IR instructions

IR generation is usually a single forward pass through the AST. You write a function (often called a visitor or code generator) that takes an AST node as input and emits the corresponding IR instructions. For each node type in your AST, you write a handler that knows how to generate IR for that construct.

Start with simple constructs: literals, variables, and arithmetic. A literal like 42 might emit a single IR instruction to load a constant. A variable reference might emit an instruction to load from memory or a register. A binary operation like addition emits code to evaluate both operands, then emits an add instruction.

Control flow — if statements, loops, function calls — requires more care. In IR, control flow is usually represented as basic blocks (straight-line sequences of instructions with no branches) connected by jump instructions. An if statement becomes two or more basic blocks: one for the condition, one for the true branch, one for the false branch, and one for the code after the if. Your IR generator creates these blocks and emits branch instructions to connect them.

Here is a rough outline of what a code generator looks like in pseudocode:

function generate_ir(ast_node):   if ast_node is a literal:     emit load_constant instruction   else if ast_node is a variable:     emit load_variable instruction   else if ast_node is a binary operation:     generate_ir(left operand)     generate_ir(right operand)     emit operation instruction   else if ast_node is an if statement:     create true_block and false_block     generate_ir(condition)     emit branch instruction to true_block or false_block     generate_ir(true_block body)     generate_ir(false_block body)

Handle variables and memory

Your IR needs to represent where variables live. In a compiled language, variables might live in memory (on the stack or heap) or in processor registers. Your IR generator needs to track which variables are in scope, what type they are, and where they are stored.

Most IR formats use virtual registers — unlimited temporary storage locations that the backend later maps to real processor registers. When you generate IR for an assignment like x = 5, you emit an instruction to load the constant 5 into a virtual register, then emit an instruction to store that register to the memory location for x.

Keep a symbol table — a data structure that maps variable names to their storage locations and types. When you encounter a variable in the AST, look it up in the symbol table to find where it lives. When you enter a new scope (like a function body), create a new scope in the symbol table. When you exit the scope, clean it up.

Test your IR by running it

The only way to know if your IR generator works is to generate IR and run it. Write a simple test program in your source language, generate IR from it, and see if the IR produces the correct output.

If you are using LLVM, you can use the llc tool to compile LLVM IR to machine code, then run the resulting executable. If you wrote a custom IR, you need a backend or interpreter that can execute it. Start with an interpreter — it is easier to debug than a code generator, and it lets you test the IR without building a full backend.

Test incrementally. Start with the simplest constructs: constants, arithmetic, variable assignment. Get those working, then add function calls, then loops, then more complex features. Each time you add a feature to your IR generator, write a test case that exercises it.

Common mistakes to watch for: forgetting to initialize variables, emitting instructions in the wrong order, not handling function calls correctly, and not managing the stack or register allocation properly. An interpreter makes these bugs visible quickly because you can trace the execution step by step.

Optimize the IR before generating code

Once you have a working IR generator, you can add optimization passes. An optimization pass walks the IR and transforms it to make it faster or smaller, without changing what the program does. Common optimizations include constant folding (replacing 5 + 3 with 8 at compile time), dead code elimination (removing code that never runs), and inlining (replacing a function call with the function body).

Optimizations are optional — your compiler works without them — but they make the generated code faster. If you are using LLVM, it provides dozens of built-in optimization passes that you can enable with a single function call. If you wrote a custom IR, you can write your own optimization passes.

The key insight is that IR is a good place to optimize because it is independent of the target architecture. An optimization that works on LLVM IR works for any processor that LLVM supports. If you optimize at the machine code level instead, you have to write the same optimization for each processor you support.

Frequently Asked Questions

Do I have to use LLVM, or can I write my own IR?

You can write your own IR, but LLVM is the standard choice for a reason. It is mature, well-documented, and its backend handles the hard parts of code generation and optimization. Unless you have a specific reason to invent your own IR — like building a language for a very specialized target — using LLVM saves you months of work.

What is the difference between IR and bytecode?

IR is usually compiled to machine code by a backend. Bytecode is usually interpreted by a virtual machine at runtime. The distinction is blurry — some bytecode formats (like Java bytecode) are compiled to machine code by a JIT compiler — but the intent is different. IR is an intermediate step in ahead-of-time compilation. Bytecode is the final form for an interpreted or JIT-compiled language.

How do I handle function calls in IR?

Most IR formats have an explicit call instruction that names the function and lists the arguments. Your IR generator emits code to evaluate each argument, then emits a call instruction with those arguments. The backend then generates code to push arguments onto the stack or into registers, jump to the function, and handle the return value.

What if my language has features that do not map cleanly to IR?

Break the feature down into simpler operations that IR can represent. For example, if your language has automatic memory management, emit explicit allocation and deallocation instructions in the IR, then write a garbage collector as a runtime library. If your language has exceptions, emit IR instructions for try-catch blocks and exception handling. The IR should be low-level enough that the backend does not have to guess what you meant.

How do I debug my IR generator?

Print the IR to a file and read it. Most IR formats are human-readable (LLVM IR is text-based). If the output is wrong, trace through your code generator by hand to see where the mistake is. Use an interpreter to run the IR and check the output. Add logging to your code generator to print which AST nodes it is processing and what IR it emits. Start with tiny test cases — a single variable assignment or a simple arithmetic expression — and build up from there.