Axle v0.14.1

FFI — calling C from Axle

When the stdlib doesn’t already wrap what you need, Axle can call libc, libm, or any other C-ABI symbol directly.

One marker, four positions. extern "C" says this belongs to the other side, and where you write it says what it governs:

  • extern "C" fn f(…) : T; — a symbol the linker resolves. No body.
  • extern "C" struct / extern "C" class — a layout that came from the other side, so the compiler leaves its field order alone.
  • extern "C" (A, B) => R in type position — a thin function pointer, one machine word, for a callback.
  • extern "C" from "lib" { … } — a group of imports, and the library they come from.

Around it:

  • @link(symbol = "...") — pins the C-side name, in the one case where it differs from the Axle one.
  • ptr<T> / ptr — raw pointer types for C interop.
  • unsafe { } — block-level marker for pointer-dereferencing code.

The same marker with a body goes the other way — see Exporting to C.

What this page covers: what “the C ABI” means here · extern "C" imports and @link · ptr<T> and unsafe blocks · the unchecked array and word accessors · extern "C" records and how to measure their layout · callbacks · grouping a file of bindings · where a pointer comes from · a complete allocate-fill-read-free example · static fn · the dangers at the boundary · what the stdlib already wraps, and what the boundary cannot promise.

What “the C ABI” means

An ABI (Application Binary Interface) is the machine-level contract two pieces of compiled code agree on so they can call each other without sharing source : which registers carry the first, second, … argument, how a return value comes back, how the stack is laid out, how a structure is packed. The C ABI is the one every operating system publishes and every language can speak — it is the lingua franca of native code. When Axle calls write or sqrt, it is not calling “C” — it is emitting a call that follows the platform’s C ABI, and the linker wires it to whatever symbol libc/libm exposes under that name.

That is the whole reason FFI works : because Axle honours the same register/stack convention the C library was compiled with, an Axle extern "C" fn and the real C function meet in the middle with no glue. The flip side — and the source of every danger below — is that the ABI is a contract by convention, not one the compiler can verify across the boundary. Get the signature wrong and nothing warns you; the mismatch surfaces as a crash or silent corruption at run time.

The stdlib already binds the libc memory routines and the libm transcendentals — import them instead of re-declaring them. The libc bindings carry a libc_ prefix, because malloc and free are Axle keywords: use std::ffi::libc::libc_malloc;, libc_free, libc_memset, libc_memcpy. The libm ones keep their C names: use std::ffi::libm::sqrt;, sin, log, …

A use path is checked, and a wrong one is E0756. The path may be shortened to the item: std::ffi::libc::libc_malloc resolves the symbol in std/ffi/libc/mem without naming the mem module.

The symbol is the name as written. A declaration needs nothing beyond the signature:

extern "C" fn getpid() : i32;          // resolves the symbol "getpid"

extern "C" fn sqrt(x : f64) : f64;     // resolves the symbol "sqrt"
  • extern "C" fn has no body — only a signature, terminated by ;. The linker resolves the symbol against libc / libm / whatever you link.
  • The Axle identifier is what your code calls, and by default it is also what the linker looks for.

@link(symbol = …) is for the one case where the two should differ — a C name that is not a good Axle name, or two bindings to one symbol:

@link(symbol = "write")
extern "C" fn libc_write(fd : i32, buf : ptr<i8>, n : i64) : i64;

Here libc_write is what your code calls and write is what the linker resolves — the libc_ prefix keeps POSIX’s very short name from occupying the identifier. Writing @link(symbol = "getpid") above fn getpid repeats the name for nothing.

@link gathers what binds a declaration to the world outside the program, as named fields, each optional: symbol is the one it has. A field it does not have is refused (E0011, the closest one suggested), and so is a @link that names nothing, a positional value or a field written twice (E0001).

Some symbols are already declared by the Axle runtime itself — malloc, calloc, free, memcpy, memset. A binding to one of them must have that declaration’s shape: the same number of parameters, each of the same width, and the same return. @link(symbol = "malloc") above a function taking an i32 is refused (E0764), because malloc takes a 64-bit size and one symbol cannot be linked with two signatures. An extern "C" export that defines one of these symbols is held to the same shape.

