Chapter 7 · Programming
How to Build a Compiler Backend in C++: x86-64 Generation
Build a compiler backend in C++ that translates checked IR into x86-64 NASM assembly with stack frames, arithmetic operations and native Linux executables.
By Gianni Updated 32 min Intermediate
On this page
The compiler can now lower a checked Si program into an Intermediate Representation (IR). That IR is easier to analyse than the original AST, but a processor still cannot execute it directly. The next stage is the compiler backend, which translates the checked IR into target-specific instructions.
In this chapter, we build the first gsisi backend in C++ and generate x86-64 assembly in NASM syntax for Linux. We will create stack frames, assign storage to locals and temporaries, translate arithmetic and unary IR operations, emit function returns, assemble the generated NASM source, link it as ELF64, and run the resulting native executable.
Si source
↓
Lexer
↓
Parser
↓
AST
↓
Semantic analyser
↓
Intermediate Representation
↓
Assembly generator
↓
x86-64 NASM assembly
↓
Assembler and linker
↓
Native Linux executable
This first backend intentionally supports only straight-line programs. It handles constants, local variables, arithmetic, unary operations, and returns. Comparisons, control flow, function arguments, and calls are deliberately postponed to later chapters so the first code-generation stage stays small enough to understand instruction by instruction.
What You Will Learn
By the end of this chapter, you will be able to:
- explain what a compiler backend does;
- explain how IR is translated into x86-64 assembly;
- translate a supported subset of gsisi IR into NASM syntax;
- build stack frames for generated Si functions;
- assign stack slots to local variables and generated temporaries;
- generate constants, loads, stores, arithmetic, and unary operations;
- return an integer from a compiled Si program;
- assemble and link generated code into a native Linux executable;
- test assembly output independently from earlier compiler stages;
- run an end-to-end backend smoke test using
tests/test-4.si.
What Is a Compiler Backend?
A compiler backend is the part of a compiler that turns a lower-level program representation into code for a target platform. Earlier stages are concerned with source syntax, names, types, and language semantics. The backend is concerned with how the validated program will actually execute on the target machine.
In this project, the backend receives an IRProgram and produces x86-64 NASM assembly. That means the backend does not need to understand Si grammar or AST node classes. It only needs to understand the IR operations that Chapter 7 supports.
The backend is responsible for decisions such as:
- how generated values are stored;
- which x86-64 registers are used for individual operations;
- how function stack frames are laid out;
- how IR arithmetic maps to machine instructions;
- where return values are placed;
- how generated labels and entry points are named.
Later chapters will extend the same backend with calling conventions, comparisons, branches, loops, and runtime support.
How Does Compiler Code Generation Work?
Code generation translates validated compiler IR into instructions for a specific target architecture.
For this first backend, the process is:
- Validate that the incoming IR only contains operations supported in Chapter 7.
- Allocate a 32-bit stack slot for each local variable and temporary result.
- Emit the program entry point and each generated function.
- Translate each supported IR instruction into one or more x86-64 instructions.
- Place integer return values in
eax. - Emit a shared function epilogue that restores the stack frame.
- Write NASM assembly only after the complete IR has passed backend validation.
- Assemble the NASM source to ELF64 and link it into a native Linux executable.
The important architectural boundary is that semantic correctness has already been checked. The backend’s job is to preserve that behaviour while expressing it in the target machine’s instruction set.
IR vs Assembly vs Machine Code
These forms represent the same program at different levels:
| Representation | Purpose | Example concepts | Target-specific? |
|---|---|---|---|
| Compiler IR | Simple backend-facing operations | temporaries, loads, stores, arithmetic, returns | Mostly no |
| Assembly | Human-readable target instructions | mov, add, imul, idiv, registers, labels | Yes |
| Machine code | Binary instructions executed by the CPU | encoded x86-64 instructions | Yes |
NASM converts the assembly generated in this chapter into an ELF64 object file. The linker then produces the executable that Linux can load and run.
Target Platform
The backend in this series targets:
- Operating system: Linux
- Architecture: x86-64
- Assembler syntax: NASM
- Object format: ELF64
- Si integer width: 32 bits
- Si boolean representation:
0for false and1for true
These target choices matter because assembly syntax, executable formats, entry points, calling conventions, and operating-system interfaces are platform-dependent. The generated assembly in this chapter is therefore specifically for Linux x86-64 using NASM and ELF64.
It will not run natively as a Windows or macOS executable. The Docker development environment gives the project a consistent Linux x86-64 toolchain with CMake, NASM, and the GNU linker.
Supported IR in Chapter 7
The Chapter 7 backend deliberately supports a narrow IR subset. The following operations can be translated into assembly:
const_int
const_bool
load
store
add
sub
mul
div
negate
not
return
The following operations are valid parts of the wider IR, but this first backend rejects them explicitly because their x86-64 lowering has not been implemented yet:
less
less_equal
greater
greater_equal
equal
not_equal
call
jump
branch
Function parameters are also rejected in this chapter. Explicit rejection is an important backend rule: a clear compiler error is much safer than silently emitting assembly whose behaviour is wrong.
A valid Chapter 7 program therefore looks like this:
fn main(): int {
let x: int = 5;
let y: int = 10;
let result: int = x + y;
How IR Becomes x86-64 Assembly
Consider this IR:
fn main() -> int
locals:
x: int
entry:
%t0 = const_int 5
store %t0, x
%t1 = load x
return %t1
The backend can translate it into assembly like this:
global _start
section .text
_start:
call si_main
mov edi, eax
mov eax, 60
syscall
si_main:
push rbp
mov rbp, rsp
sub rsp, 16
.L_main_entry:
mov dword [rbp - 8], 5
mov eax, dword [rbp - 8]
mov dword [rbp - 4], eax
mov eax, dword [rbp - 4]
mov dword [rbp - 12], eax
mov eax, dword [rbp - 12]
jmp .L_main_return
.L_main_return:
mov rsp, rbp
pop rbp
ret
The exact stack offsets depend on allocation order, but the important behaviour is stable:
- Reserve stack storage.
- Store constants and temporary results in stack slots.
- Load the return value into
eax. - Restore the stack frame.
- Return to
_start, which converts the Simainresult into the Linux process exit status.
This example illustrates the central idea of the backend: one compact IR instruction may become one or several target instructions, but the observable program behaviour must remain the same.
x86-64 Basics Needed for Code Generation
Before implementing the generator, we only need the x86-64 concepts that this chapter actually uses. The goal is not to teach the complete instruction set; it is to understand the registers, stack operations, return convention, and Linux entry sequence required by this backend.
Registers
Registers are fast storage locations inside the processor. This first backend uses only a small subset:
eax— 32-bit arithmetic and integer return values.edx— the high half of the dividend used by signed division.edi— the first 32-bit integer argument and the process exit status.rbp— the current stack-frame base pointer.rsp— the current stack pointer.
The first letter distinguishes register widths:
rax = 64-bit register
eax = lower 32 bits of rax
ax = lower 16 bits of rax
al = lower 8 bits of rax
Si integers are defined as 32-bit values in this compiler, so generated integer arithmetic normally uses eax rather than the full 64-bit rax register.
mov eax, 5
add eax, 10
After these instructions, eax contains 15.
Stack Frames
Every generated Si function uses a simple stack frame. This gives locals and temporaries stable memory locations relative to rbp:
push rbp
mov rbp, rsp
sub rsp, 32
This sequence:
- Saves the caller’s base pointer.
- Makes
rbpthe stable base of the new frame. - Reserves space for locals and temporaries.
A 32-bit stack slot can be accessed with:
mov dword [rbp - 4], 10
mov eax, dword [rbp - 4]
dword means a 32-bit value.
Stack Alignment
The System V AMD64 ABI expects the stack to follow 16-byte alignment rules. Our frame allocator rounds the required storage size up to a multiple of 16:
4 bytes required -> 16-byte frame
12 bytes required -> 16-byte frame
20 bytes required -> 32-byte frame
Rounding the frame size can reserve a little more memory than the straight-line program strictly needs, but it keeps the stack layout simple and prepares the backend for function calls in later chapters.
Return Values
A 32-bit integer function returns its result in eax.
return 42;
becomes:
mov eax, dword [rbp - 4]
jmp .L_main_return
Every IR return jumps to one shared assembly epilogue. This centralises stack restoration and prevents every source-level return from duplicating the same cleanup sequence.
The Linux Entry Point
Because this chapter links a raw ELF64 executable directly rather than through the C runtime, execution begins at _start. The generated _start wrapper then calls the compiled Si main function.
global _start
section .text
_start:
call si_main
mov edi, eax
mov eax, 60
syscall
This code:
- Calls the compiled Si
mainfunction. - Copies its return value into
edi. - Loads Linux syscall number
60, which isexit. - Asks the kernel to terminate the process.
Linux exposes only the low eight bits of the process exit status to the shell. For that reason, the tests use a small value such as 42.
Compiler Backend Design
The AssemblyGenerator is intentionally small, but it has four distinct responsibilities:
generate(program):
validate the complete IR program
emit the assembly header and _start
for every function:
allocate stack slots
emit the function prologue
emit every supported instruction
emit one shared epilogue
Validation happens before any output is written. This is a useful backend invariant: if the IR contains an unsupported operation or unsupported function shape, generation fails before a partial assembly program can escape.
Designing AssemblyGenerator.hpp
Create gsisi/include/AssemblyGenerator.hpp:
#pragma once
#include <cstddef>
#include <ostream>
#include <string>
#include <unordered_map>
#include "IR.hpp"
class AssemblyGenerator {
public:
void generate(
const IRProgram& program,
std::ostream& output
);
private:
struct FunctionContext {
std::unordered_map<std::string, int> stackSlots;
int nextOffset{0};
std::string returnLabel;
};
FunctionContext _context;
//----------------------------------
// Validation
//----------------------------------
void validateProgram(const IRProgram& program) const;
void validateFunction(const IRFunction& function) const;
static void validateInstruction(
const IRInstruction& instruction
);
//----------------------------------
// Program and functions
//----------------------------------
void emitProgram(
const IRProgram& program,
std::ostream& output
);
void emitFunction(
const IRFunction& function,
std::ostream& output
);
//----------------------------------
// Stack slots
//----------------------------------
void allocateStackSlots(const IRFunction& function);
void allocateSlot(const std::string& name);
[[nodiscard]] int slotOffset(
const std::string& name
) const;
[[nodiscard]] int stackSize() const noexcept;
[[nodiscard]] std::string stackOperand(
const std::string& name
) const;
//----------------------------------
// Instructions
//----------------------------------
void emitInstruction(
const IRInstruction& instruction,
std::ostream& output
);
void emitConstant(
const IRInstruction& instruction,
std::ostream& output
);
void emitLoad(
const IRInstruction& instruction,
std::ostream& output
);
void emitStore(
const IRInstruction& instruction,
std::ostream& output
);
void emitBinary(
const IRInstruction& instruction,
std::ostream& output
);
void emitUnary(
const IRInstruction& instruction,
std::ostream& output
);
void emitReturn(
const IRInstruction& instruction,
std::ostream& output
);
//----------------------------------
// Helpers
//----------------------------------
static void requireDestination(
const IRInstruction& instruction
);
static void requireArgumentCount(
const IRInstruction& instruction,
std::size_t expected
);
[[nodiscard]] static bool isSupported(
IROp operation
) noexcept;
[[nodiscard]] static std::string functionLabel(
const std::string& name
);
[[nodiscard]] static std::string blockLabel(
const std::string& functionName,
const std::string& blockName
);
};
FunctionContext stores information that belongs to the function currently being emitted. It is reset before each function, so one AssemblyGenerator object can safely generate several programs.
Implementing AssemblyGenerator.cpp
Create gsisi/src/AssemblyGenerator.cpp:
#include "AssemblyGenerator.hpp"
#include <stdexcept>
#include <utility>
Validating the IR
Earlier compiler stages should produce structurally valid IR, but the backend still owns one important form of validation: it must enforce which IR operations and function shapes this target implementation currently knows how to translate.
Generator Entry Point
void AssemblyGenerator::generate(
const IRProgram& program,
std::ostream& output
) {
validateProgram(program);
emitProgram(program, output);
if (!output) {
throw std::runtime_error(
"Assembly error: failed while writing output"
);
}
}
The entire program is validated before emitProgram() writes the first line.
Program Validation
void AssemblyGenerator::validateProgram(
const IRProgram& program
) const {
bool hasMain = false;
for (const IRFunction& function : program.functions) {
if (function.name == "main") {
hasMain = true;
}
validateFunction(function);
}
if (!hasMain) {
throw std::runtime_error(
"Assembly error: IR program has no main function"
);
}
}
Semantic analysis already requires main, but checking again makes the backend safer when it is tested directly with manually constructed IR.
Function Validation
Function parameters require calling-convention support and are introduced in Chapter 8. Until then, the backend rejects them explicitly rather than guessing how they should be passed:
void AssemblyGenerator::validateFunction(
const IRFunction& function
) const {
if (!function.parameters.empty()) {
throw std::runtime_error(
"Assembly error: function parameters are not "
"supported in Chapter 7 ('" +
function.name + "')"
);
}
for (const IRBlock& block : function.blocks) {
for (const IRInstruction& instruction : block.instructions) {
validateInstruction(instruction);
}
}
}
Instruction Validation
void AssemblyGenerator::validateInstruction(
const IRInstruction& instruction
) {
if (!isSupported(instruction.op)) {
throw std::runtime_error(
"Assembly error: instruction '" +
std::string(toString(instruction.op)) +
"' is not supported in Chapter 7"
);
}
switch (instruction.op) {
case IROp::ConstInt:
case IROp::ConstBool:
requireDestination(instruction);
requireArgumentCount(instruction, 0);
return;
case IROp::Load:
requireDestination(instruction);
requireArgumentCount(instruction, 1);
return;
case IROp::Store:
requireArgumentCount(instruction, 2);
return;
case IROp::Add:
case IROp::Subtract:
case IROp::Multiply:
case IROp::Divide:
requireDestination(instruction);
requireArgumentCount(instruction, 2);
return;
case IROp::Negate:
case IROp::Not:
requireDestination(instruction);
requireArgumentCount(instruction, 1);
return;
case IROp::Return:
if (instruction.args.size() > 1) {
throw std::runtime_error(
"Assembly error: return expects zero or one argument"
);
}
return;
default:
break;
}
throw std::runtime_error(
"Assembly internal error: unvalidated instruction"
);
}
The helper functions produce clearer errors than allowing std::vector::at() to fail later.
void AssemblyGenerator::requireDestination(
const IRInstruction& instruction
) {
if (instruction.dst.empty()) {
throw std::runtime_error(
"Assembly error: instruction '" +
std::string(toString(instruction.op)) +
"' requires a destination"
);
}
}
void AssemblyGenerator::requireArgumentCount(
const IRInstruction& instruction,
std::size_t expected
) {
if (instruction.args.size() != expected) {
throw std::runtime_error(
"Assembly error: instruction '" +
std::string(toString(instruction.op)) +
"' expects " + std::to_string(expected) +
" argument(s), got " +
std::to_string(instruction.args.size())
);
}
}
Finally, define the supported subset:
bool AssemblyGenerator::isSupported(
IROp operation
) noexcept {
switch (operation) {
case IROp::ConstInt:
case IROp::ConstBool:
case IROp::Load:
case IROp::Store:
case IROp::Add:
case IROp::Subtract:
case IROp::Multiply:
case IROp::Divide:
case IROp::Negate:
case IROp::Not:
case IROp::Return:
return true;
default:
return false;
}
}
Emitting the Program
After validation succeeds, the generated assembly file begins with the Linux _start entry point and then emits every Si function:
void AssemblyGenerator::emitProgram(
const IRProgram& program,
std::ostream& output
) {
output << "global _start\n\n";
output << "section .text\n\n";
output << "_start:\n";
output << " call si_main\n";
output << " mov edi, eax\n";
output << " mov eax, 60\n";
output << " syscall\n\n";
for (const IRFunction& function : program.functions) {
emitFunction(function, output);
}
}
Si function names receive a si_ prefix:
std::string AssemblyGenerator::functionLabel(
const std::string& name
) {
return "si_" + name;
}
This turns:
main -> si_main
add -> si_add
IR basic blocks are mapped to local NASM labels scoped under the generated function label:
std::string AssemblyGenerator::blockLabel(
const std::string& functionName,
const std::string& blockName
) {
return ".L_" + functionName + "_" + blockName;
}
For example:
.L_main_entry
.L_main_return
Allocating Stack Slots
This first backend uses a deliberately simple storage model: every local variable and every temporary destination receives its own 4-byte stack slot.
x -> [rbp - 4]
%t0 -> [rbp - 8]
%t1 -> [rbp - 12]
The IR generator from Chapter 6 has already resolved source-level shadowing by assigning unique storage names such as x and x$1. The backend therefore does not need to understand lexical scope; it simply allocates those IR names as distinct stack slots.
Collecting Slots
void AssemblyGenerator::allocateStackSlots(
const IRFunction& function
) {
_context = FunctionContext{};
_context.returnLabel =
".L_" + function.name + "_return";
for (const IRLocal& local : function.locals) {
allocateSlot(local.name);
}
for (const IRBlock& block : function.blocks) {
for (const IRInstruction& instruction : block.instructions) {
if (!instruction.dst.empty()) {
allocateSlot(instruction.dst);
}
}
}
}
Resetting _context prevents stack slots from one function leaking into another.
Allocating One Slot
void AssemblyGenerator::allocateSlot(
const std::string& name
) {
if (_context.stackSlots.contains(name)) {
return;
}
_context.nextOffset += 4;
_context.stackSlots.emplace(
name,
_context.nextOffset
);
}
Looking Up a Slot
int AssemblyGenerator::slotOffset(
const std::string& name
) const {
const auto found = _context.stackSlots.find(name);
if (found == _context.stackSlots.end()) {
throw std::runtime_error(
"Assembly error: no stack slot for IR value '" +
name + "'"
);
}
return found->second;
}
Aligning the Frame
int AssemblyGenerator::stackSize() const noexcept {
constexpr int alignment = 16;
const int size = _context.nextOffset;
return (
(size + alignment - 1) / alignment
) * alignment;
}
When a function needs no local or temporary storage, the aligned frame size remains 0, so no unnecessary stack subtraction is emitted.
Formatting a Stack Operand
std::string AssemblyGenerator::stackOperand(
const std::string& name
) const {
return
"dword [rbp - " +
std::to_string(slotOffset(name)) +
"]";
}
Using one helper keeps all emitted memory operands consistent.
Emitting Functions
Each generated function follows the same assembly layout: a label, a stack-frame prologue, its emitted basic blocks, and one shared epilogue.
void AssemblyGenerator::emitFunction(
const IRFunction& function,
std::ostream& output
) {
allocateStackSlots(function);
output << functionLabel(function.name) << ":\n";
output << " push rbp\n";
output << " mov rbp, rsp\n";
const int frameSize = stackSize();
if (frameSize > 0) {
output
<< " sub rsp, "
<< frameSize
<< "\n";
}
for (const IRBlock& block : function.blocks) {
output
<< blockLabel(function.name, block.name)
<< ":\n";
for (const IRInstruction& instruction : block.instructions) {
emitInstruction(instruction, output);
}
}
output << _context.returnLabel << ":\n";
output << " mov rsp, rbp\n";
output << " pop rbp\n";
output << " ret\n\n";
}
The backend prints block labels even though Chapter 7 does not emit jumps yet. Those labels become active control-flow targets in Chapter 9.
Routing Instructions
void AssemblyGenerator::emitInstruction(
const IRInstruction& instruction,
std::ostream& output
) {
switch (instruction.op) {
case IROp::ConstInt:
case IROp::ConstBool:
emitConstant(instruction, output);
return;
case IROp::Load:
emitLoad(instruction, output);
return;
case IROp::Store:
emitStore(instruction, output);
return;
case IROp::Add:
case IROp::Subtract:
case IROp::Multiply:
case IROp::Divide:
emitBinary(instruction, output);
return;
case IROp::Negate:
case IROp::Not:
emitUnary(instruction, output);
return;
case IROp::Return:
emitReturn(instruction, output);
return;
default:
throw std::runtime_error(
"Assembly internal error: unsupported "
"instruction reached emission"
);
}
}
Unsupported operations should have been rejected during validation. The default case still protects the generator against internal mistakes.
Constants
Integer Constants
IR:
%t0 = const_int 10
Assembly:
mov dword [rbp - 4], 10
Boolean Constants
Si booleans use the integer representation 0 for false and 1 for true:
false -> 0
true -> 1
Both constant operations use the same emitter:
void AssemblyGenerator::emitConstant(
const IRInstruction& instruction,
std::ostream& output
) {
const int value =
instruction.op == IROp::ConstBool
? (instruction.boolValue ? 1 : 0)
: instruction.intValue;
output
<< " mov "
<< stackOperand(instruction.dst)
<< ", "
<< value
<< '\n';
}
Loads and Stores
Load
A load reads a value from a local storage slot and writes that value into the stack slot allocated for a generated temporary.
%t1 = load x
mov eax, dword [rbp - 4]
mov dword [rbp - 8], eax
void AssemblyGenerator::emitLoad(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, "
<< stackOperand(instruction.args[0])
<< '\n'
<< " mov "
<< stackOperand(instruction.dst)
<< ", eax\n";
}
Store
A store performs the opposite movement: it copies a temporary result into the stack slot assigned to local storage.
store %t0, x
mov eax, dword [rbp - 8]
mov dword [rbp - 4], eax
void AssemblyGenerator::emitStore(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, "
<< stackOperand(instruction.args[0])
<< '\n'
<< " mov "
<< stackOperand(instruction.args[1])
<< ", eax\n";
}
This implementation intentionally writes most intermediate values to memory. A later optimisation could keep frequently used values in registers.
Generating x86-64 Arithmetic
All supported binary arithmetic follows the same simple lowering pattern:
- Load the left operand into
eax. - Apply the x86-64 operation using the right operand.
- Store the value from
eaxinto the destination stack slot.
This makes eax the working register for Chapter 7 arithmetic while the stack remains the persistent home for IR values.
void AssemblyGenerator::emitBinary(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, "
<< stackOperand(instruction.args[0])
<< '\n';
const std::string right =
stackOperand(instruction.args[1]);
switch (instruction.op) {
case IROp::Add:
output
<< " add eax, "
<< right
<< '\n';
break;
case IROp::Subtract:
output
<< " sub eax, "
<< right
<< '\n';
break;
case IROp::Multiply:
output
<< " imul eax, "
<< right
<< '\n';
break;
case IROp::Divide:
output << " cdq\n";
output
<< " idiv "
<< right
<< '\n';
break;
default:
throw std::runtime_error(
"Assembly internal error: invalid "
"binary instruction"
);
}
output
<< " mov "
<< stackOperand(instruction.dst)
<< ", eax\n";
}
Addition
%t2 = add %t0, %t1
mov eax, dword [rbp - 4]
add eax, dword [rbp - 8]
mov dword [rbp - 12], eax
Subtraction
sub eax, dword [rbp - 8]
Operand order matters for subtraction: because the left value is loaded into eax first, the emitted instruction computes left - right.
Multiplication
imul eax, dword [rbp - 8]
imul performs signed integer multiplication, which matches the signed 32-bit integer model used by Si.
Division
Signed division requires two instructions:
cdq
idiv dword [rbp - 8]
cdq sign-extends eax into edx\:eax. Then idiv divides that signed 64-bit pair by the 32-bit operand. The quotient is placed in eax, while the remainder is placed in edx.
Division by zero is not guarded by generated code in this chapter. If it occurs at runtime, the processor raises the corresponding fault rather than the compiler producing a source-level diagnostic.
Generating Unary Operations
void AssemblyGenerator::emitUnary(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, "
<< stackOperand(instruction.args[0])
<< '\n';
switch (instruction.op) {
case IROp::Negate:
output << " neg eax\n";
break;
case IROp::Not:
output << " cmp eax, 0\n";
output << " sete al\n";
output << " movzx eax, al\n";
break;
default:
throw std::runtime_error(
"Assembly internal error: invalid "
"unary instruction"
);
}
output
<< " mov "
<< stackOperand(instruction.dst)
<< ", eax\n";
}
Negation
let value: int = -5;
mov eax, dword [rbp - 4]
neg eax
mov dword [rbp - 8], eax
Logical Not
let enabled: bool = !false;
mov eax, dword [rbp - 4]
cmp eax, 0
sete al
movzx eax, al
mov dword [rbp - 8], eax
sete al writes 1 when the compared value is equal to zero and 0 otherwise. movzx then expands the one-byte result in al back into the 32-bit value representation used by the rest of the backend.
Generating Return Instructions
void AssemblyGenerator::emitReturn(
const IRInstruction& instruction,
std::ostream& output
) {
if (!instruction.args.empty()) {
output
<< " mov eax, "
<< stackOperand(instruction.args[0])
<< '\n';
}
output
<< " jmp "
<< _context.returnLabel
<< '\n';
}
A value-returning function loads the result into eax. A void return only jumps to the epilogue.
Updating main.cpp
The compiler entry point now connects the entire front end to the first machine-specific backend. It accepts an input Si file and an output assembly file.
Update gsisi/src/main.cpp:
#include <exception>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "AssemblyGenerator.hpp"
#include "IR.hpp"
#include "IRGenerator.hpp"
#include "IRPrinter.hpp"
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
#include "Token.hpp"
/**
* Usage:
* ./si_compiler <source_file.si> <output_file.asm>
*/
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr
<< "Usage: " << argv[0]
<< " <source_file.si> <output_file.asm>\n";
return 1;
}
std::ifstream inputFile(
argv[1],
std::ios::binary
);
if (!inputFile) {
std::cerr
<< "Error: could not open source file '"
<< argv[1]
<< "'\n";
return 1;
}
std::ostringstream sourceBuffer;
sourceBuffer << inputFile.rdbuf();
if (inputFile.bad()) {
std::cerr
<< "Error: failed while reading source file '"
<< argv[1]
<< "'\n";
return 1;
}
try {
Lexer lexer(sourceBuffer.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 irGenerator;
IRProgram ir = irGenerator.generate(*ast);
std::cout
<< "--- Intermediate Representation ---\n";
dumpIR(std::cout, ir);
std::cout
<< "-----------------------------------\n";
std::ostringstream assemblyBuffer;
AssemblyGenerator assemblyGenerator;
assemblyGenerator.generate(ir, assemblyBuffer);
std::ofstream assemblyFile(
argv[2],
std::ios::binary | std::ios::trunc
);
if (!assemblyFile) {
std::cerr
<< "Error: could not open output file '"
<< argv[2]
<< "'\n";
return 1;
}
assemblyFile << assemblyBuffer.str();
assemblyFile.close();
if (!assemblyFile) {
std::cerr
<< "Error: failed while writing output file '"
<< argv[2]
<< "'\n";
return 1;
}
std::cout
<< "Assembly written to "
<< argv[2]
<< '\n';
}
catch (const std::exception& error) {
std::cerr
<< "Compilation error: "
<< error.what()
<< '\n';
return 1;
}
return 0;
}
The assembly generator first writes into an in-memory string stream. The output file is opened only after lexing, parsing, semantic analysis, IR generation and backend validation all succeed. A failed compilation therefore does not create or truncate the requested .asm file.
Test Program
Keep the end-to-end Chapter 7 program at gsisi/tests/test-4.si:
fn main(): int {
let hundred: int = 100;
let five: int = 5;
let quotient: int = hundred / five;
let doubled: int = quotient * 2;
let answer: int = doubled + 2;
let enabled: bool = !false;
return answer;
}
The program returns:
100 / 5 = 20
20 * 2 = 40
40 + 2 = 42
The enabled variable also exercises boolean constants and logical negation, even though it is not used afterward.
Build and Run Manually
Configure and build the compiler from the repository root:
cmake -S . -B build
cmake --build build
Generate assembly:
./build/si_compiler tests/test-4.si build/test.asm
Assemble it into an ELF64 object file:
nasm -f elf64 build/test.asm -o build/test.o
Link the object file into an executable:
ld build/test.o -o build/test
Run it and inspect the exit status:
./build/test
echo $?
Expected output:
42
The compiled program itself does not print 42. It returns 42 from Si main, _start passes that value to the Linux exit system call, and the shell displays the resulting process status because of echo $?.
Testing the Assembly Generator
Backend unit tests should verify meaningful assembly fragments and invariants without overfitting to every generated stack offset. The tests therefore construct small IR programs directly, isolating assembly generation from lexing, parsing, semantic analysis, and AST-to-IR lowering.
Create gsisi/tests/AssemblyGeneratorTests.cpp:
#include <exception>
#include <functional>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "AssemblyGenerator.hpp"
#include "IR.hpp"
namespace {
void require(
bool condition,
const std::string& message
) {
if (!condition) {
throw std::runtime_error(message);
}
}
void requireContains(
const std::string& text,
const std::string& expected
) {
require(
text.find(expected) != std::string::npos,
"missing assembly fragment: " + expected
);
}
std::string generateAssembly(
const IRProgram& program
) {
std::ostringstream output;
AssemblyGenerator generator;
generator.generate(program, output);
return output.str();
}
IRProgram makeMainProgram(
std::vector<IRInstruction> instructions,
std::vector<IRLocal> locals = {}
) {
IRFunction main;
main.name = "main";
main.returnType = Type::Int;
main.locals = std::move(locals);
main.blocks.push_back(IRBlock{
.name = "entry",
.instructions = std::move(instructions)
});
IRProgram program;
program.functions.push_back(std::move(main));
return program;
}
void testEntryPointAndReturn() {
const IRProgram program = makeMainProgram({
IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = "%t0",
.intValue = 42
},
IRInstruction{
.op = IROp::Return,
.args = {"%t0"}
}
});
const std::string assembly =
generateAssembly(program);
requireContains(assembly, "global _start");
requireContains(assembly, "call si_main");
requireContains(assembly, "si_main:");
requireContains(
assembly,
"mov dword [rbp - 4], 42"
);
requireContains(
assembly,
"jmp .L_main_return"
);
}
void testStackAlignmentAndMemoryOperations() {
const IRProgram program = makeMainProgram(
{
IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = "%t0",
.intValue = 7
},
IRInstruction{
.op = IROp::Store,
.args = {"%t0", "x"}
},
IRInstruction{
.op = IROp::Load,
.type = Type::Int,
.dst = "%t1",
.args = {"x"}
},
IRInstruction{
.op = IROp::Return,
.args = {"%t1"}
}
},
{
IRLocal{
.name = "x",
.type = Type::Int
}
}
);
const std::string assembly =
generateAssembly(program);
requireContains(assembly, "sub rsp, 16");
requireContains(
assembly,
"mov dword [rbp - 4], eax"
);
requireContains(
assembly,
"mov eax, dword [rbp - 4]"
);
}
void testArithmeticOperations() {
const IRProgram program = makeMainProgram({
IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = "%t0",
.intValue = 20
},
IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = "%t1",
.intValue = 6
},
IRInstruction{
.op = IROp::Subtract,
.type = Type::Int,
.dst = "%t2",
.args = {"%t0", "%t1"}
},
IRInstruction{
.op = IROp::Multiply,
.type = Type::Int,
.dst = "%t3",
.args = {"%t2", "%t1"}
},
IRInstruction{
.op = IROp::Divide,
.type = Type::Int,
.dst = "%t4",
.args = {"%t3", "%t1"}
},
IRInstruction{
.op = IROp::Return,
.args = {"%t4"}
}
});
const std::string assembly =
generateAssembly(program);
requireContains(assembly, "sub eax,");
requireContains(assembly, "imul eax,");
requireContains(assembly, "cdq");
requireContains(assembly, "idiv dword");
}
void testUnaryOperations() {
const IRProgram program = makeMainProgram({
IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = "%t0",
.intValue = 5
},
IRInstruction{
.op = IROp::Negate,
.type = Type::Int,
.dst = "%t1",
.args = {"%t0"}
},
IRInstruction{
.op = IROp::ConstBool,
.type = Type::Bool,
.dst = "%t2",
.boolValue = false
},
IRInstruction{
.op = IROp::Not,
.type = Type::Bool,
.dst = "%t3",
.args = {"%t2"}
},
IRInstruction{
.op = IROp::Return,
.args = {"%t1"}
}
});
const std::string assembly =
generateAssembly(program);
requireContains(assembly, "neg eax");
requireContains(assembly, "cmp eax, 0");
requireContains(assembly, "sete al");
requireContains(assembly, "movzx eax, al");
}
void testUnsupportedOperationIsRejectedBeforeOutput() {
const IRProgram program = makeMainProgram({
IRInstruction{
.op = IROp::Call,
.callee = "out"
},
IRInstruction{
.op = IROp::Return
}
});
std::ostringstream output;
AssemblyGenerator generator;
bool rejected = false;
try {
generator.generate(program, output);
}
catch (const std::runtime_error& error) {
rejected =
std::string(error.what()).find(
"not supported"
) != std::string::npos;
}
require(rejected, "call instruction should be rejected");
require(
output.str().empty(),
"validation should happen before assembly output"
);
}
void testParametersAreRejected() {
IRProgram program = makeMainProgram({
IRInstruction{
.op = IROp::Return
}
});
program.functions[0].parameters.push_back(
IRParameter{
.name = "value",
.type = Type::Int
}
);
bool rejected = false;
try {
static_cast<void>(generateAssembly(program));
}
catch (const std::runtime_error& error) {
rejected =
std::string(error.what()).find(
"parameters"
) != std::string::npos;
}
require(rejected, "parameters should be rejected");
}
} // namespace
int main() {
const std::vector<
std::pair<std::string, std::function<void()>>
> tests = {
{
"entry point and return",
testEntryPointAndReturn
},
{
"stack alignment and memory operations",
testStackAlignmentAndMemoryOperations
},
{
"arithmetic operations",
testArithmeticOperations
},
{
"unary operations",
testUnaryOperations
},
{
"unsupported operations",
testUnsupportedOperationIsRejectedBeforeOutput
},
{
"function parameters",
testParametersAreRejected
}
};
int passed = 0;
for (const auto& [name, test] : tests) {
try {
test();
std::cout << "[PASS] " << name << '\n';
++passed;
}
catch (const std::exception& error) {
std::cerr
<< "[FAIL] "
<< name
<< ": "
<< error.what()
<< '\n';
}
}
std::cout
<< passed
<< '/'
<< tests.size()
<< " tests passed\n";
return passed == static_cast<int>(tests.size())
? 0
: 1;
}
These tests verify:
_startand the Simainlabel.- Shared function epilogues.
- Stack-frame alignment.
- Loads and stores.
- Signed arithmetic and division.
- Unary negation and logical not.
- Rejection of unsupported IR before output.
- Rejection of function parameters in Chapter 7.
Native Backend Smoke Test
Text-based unit tests prove that the generator emits the expected assembly structure, but they do not prove that the complete toolchain accepts the result. The native smoke test closes that gap by proving that NASM can assemble the file, ld can link it, and Linux can execute the binary.
Create gsisi/tests/run_backend_smoke_test.sh:
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 6 ]]; then
echo "Usage: $0 <compiler> <source.si> <work-dir> <nasm> <ld> <expected-exit>" >&2
exit 2
fi
compiler="$1"
source_file="$2"
work_dir="$3"
nasm_executable="$4"
linker_executable="$5"
expected_exit="$6"
rm -rf "$work_dir"
mkdir -p "$work_dir"
assembly_file="$work_dir/program.asm"
object_file="$work_dir/program.o"
executable_file="$work_dir/program"
"$compiler" "$source_file" "$assembly_file"
"$nasm_executable" -f elf64 "$assembly_file" -o "$object_file"
"$linker_executable" "$object_file" -o "$executable_file"
# The test program intentionally returns 42, which is a non-zero
# process status. Temporarily disable immediate-exit behaviour so
# we can inspect it rather than treating it as a shell failure.
set +e
"$executable_file"
actual_exit=$?
set -e
if [[ "$actual_exit" -ne "$expected_exit" ]]; then
echo "Expected exit code $expected_exit, got $actual_exit" >&2
exit 1
fi
echo "Native backend smoke test passed with exit code $actual_exit"
The smoke-test script receives the compiler, NASM, and linker paths from CMake rather than assuming fixed tool locations. That keeps the test coupled to the configured toolchain instead of the developer’s shell environment.
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
)
add_executable(assembly_generator_tests
tests/AssemblyGeneratorTests.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)
target_link_libraries(assembly_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
# ${CMAKE_CURRENT_SOURCE_DIR}/build/test-2.asm
# )
# add_test(
# NAME compiler_ir_smoke_test
# COMMAND si_compiler
# ${CMAKE_CURRENT_SOURCE_DIR}/tests/test-3.si
# ${CMAKE_CURRENT_SOURCE_DIR}/build/test-3.asm
# )
add_test(
NAME assembly_generator_tests
COMMAND assembly_generator_tests
)
find_program(
NASM_EXECUTABLE
nasm
REQUIRED
)
find_program(
LD_EXECUTABLE
ld
REQUIRED
)
add_test(
NAME backend_native_smoke
COMMAND
bash
${CMAKE_CURRENT_SOURCE_DIR}/tests/run_backend_smoke_test.sh
$<TARGET_FILE:si_compiler>
${CMAKE_CURRENT_SOURCE_DIR}/tests/test-4.si
${CMAKE_CURRENT_BINARY_DIR}/backend-smoke
${NASM_EXECUTABLE}
${LD_EXECUTABLE}
42
)
endif()
We use enable_testing() directly instead of include(CTest). This avoids the extra CTest dashboard files that caused permission problems in some Docker-mounted build directories.
The script is invoked through bash, so it does not require executable permissions on the host filesystem.
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
Expected result:
100% tests passed, 0 tests failed out of 5
You can also run the C++ test executable directly:
./build/assembly_generator_tests
Expected summary:
6/6 tests passed
Inspecting Generated Assembly
Readable output is one of the main advantages of keeping this first backend intentionally simple. Generate the test program and inspect the assembly directly:
./build/si_compiler tests/test-4.si build/test.asm
less build/test.asm
You should see:
global _start.- A call to
si_main. - A
si_mainstack-frame prologue. - 16-byte-aligned stack allocation.
idivfor the division.imulfor multiplication.addfor addition.cmp,seteandmovzxfor logical not.- A jump to one shared return label.
- A function epilogue followed by
ret.
Common Compiler Backend Mistakes
Even a small backend can produce assembly that looks plausible but behaves incorrectly. Common implementation mistakes include:
- Generating output before validation completes. Unsupported IR should fail before a partial assembly program is written.
- Reusing stack-slot state across functions. Each function needs an independent frame layout.
- Using the wrong operand order. Subtraction and division are not commutative, so left and right operands must remain in the intended order.
- Forgetting stack-frame cleanup. Every generated return path must eventually restore
rspandrbp. - Confusing 32-bit and 64-bit values. Si integers are 32-bit in this compiler, so the chosen register widths and memory operands must match that representation.
- Treating unsupported IR as if it were implemented. Rejecting calls, branches, comparisons, or parameters is safer than emitting incomplete code.
- Testing only assembly text. A native smoke test is valuable because it verifies the assembler, linker, generated executable, and runtime exit behaviour together.
- Optimising too early. Keeping every IR value in a visible stack slot produces extra moves, but it makes the first backend much easier to reason about.
Compiler Backend vs Front End
The compiler front end and backend solve different problems:
| Compiler part | Main responsibility | Examples in this project |
|---|---|---|
| Front end | Understand and validate the source program | lexer, parser, semantic analyser |
| Middle end / IR stage | Represent validated behaviour in a simpler form | IR generator and IRProgram |
| Backend | Translate IR for a target platform | x86-64 NASM assembly generator |
This separation is why the assembly generator does not need to know whether an IR add originally came from a nested expression, a variable declaration, or a return statement.
Frequently Asked Questions
What does a compiler backend do?
A compiler backend translates a validated lower-level representation into code for a target platform. It handles machine-specific concerns such as instructions, registers, stack frames, labels, calling conventions, and executable-format integration.
What is x86-64 code generation?
x86-64 code generation is the process of translating compiler operations into instructions for the 64-bit x86 architecture. In this chapter, IR operations are translated into NASM assembly before NASM encodes them into machine code.
Why generate NASM assembly instead of machine code directly?
Generating assembly keeps the first backend readable and easy to debug. NASM handles instruction encoding and object-file generation, allowing the compiler project to focus on correct lowering and target semantics.
How does the backend translate IR into assembly?
The backend validates the IR, allocates stack slots, emits a function prologue, translates supported IR instructions into x86-64 operations, and sends every return through a shared epilogue.
Why are all locals and temporaries stored on the stack?
This chapter deliberately avoids register allocation. Giving every IR value a stack slot creates more memory traffic, but it makes storage explicit and keeps the first backend straightforward to inspect and test.
Why does a function return an integer in eax?
The backend follows the target convention used by this chapter for 32-bit integer return values. The compiled Si main result is then copied into the Linux process exit-status argument by _start.
What is a stack frame in compiler code generation?
A stack frame is the section of stack storage reserved for one active function. This backend uses it to give local variables and temporary IR values stable addresses relative to rbp.
Why does the backend validate IR again?
Semantic analysis validates the source program, but the backend has its own capability boundary. Chapter 7 must reject otherwise valid IR features—such as calls or branches—that this first assembly generator cannot translate yet.
Does this backend optimise the generated assembly?
No. Correctness and transparency come first. Every IR value is currently assigned a memory slot, so the generated assembly contains more mov instructions than an optimising compiler would typically produce.
What comes after this backend chapter?
Chapter 8 extends the backend with the System V AMD64 calling convention, function arguments, user-defined calls, and the runtime out(int) function. Comparisons and control-flow lowering are added after that.
Common Errors
Unsupported IR Instruction
A program containing a comparison, loop, branch or call will currently fail with a message such as:
Assembly error: instruction 'less' is not supported in Chapter 7
Use the straight-line tests/test-4.si program for Chapter 7. Function calls arrive in Chapter 8, while comparisons and branch-based control flow arrive in Chapter 9.
Function Parameters Are Not Supported
A function such as:
fn add(a: int, b: int): int {
return a + b;
}
produces an explicit Chapter 7 error because argument passing, parameter locations, and the calling convention have not been implemented yet.
nasm: command not found
The Docker image must contain NASM:
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
cmake \
gdb \
nasm \
sudo && \
rm -rf /var/lib/apt/lists/*
Rebuild the development image after changing the Dockerfile.
Operation not permitted During CMake Configuration
Do not use include(CTest) for this project. Use:
enable_testing()
If an old build directory was created by another user, remove it and configure again:
sudo rm -rf build
cmake -S . -B build -DBUILD_TESTING=ON
A build directory outside the bind-mounted repository is another safe option:
cmake -S /home/dev/gsisi -B /home/dev/gsisi-build -DBUILD_TESTING=ON
cmake --build /home/dev/gsisi-build
ctest --test-dir /home/dev/gsisi-build --output-on-failure
Current Backend Limitations
This backend is intentionally small and unoptimised. It establishes a correct IR-to-assembly path before more complex target features are added.
It does not yet support:
- Function parameters.
- User-defined function calls.
- The built-in
outfunction. - Comparisons.
- Conditional branches.
- Loops.
- Register allocation.
- Runtime division-by-zero checks.
- Debug information.
- Separate object files.
- macOS or Windows targets.
It also stores every IR value in memory even when a register could keep that value live. This produces more mov instructions and a larger stack frame than an optimising compiler would normally need.
That is intentional. Correctness, traceability, and a clean backend boundary are more important than performance at this stage. The repetitive assembly makes it easy to connect each IR operation to the instructions it generated.
x86-64 Compiler Backend: Key Takeaways
- The compiler now has its first machine-specific backend for Linux x86-64.
- The backend translates a supported subset of checked IR into NASM assembly.
- Backend validation runs before any assembly output is written.
- Si locals and generated temporaries receive explicit 32-bit stack slots.
- Function stack frames are rounded to a 16-byte boundary.
- Constants, loads, stores, arithmetic, unary operations, and returns now generate x86-64 instructions.
eaxacts as the working register for 32-bit arithmetic and integer return values in this chapter.- Every generated return jumps to one shared function epilogue.
_startcalls the compiled Simainfunction and exits using its return value.- Unit tests verify assembly structure, arithmetic lowering, stack behaviour, and unsupported-operation errors.
- A native smoke test compiles
tests/test-4.si, assembles it with NASM, links it withld, executes it, and verifies exit status42. - The intentionally simple stack-based design is ready to be extended with calling conventions and later control-flow support.
Next Chapter
In Chapter 8, we will extend this backend with the System V AMD64 calling convention, pass function arguments in registers, generate user-defined function calls, and add a small runtime containing out(int).
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.