Generated code
The code generator is deliberately simple. It does not allocate registers or optimise. Every expression leaves its value in one register, and everything else goes on the stack.
Expressions
Evaluating an expression leaves its value in rax. A binary operator evaluates the left side, pushes it, evaluates the right side, moves it to rcx, pops the left side back into rax, and applies the operation.
Functions and calls
The calling convention is the compiler’s own, and is not compatible with C.
- The caller pushes the arguments left to right, calls, and removes them afterwards.
- The result comes back in
rax. - The function saves
rbpand points it at its frame. Argument numberiofn, counted from 0, is atrbp + 16 + 8 * (n - 1 - i).
Locals
A let evaluates its value and pushes it. The name then refers to a fixed position below rbp, known from how many values are already on the stack at that point. At the end of a block its locals are popped in one step.
The generator tracks that depth as it descends, so every local’s position is known without a separate pass, and sibling blocks reuse the same stack space.
if and match
An if tests rax and jumps over the first branch when it is zero. The first branch ends with a jump over the second.
A match pushes the value it is matching on. Each arm but the last compares the case number at the start of the value’s block with its own and jumps to the next arm if they differ. An arm pushes each field it names, runs its body, and pops them.
Self tail calls
When a function calls itself in last position, the generator evaluates the new arguments, stores them over the old ones, resets the stack to the frame, and jumps to the start of the function body.
Reserved registers
Three registers are set when the program starts and never used for anything else.
| Register | Holds |
|---|---|
r13 | The stack pointer as the kernel left it, which points at the argument count. |
r14 | The end of the memory the heap has been given. |
r15 | The next free address on the heap. |
Allocation
Making a record, a union value or new bytes calls one shared routine with a size. It returns the current value of r15 and moves r15 up. If that passes r14, it asks the kernel to extend the heap to the next page boundary first.
Runtime errors
A division first tests the divisor. If it is zero, the code loads the address and length of a message stored right after the call, and calls a shared routine that writes it to standard error and exits with status 1. The message is built by the compiler from the position of the operator.
A divisor of -1 is handled without the processor’s divide instruction, since dividing the smallest int by -1 would otherwise fault.
Layout of the executable
The file is a 64-byte file header, a 56-byte program header, then the code: a start routine, the allocation routine, the runtime error routine, and every function in the order the compiler read them. A bytes literal sits inside the function that uses it, with a jump around it.
All jumps and calls are relative, so the kernel can load the program at any address.