Chapter 6 · Programming
How to Build a Compiler IR in C++: Three-Address Code
Build an intermediate representation in C++. Lower a checked AST into three-address-style instructions with temporaries, variables, basic blocks, branches, loops and function calls.
By Gianni Updated 35 min Intermediate
On this page
The compiler can now tokenise Si source code, parse it into an Abstract Syntax Tree (AST), and reject programs that are semantically invalid. The next stage is to lower that checked AST into a compiler Intermediate Representation (IR).
An Intermediate Representation is a compiler-internal form that sits between the source-language AST and the final machine-specific backend. Instead of making the x86-64 generator understand high-level constructs such as nested expressions, while statements, and source-level variable declarations, we translate them into a smaller set of explicit operations first.
Generating x86-64 assembly directly from the AST is possible, but it tightly couples the backend to the exact shape of the source language. An IR introduces a cleaner boundary:
Si source
↓
Lexer
↓
Parser
↓
AST
↓
Semantic analyser
↓
Intermediate Representation
↓
x86-64 backend
The AST describes the program using source-language structure. The IR expresses the same behaviour with smaller compiler operations that are easier for a backend to consume. In this chapter, that representation is a compact three-address-style IR built in C++.
What You Will Learn
By the end of this chapter, you will be able to:
- explain what an Intermediate Representation does in a compiler;
- explain why compilers introduce an IR between the AST and machine code;
- design a compact three-address-style IR in C++;
- lower AST expressions into temporaries and explicit instructions;
- represent variables with
loadandstoreoperations; - represent
ifstatements and loops with basic blocks and terminators; - lower function calls and return values;
- preserve variable shadowing with unique internal storage names;
- test the generated IR independently of the x86-64 backend.
What Is an Intermediate Representation in a Compiler?
An Intermediate Representation (IR) is an internal program representation used between compiler stages. It preserves the behaviour needed by later stages while removing much of the syntax and tree structure that was useful during parsing.
A compiler IR gives the front end and back end a stable interface. The front end can understand source-language constructs such as declarations, expressions, and loops, while the backend can focus on a smaller instruction set with explicit values and control flow.
In this compiler, the checked AST is lowered into an IR containing:
- functions and parameters;
- local storage slots;
- generated temporaries;
- explicit arithmetic and comparison instructions;
loadandstoreoperations;- function calls;
- basic blocks;
- branches, jumps, and returns.
The resulting IR is not machine code and it is not a second source language. It is a compiler-oriented representation designed to make later translation and optimisation easier.
How Does IR Generation Work?
IR generation, also called AST lowering, translates higher-level AST nodes into simpler IR instructions.
At a high level, the lowering process works like this:
- The semantic analyser validates the AST first.
- The IR generator visits functions, statements, and expressions.
- Complex expressions are flattened into sequences of temporary values.
- Source variables are mapped to internal storage names.
- Structured control flow becomes basic blocks connected by jumps and branches.
- Function calls and returns become explicit IR instructions.
- The completed
IRProgramis passed to later compiler stages.
The important idea is that lowering changes the representation, not the intended program behaviour.
AST vs IR vs Assembly
These representations serve different purposes:
| Representation | Main purpose | Typical structure | Machine-specific? |
|---|---|---|---|
| AST | Preserve source-language meaning and hierarchy | Tree of declarations, statements, and expressions | No |
| Compiler IR | Expose simpler operations and explicit control flow | Instructions, temporaries, functions, and basic blocks | Usually less machine-specific |
| Assembly | Describe instructions for the target architecture | Registers, instructions, labels, calling conventions | Yes |
This chapter builds the middle layer. The x86-64 backend in the next chapter can consume this IR without needing to understand the original Si syntax.
Why Do Compilers Use an Intermediate Representation?
Consider this Si declaration:
let x: int = 5 + 2 * 10;
The parser produces a tree that preserves operator precedence:
LetStatement x
└── BinaryExpression +
├── IntLiteral 5
└── BinaryExpression *
├── IntLiteral 2
└── IntLiteral 10
That structure is ideal for syntax and semantic analysis, but assembly generation works more naturally with a sequence of small operations:
%t0 = const_int 5
%t1 = const_int 2
%t2 = const_int 10
%t3 = mul %t1, %t2
%t4 = add %t0, %t3
store %t4, x
The IR removes most of the tree structure while preserving the program’s behaviour. This creates several practical advantages:
- It is easier to print and inspect than a large AST.
- It is easier for the backend to translate into assembly.
- It provides a natural place for later optimisation passes.
- It reduces the backend’s dependence on the exact source-language syntax.
- It makes compiler stages easier to test independently.
This separation becomes increasingly valuable as either the source language or target backend grows.
What Is Three-Address Code?
Three-address code (TAC) is a common intermediate-code style in which complex expressions are decomposed into small operations involving explicit operands and generated temporary values.
Our educational IR is inspired by three-address code. A typical instruction contains one destination and up to two input values:
destination = operation input1, input2
For example:
%t2 = add %t0, %t1
The generated temporary %t2 stores the result of adding %t0 and %t1.
Not every instruction in this project literally has three addresses. Calls may have several arguments, and branches refer to block labels. Three-address-style describes the overall lowering strategy: complex source expressions are broken into small instructions with explicit inputs, outputs, and temporary values.
This project also keeps variables in explicit storage slots and uses load and store instructions rather than pretending every source variable is already a temporary. That makes the representation straightforward for the x86-64 backend we will build next.
Core Intermediate Representation Concepts
The IR is deliberately small. Its structure can be understood through five main concepts:
- Program — a collection of functions.
- Function — parameters, local storage and basic blocks.
- Basic block — a straight-line sequence of instructions.
- Instruction — one operation such as
add,load,callorbranch. - Temporary — a generated value such as
%t0.
A basic block has one entry point and normally ends with a terminator:
jump target
branch %condition, true_block, false_block
return %value
A terminator instruction determines where control flows next. Because the terminator ends the block, no normal instruction may appear after it in the same basic block. This invariant makes later control-flow handling much easier.
Example: Lowering a while Loop from AST to IR
This Si program:
fn main(): int {
let x: int = 1;
while (x < 5) {
x = x + 1;
}
out(x);
return x;
}
can be lowered to:
fn main() -> int
locals:
x: int
entry:
%t0 = const_int 1
store %t0, x
jump while_cond0
while_cond0:
%t1 = load x
%t2 = const_int 5
%t3 = less %t1, %t2
branch %t3, while_body1, while_end2
while_body1:
%t4 = load x
%t5 = const_int 1
%t6 = add %t4, %t5
store %t6, x
jump while_cond0
while_end2:
%t7 = load x
call out(%t7)
%t8 = load x
return %t8
The important change is structural: the backend no longer needs to understand a WhileStatementNode. It only needs to translate constants, loads, comparisons, branches, jumps, calls, and returns. The source-level loop has become explicit control flow.
Designing the Intermediate Representation in C++
Create gsisi/include/IR.hpp:
#pragma once
#include <string>
#include <vector>
#include "Type.hpp"
//--------------------------------------
// Operations
//--------------------------------------
enum class IROp {
ConstInt,
ConstBool,
Load,
Store,
Add,
Subtract,
Multiply,
Divide,
Less,
LessEqual,
Greater,
GreaterEqual,
Equal,
NotEqual,
Negate,
Not,
Call,
Jump,
Branch,
Return
};
//--------------------------------------
// Function data
//--------------------------------------
struct IRParameter {
std::string name;
Type type{Type::Unknown};
};
struct IRLocal {
std::string name;
Type type{Type::Unknown};
};
//--------------------------------------
// Instructions and blocks
//--------------------------------------
struct IRInstruction {
IROp op{IROp::Return};
Type type{Type::Void};
// Result produced by the instruction, such as %t0.
std::string dst;
// Input values or variable storage names.
std::vector<std::string> args;
// Call data.
std::string callee;
// Control-flow targets.
std::string target;
std::string trueTarget;
std::string falseTarget;
// Literal payloads.
int intValue{};
bool boolValue{};
};
struct IRBlock {
std::string name;
std::vector<IRInstruction> instructions;
};
struct IRFunction {
std::string name;
std::vector<IRParameter> parameters;
std::vector<IRLocal> locals;
Type returnType{Type::Void};
std::vector<IRBlock> blocks;
};
struct IRProgram {
std::vector<IRFunction> functions;
};
//--------------------------------------
// Helpers
//--------------------------------------
[[nodiscard]] inline const char* toString(IROp op) noexcept {
switch (op) {
case IROp::ConstInt: return "const_int";
case IROp::ConstBool: return "const_bool";
case IROp::Load: return "load";
case IROp::Store: return "store";
case IROp::Add: return "add";
case IROp::Subtract: return "sub";
case IROp::Multiply: return "mul";
case IROp::Divide: return "div";
case IROp::Less: return "less";
case IROp::LessEqual: return "less_equal";
case IROp::Greater: return "greater";
case IROp::GreaterEqual: return "greater_equal";
case IROp::Equal: return "equal";
case IROp::NotEqual: return "not_equal";
case IROp::Negate: return "negate";
case IROp::Not: return "not";
case IROp::Call: return "call";
case IROp::Jump: return "jump";
case IROp::Branch: return "branch";
case IROp::Return: return "return";
}
return "unknown";
}
[[nodiscard]] inline bool isTerminator(IROp op) noexcept {
return op == IROp::Jump ||
op == IROp::Branch ||
op == IROp::Return;
}
IRInstruction deliberately uses one compact structure for all supported operations. Different operations populate different fields:
%t0 = const_int 10
op = ConstInt
dst = "%t0"
intValue = 10
type = Int
A branch uses different fields:
branch %t3, while_body1, while_end2
op = Branch
args[0] = "%t3"
trueTarget = "while_body1"
falseTarget = "while_end2"
A production compiler might use tagged unions, variants, a class hierarchy, or a more strongly typed instruction model. The flexible structure above is a deliberate educational trade-off: it keeps the IR compact, readable, and easy to print while the compiler architecture is still small.
Printing the IR
The IR printer should remain separate from the IR data structures. Keeping presentation outside the core model makes the representation easier to reuse in tests and later compiler passes.
Create gsisi/include/IRPrinter.hpp:
#pragma once
#include <ostream>
#include "IR.hpp"
void dumpIRInstruction(std::ostream& os, const IRInstruction& instruction);
void dumpIR(std::ostream& os, const IRProgram& program);
Create gsisi/src/IRPrinter.cpp:
#include "IRPrinter.hpp"
#include <cstddef>
#include "Type.hpp"
void dumpIRInstruction(std::ostream& os, const IRInstruction& instruction) {
switch (instruction.op) {
case IROp::ConstInt:
os << " " << instruction.dst
<< " = const_int " << instruction.intValue << '\n';
return;
case IROp::ConstBool:
os << " " << instruction.dst
<< " = const_bool "
<< (instruction.boolValue ? "true" : "false")
<< '\n';
return;
case IROp::Load:
os << " " << instruction.dst
<< " = load " << instruction.args.at(0) << '\n';
return;
case IROp::Store:
os << " store "
<< instruction.args.at(0) << ", "
<< instruction.args.at(1) << '\n';
return;
case IROp::Add:
case IROp::Subtract:
case IROp::Multiply:
case IROp::Divide:
case IROp::Less:
case IROp::LessEqual:
case IROp::Greater:
case IROp::GreaterEqual:
case IROp::Equal:
case IROp::NotEqual:
os << " " << instruction.dst
<< " = " << toString(instruction.op)
<< " " << instruction.args.at(0)
<< ", " << instruction.args.at(1)
<< '\n';
return;
case IROp::Negate:
case IROp::Not:
os << " " << instruction.dst
<< " = " << toString(instruction.op)
<< " " << instruction.args.at(0)
<< '\n';
return;
case IROp::Call:
os << " ";
if (!instruction.dst.empty()) {
os << instruction.dst << " = ";
}
os << "call " << instruction.callee << '(';
for (std::size_t i = 0; i < instruction.args.size(); ++i) {
if (i != 0) {
os << ", ";
}
os << instruction.args[i];
}
os << ")\n";
return;
case IROp::Jump:
os << " jump " << instruction.target << '\n';
return;
case IROp::Branch:
os << " branch "
<< instruction.args.at(0) << ", "
<< instruction.trueTarget << ", "
<< instruction.falseTarget << '\n';
return;
case IROp::Return:
os << " return";
if (!instruction.args.empty()) {
os << " " << instruction.args.at(0);
}
os << '\n';
return;
}
}
void dumpIR(std::ostream& os, const IRProgram& program) {
for (const IRFunction& function : program.functions) {
os << "fn " << function.name << '(';
for (std::size_t i = 0; i < function.parameters.size(); ++i) {
if (i != 0) {
os << ", ";
}
os << function.parameters[i].name
<< ": " << toString(function.parameters[i].type);
}
os << ") -> " << toString(function.returnType) << '\n';
if (!function.locals.empty()) {
os << "locals:\n";
for (const IRLocal& local : function.locals) {
os << " " << local.name
<< ": " << toString(local.type) << '\n';
}
}
os << '\n';
for (const IRBlock& block : function.blocks) {
os << block.name << ":\n";
for (const IRInstruction& instruction : block.instructions) {
dumpIRInstruction(os, instruction);
}
os << '\n';
}
}
}
The printer does not alter the program. It only provides a readable representation for debugging and testing.
How AST-to-IR Lowering Works
The IR generator walks the semantically checked AST and emits IR into the current function and basic block. Statements primarily emit instructions, while expressions emit instructions and also return the temporary or storage-backed value that represents their result.
Statements emit instructions:
let x: int = 10;
becomes:
%t0 = const_int 10
store %t0, x
Expressions emit instructions and produce an IR value:
5 + 10
emits:
%t0 = const_int 5
%t1 = const_int 10
%t2 = add %t0, %t1
and returns:
IRValue {
name = "%t2"
type = int
}
The semantic analyser has already checked declarations, names, calls, and types. The IR generator can therefore focus on lowering rather than repeating normal source-language validation. Defensive internal checks remain useful because they catch impossible compiler states if an earlier invariant is ever broken.
Designing the C++ IRGenerator
Create gsisi/include/IRGenerator.hpp:
#pragma once
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "AST.hpp"
#include "IR.hpp"
#include "Operator.hpp"
#include "Type.hpp"
class IRGenerator {
public:
[[nodiscard]] IRProgram generate(ProgramNode& program);
private:
struct IRValue {
std::string name;
Type type{Type::Unknown};
};
struct VariableInfo {
std::string storageName;
Type type{Type::Unknown};
};
struct FunctionInfo {
std::vector<Type> parameterTypes;
Type returnType{Type::Void};
};
enum class StorageKind {
Parameter,
Local
};
//----------------------------------
// Generator state
//----------------------------------
IRProgram _program;
IRFunction* _currentFunction{nullptr};
IRBlock* _currentBlock{nullptr};
std::unordered_map<std::string, FunctionInfo> _functions;
std::vector<std::unordered_map<std::string, VariableInfo>> _scopes;
std::unordered_map<std::string, int> _storageNameCounts;
std::unordered_set<std::string> _blockNames;
int _temporaryCounter{0};
int _labelCounter{0};
//----------------------------------
// Setup
//----------------------------------
void reset();
void registerBuiltins();
void collectFunctionSignatures(ProgramNode& program);
//----------------------------------
// Scope and storage
//----------------------------------
void enterScope();
void exitScope();
[[nodiscard]] std::string declareVariable(
const std::string& sourceName,
Type type,
StorageKind kind
);
[[nodiscard]] VariableInfo lookupVariable(
const std::string& sourceName
) const;
[[nodiscard]] std::string makeStorageName(
const std::string& sourceName
);
//----------------------------------
// IR helpers
//----------------------------------
[[nodiscard]] std::string newTemporary();
[[nodiscard]] std::string newLabel(const std::string& prefix);
void startBlock(const std::string& name);
void emit(IRInstruction instruction);
[[nodiscard]] bool currentBlockTerminated() const;
//----------------------------------
// Statements
//----------------------------------
void emitFunction(FunctionDeclarationNode& node);
void emitBlock(BlockNode& node, bool createScope = true);
void emitStatement(StatementNode& node);
void emitLetStatement(LetStatementNode& node);
void emitAssignment(AssignmentNode& node);
void emitIfStatement(IfStatementNode& node);
void emitWhileStatement(WhileStatementNode& node);
void emitReturnStatement(ReturnStatementNode& node);
void emitExpressionStatement(ExpressionStatementNode& node);
//----------------------------------
// Expressions
//----------------------------------
[[nodiscard]] IRValue emitExpression(ExpressionNode& node);
[[nodiscard]] IRValue emitIntLiteral(IntLiteralNode& node);
[[nodiscard]] IRValue emitBoolLiteral(BoolLiteralNode& node);
[[nodiscard]] IRValue emitIdentifier(IdentifierNode& node);
[[nodiscard]] IRValue emitBinaryExpression(BinaryExpressionNode& node);
[[nodiscard]] IRValue emitUnaryExpression(UnaryExpressionNode& node);
[[nodiscard]] IRValue emitFunctionCall(FunctionCallNode& node);
//----------------------------------
// Operators
//----------------------------------
[[nodiscard]] IROp binaryOpToIROp(BinaryOp op) const;
[[nodiscard]] IROp unaryOpToIROp(UnaryOp op) const;
[[nodiscard]] Type binaryResultType(BinaryOp op) const;
};
Two scope rules are especially important:
- The function body shares a scope with its parameters.
- Nested blocks create additional scopes.
This deliberately matches the semantic analyser. A parameter cannot be redeclared in the outermost function body, but a nested block may shadow it. Keeping semantic analysis and IR generation consistent prevents a valid source name from being mapped to the wrong storage slot.
Resetting the IR Generator and Starting Lowering
Create gsisi/src/IRGenerator.cpp:
#include "IRGenerator.hpp"
#include <stdexcept>
#include <utility>
The same generator object can be reused for multiple ASTs. generate() therefore begins from a clean state before registering built-ins, collecting function signatures, and lowering each function:
void IRGenerator::reset() {
_program = IRProgram{};
_currentFunction = nullptr;
_currentBlock = nullptr;
_functions.clear();
_scopes.clear();
_storageNameCounts.clear();
_blockNames.clear();
_temporaryCounter = 0;
_labelCounter = 0;
}
IRProgram IRGenerator::generate(ProgramNode& program) {
reset();
registerBuiltins();
collectFunctionSignatures(program);
for (auto& statement : program.nodes) {
auto* function =
dynamic_cast<FunctionDeclarationNode*>(statement.get());
if (!function) {
throw std::runtime_error(
"IR error: only functions are allowed at global scope"
);
}
emitFunction(*function);
}
return std::move(_program);
}
Registering Function Signatures
The IR generator needs every function signature before it lowers call expressions. Collecting signatures first mirrors the two-pass idea used during semantic analysis and supports calls to functions declared later in the source file.
void IRGenerator::registerBuiltins() {
_functions.emplace(
"out",
FunctionInfo{
.parameterTypes = {Type::Int},
.returnType = Type::Void
}
);
}
void IRGenerator::collectFunctionSignatures(ProgramNode& program) {
for (auto& statement : program.nodes) {
auto* function =
dynamic_cast<FunctionDeclarationNode*>(statement.get());
if (!function) {
throw std::runtime_error(
"IR error: only functions are allowed at global scope"
);
}
FunctionInfo signature;
signature.returnType = function->returnType;
for (const ParameterNode& parameter : function->parameters) {
signature.parameterTypes.push_back(parameter.type);
}
const auto [_, inserted] =
_functions.emplace(function->name, std::move(signature));
if (!inserted) {
throw std::runtime_error(
"IR error: duplicate function '" + function->name + "'"
);
}
}
}
The semantic analyser should already reject duplicate and unknown functions before IR generation begins. Rechecking critical invariants here is still useful because the IR generator is a compiler boundary and should fail clearly if it ever receives invalid internal input.
Scopes and Unique Storage Names
The source language allows a nested block to shadow an outer variable:
fn main(): int {
let x: int = 1;
if (true) {
let x: int = 2;
out(x);
}
return x;
}
If both declarations were represented by the same IR storage name x, the backend could no longer distinguish the outer variable from the shadowing declaration. The IR generator therefore assigns a unique internal storage name to each declaration:
locals:
x: int
x$1: int
The scope stack maps each source name to the correct internal storage name.
void IRGenerator::enterScope() {
_scopes.emplace_back();
}
void IRGenerator::exitScope() {
if (_scopes.empty()) {
throw std::runtime_error(
"IR internal error: no active scope to exit"
);
}
_scopes.pop_back();
}
std::string IRGenerator::makeStorageName(
const std::string& sourceName
) {
int& count = _storageNameCounts[sourceName];
if (count++ == 0) {
return sourceName;
}
return sourceName + "$" + std::to_string(count - 1);
}
std::string IRGenerator::declareVariable(
const std::string& sourceName,
Type type,
StorageKind kind
) {
if (_scopes.empty() || !_currentFunction) {
throw std::runtime_error(
"IR internal error: variable declared without an active function"
);
}
auto& currentScope = _scopes.back();
if (currentScope.contains(sourceName)) {
throw std::runtime_error(
"IR error: duplicate variable '" + sourceName + "'"
);
}
std::string storageName = makeStorageName(sourceName);
currentScope.emplace(
sourceName,
VariableInfo{
.storageName = storageName,
.type = type
}
);
if (kind == StorageKind::Parameter) {
_currentFunction->parameters.push_back(
IRParameter{storageName, type}
);
} else {
_currentFunction->locals.push_back(
IRLocal{storageName, type}
);
}
return storageName;
}
IRGenerator::VariableInfo IRGenerator::lookupVariable(
const std::string& sourceName
) const {
for (auto scope = _scopes.rbegin();
scope != _scopes.rend();
++scope) {
const auto found = scope->find(sourceName);
if (found != scope->end()) {
return found->second;
}
}
throw std::runtime_error(
"IR error: unknown variable '" + sourceName + "'"
);
}
The Si source still uses the name x; only the compiler’s internal representation changes. This is an important example of the IR preserving program meaning while discarding source-level naming ambiguity.
Temporaries, Labels and Basic Blocks
Generated temporaries hold intermediate expression results. They are unique within each function:
std::string IRGenerator::newTemporary() {
return "%t" + std::to_string(_temporaryCounter++);
}
Generated labels identify basic blocks and are also unique within each function:
std::string IRGenerator::newLabel(const std::string& prefix) {
return prefix + std::to_string(_labelCounter++);
}
Starting a basic block adds it to the current function and makes it the active destination for subsequently emitted instructions:
void IRGenerator::startBlock(const std::string& name) {
if (!_currentFunction) {
throw std::runtime_error(
"IR internal error: no active function"
);
}
if (!_blockNames.insert(name).second) {
throw std::runtime_error(
"IR internal error: duplicate block '" + name + "'"
);
}
_currentFunction->blocks.push_back(IRBlock{
.name = name
});
_currentBlock = &_currentFunction->blocks.back();
}
emit() rejects instructions placed after a terminator:
void IRGenerator::emit(IRInstruction instruction) {
if (!_currentBlock) {
throw std::runtime_error(
"IR internal error: no active block"
);
}
if (currentBlockTerminated()) {
throw std::runtime_error(
"IR internal error: instruction emitted after terminator"
);
}
_currentBlock->instructions.push_back(std::move(instruction));
}
bool IRGenerator::currentBlockTerminated() const {
if (!_currentBlock || _currentBlock->instructions.empty()) {
return false;
}
return isTerminator(_currentBlock->instructions.back().op);
}
Lowering Functions
Each AST function becomes an IRFunction with its own parameters, local storage, temporary counter, label counter, scopes, and basic blocks.
void IRGenerator::emitFunction(FunctionDeclarationNode& node) {
_program.functions.push_back(IRFunction{});
_currentFunction = &_program.functions.back();
_currentFunction->name = node.name;
_currentFunction->returnType = node.returnType;
_currentBlock = nullptr;
_scopes.clear();
_storageNameCounts.clear();
_blockNames.clear();
_temporaryCounter = 0;
_labelCounter = 0;
enterScope();
for (const ParameterNode& parameter : node.parameters) {
(void)declareVariable(
parameter.name,
parameter.type,
StorageKind::Parameter
);
}
startBlock("entry");
// The outer function body shares its scope with the parameters.
emitBlock(*node.body, false);
if (!currentBlockTerminated()) {
if (node.returnType != Type::Void) {
throw std::runtime_error(
"IR error: non-void function '" +
node.name +
"' reaches the end without returning"
);
}
emit(IRInstruction{
.op = IROp::Return
});
}
exitScope();
_currentBlock = nullptr;
_currentFunction = nullptr;
}
Semantic analysis already checks the return rules supported by this compiler. The IR generator still performs a final defensive check so a malformed non-void IR function cannot silently reach the backend without a terminator.
Lowering Blocks and Statements
Nested AST blocks create nested IR-generation scopes. The outermost function body does not create another one because it intentionally shares the function’s parameter scope.
void IRGenerator::emitBlock(
BlockNode& node,
bool createScope
) {
if (createScope) {
enterScope();
}
for (auto& statement : node.statements) {
if (currentBlockTerminated()) {
break;
}
emitStatement(*statement);
}
if (createScope) {
exitScope();
}
}
Once the correct function and block are active, the statement router dispatches each AST statement to its lowering routine:
void IRGenerator::emitStatement(StatementNode& node) {
if (auto* let = dynamic_cast<LetStatementNode*>(&node)) {
emitLetStatement(*let);
return;
}
if (auto* assignment = dynamic_cast<AssignmentNode*>(&node)) {
emitAssignment(*assignment);
return;
}
if (auto* ifStatement = dynamic_cast<IfStatementNode*>(&node)) {
emitIfStatement(*ifStatement);
return;
}
if (auto* whileStatement = dynamic_cast<WhileStatementNode*>(&node)) {
emitWhileStatement(*whileStatement);
return;
}
if (auto* returnStatement =
dynamic_cast<ReturnStatementNode*>(&node)) {
emitReturnStatement(*returnStatement);
return;
}
if (auto* expressionStatement =
dynamic_cast<ExpressionStatementNode*>(&node)) {
emitExpressionStatement(*expressionStatement);
return;
}
if (dynamic_cast<FunctionDeclarationNode*>(&node)) {
throw std::runtime_error(
"IR error: nested functions are not supported"
);
}
throw std::runtime_error(
"IR internal error: unsupported statement node"
);
}
Lowering Variables
Variable lowering must preserve the same scope semantics that were validated earlier. A variable initialiser is lowered before the new variable enters scope, which prevents accidental self-reference:
let x: int = x + 1;
void IRGenerator::emitLetStatement(LetStatementNode& node) {
IRValue initialValue = emitExpression(*node.initialiser);
const std::string storageName = declareVariable(
node.identifier,
node.type,
StorageKind::Local
);
emit(IRInstruction{
.op = IROp::Store,
.args = {initialValue.name, storageName}
});
}
Assignments use the scope stack to resolve the source identifier to its correct internal storage slot:
void IRGenerator::emitAssignment(AssignmentNode& node) {
const VariableInfo variable =
lookupVariable(node.identifier);
IRValue value = emitExpression(*node.expression);
emit(IRInstruction{
.op = IROp::Store,
.args = {value.name, variable.storageName}
});
}
The semantic analyser has already confirmed that the expression type matches the variable type.
Lowering if Statements
Structured source-level control flow must become explicit in the IR. An if statement is lowered into a conditional branch and separate basic blocks for the reachable paths.
if (x == 10) {
out(x);
} else {
out(0);
}
becomes:
branch %condition, if_then0, if_else2
if_then0:
...
jump if_end1
if_else2:
...
jump if_end1
if_end1:
The merge block is only needed when at least one branch can fall through to code after the if. If every branch terminates, creating an unreachable merge block would add unnecessary IR.
void IRGenerator::emitIfStatement(IfStatementNode& node) {
IRValue condition = emitExpression(*node.condition);
const std::string thenLabel = newLabel("if_then");
const std::string endLabel = newLabel("if_end");
const bool hasElse = static_cast<bool>(node.elseBranch);
const std::string elseLabel =
hasElse ? newLabel("if_else") : endLabel;
emit(IRInstruction{
.op = IROp::Branch,
.args = {condition.name},
.trueTarget = thenLabel,
.falseTarget = elseLabel
});
startBlock(thenLabel);
emitBlock(*node.thenBranch);
const bool thenFallsThrough =
!currentBlockTerminated();
if (thenFallsThrough) {
emit(IRInstruction{
.op = IROp::Jump,
.target = endLabel
});
}
bool elseFallsThrough = !hasElse;
if (hasElse) {
startBlock(elseLabel);
emitBlock(*node.elseBranch);
elseFallsThrough = !currentBlockTerminated();
if (elseFallsThrough) {
emit(IRInstruction{
.op = IROp::Jump,
.target = endLabel
});
}
}
if (thenFallsThrough || elseFallsThrough) {
startBlock(endLabel);
}
}
When both branches terminate with return, no merge block is required. The enclosing block also stops emitting because the active branch block is terminated.
Lowering while Loops
A while loop becomes explicit cyclic control flow built from three blocks:
- A condition block.
- A body block.
- An exit block.
while (i <= limit) {
i = i + 1;
}
becomes:
jump while_cond0
while_cond0:
%t0 = load i
%t1 = load limit
%t2 = less_equal %t0, %t1
branch %t2, while_body1, while_end2
while_body1:
...
jump while_cond0
while_end2:
void IRGenerator::emitWhileStatement(WhileStatementNode& node) {
const std::string conditionLabel =
newLabel("while_cond");
const std::string bodyLabel =
newLabel("while_body");
const std::string endLabel =
newLabel("while_end");
emit(IRInstruction{
.op = IROp::Jump,
.target = conditionLabel
});
startBlock(conditionLabel);
IRValue condition = emitExpression(*node.condition);
emit(IRInstruction{
.op = IROp::Branch,
.args = {condition.name},
.trueTarget = bodyLabel,
.falseTarget = endLabel
});
startBlock(bodyLabel);
emitBlock(*node.body);
if (!currentBlockTerminated()) {
emit(IRInstruction{
.op = IROp::Jump,
.target = conditionLabel
});
}
startBlock(endLabel);
}
The exit block always exists because a normal while loop may execute zero times: the first condition check can branch directly to the loop exit.
Lowering Returns and Expression Statements
A return terminates the current basic block. It either carries an IR value or represents a return from a void function:
void IRGenerator::emitReturnStatement(
ReturnStatementNode& node
) {
if (!node.expression) {
emit(IRInstruction{
.op = IROp::Return
});
return;
}
IRValue value = emitExpression(*node.expression);
emit(IRInstruction{
.op = IROp::Return,
.args = {value.name}
});
}
An expression statement still lowers the expression for its side effects but discards any produced result:
void IRGenerator::emitExpressionStatement(
ExpressionStatementNode& node
) {
(void)emitExpression(*node.expression);
}
This is mainly used for calls such as:
out(sum);
Lowering AST Expressions into IR Temporaries
Expression lowering is recursive. emitExpression() dispatches to the matching AST node, and each supported expression returns an IRValue describing the generated result:
IRGenerator::IRValue IRGenerator::emitExpression(
ExpressionNode& node
) {
if (auto* integer = dynamic_cast<IntLiteralNode*>(&node)) {
return emitIntLiteral(*integer);
}
if (auto* boolean = dynamic_cast<BoolLiteralNode*>(&node)) {
return emitBoolLiteral(*boolean);
}
if (auto* identifier = dynamic_cast<IdentifierNode*>(&node)) {
return emitIdentifier(*identifier);
}
if (auto* binary =
dynamic_cast<BinaryExpressionNode*>(&node)) {
return emitBinaryExpression(*binary);
}
if (auto* unary =
dynamic_cast<UnaryExpressionNode*>(&node)) {
return emitUnaryExpression(*unary);
}
if (auto* call = dynamic_cast<FunctionCallNode*>(&node)) {
return emitFunctionCall(*call);
}
throw std::runtime_error(
"IR internal error: unsupported expression node"
);
}
Integer and Boolean Literals
IRGenerator::IRValue IRGenerator::emitIntLiteral(
IntLiteralNode& node
) {
const std::string destination = newTemporary();
emit(IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = destination,
.intValue = node.value
});
return IRValue{
.name = destination,
.type = Type::Int
};
}
IRGenerator::IRValue IRGenerator::emitBoolLiteral(
BoolLiteralNode& node
) {
const std::string destination = newTemporary();
emit(IRInstruction{
.op = IROp::ConstBool,
.type = Type::Bool,
.dst = destination,
.boolValue = node.value
});
return IRValue{
.name = destination,
.type = Type::Bool
};
}
Identifiers
Reading an identifier first resolves its source name through the active scope stack, then emits a load from the resulting internal storage name:
IRGenerator::IRValue IRGenerator::emitIdentifier(
IdentifierNode& node
) {
const VariableInfo variable =
lookupVariable(node.name);
const std::string destination = newTemporary();
emit(IRInstruction{
.op = IROp::Load,
.type = variable.type,
.dst = destination,
.args = {variable.storageName}
});
return IRValue{
.name = destination,
.type = variable.type
};
}
Binary Expressions
For a binary expression, the generator lowers the left operand, then the right operand, and finally emits the IR operation that consumes both results:
IRGenerator::IRValue IRGenerator::emitBinaryExpression(
BinaryExpressionNode& node
) {
IRValue left = emitExpression(*node.left);
IRValue right = emitExpression(*node.right);
const std::string destination = newTemporary();
const Type resultType = binaryResultType(node.oper);
emit(IRInstruction{
.op = binaryOpToIROp(node.oper),
.type = resultType,
.dst = destination,
.args = {left.name, right.name}
});
return IRValue{
.name = destination,
.type = resultType
};
}
Because the left operand is lowered before the right operand, expressions containing function calls also receive a predictable left-to-right evaluation order in this compiler.
Unary Expressions
IRGenerator::IRValue IRGenerator::emitUnaryExpression(
UnaryExpressionNode& node
) {
IRValue operand = emitExpression(*node.right);
const std::string destination = newTemporary();
emit(IRInstruction{
.op = unaryOpToIROp(node.oper),
.type = operand.type,
.dst = destination,
.args = {operand.name}
});
return IRValue{
.name = destination,
.type = operand.type
};
}
Semantic analysis has already guaranteed that unary - receives an int and unary ! receives a bool, so this stage only needs to emit the corresponding operation.
Function Calls
Function calls make evaluation order and return values explicit. The generator first lowers each argument:
add(sum, i)
%t0 = load sum
%t1 = load i
%t2 = call add(%t0, %t1)
A void call has no destination:
call out(%t0)
IRGenerator::IRValue IRGenerator::emitFunctionCall(
FunctionCallNode& node
) {
const auto function = _functions.find(node.name);
if (function == _functions.end()) {
throw std::runtime_error(
"IR error: unknown function '" + node.name + "'"
);
}
const FunctionInfo& signature = function->second;
std::vector<std::string> arguments;
arguments.reserve(node.arguments.size());
for (auto& argument : node.arguments) {
IRValue value = emitExpression(*argument);
arguments.push_back(value.name);
}
std::string destination;
if (signature.returnType != Type::Void) {
destination = newTemporary();
}
emit(IRInstruction{
.op = IROp::Call,
.type = signature.returnType,
.dst = destination,
.args = std::move(arguments),
.callee = node.name
});
return IRValue{
.name = destination,
.type = signature.returnType
};
}
Mapping AST Operators to IR Operations
The AST and IR deliberately use separate operator enums. Mapping between them keeps the source-oriented AST layer independent from the lower-level instruction vocabulary used by the IR and backend.
IROp IRGenerator::binaryOpToIROp(BinaryOp op) const {
switch (op) {
case BinaryOp::Add: return IROp::Add;
case BinaryOp::Subtract: return IROp::Subtract;
case BinaryOp::Multiply: return IROp::Multiply;
case BinaryOp::Divide: return IROp::Divide;
case BinaryOp::Less: return IROp::Less;
case BinaryOp::LessEqual: return IROp::LessEqual;
case BinaryOp::Greater: return IROp::Greater;
case BinaryOp::GreaterEqual: return IROp::GreaterEqual;
case BinaryOp::Equal: return IROp::Equal;
case BinaryOp::NotEqual: return IROp::NotEqual;
}
throw std::runtime_error(
"IR internal error: unsupported binary operator"
);
}
IROp IRGenerator::unaryOpToIROp(UnaryOp op) const {
switch (op) {
case UnaryOp::Negate: return IROp::Negate;
case UnaryOp::Not: return IROp::Not;
}
throw std::runtime_error(
"IR internal error: unsupported unary operator"
);
}
Type IRGenerator::binaryResultType(BinaryOp op) const {
switch (op) {
case BinaryOp::Add:
case BinaryOp::Subtract:
case BinaryOp::Multiply:
case BinaryOp::Divide:
return Type::Int;
case BinaryOp::Less:
case BinaryOp::LessEqual:
case BinaryOp::Greater:
case BinaryOp::GreaterEqual:
case BinaryOp::Equal:
case BinaryOp::NotEqual:
return Type::Bool;
}
throw std::runtime_error(
"IR internal error: unsupported binary operator"
);
}
The semantic analyser has already checked operand compatibility. The mapping helpers only select the IR operation and result type that later compiler stages require.
Updating main.cpp
Update gsisi/src/main.cpp:
#include <exception>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "IRGenerator.hpp"
#include "IRPrinter.hpp"
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
#include "Token.hpp"
/**
* Usage: ./si_compiler <source_file.si>
*/
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr
<< "Usage: " << argv[0]
<< " <source_file.si>\n";
return 1;
}
std::ifstream inputFile(argv[1], std::ios::binary);
if (!inputFile) {
std::cerr
<< "Error: could not open '"
<< argv[1]
<< "'\n";
return 1;
}
std::ostringstream buffer;
buffer << inputFile.rdbuf();
if (inputFile.bad()) {
std::cerr
<< "Error: failed while reading '"
<< argv[1]
<< "'\n";
return 1;
}
try {
Lexer lexer(buffer.str());
std::vector<Token> tokens = lexer.tokenise();
Parser parser(std::move(tokens));
std::unique_ptr<ProgramNode> ast =
parser.parseProgram();
SemanticAnalyser analyser;
analyser.analyze(*ast);
IRGenerator generator;
IRProgram ir = generator.generate(*ast);
dumpIR(std::cout, ir);
}
catch (const std::exception& error) {
std::cerr
<< "Compilation error: "
<< error.what()
<< '\n';
return 1;
}
return 0;
}
The pipeline now stops immediately when lexing, parsing or semantic analysis fails. IR is generated only from a checked AST.
Test Program
Keep the end-to-end sample at gsisi/tests/test-3.si:
fn add(a: int, b: int): int {
return a + b;
}
fn main(): int {
let limit: int = 10;
let sum: int = 0;
let i: int = 1;
while (i <= limit) {
sum = add(sum, i);
i = i + 1;
}
if (sum == 55) {
out(sum);
} else {
out(0);
}
return sum;
}
Running the compiler should print IR containing:
- Two functions:
addandmain. - A loop condition, body and exit block.
- A call to
add. - A conditional branch for the final
if. - A call to the built-in
out. - A final
return.
Temporary and label numbers are implementation details rather than part of the IR’s semantic contract. Tests should therefore verify operations, relationships, blocks, and invariants instead of depending on every generated number.
Testing the IR Generator
IR generation deserves its own tests. If the lowering stage is verified independently, a later backend failure is much easier to localise because we can distinguish malformed IR from incorrect assembly generation.
Create gsisi/tests/IRGeneratorTests.cpp:
#include <algorithm>
#include <exception>
#include <functional>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "IR.hpp"
#include "IRGenerator.hpp"
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
#include "Token.hpp"
namespace {
void require(bool condition, const std::string& message) {
if (!condition) {
throw std::runtime_error(message);
}
}
IRProgram lower(const std::string& source) {
Lexer lexer(source);
std::vector<Token> tokens = lexer.tokenise();
Parser parser(std::move(tokens));
std::unique_ptr<ProgramNode> ast =
parser.parseProgram();
SemanticAnalyser analyser;
analyser.analyze(*ast);
IRGenerator generator;
return generator.generate(*ast);
}
const IRFunction& findFunction(
const IRProgram& program,
const std::string& name
) {
const auto found = std::find_if(
program.functions.begin(),
program.functions.end(),
[&](const IRFunction& function) {
return function.name == name;
}
);
require(
found != program.functions.end(),
"missing IR function '" + name + "'"
);
return *found;
}
std::size_t countOperation(
const IRFunction& function,
IROp operation
) {
std::size_t count = 0;
for (const IRBlock& block : function.blocks) {
count += static_cast<std::size_t>(
std::count_if(
block.instructions.begin(),
block.instructions.end(),
[&](const IRInstruction& instruction) {
return instruction.op == operation;
}
)
);
}
return count;
}
void testArithmeticPrecedence() {
const IRProgram program = lower(R"(
fn main(): int {
let value: int = 5 + 2 * 10;
return value;
}
)");
const IRFunction& main = findFunction(program, "main");
const IRBlock& entry = main.blocks.at(0);
auto multiply = std::find_if(
entry.instructions.begin(),
entry.instructions.end(),
[](const IRInstruction& instruction) {
return instruction.op == IROp::Multiply;
}
);
auto add = std::find_if(
entry.instructions.begin(),
entry.instructions.end(),
[](const IRInstruction& instruction) {
return instruction.op == IROp::Add;
}
);
require(
multiply != entry.instructions.end(),
"expected multiply instruction"
);
require(
add != entry.instructions.end(),
"expected add instruction"
);
require(
multiply < add,
"multiplication must be lowered before addition"
);
}
void testWhileControlFlow() {
const IRProgram program = lower(R"(
fn main(): int {
let i: int = 0;
while (i < 3) {
i = i + 1;
}
return i;
}
)");
const IRFunction& main = findFunction(program, "main");
require(
countOperation(main, IROp::Branch) == 1,
"while loop should emit one conditional branch"
);
require(
countOperation(main, IROp::Jump) >= 2,
"while loop should emit entry and back-edge jumps"
);
require(
main.blocks.size() == 4,
"while loop should create entry, condition, body and exit blocks"
);
}
void testValueAndVoidCalls() {
const IRProgram program = lower(R"(
fn add(a: int, b: int): int {
return a + b;
}
fn main(): int {
let result: int = add(20, 22);
out(result);
return result;
}
)");
const IRFunction& main = findFunction(program, "main");
const IRInstruction* addCall = nullptr;
const IRInstruction* outCall = nullptr;
for (const IRBlock& block : main.blocks) {
for (const IRInstruction& instruction : block.instructions) {
if (instruction.op != IROp::Call) {
continue;
}
if (instruction.callee == "add") {
addCall = &instruction;
}
if (instruction.callee == "out") {
outCall = &instruction;
}
}
}
require(addCall != nullptr, "missing call to add");
require(outCall != nullptr, "missing call to out");
require(
!addCall->dst.empty() && addCall->type == Type::Int,
"non-void call should produce an int temporary"
);
require(
outCall->dst.empty() && outCall->type == Type::Void,
"void call should not produce a destination"
);
}
void testVariableShadowingUsesDistinctStorage() {
const IRProgram program = lower(R"(
fn main(): int {
let x: int = 1;
if (true) {
let x: int = 2;
out(x);
}
return x;
}
)");
const IRFunction& main = findFunction(program, "main");
require(
main.locals.size() == 2,
"expected two local storage slots"
);
require(
main.locals[0].name == "x",
"first declaration should keep the readable name x"
);
require(
main.locals[1].name == "x$1",
"shadowed declaration should use x$1"
);
bool loadedOuter = false;
bool loadedInner = false;
for (const IRBlock& block : main.blocks) {
for (const IRInstruction& instruction : block.instructions) {
if (instruction.op != IROp::Load) {
continue;
}
loadedOuter |= instruction.args.at(0) == "x";
loadedInner |= instruction.args.at(0) == "x$1";
}
}
require(
loadedOuter && loadedInner,
"IR should load both the outer and inner storage slots"
);
}
void testEveryBlockEndsWithATerminator() {
const IRProgram program = lower(R"(
fn choose(flag: bool): int {
if (flag) {
return 1;
} else {
return 2;
}
}
fn main(): int {
return choose(true);
}
)");
for (const IRFunction& function : program.functions) {
for (const IRBlock& block : function.blocks) {
require(
!block.instructions.empty(),
"basic block must not be empty"
);
require(
isTerminator(block.instructions.back().op),
"basic block '" + block.name +
"' must end with a terminator"
);
}
}
}
void testGeneratorCanBeReused() {
const std::string source = R"(
fn main(): int {
return 1;
}
)";
Lexer lexer(source);
std::vector<Token> tokens = lexer.tokenise();
Parser parser(std::move(tokens));
std::unique_ptr<ProgramNode> ast =
parser.parseProgram();
SemanticAnalyser analyser;
analyser.analyze(*ast);
IRGenerator generator;
const IRProgram first = generator.generate(*ast);
const IRProgram second = generator.generate(*ast);
require(
first.functions.size() == 1 &&
second.functions.size() == 1,
"generator state should reset between runs"
);
require(
first.functions[0].blocks[0].instructions[0].dst == "%t0" &&
second.functions[0].blocks[0].instructions[0].dst == "%t0",
"temporary numbering should restart for each generation"
);
}
void runTest(
const std::string& name,
const std::function<void()>& test
) {
try {
test();
std::cout << "[PASS] " << name << '\n';
}
catch (const std::exception& error) {
std::cerr
<< "[FAIL] " << name
<< ": " << error.what()
<< '\n';
throw;
}
}
} // namespace
int main() {
try {
runTest(
"arithmetic precedence",
testArithmeticPrecedence
);
runTest(
"while control flow",
testWhileControlFlow
);
runTest(
"value and void calls",
testValueAndVoidCalls
);
runTest(
"variable shadowing",
testVariableShadowingUsesDistinctStorage
);
runTest(
"block terminators",
testEveryBlockEndsWithATerminator
);
runTest(
"generator reuse",
testGeneratorCanBeReused
);
}
catch (...) {
return 1;
}
std::cout << "6/6 IR generator tests passed\n";
return 0;
}
These tests verify behaviour rather than a single full-text IR snapshot. That makes them less fragile when temporary or label naming changes.
Update CMakeLists.txt
Update gsisi/CMakeLists.txt:
cmake_minimum_required(VERSION 3.10)
# Name of your project
project(gsisi LANGUAGES CXX)
# Use C++20
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
file(GLOB SOURCES "src/*.cpp")
list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp")
add_library(gsisi_frontend ${SOURCES})
target_include_directories(gsisi_frontend
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
add_executable(si_compiler src/main.cpp)
target_link_libraries(si_compiler
PRIVATE
gsisi_frontend
)
# --------------------------------------------------
# Tests
# --------------------------------------------------
option(BUILD_TESTING "Build the gsisi tests" ON)
if(BUILD_TESTING)
enable_testing()
add_executable(parser_tests
tests/ParserTests.cpp
${SOURCES}
)
add_executable(semantic_tests
tests/SemanticAnalyserTests.cpp
)
add_executable(ir_generator_tests
tests/IRGeneratorTests.cpp
)
target_include_directories(parser_tests PRIVATE include)
target_link_libraries(semantic_tests PRIVATE gsisi_frontend)
target_link_libraries(ir_generator_tests PRIVATE gsisi_frontend)
add_test(
NAME parser_tests
COMMAND parser_tests
)
add_test(
NAME semantic_tests
COMMAND semantic_tests
)
add_test(
NAME ir_generator_tests
COMMAND ir_generator_tests
)
add_test(
NAME semantic_sample
COMMAND si_compiler
${CMAKE_CURRENT_SOURCE_DIR}/tests/test-2.si
)
add_test(
NAME compiler_ir_smoke_test
COMMAND si_compiler
${CMAKE_CURRENT_SOURCE_DIR}/tests/test-3.si
)
endif()
We use enable_testing() directly instead of include(CTest). This registers local tests without creating the additional CTest dashboard targets that caused permission problems in some Docker-mounted build directories.
Build and Run the Tests
From the repository root:
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
Run the compiler against the shared sample program:
./build/si_compiler tests/test-3.si
Expected CTest summary:
100% tests passed, 0 tests failed out of 5
The custom test executable should end with:
6/6 IR generator tests passed
If the bind-mounted repository does not allow the container user to write to build/, place the build tree in the container’s home directory:
cmake -S . -B /home/dev/gsisi-build -DBUILD_TESTING=ON
cmake --build /home/dev/gsisi-build
ctest --test-dir /home/dev/gsisi-build --output-on-failure
Common AST-to-IR Lowering Mistakes
Lowering is straightforward in principle, but several implementation mistakes can make the generated IR subtly incorrect:
- Generating assembly-oriented decisions too early. The IR should expose program operations without prematurely committing to x86-64 registers or instruction encodings.
- Losing source-level shadowing. Distinct declarations that share one source name still need distinct internal storage identities.
- Emitting instructions after a terminator. Once a block ends with a jump, branch, or return, later instructions in that block are unreachable and violate the IR invariant used by the backend.
- Forgetting a loop back edge. A lowered loop body must return control to the condition block when it falls through.
- Creating unnecessary merge blocks. If every branch of an
ifterminates, there is no reachable continuation block to merge into. - Rechecking all semantic rules during lowering. The generator should rely on the checked AST and keep only defensive internal validation.
- Testing exact temporary numbers instead of structure. Generated names are implementation details; control flow and operation relationships are the important contract.
Intermediate Representation vs Three-Address Code
The terms are related but not interchangeable.
An Intermediate Representation is any compiler-internal form used between stages. Three-address code is one family of IR designs that decomposes complex expressions into small instructions with explicit operands and temporaries.
The representation in this chapter is therefore best described as a three-address-style Intermediate Representation. It uses temporaries for expression results, but it also includes explicit storage operations, calls, basic blocks, and control-flow terminators tailored to this compiler.
Frequently Asked Questions
What is an Intermediate Representation in compiler design?
An Intermediate Representation, or IR, is an internal form of a program used between compiler stages. It gives the compiler a representation that is easier to analyse, optimise, or translate than the original source syntax.
Why not generate assembly directly from the AST?
Direct AST-to-assembly generation is possible, especially for a very small compiler. Introducing an IR creates a cleaner boundary: source-language constructs are lowered once, while the backend only needs to understand a smaller set of IR operations and control-flow rules.
What is three-address code?
Three-address code is an intermediate-code style that breaks complex expressions into small operations with explicit operands and temporary results. A typical binary instruction has one destination and two input values.
Is this IR exactly traditional three-address code?
No. It is a three-address-style IR. Calls can have multiple arguments, branches reference block labels, and variables use explicit load and store operations. The design borrows the main idea of flattening expressions into small explicit instructions.
What is AST lowering?
AST lowering is the process of translating higher-level AST nodes into a lower-level representation. In this chapter, expressions become temporaries and operations, variables become storage references, and structured control flow becomes basic blocks with explicit branches and jumps.
What is a basic block in a compiler?
A basic block is a straight-line sequence of instructions with one entry point. In this IR, a block normally ends with a terminator such as jump, branch, or return, which determines where control flows next.
Why does the IR use temporary values?
Temporaries make intermediate expression results explicit. Instead of keeping a nested expression tree, each operation can consume named values produced by earlier instructions.
Why are shadowed variables renamed internally?
Two different declarations can use the same source name in nested scopes. Unique internal storage names preserve their separate identities so the backend does not confuse the inner declaration with the outer one.
Is this IR in SSA form?
No. This implementation uses explicit mutable storage with load and store operations and does not build Static Single Assignment form. SSA could be introduced as a later compiler transformation if the project needs it.
What happens after IR generation?
The next stage consumes the validated IR rather than the source AST. In this project, Chapter 7 translates the IR into x86-64 NASM assembly.
What the IR Guarantees
The IR acts as a contract between the front end and the backend. After successful lowering, the backend may assume:
- Every function and variable reference has already been resolved.
- Every expression has a valid static type.
- Shadowed variables use distinct storage names.
- Complex expressions have been flattened into temporaries.
- Control flow is represented with explicit blocks and terminators.
- Calls and return values are explicit instructions.
The backend therefore does not need to understand Si grammar or AST node classes. It only needs to translate the IR’s operations, storage references, temporaries, blocks, calls, and terminators.
What the IR Does Not Do Yet
This IR is intentionally unoptimised. The first goal is a correct and inspectable lowering stage with a clean backend boundary.
let x: int = 2 + 3;
still becomes:
%t0 = const_int 2
%t1 = const_int 3
%t2 = add %t0, %t1
store %t2, x
A later optimisation pass can fold it into:
%t0 = const_int 5
store %t0, x
Once correctness is stable, dedicated optimisation passes can transform the IR without changing the parser or source AST. Possible later passes include:
- Constant folding.
- Dead-code elimination.
- Copy propagation.
- Removal of unused temporaries.
- Simplification of constant branches.
- Unreachable-block removal.
Compiler IR: Key Takeaways
- An Intermediate Representation creates a stable boundary between the source-oriented front end and the machine-specific backend.
- The checked AST is lowered into a compact three-address-style IR.
- Complex expressions are flattened into explicit instructions and temporary values.
- Variables use
loadandstoreoperations with unique internal storage names. - Basic blocks plus jump, branch, and return terminators make control flow explicit.
- Structured
ifstatements andwhileloops are lowered into block-level control flow. - Function calls and return values are represented independently of source syntax.
- Semantic analysis validates the source program first, allowing IR generation to focus on lowering.
- Defensive IR checks protect important compiler invariants.
- Automated tests verify instruction order, calls, control flow, block termination, shadowing, and generator reuse.
- The resulting IR is ready for the x86-64 backend and future optimisation passes.
Next Chapter
In Chapter 7, we will build the first machine-specific backend and translate this validated Intermediate Representation into x86-64 NASM assembly.
Get the next part
New parts of From Source to Machine Code land in your inbox the day they publish. No spam, unsubscribe in one click.
We send your address to Kit, our email provider, and ask you to confirm by email before anything is sent. See our Privacy Statement.