Skip to content
CryoCryo home
LanguageProgram structure

18Foreign Function Interface

Cryo follows the platform C ABI and uses an LLVM backend, so calling C from Cryo and Cryo from C is straightforward.

18.1 Extern Blocks

Declare C function signatures with Cryo syntax inside an extern "C" block. The compiler trusts the declarations and the linker resolves the symbols.

extern "C" {
    function puts(s: string) -> int;
    function atoi(s: string) -> int;
}

A standalone extern is also valid:

extern function exit(code: int) -> void;

It is the programmer's responsibility to ensure the Cryo signature matches the C signature; the compiler cannot verify this across the language boundary.

18.2 C Header Import

For larger C libraries, transcribing every signature by hand is error-prone. Cryo can import a C header directly. The compiler drives libclang (Clang's stable C API), which parses the header and generates Cryo declarations for the functions and the structs, unions, enums, and typedefs it finds.

extern module c := "C" {
    #include <stdio.h>       // angle: system include search path
    #include <stdlib.h>
    #include "./my_header.h" // quoted: relative to this source file
}

function main() -> int {
    c::printf("Value: %d\n", 42);
    const buf: void* = c::malloc(256);
    c::free(buf);
    // Imported C types are reached through the alias too:
    const p: c::Point = c::Point { x: 1 as i32, y: 2 as i32 };
    return 0;
}

Each #include takes either an angle-bracketed name (<stdio.h>, resolved on the C preprocessor's system include search path) or a quoted path ("./my_header.h", resolved relative to the importing file) - exactly as in C. The identifier after extern module (c here) introduces a namespace into which the imported declarations are placed; access both functions and types with :: (c::printf, c::Point) to prevent collisions between C and Cryo names. Only declarations from the named header(s) are imported - types pulled in transitively from system headers (size_t, int32_t, ...) are resolved to their Cryo primitive directly rather than re-emitted.

Type mapping: a C struct/union becomes a ![repr(c)] type struct (a union is a layout-faithful opaque storage blob - no field access); a named enum becomes a type enum with its explicit discriminant values; an anonymous enum (which has no nameable type - its constants are plain integers in C) contributes one alias-namespaced const per constant (enum { LO = 1, HI = 2 } -> c::LO, c::HI); a typedef becomes a type alias; function-pointer parameters/fields map to Cryo (Args) -> Ret. Imported types and constants are namespaced under the alias only (c::Point, c::LO), never the global namespace.

Some C constructs have no first-class Cryo equivalent, so they are translated to layout-faithful opaque storage - a struct of that type round-trips by value over the FFI boundary (correct size and alignment, verifiable with static_assert; see section 18.5) but offers no member access to that part: a bitfield run (adjacent unsigned x : 3 fields share a storage unit) collapses to one blob field; a C11 anonymous struct/union member (union { ... }; with no field name) becomes an aligned blob field named _anon0, _anon1, ...; and a field whose type is an inline anonymous struct/union likewise maps to a blob rather than a pointer. Each such approximation is reported (see below).

Object-like #define constants whose body is a single literal are imported too, each as an alias-namespaced const with an inferred type: a numeric literal (#define MAX_LEN 256 -> c::MAX_LEN: i32; hex, octal, negative, and floating-point are supported, with integer width inferred from the value and u/l suffixes); a string literal (#define NAME "cryo" -> c::NAME: string, with C escape sequences decoded); and a character literal (#define TAB '\t' -> c::TAB: char carrying the code point). Macros that can't be bound - function-like macros (#define SQUARE(x) ...), valueless guards (#define HEADER_H), and compound expressions (#define AREA (W * H)) - are skipped rather than silently dropped. Only macros defined in the named header itself are considered (the compiler's predefined macros are excluded).

Every construct that is skipped or approximated is recorded and printed as a per-header translation report under --debug ([skip] for an unbound construct, [approx] for a layout-only binding), so nothing is lost silently.

To import only a header's function prototypes - suppressing all struct/enum/typedef emission - apply the ![functions_only] directive to the extern-module block. It is valid only on a C-import extern module:

![functions_only]
extern module c := "C" {
    #include "./api.h"   // import c::do_thing(...) etc.; skip the header's types
}

An aliased import block holds only #include directives, never Cryo declarations; conversely, a plain extern "C" block (section 18.1) holds only hand-written Cryo signatures, never #include. Don't also hand-declare a symbol that an imported header already defines (e.g. puts from <stdio.h>) - reach it through the alias (c::puts) instead.

18.3 Calling Cryo from C

Cryo emits each declaration under its mangled symbol name. To make a function callable from C, apply ![no_mangle] to its definition: it then ships under its bare declared identifier, which your C header declares directly.

function cryo_add(a: i32, b: i32) -> i32 { return a + b; } ![no_mangle]
// emits `cryo_add` (not the mangled `C$…` symbol); C sees `int cryo_add(int, int);`

Use ![symbol("name")] instead when the exported name must differ from the Cryo identifier. The older approach - a thin wrapper inside an extern "C" block that forwards to the mangled function, declared in C under the wrapper's mangled symbol - still works and is the only option when you cannot annotate the definition itself (e.g. exporting a stdlib function you don't own).

18.4 Function-pointer callbacks

Many C APIs take a function pointer (qsort, event loops, AST visitors). Declare the parameter with Cryo's function-pointer type (Args) -> Ret and pass a named Cryo function by its bare name - taking the address is implicit, no &:

extern "C" {
    function qsort(base: void*, nmemb: u64, size: u64,
                   compar: (const void*, const void*) -> i32) -> void;
}

function cmp_i32(a: const void*, b: const void*) -> i32 {
    const pa: i32* = a as i32*;
    const pb: i32* = b as i32*;
    return *pa - *pb;
}

// qsort(&arr[0] as void*, n, 4, cmp_i32);   // sorts in place via the callback

The callback must be a bare function pointer - a named function or a non-capturing lambda. A capturing closure is not C-compatible: a C function pointer has no environment slot. Thread per-call state through an explicit void* client-data parameter instead (the standard C idiom, e.g. libclang's clang_visitChildren(cursor, visitor, client_data)):

extern "C" {
    function with_each(items: void*, n: u64,
                       visit: (void*, void*) -> i32, client: void*) -> void;
}

Where a C API documents an optional callback, a typed null is a valid value: pass null and guard the call site with f == null.

18.5 Compile-time layout assertions (static_assert)

When binding a C library, a Cryo struct must match the C struct's layout exactly. static_assert checks a constant condition at compile time and fails the build (E0237) if it is false - so a binding can verify its own layout against the numbers the C side guarantees, rather than miscompiling silently.

type struct Color { r: u8; g: u8; b: u8; a: u8; }

static_assert(sizeof(Color) == 4);
static_assert(alignof(Color) == 1);
static_assert(sizeof(Color) == 4, "Color must be 4 bytes to match the C ABI");

static_assert is a module-scope declaration: static_assert(cond) or static_assert(cond, "message"). The condition is folded after layouts are computed and may use integer/boolean literals, sizeof(T), alignof(T), and the arithmetic, comparison, logical, and bitwise operators. A condition that is false - or that is not a compile-time constant - is a compile error. (It is a general feature, not FFI-only, but layout verification is its primary use.)