extern, export and C ABI

Use extern to call a function implemented in a native library and export to make a Xenon function callable from native code. Both use the target platform's C ABI.

The two sides of a native call

An ABI (application binary interface) determines how compiled code passes arguments, returns results and represents data. The caller and implementation must agree on the native symbol, signature, target architecture and data layout. Xenon emits native calls directly; it does not generate a serialization or marshalling layer.

DeclarationImplementationNative name in namespace Example.Math
extern int Add(int a, int b);Provided by a linked object or libraryAdd
export int Add(int a, int b) { ... }Compiled from this Xenon bodyExample_Math_Add
public int Add(int a, int b) { ... }Compiled with ordinary Xenon linkageExample.Math.Add

These are alternatives, not declarations to place together. extern and export are mutually exclusive modifiers on namespace-level functions. An extern declaration ends with ; and has no body; an exported function must have a body.

Jump to imports, exports, compiler stages, library builds, ABI types or common failures.

extern: declaring an imported function

extern tells the compiler the function's signature, but supplies no implementation. Within Xenon it is resolved and type-checked like a function in the declaration's namespace. At the native boundary, only the declared identifier is used: the following call refers to puts, not Example_puts or Example.puts.

namespace Example;

extern int puts(readonly byte* text);

int Main()
{
    puts("Hello from Xenon");
    return 0;
}

The string literal supplies a null-terminated byte string through readonly byte*; no separate string object is passed. The native function must respect that read-only storage. See literals and variables.

The compiler checks arguments against your declaration, not against a C header or the binary's actual implementation. A wrong signature can compile and still fail at runtime. A declaration also does not select a DLL or load a library by filename: the build must provide the symbol through native linking.

extern does not imply public. Use public extern when other Xenon namespaces need to call the declaration; extern public is also accepted. Neither form changes the imported native name.

Connecting an import to a library

Declare dependencies in the consuming project's .xeproj file. For example, if native/lib contains a library named math that provides Example_Math_Add:

# Consumer.xeproj
[project]
name = "Consumer"
type = "executable"

[source]
root = "src"

[libraries]
library-paths = ["native/lib"]
libraries = ["math"]
// src/Main.xe
namespace Consumer;

extern int Example_Math_Add(int a, int b);

int Main()
{
    return Example_Math_Add(20, 22);
}

library-paths entries are resolved relative to the project file. On Windows, the driver passes search directories as /LIBPATH:... and the bare name math as math.lib. On Linux/macOS it passes -L... and -lmath. Build this project with xenon build Consumer.xeproj.

For a static dependency, supply math.lib on Windows or libmath.a on Unix. For a shared dependency on Windows, link its import library (math.lib) and make math.dll available at runtime. Unix shared dependencies use libmath.so or libmath.dylib. Linker search paths do not install or copy dependent shared libraries. On macOS, Xenon adds a loader-relative @loader_path rpath and gives generated dynamic libraries an @rpath install name; other runtime dependencies must still be available to the platform loader.

The host linker setup already includes the standard C runtime, which is why the puts example needs no custom [libraries] entry. Your own library dependencies still need to be supplied explicitly.

Readonly extern contracts

An ordinary extern declaration is effectful by default and cannot be called from readonly code. Qualifiers on its parameters alone do not change that. Put readonly immediately before the function name to declare the external implementation's effect contract.

extern int readonly abs(int value);

int readonly Magnitude(int value)
{
    return abs(value);
}

The compiler trusts this declaration; it cannot inspect the native implementation. The programmer must ensure it has no hidden side effects and mutates external state only through declared mutable pointer/reference parameters. This contract does not imply that the function never writes memory. It does not change the C symbol name or calling convention.

An exported function can use the same placement, for example export int readonly Add(int a, int b) { return a + b; }. Its body is available to the compiler and is checked against the readonly effect rules. Native C callers do not gain an enforcement mechanism from this qualifier.

export: exposing a Xenon implementation

export compiles a normal Xenon body and exposes it under a native C-compatible name. Every dot in the full Xenon name is replaced with an underscore. Parameter and return types are not encoded in that name.

namespace Example.Math;

export int Add(int a, int b)
{
    return a + b;
}
Example.Math.AddExample_Math_Add

Xenon code still calls this function as Add(...) within its namespace or Example.Math.Add(...) from another namespace. Only native consumers use Example_Math_Add. The consumer example above shows how a separately compiled Xenon project imports that same exported symbol.

An exported function is automatically public in Xenon. In contrast, public alone retains the dotted Xenon name and does not request the C export naming rule or a Windows DLL export. Private functions are not exposed as public native entry points. See access modifiers.

Names must remain unique after flattening. For example, A.B_C and A_B.C both become A_B_C. Do not rely on namespaces or signature differences to distinguish those native entry points. Choose distinct exported names and keep them stable for native callers.

Build behavior

  1. An extern declaration introduces a native function that must be supplied by a linked library.
  2. An export declaration checks its Xenon signature and body, then exposes the flattened native name described above.
  3. C-compatible structs may cross the boundary by value. Arrays still require an element pointer and explicit length.
  4. Object-only output retains unresolved native references until a later link.
  5. Executable and shared-library builds link their native dependencies; static-library builds archive the object for a later consumer link.

