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) => Rin 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.
extern "C" fn + @link
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" fnhas 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 :
| Type | Meaning |
|---|---|
ptr<T> | Typed pointer — element type is T, dereference (*p) reads one T |
ptr | Opaque 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>andi64 ↔ ptr<T>casts — are rejected outside anunsafe { }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 ati;setUnchecked(arr, i, v)writesvthere.- 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. Preferarr[i]unless the guard is a proven bottleneck. - What
unsafewaives 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 asarr4[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/getU64BEread an unsigned word at a byte index;setU16LE…setU64BEwrite 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 ani8[]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, likegetUnchecked, and are confined tounsafe { }(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 opaqueptr(raw bytes, no element type).- Cast it
ptr → i64 → ptr<T>insideunsafeto get a typed view. - Step the address by
index × sizeof(T)bytes to reach elementindex(anf64is 8 bytes). - Store through the typed pointer with
*p = value; read with*p. libc_free(raw)releases it — pair everymallocwith exactly onefree, 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" fnto 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. Theextern "C" fnsignature is a promise you must keep. - A
stringis not achar*. An Axlestringcarries 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 throughstd::ffi::libc::cstrto get a C-shaped view, and keep the Axlestringalive for as long as C reads the pointer. - No null or bounds check on
*p. Dereferencing aptr<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/catchon 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.
| Module | Wraps |
|---|---|
std::ffi::libc::mem | libc_malloc, libc_calloc, libc_realloc, libc_free, libc_memset, libc_memcpy, libc_memmove, libc_memcmp, libc_strlen, libc_strcmp, libc_strncmp |
std::ffi::libc::fs | fopen, fread, fwrite, fseek, ftell, fclose, rename, remove, … (the C stdio set) |
std::ffi::libc::cstr | C-string helpers (strlen, copy from an Axle string) |
std::ffi::libm | sqrt, 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:
| Limit | Why it holds |
|---|---|
An extern "C" fn signature is matched by name only | the linker never compares types, so a wrong signature links and then reads the wrong registers |
Nothing bounds-checks or null-checks a *p | the 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 pointer | a 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 cross | any other type has no representation the other side can build or read |
| An exported function’s library name is not its symbol name | the 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 { ... }, andstatic fncross-linked.