Chapter 8 · Programming
x86-64 Function Calls in a C++ Compiler: System V ABI and Runtime
Add System V AMD64 function calls to a C++ compiler with x86-64 argument registers, stack alignment, return values, a NASM runtime and ELF64 linking.
By Gianni Updated 34 min Intermediate
On this page
Chapter 7 gave gsisi its first x86-64 backend. It can compile straight-line programs containing constants, local variables, arithmetic, unary operations, and returns. The next major step is making separately compiled functions communicate correctly.
In this chapter, we add compiler function calls using the x86-64 System V ABI, pass integer and boolean arguments in registers, capture return values from eax, preserve 16-byte stack alignment, and build a small NASM runtime for out(int).
We will also extend the C++ compiler driver so one command can generate assembly, invoke NASM, link the program with the runtime, and produce a native Linux executable.
Si source
↓
Lexer → Parser → Semantic analyser
↓
Intermediate Representation
↓
Assembly generator
↓
NASM assembly + Si runtime
↓
ELF64 object files
↓
GNU linker
↓
Native Linux executable
By the end of the chapter, the compiler will be able to compile, link, and run a program in which one Si function calls another and then calls the runtime:
fn add(a: int, b: int): int {
return a + b;
}
fn main(): int {
let result: int = add(20, 22);
out(result);
return 0;
}
It prints:
42
What You Will Learn
By the end of this chapter, you will be able to:
- explain what a calling convention does in a compiler backend;
- explain the x86-64 System V ABI rules used by this compiler;
- pass Si integer and boolean arguments in ABI registers;
- copy incoming parameters into a function’s stack frame;
- generate calls to user-defined and runtime functions;
- read integer and boolean return values from
eax; - keep the stack correctly aligned before every
callinstruction; - implement a small NASM runtime that prints signed 32-bit integers;
- invoke NASM and
ldsafely from C++ without constructing shell commands; - test ABI lowering, runtime imports, paths containing spaces, and native output.
What Is the x86-64 System V ABI?
The System V AMD64 ABI is the calling-convention and binary-interface specification used by many Unix-like x86-64 systems, including Linux. For compiler code generation, it defines the contract that a caller and callee must both follow when values cross a function boundary.
The rules relevant to this chapter determine:
- which registers carry the first integer-like arguments;
- where a function places its return value;
- how the stack is aligned around calls;
- which side is responsible for establishing and restoring a function frame.
The backend does not need to implement every part of the full ABI yet. It only needs the subset required by Si’s current 32-bit int and bool values and the first six register arguments.
How Do Function Calls Work in a Compiler?
A compiler lowers a source-level call through several stages. By the time the backend sees a call, semantic analysis has already verified the function name and argument types, and the IR has already represented the call explicitly.
The backend then performs the machine-specific work:
- Load each IR argument into the ABI register assigned to its position.
- Emit a
callto the generated function or runtime symbol. - Let the callee copy incoming parameter registers into its own storage.
- Execute the callee body.
- Place a non-void result in
eax. - Return to the caller.
- Save the returned value into the destination temporary when the IR call produces a value.
This separation keeps source-language rules out of the assembly generator while making the platform calling convention explicit.
Calling Convention at a Glance
| Responsibility | Chapter 8 rule |
|---|---|
| Target platform | Linux x86-64 |
| ABI | System V AMD64 |
| Integer / boolean width | 32 bits |
| Arguments 1–6 | edi, esi, edx, ecx, r8d, r9d |
| Integer / boolean return | eax |
| Additional arguments | Not supported yet |
| Stack alignment | 16-byte alignment before calls |
| Runtime function | out(int): void |
Target and Calling Convention
This chapter keeps the same target as Chapter 7:
- Operating system: Linux
- Architecture: x86-64
- Object format: ELF64
- Assembly syntax: NASM
- Calling convention: x86-64 System V ABI
- Si integer width: 32 bits
- Si boolean representation:
0or1
The ABI is a contract between callers and callees. Both sides must agree on where arguments are placed, where return values are found, which machine conventions are followed, and how the stack is aligned. If either side violates that contract, separately generated functions may exchange the wrong values or fail at runtime.
For integer-like arguments, the first six values use these registers:
| Argument | 64-bit register | 32-bit register used by Si |
|---|---|---|
| 1 | rdi | edi |
| 2 | rsi | esi |
| 3 | rdx | edx |
| 4 | rcx | ecx |
| 5 | r8 | r8d |
| 6 | r9 | r9d |
Si currently represents both int and bool as 32-bit values, so the backend uses the 32-bit register names. A non-void integer or boolean result is returned in eax, which gives callers one predictable place to retrieve the result.
This chapter deliberately stops at six arguments because those values fit entirely in the supported register-argument path. A later backend extension could implement the ABI rules for additional arguments passed on the stack.
How an x86-64 Function Call Works
Consider a simple Si function call:
add(20, 22)
The IR generator lowers it into values followed by a call:
%t0 = const\_int 20
%t1 = const\_int 22
%t2 = call add(%t0, %t1)
The backend turns that IR call into the machine-level caller sequence: it moves each argument into the correct ABI register, emits call, and stores the returned value from eax when the function is non-void.
mov edi, dword [rbp - 4]
mov esi, dword [rbp - 8]
call si_add
mov dword [rbp - 12], eax
Inside si_add, the incoming registers are copied into stack slots:
si_add:
push rbp
mov rbp, rsp
sub rsp, 16
mov dword [rbp - 4], edi
mov dword [rbp - 8], esi
After those register-to-stack copies, parameters behave like ordinary local storage. Existing IR load instructions can read a and b without knowing that their original values crossed the function boundary in registers. This keeps the rest of the backend independent from parameter-passing details.
Stack Alignment Before Calls
Correct stack alignment is part of the calling convention, not an optional optimisation. The System V AMD64 ABI imposes 16-byte alignment requirements around function calls, so the backend must design each generated frame with future calls in mind.
Our function prologue is:
push rbp
mov rbp, rsp
sub rsp, frame_size
push rbp compensates for the return address that call placed on the stack. The backend then rounds every frame size up to a multiple of 16. As a result, rsp is correctly aligned whenever generated code calls another Si function or the runtime.
Stack-alignment bugs are easy to miss because trivial functions may still appear to work. Following the ABI consistently now prevents much harder-to-diagnose failures when runtime code, library code, or later backend features depend on the expected alignment.
Supported IR in Chapter 8
Chapter 8 extends the straight-line backend from Chapter 7 with one major IR operation: call. The supported set is now:
const_int
const_bool
load
store
add
sub
mul
div
negate
not
call
return
Comparisons and explicit control-flow instructions remain outside this chapter and are still rejected by the backend:
less
less_equal
greater
greater_equal
equal
not_equal
jump
branch
The backend validates the complete IR program before writing assembly. That all-or-nothing design is especially important now that calls introduce signatures, destinations, external runtime symbols, and argument-count rules.
Extending the Assembly Generator for Function Calls
Function calls introduce information that Chapter 7 did not need: the backend must know a callee’s parameter count and return type before it can validate or emit a call. It therefore registers the built-in runtime function first:
out(int) -> void
It then collects all user-defined function signatures from the IR program before validating any body. During validation, every call must satisfy these rules:
- The callee must exist.
- The argument count must match the function signature.
- No function or call may use more than six arguments.
- A value-returning call must have a destination temporary.
- A
voidcall must not have a destination. - The IR call type must match the callee’s return type.
Replace gsisi/include/AssemblyGenerator.hpp with the following version:
#pragma once
#include <cstddef>
#include <ostream>
#include <string>
#include <unordered_map>
#include <unordered_set>
#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;
};
struct FunctionSignature {
std::size_t parameterCount{};
Type returnType{Type::Void};
bool runtimeFunction{false};
};
FunctionContext _context;
std::unordered_map<std::string, FunctionSignature> _signatures;
std::unordered_set<std::string> _runtimeImports;
void reset();
void collectSignatures(const IRProgram& program);
void collectRuntimeImports(const IRProgram& program);
void validateProgram(const IRProgram& program) const;
void validateFunction(const IRFunction& function) const;
void validateInstruction(
const IRInstruction& instruction,
const IRFunction& function
) const;
void emitProgram(const IRProgram& program, std::ostream& output);
void emitFunction(const IRFunction& function, std::ostream& output);
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;
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 emitCall(const IRInstruction& instruction, std::ostream& output);
void emitReturn(const IRInstruction& instruction, std::ostream& output);
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 const char* parameterRegister(std::size_t index);
[[nodiscard]] static std::string functionLabel(const std::string& name);
[[nodiscard]] static std::string blockLabel(
const std::string& functionName,
const std::string& blockName
);
};
Create the matching implementation at gsisi/src/AssemblyGenerator.cpp:
#include "AssemblyGenerator.hpp"
#include <array>
#include <stdexcept>
#include <unordered_set>
#include <utility>
void AssemblyGenerator::generate(
const IRProgram& program,
std::ostream& output
) {
reset();
collectSignatures(program);
collectRuntimeImports(program);
validateProgram(program);
emitProgram(program, output);
if (!output) {
throw std::runtime_error("Assembly error: failed while writing output");
}
}
void AssemblyGenerator::reset() {
_context = FunctionContext{};
_signatures.clear();
_runtimeImports.clear();
_signatures.emplace(
"out",
FunctionSignature{
.parameterCount = 1,
.returnType = Type::Void,
.runtimeFunction = true
}
);
}
void AssemblyGenerator::collectSignatures(const IRProgram& program) {
for (const IRFunction& function : program.functions) {
if (function.name.empty()) {
throw std::runtime_error("Assembly error: function name cannot be empty");
}
if (_signatures.contains(function.name)) {
throw std::runtime_error(
"Assembly error: duplicate or reserved function name '" +
function.name + "'"
);
}
_signatures.emplace(
function.name,
FunctionSignature{
.parameterCount = function.parameters.size(),
.returnType = function.returnType,
.runtimeFunction = false
}
);
}
}
void AssemblyGenerator::collectRuntimeImports(const IRProgram& program) {
for (const IRFunction& function : program.functions) {
for (const IRBlock& block : function.blocks) {
for (const IRInstruction& instruction : block.instructions) {
if (instruction.op != IROp::Call) {
continue;
}
const auto found = _signatures.find(instruction.callee);
if (found != _signatures.end() && found->second.runtimeFunction) {
_runtimeImports.insert(instruction.callee);
}
}
}
}
}
void AssemblyGenerator::validateProgram(const IRProgram& program) const {
const auto mainFunction = _signatures.find("main");
if (mainFunction == _signatures.end()) {
throw std::runtime_error("Assembly error: IR program has no main function");
}
if (mainFunction->second.parameterCount != 0 ||
mainFunction->second.returnType != Type::Int) {
throw std::runtime_error(
"Assembly error: main must have signature fn main(): int"
);
}
for (const IRFunction& function : program.functions) {
validateFunction(function);
}
}
void AssemblyGenerator::validateFunction(const IRFunction& function) const {
if (function.parameters.size() > 6) {
throw std::runtime_error(
"Assembly error: function '" + function.name +
"' has more than 6 parameters"
);
}
std::unordered_set<std::string> storageNames;
for (const IRParameter& parameter : function.parameters) {
if (parameter.name.empty() || parameter.type == Type::Void) {
throw std::runtime_error(
"Assembly error: invalid parameter in function '" +
function.name + "'"
);
}
if (!storageNames.insert(parameter.name).second) {
throw std::runtime_error(
"Assembly error: duplicate storage name '" +
parameter.name + "' in function '" + function.name + "'"
);
}
}
for (const IRLocal& local : function.locals) {
if (local.name.empty() || local.type == Type::Void) {
throw std::runtime_error(
"Assembly error: invalid local in function '" +
function.name + "'"
);
}
if (!storageNames.insert(local.name).second) {
throw std::runtime_error(
"Assembly error: duplicate storage name '" +
local.name + "' in function '" + function.name + "'"
);
}
}
for (const IRBlock& block : function.blocks) {
for (const IRInstruction& instruction : block.instructions) {
validateInstruction(instruction, function);
}
}
}
void AssemblyGenerator::validateInstruction(
const IRInstruction& instruction,
const IRFunction& function
) const {
if (!isSupported(instruction.op)) {
throw std::runtime_error(
"Assembly error: instruction '" +
std::string(toString(instruction.op)) +
"' is not supported in Chapter 8"
);
}
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::Call: {
if (instruction.callee.empty()) {
throw std::runtime_error("Assembly error: call has no callee");
}
const auto found = _signatures.find(instruction.callee);
if (found == _signatures.end()) {
throw std::runtime_error(
"Assembly error: call to unknown function '" +
instruction.callee + "'"
);
}
const FunctionSignature& signature = found->second;
if (instruction.args.size() != signature.parameterCount) {
throw std::runtime_error(
"Assembly error: function '" + instruction.callee +
"' expects " + std::to_string(signature.parameterCount) +
" argument(s), got " +
std::to_string(instruction.args.size())
);
}
if (instruction.args.size() > 6) {
throw std::runtime_error(
"Assembly error: calls with more than 6 arguments are not supported"
);
}
if (instruction.type != signature.returnType) {
throw std::runtime_error(
"Assembly error: call result type does not match function '" +
instruction.callee + "'"
);
}
if (signature.returnType == Type::Void) {
if (!instruction.dst.empty()) {
throw std::runtime_error(
"Assembly error: void call cannot have a destination"
);
}
} else {
requireDestination(instruction);
}
return;
}
case IROp::Return:
if (function.returnType == Type::Void) {
requireArgumentCount(instruction, 0);
} else {
requireArgumentCount(instruction, 1);
}
return;
default:
break;
}
throw std::runtime_error("Assembly internal error: unvalidated instruction");
}
void AssemblyGenerator::emitProgram(
const IRProgram& program,
std::ostream& output
) {
output << "global _start\n";
for (const std::string& runtimeFunction : _runtimeImports) {
output << "extern " << functionLabel(runtimeFunction) << '\n';
}
output << "\nsection .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);
}
}
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 (std::size_t index = 0; index < function.parameters.size(); ++index) {
output
<< " mov " << stackOperand(function.parameters[index].name)
<< ", " << parameterRegister(index) << '\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";
}
void AssemblyGenerator::allocateStackSlots(const IRFunction& function) {
_context = FunctionContext{};
_context.returnLabel = ".L_" + function.name + "_return";
for (const IRParameter& parameter : function.parameters) {
allocateSlot(parameter.name);
}
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);
}
}
}
}
void AssemblyGenerator::allocateSlot(const std::string& name) {
if (_context.stackSlots.contains(name)) {
return;
}
_context.nextOffset += 4;
_context.stackSlots.emplace(name, _context.nextOffset);
}
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;
}
int AssemblyGenerator::stackSize() const noexcept {
constexpr int alignment = 16;
const int size = _context.nextOffset;
return ((size + alignment - 1) / alignment) * alignment;
}
std::string AssemblyGenerator::stackOperand(const std::string& name) const {
return "dword [rbp - " + std::to_string(slotOffset(name)) + "]";
}
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::Call:
emitCall(instruction, output);
return;
case IROp::Return:
emitReturn(instruction, output);
return;
default:
throw std::runtime_error(
"Assembly internal error: unsupported instruction reached emission"
);
}
}
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';
}
void AssemblyGenerator::emitLoad(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, " << stackOperand(instruction.args[0]) << '\n'
<< " mov " << stackOperand(instruction.dst) << ", eax\n";
}
void AssemblyGenerator::emitStore(
const IRInstruction& instruction,
std::ostream& output
) {
output
<< " mov eax, " << stackOperand(instruction.args[0]) << '\n'
<< " mov " << stackOperand(instruction.args[1]) << ", eax\n";
}
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";
}
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";
}
void AssemblyGenerator::emitCall(
const IRInstruction& instruction,
std::ostream& output
) {
for (std::size_t index = 0; index < instruction.args.size(); ++index) {
output
<< " mov " << parameterRegister(index) << ", "
<< stackOperand(instruction.args[index]) << '\n';
}
output << " call " << functionLabel(instruction.callee) << '\n';
if (!instruction.dst.empty()) {
output
<< " mov " << stackOperand(instruction.dst)
<< ", eax\n";
}
}
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';
}
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())
);
}
}
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::Call:
case IROp::Return:
return true;
default:
return false;
}
}
const char* AssemblyGenerator::parameterRegister(std::size_t index) {
static constexpr std::array<const char*, 6> registers{
"edi", "esi", "edx", "ecx", "r8d", "r9d"
};
if (index >= registers.size()) {
throw std::runtime_error(
"Assembly error: more than 6 register arguments are not supported"
);
}
return registers[index];
}
std::string AssemblyGenerator::functionLabel(const std::string& name) {
return "si_" + name;
}
std::string AssemblyGenerator::blockLabel(
const std::string& functionName,
const std::string& blockName
) {
return ".L_" + functionName + "_" + blockName;
}
Important Backend Changes
The updated implementation makes several important changes:
- Function signatures are collected before validation. Calls can target functions declared later in the source file.
- Parameters receive stack slots. Incoming ABI registers are copied into those slots in the function prologue.
- Runtime functions are imported explicitly. A program that calls
out()emitsextern si_out. - Calls use memory-to-register moves. Each temporary is loaded directly into its ABI argument register.
- Return values are captured from
eax. Void calls do not allocate or write a destination. - Generator state is reset. One
AssemblyGeneratorobject can safely compile multiple programs. - Unsupported control flow is rejected before emission. The output stream remains untouched by partial code generation.
Building a Small Compiler Runtime Library
The compiler already recognises the built-in signature out(int): void, but recognising a function is not the same as providing its machine-code implementation. The generated executable still needs code that converts the integer to text and writes it to standard output.
That machine-specific support belongs in a small compiler runtime library rather than in the source-language front end.
Create gsisi/libsi/out.asm:
global si_out
section .text
; void out(int value)
; Receives value in edi and writes a signed decimal number followed by '\n'.
si_out:
push rbp
mov rbp, rsp
sub rsp, 48
mov eax, edi
xor r8d, r8d
test eax, eax
jns .magnitude_ready
mov r8d, 1
neg eax
.magnitude_ready:
lea rsi, [rbp - 1]
mov byte [rsi], 10
mov ecx, 10
.convert_digit:
xor edx, edx
div ecx
add dl, '0'
dec rsi
mov byte [rsi], dl
test eax, eax
jnz .convert_digit
test r8d, r8d
jz .write
dec rsi
mov byte [rsi], '-'
.write:
mov rdx, rbp
sub rdx, rsi
mov eax, 1
mov edi, 1
syscall
mov rsp, rbp
pop rbp
ret
The runtime receives its argument in edi, converts the signed 32-bit value to decimal digits and writes the result to standard output with Linux’s write system call.
Unlike the earlier unsigned-only version, this implementation correctly handles:
- Positive values such as
42 - Zero
- Negative values such as
-42 - The 32-bit sign bit without relying on a larger C runtime
The conversion buffer lives in the runtime function’s stack frame instead of a global .bss buffer. Each call therefore owns its temporary conversion storage, avoiding shared mutable state between invocations.
The conversion loop deliberately runs at least once, so out(0) produces 0 rather than an empty line.
Declaring Runtime Symbols
User-defined functions are emitted in the program assembly file, so NASM can resolve labels such as si_add locally. Runtime functions are different: out() is assembled into a separate object file, so the generated program must declare that symbol as external.
extern si_out
The assembly generator scans the IR and emits runtime imports only for functions that are actually called. A program that never uses out() therefore has no unnecessary extern si_out declaration.
Automating NASM Assembly and ELF64 Linking
Chapter 7 required three manual commands:
./build/si_compiler tests/test.si build/test.asm
nasm -f elf64 build/test.asm -o build/test.o
ld build/test.o -o build/test
The compiler driver will now perform the assembly and linking stages automatically, turning the compiler from an assembly-file generator into a tool that can produce a native executable in one invocation.
A tempting implementation is:
std::system(("nasm ... " + userPath).c_str());
That approach is fragile and unnecessarily couples path handling to shell parsing. Spaces can break naïvely constructed commands, and path text can be interpreted as shell syntax.
The build driver below uses fork(), execvp(), and waitpid() instead. Each command-line argument is passed directly as a process argument, so paths containing spaces do not require shell quoting.
Create gsisi/include/BuildDriver.hpp:
#pragma once
#include <filesystem>
#include <string>
#include <vector>
class BuildDriver {
public:
void build(
const std::filesystem::path& assemblyFile,
const std::filesystem::path& runtimeAssemblyFile,
const std::filesystem::path& executableFile
) const;
private:
static void runCommand(const std::vector<std::string>& arguments);
};
Create gsisi/src/BuildDriver.cpp:
#include "BuildDriver.hpp"
#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <system_error>
#include <sys/wait.h>
#include <unistd.h>
void BuildDriver::build(
const std::filesystem::path& assemblyFile,
const std::filesystem::path& runtimeAssemblyFile,
const std::filesystem::path& executableFile
) const {
if (!std::filesystem::exists(assemblyFile)) {
throw std::runtime_error(
"Build error: generated assembly does not exist: " +
assemblyFile.string()
);
}
if (!std::filesystem::exists(runtimeAssemblyFile)) {
throw std::runtime_error(
"Build error: runtime assembly does not exist: " +
runtimeAssemblyFile.string()
);
}
if (!executableFile.parent_path().empty()) {
std::filesystem::create_directories(executableFile.parent_path());
}
std::filesystem::path programObject = assemblyFile;
programObject.replace_extension(".o");
std::filesystem::path runtimeObject = executableFile;
runtimeObject += ".runtime.o";
try {
runCommand({
"nasm", "-f", "elf64",
assemblyFile.string(),
"-o", programObject.string()
});
runCommand({
"nasm", "-f", "elf64",
runtimeAssemblyFile.string(),
"-o", runtimeObject.string()
});
runCommand({
"ld",
programObject.string(),
runtimeObject.string(),
"-o", executableFile.string()
});
} catch (...) {
std::error_code ignored;
std::filesystem::remove(programObject, ignored);
std::filesystem::remove(runtimeObject, ignored);
throw;
}
std::error_code ignored;
std::filesystem::remove(programObject, ignored);
std::filesystem::remove(runtimeObject, ignored);
}
void BuildDriver::runCommand(
const std::vector<std::string>& arguments
) {
if (arguments.empty()) {
throw std::runtime_error("Build internal error: empty command");
}
std::vector<char*> argv;
argv.reserve(arguments.size() + 1);
for (const std::string& argument : arguments) {
argv.push_back(const_cast<char*>(argument.c_str()));
}
argv.push_back(nullptr);
const pid_t child = fork();
if (child < 0) {
throw std::system_error(errno, std::generic_category(), "fork failed");
}
if (child == 0) {
execvp(argv[0], argv.data());
_exit(127);
}
int status = 0;
pid_t result = 0;
do {
result = waitpid(child, &status, 0);
} while (result < 0 && errno == EINTR);
if (result < 0) {
throw std::system_error(errno, std::generic_category(), "waitpid failed");
}
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
throw std::runtime_error(
"Build command failed: " + arguments.front()
);
}
}
The driver creates two temporary object files:
program.asm -> program.o
libsi/out.asm -> program.runtime.o
It links both objects into the requested executable and removes the temporary object files afterward. The generated .asm file remains available for debugging.
This implementation is intentionally Linux-specific, which matches the compiler target and Docker environment used throughout the series.
Updating the Compiler Driver
The compiler command-line interface now accepts a Si source file and the path of the native executable it should produce:
./si_compiler <source_file.si> <output_executable>
The driver writes generated assembly next to the executable by appending .asm to the requested output name.
The runtime is copied next to si_compiler during the CMake build. On Linux, /proc/self/exe gives the real location of the running compiler, so the driver can find libsi/out.asm even when gsisi is launched from a different working directory.
Update gsisi/src/main.cpp:
#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include "AssemblyGenerator.hpp"
#include "BuildDriver.hpp"
#include "IR.hpp"
#include "IRGenerator.hpp"
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
#include "Token.hpp"
namespace {
std::string readSourceFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
if (!input) {
throw std::runtime_error(
"could not open source file '" + path.string() + "'"
);
}
std::ostringstream buffer;
buffer << input.rdbuf();
if (input.bad()) {
throw std::runtime_error(
"failed while reading source file '" + path.string() + "'"
);
}
return buffer.str();
}
std::filesystem::path compilerDirectory() {
std::error_code error;
const std::filesystem::path executable =
std::filesystem::read_symlink("/proc/self/exe", error);
if (error) {
throw std::runtime_error(
"could not locate the compiler executable: " + error.message()
);
}
return executable.parent_path();
}
void writeAssemblyFile(
const std::filesystem::path& path,
const std::string& assembly
) {
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path());
}
std::ofstream output(
path,
std::ios::binary | std::ios::trunc
);
if (!output) {
throw std::runtime_error(
"could not open assembly file '" + path.string() + "'"
);
}
output << assembly;
output.close();
if (!output) {
throw std::runtime_error(
"failed while writing assembly file '" + path.string() + "'"
);
}
}
} // namespace
/**
* Usage:
* ./si_compiler <source_file.si> <output_executable>
*/
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr
<< "Usage: " << argv[0]
<< " <source_file.si> <output_executable>\n";
return 1;
}
try {
const std::filesystem::path sourcePath = argv[1];
const std::filesystem::path executablePath = argv[2];
std::filesystem::path assemblyPath = executablePath;
assemblyPath += ".asm";
const std::filesystem::path runtimePath =
compilerDirectory() / "libsi" / "out.asm";
Lexer lexer(readSourceFile(sourcePath));
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::ostringstream assemblyBuffer;
AssemblyGenerator assemblyGenerator;
assemblyGenerator.generate(ir, assemblyBuffer);
writeAssemblyFile(assemblyPath, assemblyBuffer.str());
BuildDriver buildDriver;
buildDriver.build(
assemblyPath,
runtimePath,
executablePath
);
std::cout
<< "Built executable: "
<< executablePath
<< '\n';
}
catch (const std::exception& error) {
std::cerr
<< "Compilation error: "
<< error.what()
<< '\n';
return 1;
}
return 0;
}
The assembly is generated in memory before the file is opened. Lexing, parsing, semantic analysis, IR validation and backend validation must all succeed before an existing .asm file can be replaced.
Test Program
Keep the end-to-end sample at gsisi/tests/test-5.si:
fn add(a: int, b: int): int {
return a + b;
}
fn main(): int {
let result: int = add(20, 22);
out(result);
return 0;
}
This small program exercises the three most important new paths introduced in Chapter 8:
- Two arguments are passed to
add(). - The returned value is read from
eaxand stored inresult. out(result)calls the external runtime function and prints42.
main returns zero so the native smoke test itself finishes successfully.
Testing the Assembly Generator
Backend unit tests continue to construct IR directly. This isolates calling-convention and assembly-generation failures from lexer, parser, semantic-analysis, and AST-to-IR bugs.
Replace 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();
}
IRInstruction constInt(std::string destination, int value) {
return IRInstruction{
.op = IROp::ConstInt,
.type = Type::Int,
.dst = std::move(destination),
.intValue = value
};
}
IRInstruction returnValue(std::string value) {
return IRInstruction{
.op = IROp::Return,
.args = {std::move(value)}
};
}
IRFunction makeMain(std::vector<IRInstruction> instructions) {
IRFunction function;
function.name = "main";
function.returnType = Type::Int;
function.blocks.push_back(IRBlock{
.name = "entry",
.instructions = std::move(instructions)
});
return function;
}
void testParametersAreCopiedFromAbiRegisters() {
IRFunction add;
add.name = "add";
add.parameters = {
IRParameter{"a", Type::Int},
IRParameter{"b", Type::Int}
};
add.returnType = Type::Int;
add.blocks.push_back(IRBlock{
.name = "entry",
.instructions = {
IRInstruction{.op = IROp::Load, .type = Type::Int, .dst = "%t0", .args = {"a"}},
IRInstruction{.op = IROp::Return, .args = {"%t0"}}
}
});
IRProgram program;
program.functions.push_back(std::move(add));
program.functions.push_back(makeMain({
constInt("%t0", 0),
returnValue("%t0")
}));
const std::string assembly = generateAssembly(program);
requireContains(assembly, "mov dword [rbp - 4], edi");
requireContains(assembly, "mov dword [rbp - 8], esi");
}
void testUserFunctionCallUsesRegistersAndCapturesEax() {
IRFunction add;
add.name = "add";
add.parameters = {
IRParameter{"a", Type::Int},
IRParameter{"b", Type::Int}
};
add.returnType = Type::Int;
add.blocks.push_back(IRBlock{
.name = "entry",
.instructions = {
IRInstruction{.op = IROp::Load, .type = Type::Int, .dst = "%t0", .args = {"a"}},
IRInstruction{.op = IROp::Load, .type = Type::Int, .dst = "%t1", .args = {"b"}},
IRInstruction{.op = IROp::Add, .type = Type::Int, .dst = "%t2", .args = {"%t0", "%t1"}},
IRInstruction{.op = IROp::Return, .args = {"%t2"}}
}
});
IRFunction main = makeMain({
constInt("%t0", 20),
constInt("%t1", 22),
IRInstruction{
.op = IROp::Call,
.type = Type::Int,
.dst = "%t2",
.args = {"%t0", "%t1"},
.callee = "add"
},
returnValue("%t2")
});
IRProgram program;
program.functions.push_back(std::move(add));
program.functions.push_back(std::move(main));
const std::string assembly = generateAssembly(program);
requireContains(assembly, "mov edi, dword [rbp - 4]");
requireContains(assembly, "mov esi, dword [rbp - 8]");
requireContains(assembly, "call si_add");
requireContains(assembly, "mov dword [rbp - 12], eax");
}
void testRuntimeCallDeclaresExternalSymbol() {
IRProgram program;
program.functions.push_back(makeMain({
constInt("%t0", 42),
IRInstruction{
.op = IROp::Call,
.type = Type::Void,
.args = {"%t0"},
.callee = "out"
},
constInt("%t1", 0),
returnValue("%t1")
}));
const std::string assembly = generateAssembly(program);
requireContains(assembly, "extern si_out");
requireContains(assembly, "call si_out");
}
void testSixArgumentsUseAllRegisterArguments() {
IRFunction function;
function.name = "six";
function.returnType = Type::Int;
for (int index = 0; index < 6; ++index) {
function.parameters.push_back(IRParameter{
"p" + std::to_string(index),
Type::Int
});
}
function.blocks.push_back(IRBlock{
.name = "entry",
.instructions = {
IRInstruction{.op = IROp::Load, .type = Type::Int, .dst = "%t0", .args = {"p0"}},
IRInstruction{.op = IROp::Return, .args = {"%t0"}}
}
});
std::vector<IRInstruction> mainInstructions;
std::vector<std::string> arguments;
for (int index = 0; index < 6; ++index) {
const std::string temporary = "%t" + std::to_string(index);
mainInstructions.push_back(constInt(temporary, index + 1));
arguments.push_back(temporary);
}
mainInstructions.push_back(IRInstruction{
.op = IROp::Call,
.type = Type::Int,
.dst = "%t6",
.args = arguments,
.callee = "six"
});
mainInstructions.push_back(returnValue("%t6"));
IRProgram program;
program.functions.push_back(std::move(function));
program.functions.push_back(makeMain(std::move(mainInstructions)));
const std::string assembly = generateAssembly(program);
requireContains(assembly, "mov r8d,");
requireContains(assembly, "mov r9d,");
}
void testMoreThanSixParametersAreRejected() {
IRFunction function;
function.name = "too_many";
function.returnType = Type::Int;
for (int index = 0; index < 7; ++index) {
function.parameters.push_back(IRParameter{
"p" + std::to_string(index),
Type::Int
});
}
function.blocks.push_back(IRBlock{
.name = "entry",
.instructions = {
constInt("%t0", 0),
returnValue("%t0")
}
});
IRProgram program;
program.functions.push_back(std::move(function));
program.functions.push_back(makeMain({
constInt("%t0", 0),
returnValue("%t0")
}));
bool rejected = false;
try {
generateAssembly(program);
} catch (const std::runtime_error& error) {
rejected = std::string(error.what()).find("more than 6 parameters") != std::string::npos;
}
require(rejected, "expected seven-parameter function to be rejected");
}
void testInvalidCallSignatureIsRejected() {
IRFunction identity;
identity.name = "identity";
identity.parameters = {IRParameter{"value", Type::Int}};
identity.returnType = Type::Int;
identity.blocks.push_back(IRBlock{
.name = "entry",
.instructions = {
IRInstruction{.op = IROp::Load, .type = Type::Int, .dst = "%t0", .args = {"value"}},
IRInstruction{.op = IROp::Return, .args = {"%t0"}}
}
});
IRProgram program;
program.functions.push_back(std::move(identity));
program.functions.push_back(makeMain({
IRInstruction{
.op = IROp::Call,
.type = Type::Int,
.dst = "%t0",
.callee = "identity"
},
returnValue("%t0")
}));
bool rejected = false;
try {
generateAssembly(program);
} catch (const std::runtime_error& error) {
rejected = std::string(error.what()).find("expects 1 argument") != std::string::npos;
}
require(rejected, "expected wrong argument count to be rejected");
}
void testGeneratorCanBeReused() {
AssemblyGenerator generator;
for (int value : {1, 2}) {
IRProgram program;
program.functions.push_back(makeMain({
constInt("%t0", value),
returnValue("%t0")
}));
std::ostringstream output;
generator.generate(program, output);
requireContains(output.str(), ", " + std::to_string(value));
}
}
} // namespace
int main() {
const std::vector<std::pair<std::string, std::function<void()>>> tests{
{"parameters use ABI registers", testParametersAreCopiedFromAbiRegisters},
{"user call and return value", testUserFunctionCallUsesRegistersAndCapturesEax},
{"runtime call import", testRuntimeCallDeclaresExternalSymbol},
{"six register arguments", testSixArgumentsUseAllRegisterArguments},
{"reject seven parameters", testMoreThanSixParametersAreRejected},
{"reject invalid call signature", testInvalidCallSignatureIsRejected},
{"generator reuse", testGeneratorCanBeReused}
};
int failures = 0;
for (const auto& [name, test] : tests) {
try {
test();
std::cout << "[PASS] " << name << '\n';
} catch (const std::exception& error) {
++failures;
std::cerr << "[FAIL] " << name << ": " << error.what() << '\n';
}
}
std::cout << (tests.size() - failures) << "/" << tests.size()
<< " tests passed\n";
return failures == 0 ? 0 : 1;
}
These tests cover:
- Copying incoming parameters from
ediandesi - Passing user-function arguments in ABI registers
- Saving a non-void return value from
eax - Emitting
extern si_outfor runtime calls - Using all six register arguments, including
r8dandr9d - Rejecting functions with seven parameters
- Rejecting calls with the wrong argument count
- Reusing one assembly-generator object safely
Native Runtime Smoke Test
Unit tests can inspect generated text, but only a native test proves that the generated assembly, runtime and linker output work together.
Replace gsisi/tests/run_backend_smoke_test.sh:
#!/usr/bin/env bash
set -euo pipefail
compiler="$1"
source_root="$2"
work_dir="$3"
rm -rf "$work_dir"
mkdir -p "$work_dir/path with spaces"
program="$work_dir/path with spaces/test-program"
"$compiler" "$source_root/tests/test-5.si" "$program"
actual_output="$("$program")"
if [[ "$actual_output" != "42" ]]; then
echo "Expected test program to print 42, got: $actual_output" >&2
exit 1
fi
negative_source="$work_dir/negative.si"
negative_program="$work_dir/path with spaces/negative-program"
cat > "$negative_source" <<'SI'
fn main(): int {
out(-42);
out(0);
return 0;
}
SI
"$compiler" "$negative_source" "$negative_program"
negative_output="$("$negative_program")"
expected_negative=$'-42\n0'
if [[ "$negative_output" != "$expected_negative" ]]; then
echo "Unexpected signed runtime output:" >&2
printf '%s\n' "$negative_output" >&2
exit 1
fi
The smoke test deliberately builds executables in a directory named path with spaces. This verifies that the build driver passes paths as real process arguments rather than concatenating them into shell commands.
It also checks both positive and signed output:
42
-42
0
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
)
add_custom_command(
TARGET si_compiler
POST_BUILD
COMMAND
${CMAKE_COMMAND} -E make_directory
$<TARGET_FILE_DIR:si_compiler>/libsi
COMMAND
${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/libsi/out.asm
$<TARGET_FILE_DIR:si_compiler>/libsi/out.asm
COMMENT "Copying the Si runtime next to the compiler"
)
# --------------------------------------------------
# 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
)
if(NASM_EXECUTABLE AND LD_EXECUTABLE)
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}
${CMAKE_CURRENT_BINARY_DIR}/backend-smoke
)
else()
message(WARNING
"NASM or ld was not found; the native runtime smoke test is disabled"
)
endif()
endif()
We continue to use enable_testing() directly instead of include(CTest). This avoids the CTest dashboard-file permission problem that can occur in Docker bind mounts.
The native smoke test is registered only when both nasm and ld are available. The normal C++ unit tests therefore remain usable even on a development machine that cannot assemble and link the native backend output.
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:
7/7 tests passed
Compile tests/test-5.si directly into a native executable:
./build/si_compiler tests/test-5.si build/test
The compiler creates:
build/test.asm
build/test
Run the executable:
./build/test
Expected output:
42
You can inspect the generated assembly with:
less build/test.asm
System V ABI Registers Used by This Compiler
For Chapter 8, the most important ABI data path is small enough to summarise directly:
| Purpose | Register |
|---|---|
| Argument 1 | edi |
| Argument 2 | esi |
| Argument 3 | edx |
| Argument 4 | ecx |
| Argument 5 | r8d |
| Argument 6 | r9d |
| 32-bit return value | eax |
Using the 32-bit register names matches Si’s current int and bool representation. The wider 64-bit register names become relevant when the language gains 64-bit values, pointers, or other ABI-level data.
Common Function-Call Code Generation Mistakes
Function-call support introduces several backend invariants that are easy to get subtly wrong:
- Putting arguments in the wrong registers. Caller and callee must agree on the ABI register order.
- Forgetting to preserve stack alignment. Misalignment may remain invisible until runtime or library code relies on the ABI.
- Failing to save a non-void result from
eax. A later instruction or call can overwrite the return register. - Giving a destination to a
voidcall. A void function has no value for the backend to store. - Letting call signatures drift from function definitions. Argument counts and return types must match the collected signature.
- Treating runtime functions like locally emitted functions. Separately assembled runtime functions require external symbol declarations and linker input.
- Building shell command strings from paths. Direct process arguments avoid quoting problems and unnecessary shell interpretation.
- Testing only generated text. Native execution is needed to verify that the ABI, runtime, assembler, and linker agree.
Compiler Runtime Library vs Generated Code
Generated program code and runtime code play different roles.
The compiler backend emits assembly for functions written in Si. The runtime supplies low-level services that the language exposes but that are inconvenient or inappropriate to regenerate for every program. In this chapter, out(int) is the first such service.
Keeping the runtime separate has several advantages:
- generated user code stays focused on the source program;
- the runtime can be assembled once per build;
- platform-specific system-call logic remains isolated;
- the generated assembly can import only the runtime symbols it actually uses.
Frequently Asked Questions
What is the System V AMD64 calling convention?
It is the ABI used by this Linux x86-64 backend to define how functions exchange arguments and return values and how the stack is managed around calls.
Which registers hold function arguments on x86-64 System V?
For the integer-like values supported in this chapter, the first six arguments use edi, esi, edx, ecx, r8d, and r9d.
Where is an integer return value stored?
A 32-bit integer or boolean result is returned in eax. The caller stores that value into an IR destination temporary when the call expression produces a value.
Why copy function parameters into stack slots?
Copying incoming parameter registers into the function’s normal storage means the existing IR load machinery can treat parameters like ordinary variables. Later optimisation could keep values in registers instead.
Why is stack alignment important before a function call?
A calling convention defines stack-alignment expectations so independently generated code can interoperate safely. Violating those expectations can cause failures once called code relies on the prescribed alignment.
Why are only six arguments supported?
Chapter 8 implements the register-based argument path only. Additional System V integer arguments require stack-based argument passing, which is intentionally deferred.
What is a compiler runtime library?
A compiler runtime library provides low-level helper functions used by generated programs. Here, the runtime implements out(int) in NASM and uses a Linux system call to print signed integers.
Why is out() declared with extern?
The generated program references si_out, but that function is assembled into a separate runtime object file. extern si_out tells NASM that the symbol will be resolved by the linker.
Why use execvp() instead of std::system()?
The build driver passes each program argument directly to the child process. This avoids constructing shell command strings and handles paths containing spaces without shell quoting.
What comes after function-call support?
Chapter 9 extends the x86-64 backend with comparisons, jumps, and conditional branches so Si can execute if, else, and while control flow.
Common Errors
Build command failed: nasm
Confirm that NASM is installed inside the Docker environment:
nasm -v
Rebuild the development image after changing the Dockerfile.
Build error: runtime assembly does not exist
Rebuild si_compiler so CMake copies libsi/out.asm into build/libsi/:
cmake --build build --target si_compiler
ls build/libsi/out.asm
Assembly error: call to unknown function
The IR contains a callee that was not registered as either a user-defined function or a built-in runtime function. In the normal compiler pipeline, this usually indicates that an earlier semantic-analysis or IR-generation invariant has been broken.
Assembly error: function ... expects ... arguments
The IR call argument count does not match the function signature collected by the backend. Semantic analysis should normally reject the source program earlier, but defensive backend validation prevents malformed IR from becoming incorrect assembly.
Assembly error: more than 6 parameters
Chapter 8 supports only the first six integer-like arguments carried in registers. Additional stack-passed arguments are part of the wider ABI but intentionally outside this chapter’s implementation.
Docker reports permission errors in build/
The build directory may have been created by a different user. Remove it and configure again as the container’s dev user:
sudo rm -rf build
cmake -S . -B build
cmake --build build
Current Calling-Convention and Runtime Limitations
The compiler can now cross real function boundaries and link against its own runtime, but this is still a deliberately small subset of the full System V ABI and runtime design:
- A function may have at most six parameters.
- Only 32-bit integer and boolean values are passed.
- Floating-point values and structures are not supported.
- The runtime contains only
out(int): void. - Comparisons, branches and loops are not emitted yet.
- There is no separate object-file compilation for user modules.
- The compiler targets Linux x86-64 only.
Despite those limitations, Chapter 8 crosses an important compiler boundary: separately generated functions now communicate through a real platform ABI, and generated programs can call code provided by an independently assembled runtime object.
System V Function Calls: Key Takeaways
- The compiler backend now supports user-defined function calls on Linux x86-64.
- Function arguments follow the System V AMD64 register convention implemented in this chapter.
- The first six 32-bit integer-like arguments use
edi,esi,edx,ecx,r8d, andr9d. - Incoming parameters are copied into normal function stack slots so existing IR loads can read them.
- Non-void IR
callinstructions capture their result fromeax. - The backend validates function signatures, argument counts, return types, and call destinations before emission.
- Runtime calls are declared as external symbols and linked from the separate
libsi/out.asmobject. - The
out()runtime prints positive, zero, and negative signed 32-bit integers. - The build driver invokes NASM and
lddirectly rather than constructing shell command strings. - Paths containing spaces are covered by the native smoke test.
- Unit tests isolate ABI lowering, while native tests verify the full compile → assemble → link → run pipeline.
- The backend is now ready for comparisons and explicit control-flow generation in Chapter 9.
Next Chapter
In Chapter 9, we will extend this System V-aware backend with comparisons, jumps, and conditional branches so compiled Si programs can execute if, else, and while control flow.
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.