For example, the relevant IR has the following shape (function bodies and platform-specific details omitted):

; extern int puts(readonly byte* text);
declare i32 @puts(ptr)

; export int Add(int a, int b) in Example.Math
define i32 @Example_Math_Add(i32 %a, i32 %b) { ... }

; ordinary private int Helper() in Example.Math
define internal i32 @Example.Math.Helper() { ... }

For Windows targets the native export is marked dllexport. When building a Windows shared library, the driver also supplies /DLL, /EXPORT:Example_Math_Add and an import-library output path. On Linux/macOS the exported function has external linkage and the driver links with -shared/-dynamiclib. The export list is not a cross-platform filter that hides all other public symbols.

Building a library

Put the Add example in Math/src/Math.xe and create Math/Math.xeproj:

[project]
name = "math"
type = "shared-library"

[source]
root = "src"

With the Xenon CLI on your path, run:

xenon build Math/Math.xeproj --release

The command reports its object and native artifact paths, plus the import-library path when a Windows shared-library build produces one. The project pipeline also writes its LLVM IR intermediate beside the object under .xenon/obj/, but does not print that IR path. Library projects do not require Main and do not receive the executable's native main wrapper.

HostShared-library outputWith type = "static-library"
Windowsmath.dll and math.lib (import library, when exports exist)math.lib (static archive)
Linuxlibmath.solibmath.a
macOSlibmath.dyliblibmath.a

Native artifacts go under Math/build/release/<target-triple>/; intermediate objects go under Math/.xenon/obj/release/<target-triple>/. Without --release, the default profile is debug. The project name determines the artifact filename; the namespace and function name determine the exported symbol.

For a static build, change the project type to static-library. The archive keeps the same native function names. It does not bundle or resolve libraries from [libraries]; the final consumer must provide those dependencies. A Windows static archive and a DLL import library both use .lib, but they are different artifacts. Switching between these project types with the same name/profile/target reuses that filename.

Host linking requires an available native linker toolchain. Windows uses the MSVC linker/librarian and Windows SDK libraries. Linux and macOS search $CC, clang, cc or gcc for a C linker driver, and static-library builds search $AR, llvm-ar or ar for an archiver. Xcode Command Line Tools are the standard way to provide these tools on macOS. Use a non-host --target to produce an object file for a target-specific SDK and linker.

Calling the export from C or C++

Write a matching header yourself; the compiler does not generate one. This header can be included from C or C++:

/* math.h */
#ifndef EXAMPLE_MATH_H
#define EXAMPLE_MATH_H
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int32_t Example_Math_Add(int32_t a, int32_t b);

#ifdef __cplusplus
}
#endif
#endif
/* main.c */
#include "math.h"

int main(void)
{
    return Example_Math_Add(20, 22) == 42 ? 0 : 1;
}

For the Windows walkthrough, build Math with type = "static-library", copy the resulting static math.lib beside main.c and math.h, then run cl main.c math.lib /Fe:consumer.exe in a Visual Studio developer shell for the same architecture. This build needs no math.dll.

For a Unix static build, copy libmath.a beside the C source/header and run cc main.c ./libmath.a -o consumer. Both examples return exit code 0 when the exported call produces 42. The extern "C" guard prevents C++ name mangling; Xenon's extern keyword has its own declaration syntax and does not take a "C" string.

ABI types

Scalar and pointer types use the selected target ABI. Keep the C declaration consistent with both width and signedness; use <stdint.h> for explicit integer widths.

XenonNative representationC declaration
sbyte / byte8-bit signed / unsignedint8_t / uint8_t
short / ushort16-bit signed / unsignedint16_t / uint16_t
int / uint32-bit signed / unsignedint32_t / uint32_t
long / ulong64-bit signed / unsignedint64_t / uint64_t
nint / nuintPointer-width signed / unsignedintptr_t / uintptr_t
clong / culong32 bits on Windows; pointer width on other targetslong / unsigned long on matching C targets
float / double32-bit / 64-bit floating pointfloat / double
T* / readonly T*Native address, with no automatic copyT* / const T*
function R(P...)*Native code pointer with an exact signatureR (*)(P...)
C-compatible structSelected target's C aggregate ABIA struct with matching field order and field types
voidNo return valuevoid return type

long in Xenon is always 64-bit; it is not a substitute for C long. Use clong for the latter. Enum values lower to their declared underlying integer type; do not assume an unrelated C enum has the same representation.

bool uses Xenon's one-bit native representation. Match narrow-integer and boolean signatures to the native ABI on your target. For a more portable C boundary, use an explicit int status value instead.

Xenon references (T&) use an address at the native boundary but retain Xenon's reference rules. Prefer explicit pointer declarations for C-facing APIs. Interface values contain Xenon dispatch information and are not ordinary C object pointers; use a wrapper function and an opaque pointer when exposing such objects.

