LLVM backend
backend-llvm is jihoo-llc, a small C++ program on LLVM 21 that compiles a
native or freestanding JIR module to an object file. jihoo build runs it and
then links the result.
- Parse the
.jirtext (backend-llvm/src/jir_parser.cpp) into the C++ mirror of the IR (backend-llvm/src/jir.h). Thejir Nheader comes first, and a module of any version but the backend’s own (jihoo-llc --version) is refused there (Versions). - Generate LLVM IR (
backend-llvm/src/codegen.cpp). Every JIR register gets analloca;mem2regturns them into SSA values, so the JIR need not be SSA. Structs and enums become named LLVM structs, and the backend checks each layout JIR records (size S align A) against the target’s data layout, so a mismatch is a compile error instead of silent memory corruption. - Optimize with LLVM’s standard pipeline for the level (
-O2by default). - Emit an object file for the target: position independent for native programs, which the C compiler usually links into a PIE, and static for freestanding ones.
What each profile gets
Section titled “What each profile gets”In both compiled profiles the program’s functions are internal symbols named
jihoo.<name>, so they never clash with C symbols, and each exported function
is also an external C function of its own name (see below).
- Native. The module defines C’s
int main(int argc, char **argv), which callsjihoo.mainand returns its result truncated toint(0 forunit).extern fns are external declarations, called with the C calling convention.printcalls the C library:printffor integers,putsfor bools and strings, and for floatsjihoo.print_float, which the module defines. It finds the shortest digits that read back (strtod,strtof) from the exact digitssnprintfgives, rounding ties up as the VM does, so native and VM output agree to the byte. - Freestanding.
_startis the only external symbol besides exported functions. Returning from it makes theexitsyscall (60 on x86_64, 93 on aarch64) with the result. The module defines weakmemcpy,memmove,memset,fmod,fmodf,floor,ceil,trunc,roundand theirfforms, which LLVM may call (on x86_64 without SSE4.1, the rounding intrinsics become calls), since there is no libc to provide them; an exported function of one of those names takes its place.
Lists and strings
Section titled “Lists and strings”A list or a string is a header { ptr data, i64 len, i64 cap }, held by
value, over memory from malloc that starts with a count of its holders
(8 bytes before data). A string with cap 0 is a literal’s constant
bytes, never counted or freed. The glue functions the backend makes per type
(jihoo.clone.T, jihoo.drop.T) count and free: a clone of a list or a
string is O(1), and one of a struct, an enum or an array clones its owning
parts. The last holder frees without an atomic write (a count of 1 means no
other thread holds the memory to count itself in).
Before a write into a list’s memory, the backend makes the list unique: it
reads the count, and only memory held more than once goes to
jihoo.unique.[T], which copies the elements (counting each once more) and
gives the copy. A function keeps a flag for each list on the path of each of
its writes, raised once the list is known unique and lowered by anything
that could give it another holder (a clone, a call, writing the register or
an index anew, replacing the list whole); a write with its flag up checks
nothing. The flags are allocas, which LLVM promotes, so in a loop the check
runs once (Design: native memory).
Calling convention
Section titled “Calling convention”Between the program’s own functions, aggregates (structs, enums and arrays) are passed by pointer: the caller passes the address of its copy and the callee copies it into its own storage, and an aggregate result is written through a hidden first parameter. Scalars and pointers are passed the C way, which is why jihoo functions with scalar signatures work as C callbacks. The details are in JIR.
A function that makes or takes a tail call also gets
a body with LLVM’s tailcc convention, jihoo.f.tc, and calls between such
bodies use it: a tail call is a musttail call, which tailcc guarantees at
every -O level and on both targets even when the two signatures differ.
jihoo.f stays a C-convention function that calls the body, so function
values, exports and main see no difference. A tail call cannot pass an
aggregate by pointer into its own frame, which is gone when the callee runs;
it copies it into jihoo.tailcall.args, a buffer as large as the largest
tail call needs (thread-local natively), which the callee copies out of
first.
The C ABI
Section titled “The C ABI”C passes structs by value its own way, which LLVM leaves to the compiler of
the calling language, as clang does for C. backend-llvm/src/cabi.cpp decides
it for each parameter and result of an extern fn or exported function, by
the target’s rules: on x86_64 (System V) by the class of each eightbyte of a
struct of up to 16 bytes, with the free registers counted so a struct goes
wholly in registers or wholly on the stack (byval); on aarch64 (AAPCS64) as
up to four floats in SIMD registers ([N x float]), else up to 16 bytes in
general registers (i64, [2 x i64]), else by pointer to a copy. Larger
results go through sret.
A call to C copies each struct into a temporary laid out as its registers and
loads them from there, and stores a returned one back the same way. An
exported function f gets a C function that does the reverse and calls
jihoo.f. tests/ffi/structs.jh and structs.c pass every kind of struct
both ways; the C signatures match what clang makes for the same C on both
targets.
Checks and traps
Section titled “Checks and traps”- Array indexing is bounds-checked and traps (
llvm.trap) when out of range. - Integer
/and%trap when dividing by zero, where the VM reports an error, and signedMIN / -1wraps toMIN(remainder 0) as on the VM. LLVM leaves both undefined, so the backend checks the divisor before dividing. - Floats use no fast-math flags and no fused multiply-add, so results match the VM bit for bit.
- Which NaN an operation makes differs between machines (x86_64 sets the sign
of
0.0 / 0.0, aarch64 and LLVM’s constant folding do not), so no NaN’s sign or payload is visible:bitcastreads every NaN as the one quiet NaN, andcopysignwith a NaN sign gives the magnitude.
Targets
Section titled “Targets”A module names its target (target x86_64 or target aarch64), and the
backend compiles for that machine’s Linux triple, whatever machine it runs on;
a module without the line is compiled for the host. syscall and asm are
lowered for x86_64 (Intel syntax) and aarch64. --emit-llvm writes the LLVM IR
as text, which is the quickest way to see what the backend made of a program (Command line).