Skip to content

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.

  1. Parse the .jir text (backend-llvm/src/jir_parser.cpp) into the C++ mirror of the IR (backend-llvm/src/jir.h). The jir N header comes first, and a module of any version but the backend’s own (jihoo-llc --version) is refused there (Versions).
  2. Generate LLVM IR (backend-llvm/src/codegen.cpp). Every JIR register gets an alloca; mem2reg turns 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.
  3. Optimize with LLVM’s standard pipeline for the level (-O2 by default).
  4. 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.

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 calls jihoo.main and returns its result truncated to int (0 for unit). extern fns are external declarations, called with the C calling convention. print calls the C library: printf for integers, puts for bools and strings, and for floats jihoo.print_float, which the module defines. It finds the shortest digits that read back (strtod, strtof) from the exact digits snprintf gives, rounding ties up as the VM does, so native and VM output agree to the byte.
  • Freestanding. _start is the only external symbol besides exported functions. Returning from it makes the exit syscall (60 on x86_64, 93 on aarch64) with the result. The module defines weak memcpy, memmove, memset, fmod, fmodf, floor, ceil, trunc, round and their f forms, 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.

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).

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.

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.

  • 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 signed MIN / -1 wraps to MIN (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: bitcast reads every NaN as the one quiet NaN, and copysign with a NaN sign gives the magnitude.

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).