Function pointers cross extern and export directly. For example, extern void Register(function void(int)* callback); matches void Register(void (*callback)(int));. C may pass a callback for Xenon to invoke, and Xenon may pass &Function to C. No additional calling-convention annotation is used. A raw function-pointer signature is itself a C ABI boundary wherever the type appears, so its return and parameter types must be C-compatible even when the pointer is stored only in a local variable or struct field.

Structs, arrays and ownership

A nonempty, non-polymorphic struct made from C-compatible scalar, enum, pointer, function-pointer and nested-struct fields may be passed to or returned from extern and export functions by value. Xenon applies the selected target's aggregate ABI instead of assuming that an LLVM struct parameter is portable. The supported targets are Windows x64, SysV AMD64, Linux AArch64, Apple ARM64 and Windows ARM64. The three ARM64 platform ABIs are classified separately; unsupported target triples produce a code-generation error rather than an ABI-incorrect object.

Validation is recursive. Empty structs, struct inheritance, virtual-dispatch storage, Xenon destructors and Xenon-only field representations are rejected for by-value C ABI use. A containing struct is also incompatible when any nested by-value struct field is incompatible. The ownership wrappers unique<T>, shared<T> and weak<T>, arrays and atomic storage do not become C-compatible merely by nesting them. Atomic storage is rejected recursively, including pointers to structs containing atomic fields, because its synchronization layout is not a C ABI contract.

namespace Example;

struct Resource
{
    public int Value;

    public ~Resource()
    {
    }
}

// Invalid: Resource has Xenon lifetime behavior that cannot cross by value.
extern void Process(Resource value);

A by-value C aggregate must have both a C-compatible representation and a lifetime model that can safely cross the native boundary. Use a pointer plus an explicit native lifetime contract when the Xenon type owns cleanup behavior.

T[] is not an external ABI type. Pass an array as an element pointer together with an explicit length:

extern void ProcessSamples(readonly float* samples, nuint length);

Pointer-based APIs keep ownership, bounds and layout explicit at the language boundary. A heap array handle is not an element pointer. See array native boundaries for obtaining an element pointer safely.

For a nonempty array, pass &array[0] and the element count. For an empty array, use the native API's agreed empty-buffer convention instead of indexing element zero. The pointer does not carry bounds, dimensions or ownership information.

The same plain struct may cross by value or by address when both sides agree on field order, field types, alignment and padding:

namespace Example;

struct Vector2
{
    public float X;
    public float Y;
}

export Vector2 Add(Vector2 left, Vector2 right)
{
    return Vector2 { left.X + right.X, left.Y + right.Y };
}

export float Sum(Vector2* value) { return value->X + value->Y; }
/* Matching C declarations. */
typedef struct Vector2 { float X; float Y; } Vector2;
Vector2 Example_Add(Vector2 left, Vector2 right);
float Example_Sum(Vector2* value);

A non-polymorphic C-compatible struct does not acquire a hidden dispatch pointer when a descendant implements an interface or introduces virtual members. Its C-visible fields, size and alignment remain unchanged, as does an existing export function taking its pointer. The descendant preserves the complete base subobject at the same address and owns any new metadata itself. Do not describe a polymorphic struct as a plain C fields-only struct; see stable inheritance layout.

A raw function-pointer field has native pointer size and alignment and is valid in a C-compatible struct passed either by value or by pointer. The matching C field must use the same complete callback signature. Callback signatures may themselves accept and return C-compatible structs by value.

Neither keyword transfers ownership or extends a lifetime. Do not retain a pointer to a caller's stack object after the call, or return a pointer to a local variable. If a library creates an object, provide a matching exported destroy/release function so allocation, destruction and deallocation stay with that library. The compiler reserves native malloc, calloc and free for its own memory operations and rejects user extern declarations with those names; see manual memory and destructors.

A Xenon exception never escapes through an exported C ABI entry point. If an exported body cannot handle an exception, Xenon reports the unhandled type, completes exception-object cleanup and terminates the process instead of unwinding through arbitrary C frames. Ordinary extern calls are treated as non-throwing with respect to Xenon exceptions. See Exceptions for the boundary rule.

Unicode char at the C boundary

Xenon char is not C char. A Xenon character is one 32-bit Unicode scalar; C char is a one-byte integer/code unit. Parameters, returns, function-pointer positions and compatible struct fields use the ABI of an unsigned 32-bit integer.

export char Echo(char value) { return value; }

/* Matching C declaration. */
uint32_t Example_Echo(uint32_t value);

The value crossing the boundary is the scalar number itself: 'A' is 0x41, 'Ж' is U+0416 and '😀' is U+1F600. It is never expanded to UTF-8 bytes. A C UTF-8 string remains readonly byte* in Xenon.

Common failures

SymptomWhat to check
Unresolved native symbol at link timeExact native spelling, [libraries] inputs/search paths, library architecture and whether the library actually provides the symbol.
Shared library cannot be loadedRuntime library location and its dependencies. A successful link does not install the DLL/shared object.
Crash or wrong result after a successful linkSignature, calling convention, widths, struct layout, pointer validity and lifetime. A linker generally cannot validate these contracts.
Cannot call an extern from readonly codeAn ordinary extern is effectful. Declare a readonly contract only if the native implementation satisfies it.