The annotation works the same way on an export — see Choosing the symbol name.

ptr<T> and ptr

Raw pointers come in two flavours :

TypeMeaning
ptr<T>Typed pointer — element type is T, dereference (*p) reads one T
ptrOpaque pointer — element type is unknown ; you can only pass it through to other FFI calls
// Typed — you can dereference it.
@link(symbol = "strlen")
extern "C" fn strlen(s : ptr<i8>) : i64;

// Opaque — only useful as a token to pass back to libc.
@link(symbol = "malloc")
extern "C" fn libc_malloc(n : i64) : ptr;

@link(symbol = "free")
extern "C" fn libc_free(p : ptr) : void;

The opaque form has no element type ; dereferencing it is rejected. The typed form ptr<T> is dereferenced with *p inside an unsafe block. To walk an array of elements, step the address explicitly — cast the pointer to i64, add index × element size, and cast back to ptr<T>.

unsafe { … } blocks

Raw pointer access is intended to live inside an unsafe { } block — a syntactic marker that says “I know this can crash if I get it wrong” :

use std::ffi::libm::sqrt;

fn norm(xs : ptr<f64>, n : i64) : f64 {
    let acc : f64 = 0.0;
    unsafe {
        let base : i64 = xs as i64;
        let i : i64 = 0;
        while (i < n) {
            let p : ptr<f64> = (base + i * 8) as ptr<f64>;
            let v : f64 = *p;
            acc = acc + v * v;
            i = i + 1;
        }
    }
    return sqrt(acc);
}

Enforcement. Raw-pointer operations — dereferencing a ptr<T> and i64 ↔ ptr<T> casts — are rejected outside an unsafe { } block with E0707 at semantic-analysis time. The block itself lowers to an ordinary block at codegen; it carries no runtime cost, it only marks the unchecked region the gate allows.

Unchecked array access — getUnchecked / setUnchecked

A normal arr[i] read/write on a dynamic array carries a runtime bounds guard. When a hot loop already proves 0 ≤ i < arr.length itself — a sort inner loop, a hand-verified scan — the two intrinsics skip that guard, the Axle counterpart of Rust’s slice::get_unchecked:

fn dot(a : f64[], b : f64[], n : i64) : f64 {
    let acc : f64 = 0.0;
    let i : i64 = 0;
    unsafe {
        while (i < n) {                       // caller guarantees n <= len
            acc = acc + getUnchecked(a, i) * getUnchecked(b, i);
            i = i + 1;
        }
    }
    return acc;
}
  • getUnchecked(arr, i) returns the element at i; setUnchecked(arr, i, v) writes v there.
  • A wrong index is out-of-bounds memory access with no diagnostic at runtime, so both are gated to an unsafe { } block — used outside one they are rejected with E0708. Prefer arr[i] unless the guard is a proven bottleneck.
  • What unsafe waives is the runtime guard, not the compiler’s own proof: a literal index the compiler can see is past the end of a fixed-size array (getUnchecked(arr4, 10)) or negative is still rejected (E0293 / E0294), exactly as arr4[10] is.
  • The element’s ownership bookkeeping is untouched: the compiler’s reference-count rewrites key on the shape of the access, which is the same node a checked arr[i] produces. An unchecked element read or store is worth exactly what its checked spelling is worth — no more, and no less.

Word access into a byte buffer — getU32BE / setU32LE and siblings

A file format or a wire protocol reads and writes 16-, 32- and 64-bit integers at byte offsets, in a stated byte order. Four byte accesses and the shifts to assemble them cost four guards and never become one load; the word intrinsics are one access:

fn header(buf : u8[]) : i32 {
    let magic : u32 = getU32BE(buf, 0);   // bytes 0..4, most significant first
    let size : u16 = getU16LE(buf, 4);    // bytes 4..6, least significant first
    setU32BE(buf, 8, magic);
    return size as i32;
}
  • getU16LE / getU16BE / getU32LE / getU32BE / getU64LE / getU64BE read an unsigned word at a byte index; setU16LE … setU64BE write one. A stored value is any integer of the word’s width, or a narrower one that widens to it.
  • Each access checks its whole span: it traps unless every byte of the word lies in the buffer. Inside a counted loop the check is hoisted like an element guard’s, once for the loop.
  • The buffer is a u8[] or an i8[] named by a variable or a field; anything else is rejected with E0763 — the access reads the buffer’s length and its address, and must not evaluate it twice.
  • getU32BEUnchecked, setU64LEUnchecked, … skip the span check on your word, like getUnchecked, and are confined to unsafe { } (E0708).
  • The byte order is the one you name, whatever the target’s: a big-endian read on a little-endian machine is a load and a byte swap.

