Exporting to C
The FFI page covers Axle calling out. This one is the other direction: Axle defines the symbol, and something outside the program calls in — a
C or C++ application, a Python ctypes binding, a plugin host, anything
that can dlopen an object and speak the C ABI.
What this page covers: the same extern "C" marker pointing the other way ·
a library end to end · the generated header · which signatures may cross and
which are refused · choosing the symbol name with @link(symbol = …) · linking
the object into a C program · what an export costs the optimiser.
It is the same marker, and the body is what says which way it points:
// no body → import: the linker resolves `abs` against libc
extern "C" fn abs(v : i32) : i32;
// a body → export: this program defines the symbol `axle_clamp`
extern "C" fn axle_clamp(v : i32) : i32 {
if (v < 0) { return 0; }
return v;
} That is deliberate. The presence of a body is already the thing that decides; a second keyword to say it again is a second thing that can disagree with the first.
An export is a definition, and calling it from Axle code is an ordinary call — the same as any function this program declares.
A library, end to end
// lib.axle
extern "C" struct Vec3 {
pub x : f64;
pub y : f64;
pub z : f64;
}
/** The dot product. Taken by pointer — see “By value is refused” below. */
extern "C" fn axle_dot(a : ptr<Vec3>, b : ptr<Vec3>) : f64 {
unsafe {
return (*a).x * (*b).x + (*a).y * (*b).y + (*a).z * (*b).z;
}
}
extern "C" fn axle_clamp(v : i32, lo : i32, hi : i32) : i32 {
if (v < lo) { return lo; }
if (v > hi) { return hi; }
return v;
}
/** A C callback, called from Axle. */
extern "C" fn axle_apply(f : extern "C" (i32) => i32, v : i32) : i32 {
return f(v);
}
/** No marker, so it stays internal — nothing outside can reach it. */
fn helper(v : i32) : i32 {
return v + 1;
} Two artefacts come out of it:
$ axle build lib.axle --emit=object -o lib.o
$ axle build lib.axle --emit=header -o lib.h --emit=object does not require a fn main. An object file is linked
into someone else’s program, and that program brings the entry point;
demanding one here would be demanding an entry point of the very thing
whose purpose is not to have one.
The generated header
/* Generated by the Axle compiler. */
#ifndef AXLE_LIB_H
#define AXLE_LIB_H
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct Vec3 {
double x;
double y;
double z;
} Vec3;
double axle_dot(Vec3* a, Vec3* b);
int32_t axle_clamp(int32_t v, int32_t lo, int32_t hi);
int32_t axle_apply(int32_t (*f)(int32_t), int32_t v);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* AXLE_LIB_H */ It carries the exported functions and the extern "C" records their
signatures reach, in dependency order — a record is declared before the
function that names it. helper is absent, because it was never exported.
It writes no sizes and no offsets. Those are the consuming compiler’s to
compute from the declaration it was just handed. A generated #define VEC3_SIZE 24 would be a second answer to a question the C compiler already
answers, and the first time the two disagreed the wrong one would win
silently.
Linking it
The object needs the Axle runtime archives beside it — allocation,
refcounting and teardown live there. The compiler links four on
every link line and refuses to produce a binary without any of them
(axle_stdlib, axle_runtime, axle_tls, axle_reactor), so a
consumer links the same set:
$ cc main.c lib.o -laxle_stdlib -laxle_runtime -laxle_tls -laxle_reactor -o app #include <stdio.h>
#include "lib.h"
static int32_t triple(int32_t v) { return v * 3; }
int main(void) {
Vec3 a = {1.0, 2.0, 3.0};
Vec3 b = {4.0, 5.0, 6.0};
printf("dot=%.0f clamp=%d apply=%d sizeof(Vec3)=%zu\n",
axle_dot(&a, &b), axle_clamp(99, 0, 10),
axle_apply(triple, 14), sizeof(Vec3));
return 0;
} dot=32 clamp=10 apply=42 sizeof(Vec3)=24 --emit=staticlib is not implemented: the artefact is the object, and the
consumer’s build links it like any other.
Choosing the symbol name
The symbol is the name as written. @link(symbol = …) pins a different one, for
when the C-side name should not be the Axle-side one:
@link(symbol = "mylib_add")
extern "C" fn add(a : i32, b : i32) : i32 {
return a + b;
} The object exports mylib_add, and the header declares mylib_add — the
two cannot drift, because one produces the other.
What may cross
An exported signature must be one the other side can build and read. Anything else is E0753, at the declaration, which is the only place the author can still change it.
No Axle-managed value. A string (the runtime’s { ptr, len } pair), a
class reference, a Shared<T> handle, a trait object — the caller has no
way to construct or interpret one:
[E0753] exported `takes_string` names `string` in its parameters,
which has no C representation
Help: the symbol is called from outside this program, and that caller has
no way to build or read an Axle-managed value. Cross the boundary with
primitives, `ptr`, and `extern "C"` records; marshal on the Axle side. By value is refused — and this is the one worth reading twice, because the type is representable:
[E0753] exported `by_value` passes `Vec3` by value in its parameters,
which does not follow the C convention
Help: Axle hands a record to a function through a pointer to the caller's
storage; C classifies its fields into registers. The two disagree, so the
callee would read the wrong bytes with no diagnostic. Take a `ptr<Vec3>`
instead, and dereference it inside an `unsafe` block. A Vec3 is a perfectly good C struct. The disagreement is about how it is
handed over: Axle passes a record by pointer to the caller’s storage, while
the SysV and Windows ABIs classify its fields into registers. Both are
correct conventions and they are not the same one, so a call across that
seam links cleanly, runs, and reads the wrong bytes. Take ptr<Vec3> and
the two sides agree again.
So the crossing set is: the primitives, ptr / ptr<T>, extern "C" records by pointer, static arrays, and thin extern "C" function
pointers. Marshal anything else on the Axle side.
What an export costs
An exported function keeps external linkage and survives the post-link
internalisation every other function undergoes. That is the point — a symbol
nobody outside can name is not exported — but it also means the optimiser
must assume it is called with arbitrary arguments, so it cannot be
specialised to its callers or removed when the program appears not to use
it. Export the boundary, not the internals: helper above stays unmarked
for exactly that reason.
Limitations
What an export cannot do, gathered from the sections above:
| Limit | Why it holds |
|---|---|
--emit=staticlib is not implemented | the artefact is the object, and the consumer’s build links it like any other |
| An export keeps external linkage and survives internalisation | it is a boundary, so the optimiser must assume arbitrary arguments and cannot drop it |
| No Axle-managed value crosses the boundary | a string, a class reference, a Shared<T> handle or a trait object has no form the caller can build (E0753) |
An extern "C" record may not cross by value | Axle hands a record over by pointer while the SysV and Windows ABIs classify its fields into registers (E0753) |
| The generated header writes no sizes and no offsets | those are the consuming compiler’s to compute from the declaration |
| The consumer must link the Axle runtime beside the object | allocation, refcounting and teardown live there |
See also
- FFI — calling C from Axle — the other direction, plus
extern "C"records,@layout(packed),sizeof/offsetofand the raw-pointer rules that apply on both sides. - Projects and dependencies —
axle.toml,[link], and native-library linking. - Reading compiler errors — the E0xxx catalogue.