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.
| Declaration | Implementation | Native name in namespace Example.Math |
|---|---|---|
extern int Add(int a, int b); | Provided by a linked object or library | Add |
export int Add(int a, int b) { ... } | Compiled from this Xenon body | Example_Math_Add |
public int Add(int a, int b) { ... } | Compiled with ordinary Xenon linkage | Example.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_AddXenon 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
- An
externdeclaration introduces a native function that must be supplied by a linked library. - An
exportdeclaration checks its Xenon signature and body, then exposes the flattened native name described above. - C-compatible structs may cross the boundary by value. Arrays still require an element pointer and explicit length.
- Object-only output retains unresolved native references until a later link.
- 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.
| Host | Shared-library output | With type = "static-library" |
|---|---|---|
| Windows | math.dll and math.lib (import library, when exports exist) | math.lib (static archive) |
| Linux | libmath.so | libmath.a |
| macOS | libmath.dylib | libmath.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.
| Xenon | Native representation | C declaration |
|---|---|---|
sbyte / byte | 8-bit signed / unsigned | int8_t / uint8_t |
short / ushort | 16-bit signed / unsigned | int16_t / uint16_t |
int / uint | 32-bit signed / unsigned | int32_t / uint32_t |
long / ulong | 64-bit signed / unsigned | int64_t / uint64_t |
nint / nuint | Pointer-width signed / unsigned | intptr_t / uintptr_t |
clong / culong | 32 bits on Windows; pointer width on other targets | long / unsigned long on matching C targets |
float / double | 32-bit / 64-bit floating point | float / double |
T* / readonly T* | Native address, with no automatic copy | T* / const T* |
function R(P...)* | Native code pointer with an exact signature | R (*)(P...) |
C-compatible struct | Selected target's C aggregate ABI | A struct with matching field order and field types |
void | No return value | void 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
| Symptom | What to check |
|---|---|
| Unresolved native symbol at link time | Exact native spelling, [libraries] inputs/search paths, library architecture and whether the library actually provides the symbol. |
| Shared library cannot be loaded | Runtime library location and its dependencies. A successful link does not install the DLL/shared object. |
| Crash or wrong result after a successful link | Signature, calling convention, widths, struct layout, pointer validity and lifetime. A linker generally cannot validate these contracts. |
| Cannot call an extern from readonly code | An ordinary extern is effectful. Declare a readonly contract only if the native implementation satisfies it. |