Axle v0.14.1

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:

LimitWhy it holds
--emit=staticlib is not implementedthe artefact is the object, and the consumer’s build links it like any other
An export keeps external linkage and survives internalisationit is a boundary, so the optimiser must assume arbitrary arguments and cannot drop it
No Axle-managed value crosses the boundarya 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 valueAxle 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 offsetsthose are the consuming compiler’s to compute from the declaration
The consumer must link the Axle runtime beside the objectallocation, refcounting and teardown live there

See also

ffiinteropcexternexport