eskiuv0.9.2
Compiler internals · ABI

Eskiu ABI

How Eskiu types and constructs lower to LLVM IR, and the calling/layout conventions a foreign caller must follow. This reflects the code generator (codegen/codegen_*.cpp); it is the contract for linking Eskiu with C (and, on the roadmap, Swift/Kotlin over the C ABI).

Eskiu targets the platform's native C ABI via LLVM, so on a given target the representations below match what a C compiler produces for the equivalent declarations. There is no separate Eskiu calling convention.


Scalar types

Eskiu LLVM Signed? Notes
int, int32 i32 signed default integer width
int8 / int16 / int64 i8 / i16 / i64 signed
uint, uint32 i32 unsigned
uint8 / uint16 / uint64 i8 / i16 / i64 unsigned
bool i1 n/a
char i8 unsigned
float float n/a IEEE-754 single
double double n/a IEEE-754 double
string ptr n/a pointer to a NUL-terminated byte buffer
void void n/a return type only

Signedness is not part of the LLVM type (both int and uint are i32); it is carried by the operations. Integer widening chooses the extension by the source type: uint*, char and bool zero-extend; signed integers sign-extend. Narrowing truncates. int↔float uses signed conversions (sitofp/fptosi).


Pointers and const

All pointers lower to LLVM's opaque ptr (address space 0), regardless of pointee type or spelling (int* and *int are identical). Pointer-to-pointer, casts between pointer types, and string are all just ptr. A checked nullable pointer ?*T has the identical representation to *T (a bare ptr); its non-null requirement is enforced by the type checker at compile time and adds no runtime cost or storage.

A leading * and a trailing [N] follow the source rule that the array binds outermost: *T[N] is an array of N ptr elements. There is no pointer-to-array spelling (T[N]* does not parse); a *T to the first element is the single ptr C code expects for a T (*)[N]. For C interop, libc size_t parameters and returns (strlen, the size argument of memcpy/memset/memmove/memcmp/memchr) are declared int64 so the extern signatures match the C ABI on 64-bit targets.

const has no ABI effect. Every const qualifier (base/pointee const T, pointer-level T* const) is stripped before lowering, so a const int* and an int* are the same at the IR/object level. const is purely a compile-time checking aid.


Aggregates

Structs

A non-packed struct without bitfields lowers to an LLVM struct of its field types in declaration order, with the target's natural alignment (LLVM inserts padding). This matches a C struct with the same fields.

Packed structs

