C libraries
Foreign functions carry C into Dusk. This page carries Dusk out. An export "C" function is a dusk function a C caller reaches directly, by its own bare symbol, with no callback pointer handed across first, and dusk build --lib compiles a whole module into a static archive plus a generated header any C ABI language links against. The two pages are the same boundary read in opposite directions.
Both arrived in 1.4.3 and were hardened in 1.4.4. The boundary rules are the ones foreign functions already sets out, applied at the define side instead of the call side, so the types that cross are the same short list.
Exporting a function
Section titled “Exporting a function”export "C" before a func marks it a C ABI entry point.
export "C" func mylib_add(a: int64, b: int64) -> int64 { return a + b}The "C" is a calling convention, the same string a foreign block carries, and "C" is the only one supported. Any other convention string is rejected with the only supported export ABI is "C".
A plain export still means only that a name is visible to other dusk files. Adding the "C" marks the function a C ABI entry point besides, and only that stronger form imposes the rules below. The symbol is then emitted exactly as written: privatization, which renames a file’s private top level names so a bare call cannot reach another file’s helper, skips an export, so its symbol is never suffixed and a C host can spell it.
Only a function can carry it. A struct, an enum, or an interface after export "C" is rejected in place, and an async func cannot carry it at all, since an async function is a state machine with no plain C signature to export.
The signature gate
Section titled “The signature gate”Three name rules keep an export from colliding with the machinery around it, and each has a real reason behind it.
The function may not be generic, since C has no monomorphization and a generic has no single symbol to point at. It may not be named main, which the program entry point owns, nor dusk__main, the entry wrapper’s own mangled name; codegen’s entry special case matched that name too, and an export wearing it took the collector anchor prologue meant for a real main. It may not begin with cool_, the prefix the runtime reserves for its own shims.
an exported C function cannot be generican exported C function cannot be named 'main'an exported C function name cannot start with 'cool_', which is reserved for the runtimeEvery parameter and the return must be a scalar, a *raw T, or a *void, and the return may also be void. A struct by value on an export is deferred within this line and rejected by name: the classification machinery that crosses a struct on the import side does not yet run on the define side, and a @csource adapter covers the shape meanwhile. A string, a slice, a managed *T, a closure, an interface value, and an error never cross, the boundary’s standing rule extended verbatim to the export position.
an exported C function parameter must be a scalar, a *raw T, or a *void; 'p' is not; a struct by value on an export is not supported yet, and a string, slice, managed pointer, closure, interface, or error never crossesWhy bool is rejected
Section titled “Why bool is rejected”bool is a scalar everywhere else on the boundary, and an export is the one place it is refused:
an exported C function parameter 'b' cannot be bool; use char or int8 at the boundary, which cross as a full byte a C caller reads wholeA dusk bool lowers to an LLVM i1, and no C header type spells a single bit. The generated header had to declare it uint8_t, so a C caller passing a byte of 2, true by every C convention, handed across a full byte the callee read as bit 0 only and took the false branch on. That was a real defect, found and closed before the 1.4.3 cut, and the fix is the reject you see: use char or int8, which cross as a whole byte, and compare against zero yourself.
Building a library
Section titled “Building a library”dusk build --lib <file> compiles a module to a linkable C library instead of an executable.
dusk build --lib mylib.duskThe module may omit main entirely. Monomorphization roots its worklist at every export "C" function, so the exports and everything they reach compile even with no main to call them, and an export reached from no dusk call at all still lands in the emitted module. The build writes two files beside each other in target/dusk-out:
[dusk] archive : target/dusk-out/libmylib.a[dusk] header : target/dusk-out/mylib.hlib<stem>.a bundles the module’s own object, every dusk runtime object, and every @csource object, built with llvm-ar in its deterministic mode so the same source yields the same archive bytes. The archive is self contained: a host links it and needs nothing of dusk’s besides. A library a @link names is not folded in, since it is an external dependency the host resolves; the header’s link line comment names it so the host adds it. A @csource file is compiled in, since it is the module’s own C.
<stem>.h carries one prototype per export inside an extern "C" guard, so a C++ host may include it too. Each prototype’s C types come from the same lowering the emitted symbol does, so the header can never drift from the archive:
| Dusk type | Prototype |
|---|---|
int8 | int8_t |
int16 | int16_t |
int32 | int32_t |
int64 | int64_t |
char | uint8_t |
rune | int32_t |
float32 | float |
float64 | double |
*raw T, *void | void* |
void (return) | void |
Symbol visibility
Section titled “Symbol visibility”The module object and every @csource object merge into one relocatable object with ld -r, and llvm-objcopy then keeps only the exports global and makes every other symbol local. Nothing but an export is visible to the host.
This exists because a library’s private symbols could otherwise interpose the host’s own libc. A private dusk helper, or a @csource’s own function, named for a libc entry such as read or rand was emitted global in the archive, so a host that linked the archive and called that libc function was silently redirected into dusk. Now a private name can never satisfy a host’s reference.
One consequence is worth stating plainly: a @csource’s functions are the library’s implementation, not its interface. A host cannot call one directly, and gets an undefined reference at link rather than a silent success. That is intended.
Faults across the boundary
Section titled “Faults across the boundary”A dusk fault crosses the export boundary as a clean abort, never corruption. An out of bounds index inside an export aborts by name exactly as it would anywhere else, fatal: index out of bounds, taking the whole process with it rather than returning something wrong to the caller.
A collector<T> minted inside an export aborts too, with fatal: the collector runs on the main thread only. A library has no dusk main, so the collector’s anchor, which the emitted main records, is never set, and the collector’s own guard fires rather than sweeping a heap it cannot see the roots of. Keep an export over plain values and raw pointers. See memory management for what the anchor does.
The archive is static only
Section titled “The archive is static only”In this release the archive is static only. Its objects are not position independent, and the runtime’s thread local storage takes the local exec model, so the archive does not link into a shared object. A position independent shared object build is left to a later release.
That draws a clean line through the FFIs you might point at it today. A C or C++ host works, and so does anything else that statically links a C archive at build time, Zig and Rust among them. A runtime dlopen does not: Python’s ctypes, Ruby’s Fiddle, and every loader like them want a .so this line does not emit yet.
A library end to end
Section titled “A library end to end”This module has no main. It exports three functions over scalars and a raw buffer, including one returning void:
@paradigm procedural
export "C" func stats_add(a: int64, b: int64) -> int64 { return a + b}
export "C" func stats_mean(xs: *raw float64, n: int64) -> float64 { if n <= 0 { return 0.0 } mut total: float64 = 0.0 mut i: int64 = 0 while i < n { total = total + xs[i] i = i + 1 } return total / float64(n)}
export "C" func stats_scale(xs: *raw float64, n: int64, k: float64) -> void { mut i: int64 = 0 while i < n { xs[i] = xs[i] * k i = i + 1 }}Building it with dusk build --lib mylib.dusk generates this header. It is reproduced exactly as the compiler wrote it, down to the parameter names, which are positional since C does not need the dusk ones:
/* mylib.h - generated by dusk build --lib. Link a C host against libmylib.a with: -pthread -lm The archive bundles the dusk runtime; provide your own C main and call these entry points. In library mode the collected heap has no dusk main anchor, so an exported function that mints a collector value faults at collection; keep exports over plain values and raw pointers. */#ifndef DUSK_MYLIB_H#define DUSK_MYLIB_H
#include <stdint.h>
#ifdef __cplusplusextern "C" {#endif
int64_t stats_add(int64_t a0, int64_t a1);double stats_mean(void* a0, int64_t a1);void stats_scale(void* a0, int64_t a1, double a2);
#ifdef __cplusplus}#endif
#endifThe C host includes the header and calls the symbols like any other C function:
#include <stdio.h>#include "mylib.h"
int main(void) { double xs[4] = {1.0, 2.0, 3.0, 4.0}; printf("add = %lld\n", (long long)stats_add(40, 2)); printf("mean = %.2f\n", stats_mean(xs, 4)); stats_scale(xs, 4, 10.0); printf("mean = %.2f\n", stats_mean(xs, 4)); return 0;}Compile the host against the two together, exactly as the header’s own link line says:
clang host.c -I target/dusk-out -L target/dusk-out -lmylib -pthread -lm -o host./hostadd = 42mean = 2.50mean = 25.00Nothing of dusk’s is on that line but the archive itself. For the other direction, a dusk program calling into C, see foreign functions.