Skip to main content

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 Updated 35 min Intermediate

Illustrated compiler pipeline showing source code passing through analysis and code-generation stages before becoming machine code.
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 load and store operations;
  • represent if statements 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;
  • load and store operations;
  • 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:

  1. The semantic analyser validates the AST first.
  2. The IR generator visits functions, statements, and expressions.
  3. Complex expressions are flattened into sequences of temporary values.
  4. Source variables are mapped to internal storage names.
  5. Structured control flow becomes basic blocks connected by jumps and branches.
  6. Function calls and returns become explicit IR instructions.
  7. The completed IRProgram is 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:

RepresentationMain purposeTypical structureMachine-specific?
ASTPreserve source-language meaning and hierarchyTree of declarations, statements, and expressionsNo
Compiler IRExpose simpler operations and explicit control flowInstructions, temporaries, functions, and basic blocksUsually less machine-specific
AssemblyDescribe instructions for the target architectureRegisters, instructions, labels, calling conventionsYes

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, call or branch.
  • 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:

  1. The function body shares a scope with its parameters.
  2. 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: add and main.
  • 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 if terminates, 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 load and store operations with unique internal storage names.
  • Basic blocks plus jump, branch, and return terminators make control flow explicit.
  • Structured if statements and while loops 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.

Subscribe / Stdin

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.