packed struct (equivalently #pragma pack(1)) lowers to an LLVM packed struct: no padding, fields back-to-back.

#pragma pack(N) for N > 1 uses an explicit manual layout that caps each field's alignment at N: a field is placed at the next offset that is a multiple of min(natural-align, N), with [k x i8] padding inserted as needed, and the total size rounded up to the struct's alignment (min(max-field-align, N)). The result is emitted as a packed LLVM struct (padding is explicit) and matches the C #pragma pack(N) ABI. Field access goes through a logical→physical index map (padding shifts indices).

LLVM sees such a struct as 1-aligned, so its C alignment (min(max-field-align, N)) is recorded beside the type (cAlignOverride / cg_set_calign) and read through cAlignOf / cg_c_align wherever C alignment matters: a struct with a field (or array element) whose C alignment is above LLVM's is laid out the same way by hand (no cap) and records its own alignment, and so do a bitfield struct and a union holding one; the C ABI lowering classifies the aggregate with that alignment and takes the leaves of a hand-laid struct from its fields, not its padding runs. The type checker's constLayout / sema_const_layout fold sizeof with the same alignment.

Bitfields

A struct with bitfields is laid out the way the target's C compiler does it.

Under the SysV/AAPCS layout every field of such a struct is addressed by byte offset. The LLVM type only reproduces the C size and alignment: each storage unit as an integer of its declared type, the normal fields outside those units as themselves, and [n x i8] for the remaining bytes (a packed struct is a packed LLVM struct of normal fields and byte runs). Reads load the bitfield's storage unit (in a packed struct, the exact byte span, e.g. i24), shift by the bit offset and mask (sign-extending for signed fields); writes, compound assignments and ++/-- are read-modify-write of that unit.

Unions

A union lowers to { M, [P x i8] }, where M is its most-aligned member (ties go to the larger one) and the byte padding brings the size up to the largest member rounded to that alignment. So the union has C's size and alignment, and lands at the C offset inside a struct (struct { int tag; union { int64 l; int i; } u; } puts u at 8). All members share offset 0; a member access reinterprets the storage at that type. Under #pragma pack(N) each member's alignment is capped at N (as C); when that lowers the union's alignment the storage is the packed <{ M, [P x i8] }> with its C alignment recorded (cAlignOverride / cg_set_calign), so it lands at the C offset in a struct and the C ABI lowering sees the C size and alignment.


Enums

Classic enums

A payload-less enum is an i32. Each member is a compile-time i32 constant (explicit = value or the running auto-increment).

Algebraic enums (ADTs)

A payload-bearing enum is a tagged union:

{ i32 tag, [N x i64] payload }

Generic ADTs (Option<T>, Either<A,B>) are monomorphized per instantiation: each concrete instance (Option_int) gets its own {i32, [N x i64]} sized for that instance, exactly like template structs.


Functions and calling convention

Parameters and results use the platform C ABI as produced by LLVM for the lowered types. Scalars and pointers pass in registers per the target ABI.

Struct return (sret). A function returning a struct larger than 16 bytes (getTypeAllocSize > 16) returns void and takes a hidden pointer as its first parameter; the caller allocates the result buffer and passes its address. Structs ≤ 16 bytes are returned by value (the target ABI splits them into registers as usual). This is the convention between Eskiu functions; calls to extern C functions follow the C ABI lowering below.

Aggregates across extern (C ABI). Between Eskiu functions a struct or union argument/result is a first-class LLVM value. An extern function that takes or returns one by value is instead declared with the lowered C signature (codegen/codegen_cabi.cpp, mirrored by the self-hosted codegen), and each call converts to and from it, so it links against C compiled by clang/gcc:

Target Aggregate argument Aggregate result
AArch64 (AAPCS64, Darwin + Linux) HFA of 1-4 float/double → [N x fp] in FP registers (with alignstack(8) off Darwin); ≤ 8 bytes → i64; ≤ 16 → [2 x i64]; larger → pointer to a caller-made copy HFA → { fp, ... }; ≤ 8 bytes → iN; ≤ 16 → [2 x i64]; larger → sret (x8)
x86-64 System V (Linux, macOS) each eightbyte classified INTEGER/SSE → one or two register values (i64, i32, double, <2 x float>, ptr, ...), as wide as the data in it (a union's widest member decides: union { int; double; } is i64, union { float; double; } is double); > 16 bytes, a misaligned field, or no free registers left → byval the same classes as a { lo, hi } pair or one value; > 16 bytes → sret
Windows x64 size 1/2/4/8 → iN; otherwise pointer to a caller-made copy size 1/2/4/8 → iN; otherwise sret
32-bit ARM (AAPCS) hard-float HFA → { fp, ... }; ≤ 64 bytes → [N x i32] ([N x i64] if 8-aligned); larger → byval hard-float HFA → { fp, ... }; ≤ 4 bytes → i32; otherwise sret
32-bit x86 (cdecl: i386 SysV, Darwin, Windows) ≤ 16 bytes made only of 32/64-bit scalars (int, int64, float, double, pointers) with no padding → those scalars as separate arguments ({ int, double } → i32, double; not on Windows, where double is 8-aligned and leaves padding); anything else → byval in a 4-byte-aligned stack slot Linux (and other SysV i386 systems): always sret. Darwin, Windows and the BSDs: 1, 2, 4 or 8 bytes whose fields are each register-sized → iN in EAX:EDX, except a single-element struct of a float/double (in ST0; not under MSVC's rules, mingw keeps it) or a pointer, returned as that scalar; otherwise sret

The coerced types match what clang emits for the same C signature (tests/selfhost/cabi_parity.sh checks the 32-bit x86 ones against clang's IR for the tests' C companions). Other targets keep the first-class lowering. Fat values (closures, slices) and va_list are not C aggregates and keep their own layout.

Narrow integers. Like clang, an extern's int8/int16/uint8/uint16/ bool/char parameters and results are declared signext or zeroext (by the type's signedness; char follows the target's C char, unsigned on AArch64 and 32-bit ARM outside Darwin and Windows), so the caller extends such an argument to 32 bits. Every Eskiu function returning one extends its result (define signext i8 @f), since a C caller (a callback, or C calling it by name) relies on that on AArch64 Darwin and x86-64.

Callbacks from C. An Eskiu function handed to C as a raw function pointer ((*void)f, the cast of a top-level function name to a pointer type) is called with the C convention. When it takes or returns an aggregate by value, the cast yields the address of a thunk __cabi_<name> instead of @name: the thunk has the lowered C signature from the table above (with the same sret/byval attributes), rebuilds the Eskiu-level values, calls f, and returns the result the C way. A function without by-value aggregates is passed as itself. Eskiu code calling f directly is unaffected.

C function pointer parameters. A parameter of fn type in an extern (one the program does not also define) is a C function pointer: it is declared ptr, not the { fn, env } closure, and each call passes the named top-level function's C address (its __cabi_ thunk when needed) or null. The type checker rejects any other argument there, since a closure's environment cannot cross into C.

Variadics. A ... parameter makes the LLVM function isVarArg. The built-in va_list is the struct { ptr, ptr, ptr, i32, i32 } (32 B, 8-aligned), a superset of the x86-64 (24 B) and AArch64 (32 B) layouts, so one type serves both; va_start/va_end lower to the LLVM intrinsics. va_arg<T> is the LLVM va_arg instruction, except on AArch64 outside Darwin and Windows, whose backend does not expand it for the AAPCS64 va_list {__stack, __gr_top, __vr_top, __gr_offs, __vr_offs}: there the compiler emits the read as clang does (an integer or pointer from the general-register save area while __gr_offs is negative, a float/double from the FP/SIMD save area in 16-byte steps while __vr_offs is, then __stack in 8-byte slots; emitVaArg / cg_va_arg). On x86-64 System V LLVM expands the instruction itself for the scalars va_arg reads. An extern parameter of type va_list (vprintf, vsnprintf) is declared ptr and gets what C passes for its va_list: the address of the storage on x86-64 System V and AArch64 outside Darwin and Windows (an array/struct type there), and the char* that llvm.va_start stored at offset 0 on Darwin AArch64, Windows x64 and 32-bit ARM (evalCVaList / cg_c_va_list). Between Eskiu functions a va_list stays a first-class value. Variadic arguments follow C default promotions: integers narrower than 32 bits widen to i32 (sign- or zero-extended), bool zero-extends, and float widens to double.


Function pointers, closures and interfaces

These use fat pointers: a two-ptr struct { ptr, ptr }.

Function values / closures. A fn(A,B)->R value is { fn, env }: a code pointer plus an environment pointer. A lambda compiles to a function whose first parameter is the env*, followed by the declared parameters; captured variables are loaded from the env struct (one field per capture, in capture order). A bare top-level function used as a value is wrapped by a thunk __fnptr_<name> with env == null.

A non-escaping closure (only called or passed to a non-escaping parameter) keeps its env on the stack (zero cost). An escaping closure (returned, stored, or passed to an escaping parameter) heap-allocates the env; free_closure(f) releases it. The choice is fixed at compile time by escape analysis.

Interfaces. An interface value is { data, vtable }: a pointer to the underlying struct plus a pointer to a per-(interface, struct) vtable constant. The vtable is an array of function pointers, one per interface method in declaration order. A method call loads data and vtable, indexes the vtable by method position, and calls the function pointer with data as the first argument.

Slices. A slice T[] is also a fat pointer, but { ptr, i64 }: a pointer to the first element plus an element count. s[i] indexes through the pointer, s.len reads field 1, and the slice is passed and returned by value like any small struct. A slice aliases its backing storage (it is built by slicing a fixed array or a raw pointer with a[lo..hi]), so it neither owns nor copies the data.


Name mangling

Template and generic-enum instances are monomorphized with a deterministic mangling: in the instance type, spaces are removed and <, >, , become _.

Source Mangled instance
List<int> List_int
Pair<int, float> Pair_int_float
Option<string> Option_string

Struct methods are emitted as Struct_method; interface vtable entries as Interface_method. Non-template top-level functions keep their source names (Eskiu does not overload, so no signature mangling is needed). extern and intrinsic declarations use their exact C symbol names: that is how Eskiu calls into C.


Async frames

An async function is lowered (before code generation) to a resumable state machine: a heap-allocated frame struct holding the live locals plus a resume state, a resume function, and a constructor that returns a *Future<T>. The Future<T>/FutureHdr layout and the completion/waker handshake are specified in async-design.md; that document is the authoritative contract for the runtime side.


See also