extern "C" struct / extern "C" class — a layout you did not choose

Axle lays a type out by descending field alignment, not in the order you wrote it. struct M { a : i8; w : i64; b : i8; } costs 24 bytes as declared, 14 of them padding, and 16 packed — a third more of them per cache line. The reordering is invisible to Axle code: every field access is translated at one point, so m.a reads a wherever a went.

It is not invisible to C, which computes offsets from the declaration. When a type mirrors a C struct — because you hand its address across the boundary, or receive one back — mark it extern "C" :

extern "C" struct SDL_Rect {
    pub x : i32;
    pub y : i32;
    pub w : i32;
    pub h : i32;
}

An extern "C" type keeps declaration order and C offsets, and no field of it is ever folded into another object. It is the same marker as the one on a function, in a different position, because it is the same statement: the shape belongs to the other side and is not the compiler’s to improve.

Measuring it

An extern "C" record has a layout the compiler is bound to keep, so it can be measured — which is what lets the declaration replace a table of hand-written constants :

extern "C" struct MSG {
    hwnd    : i64;
    message : u32;
}

fn field_address(base : i64) : i64 {
    return base + offsetof(MSG, message);
}

fn record_size() : i32 {
    return sizeof<MSG>() as i32;
}

@layout(packed) on top of the marker refuses the padding as well, for a wire format or an on-disk header where the bytes are the contract :

@layout(packed)
extern "C" struct BmpHeader {
    magic : u16;
    size  : u32;
}
// sizeof<BmpHeader>() == 6, not 8

Both measurements are refused (E0752) on a type whose layout is not promised. That is not caution for its own sake: an ordinary record is arranged for density by a heuristic that is free to improve, so its size is a decision rather than a contract — and a program that wrote that number into a file would break on a compiler upgrade with nothing to point at. @layout(packed) without the marker is E0751 for the mirror-image reason: nothing outside the program is reading those bytes, so the padding was costing nobody anything.

Two things worth knowing :

  • Only mark what actually crosses. An extern "C" type gives up the packing, so a type C never sees pays padding for nothing.
  • The marker says nothing about ownership or lifetime. It is a layout declaration, not a safety one — the rules under Dangers at the boundary apply exactly as before.

Going the other way — exporting

The same marker with a body defines a symbol something outside this program can call. That direction has its own page: Exporting to C.

Callbacks

A C function pointer is one machine word — the function’s address — while an Axle function value is a { code, env } pair, which is what lets a capturing lambda and a bare function share one call shape. extern "C" (A, B) => R in type position is the first of those:

extern "C" fn qsort(base : ptr, n : i64, size : i64,
                    cmp : extern "C" (ptr, ptr) => i32) : void;

fn cmpI32(a : ptr, b : ptr) : i32 {
    unsafe {
        let x : i32 = *(a as ptr<i32>);
        let y : i32 = *(b as ptr<i32>);
        if (x < y) { return -1; }
        if (x > y) { return 1; }
        return 0;
    }
}

fn main() : i32 {
    let xs : i32[] = [4, 1, 3, 2];
    unsafe { qsort((&xs[0]) as ptr, 4, 4, cmpI32); }
    return xs[0] - 1;
}

A named fn degrades to the thin shape where one is expected, and so does a lambda that captures nothing. Three things make one word impossible, and each says so in its own way (E0748): the function captures, it can throw, or it is async.

The reverse — a runtime address made callable — is a cast, and unsafe, because nothing about it can be checked :

extern "C" fn GetProcAddress(module : ptr, name : ptr<i8>) : ptr;

fn loadDraw(gl : ptr, name : ptr<i8>) : void {
    unsafe {
        let draw : extern "C" (u32, i32, i32) => void =
            GetProcAddress(gl, name) as extern "C" (u32, i32, i32) => void;
        draw(0, 0, 0);
    }
}

Grouping a file of bindings

extern "C" from "user32" {
    fn RegisterClassExW(wc : ptr) : i32;
    fn CreateWindowExW(style : i32) : ptr;
}

The same declarations the long form produces, plus the library name — which goes on the link line beside axle.toml’s [link]. The block saves the library name and the ABI; the symbol name stays each member’s own.

Where a pointer comes from

A ptr<T> is raw C memory — it does not point into a safe Axle value. An Axle array is a managed value, not a bare address: it does not decay to a pointer, so someArray as i64 is rejected (invalid cast). To hand C a block it can read and write, allocate the block through the C allocator, keep the opaque ptr it returns, and reinterpret that address as the typed pointer you need :

  • libc_malloc(n) returns an opaque ptr (raw bytes, no element type).
  • Cast it ptr → i64 → ptr<T> inside unsafe to get a typed view.
  • Step the address by index × sizeof(T) bytes to reach element index (an f64 is 8 bytes).
  • Store through the typed pointer with *p = value; read with *p.
  • libc_free(raw) releases it — pair every malloc with exactly one free, on every path.

A complete example — allocate, fill, read, free

This program allocates room for three f64, writes into the block through raw pointers, sums their squares, takes the square root through libm, and frees the block — a full round-trip across the FFI edge :

use std::ffi::libc::libc_malloc;
use std::ffi::libc::libc_free;
use std::ffi::libm::sqrt;

// Euclidean norm over a C-allocated f64 buffer.
fn norm(xs : ptr<f64>, n : i64) : f64 {
    let acc : f64 = 0.0;
    unsafe {
        let base : i64 = xs as i64;
        let i : i64 = 0;
        while (i < n) {
            let p : ptr<f64> = (base + i * 8) as ptr<f64>;   // element i
            acc = acc + (*p) * (*p);
            i = i + 1;
        }
    }
    return sqrt(acc);
}

fn main() : i32 {
    let raw : ptr = libc_malloc(3 * 8);        // three f64 = 24 bytes
    unsafe {
        let base : i64 = raw as i64;
        // Write 3.0, 4.0, 12.0 through typed pointers into the block.
        let p0 : ptr<f64> =  base       as ptr<f64>;   *p0 = 3.0;
        let p1 : ptr<f64> = (base + 8)  as ptr<f64>;   *p1 = 4.0;
        let p2 : ptr<f64> = (base + 16) as ptr<f64>;   *p2 = 12.0;

        let r : f64 = norm(base as ptr<f64>, 3);        // √(9+16+144)
        println("norm=" + r);                           // prints norm=13
    }
    libc_free(raw);                            // one free per malloc
    return 0;
}

*p = value is a pointer store: it writes one T at the pointer’s address, the write-side counterpart of the *p read. Both are raw loads/stores with no bounds or null check — the address arithmetic (base + i * 8) is yours to keep in range.

static fn — namespace-style class members

Static methods live inside a class but take no implicit self :

class StringUtil {
    pub static fn isAscii(s : string) : bool {
        let i : i32 = 0;
        let c : i32 = s.charAt(0);
        while (c >= 0) {
            if (c >= 128) { return false; }
            i = i + 1;
            c = s.charAt(i);
        }
        return true;
    }
}

fn main() : i32 {
    let ok : bool = StringUtil::isAscii("hello");
    println(ok);
    return 0;
}

Useful for free helpers that conceptually belong to a class. The call form is ClassName::method(args) — statics are reached through the :: path, not the . operator (which is reserved for instance members). The compiler emits a static method as a free function in the class’s namespace.

Static methods are the recommended home for FFI wrappers that want to live next to a domain class — declare the extern "C" fn at module level and wrap it :

@link(symbol = "getpid")
extern "C" fn libc_getpid() : i32;

class Posix {
    pub static fn pid() : i32 {
        return libc_getpid();
    }
}

fn main() : i32 {
    // Reach the static through `::`:
    let p : i32 = Posix::pid();
    println(p);
    return 0;
}

Dangers at the boundary

Inside safe Axle the compiler tracks ownership, lifetimes, bounds and types, and refuses code that could corrupt memory. All of that stops at the FFI edge. A extern "C" fn is a hole the checker cannot see through — beyond it you are writing C’s safety model by hand. The concrete hazards :

  • No lifetime tracking. Hand a pointer to C and the compiler no longer knows how long C keeps it. If the Axle value it points into is freed (or moves, or the arena scope ends) while C still holds the pointer, the next C access is a use-after-free — with no diagnostic, because the two sides never shared the fact.
  • The signature is unchecked. The linker matches a extern "C" fn to a C symbol by name only — it never compares types. If your declaration says (i32, i64) and the real function takes (i64, i32), or you get the return width wrong, the call still links and then reads garbage off the wrong registers. The extern "C" fn signature is a promise you must keep.
  • A string is not a char*. An Axle string carries a length and is not guaranteed NUL-terminated the way C expects; passing its raw bytes to a C function that scans for a NUL is wrong. Convert through std::ffi::libc::cstr to get a C-shaped view, and keep the Axle string alive for as long as C reads the pointer.
  • No null or bounds check on *p. Dereferencing a ptr<T> emits a raw load. A null, dangling, or past-the-end pointer is an immediate segfault (or worse, a silent read of unrelated memory). The pointer arithmetic for array walking — (base + i * elem_size) as ptr<T> — is yours to keep in range; nothing clamps it.
  • Exceptions must not cross. An Axle exception unwinding out through a C frame, or a C longjmp tearing through an Axle frame, is undefined behaviour. Keep a throw/catch on the Axle side of the call.

The unsafe { } block is the seam that makes these hazards visible. It does not make the code safe — it marks the exact region where the guarantees are suspended, so the risky lines are greppable and every reader knows the checker is off here. Keep the block as small as the pointer work requires, and validate lengths and non-null before you dereference.

What’s already in the stdlib

The std::ffi::libc::* modules carry the common bindings — no need to re-declare them.

ModuleWraps
std::ffi::libc::memlibc_malloc, libc_calloc, libc_realloc, libc_free, libc_memset, libc_memcpy, libc_memmove, libc_memcmp, libc_strlen, libc_strcmp, libc_strncmp
std::ffi::libc::fsfopen, fread, fwrite, fseek, ftell, fclose, rename, remove, … (the C stdio set)
std::ffi::libc::cstrC-string helpers (strlen, copy from an Axle string)
std::ffi::libmsqrt, sin, cos, tan, asin, acos, atan, atan2, exp, log, … (the libm transcendental set)

Reach into them with a focused use :

use std::ffi::libm::sqrt;
use std::ffi::libc::libc_malloc;
use std::ffi::libc::libc_free;

Ordinary numeric work does not need the FFI at all: the transcendentals are methods on the value itself — x.sqrt(), x.sin(), x.ln() — and the constants and two-argument forms are free functions of std::numeric::math (math::pi(), math::atan2(y, x), math::hypot(x, y)). Drop down to std::ffi::libm only when you need the raw FFI shape (e.g. for benchmarking).


Limitations

The FFI edge is where the compiler’s guarantees stop. The hazards under Dangers at the boundary are the concrete form; the shape of the surface itself has these limits:

LimitWhy it holds
An extern "C" fn signature is matched by name onlythe linker never compares types, so a wrong signature links and then reads the wrong registers
Nothing bounds-checks or null-checks a *pthe dereference lowers to a raw load
An Axle string is not a C char*it carries a length and is not guaranteed NUL-terminated
An Axle array does not decay to a pointera managed value is not a bare address; allocate through the C allocator instead
Only primitives, ptr / ptr<T>, extern "C" records by pointer, static arrays and thin function pointers may crossany other type has no representation the other side can build or read
An exported function’s library name is not its symbol namethe block’s from "…" names the library; each member keeps its own symbol, or pins one with @link

See also

  • Exporting to C — the other direction: extern "C" with a body, --emit=header, and what a C-representable signature is.
  • Annotations reference — every @… annotation and where it applies.
  • Language tour — the broader surface syntax.
  • Conventions — when to bridge to C vs stay in Axle.
  • Concept index — extern "C" fn, ptr<T>, unsafe { ... }, and static fn cross-linked.
ffiinteropcexternunsafe