Skip to content
CryoCryo home
LanguageProgram structure

17Foreign Function Interface

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

17.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.

A function declared in an extern block is a top-level declaration of the module that holds the block, with the top-level visibility default: it is public - importable as import ffi::libc::{ puts } and nameable as libc::puts - unless written private function, which keeps it to that module.

An extern block also declares imported globals — variables whose storage belongs to the library being linked:

extern "C" {
    function gv_bump() -> void;
    mut  gv_counter: i32;      // read and written through the C library's storage
    const gv_name:   u8*;
}

The type is required and an initializer is rejected. There is nothing to infer a type from, and an initializer would define the symbol here, colliding with the library that already defines it.

mut and const describe what Cryo may do with the binding, not what the library may. A const imported global is not assumed to hold still: the owning library may change the value at any time, so the compiler never treats a read of one as foldable.

Use ![symbol("name")] when the C symbol is not a valid Cryo identifier — for instance when it collides with a keyword:

extern "C" {
    ![symbol("type")]
    mut type_: i32;
}

An imported global is reached like any other: bare inside its own module, and qualified (Binding::gv_counter) from outside.

17.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 becomes a ![repr(c)] type struct and a named C union a native type union (one field per member, accessed directly); a tag that the header declares but never defines - the opaque-handle idiom, and any tag completed only on another platform - becomes a zero-field opaque record, usable through a pointer exactly as in C; 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 17.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 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).

A body that is not a bare literal is constant-folded by clang and bound to the value it computes. That covers the shape most current C APIs use for flags, where a macro's body is another macro's invocation pinning the constant's width (#define SDL_INIT_VIDEO SDL_UINT32_C(0x20)), as well as ordinary constant arithmetic (#define AREA (W * H)). The width the C expression has is the width bound: SDL_UINT64_C(0x10000000) binds as u64 even though its value would fit u32, because an API that ORs it into a 64-bit field needs it to.

Macros that still can't be bound - function-like macros (#define SQUARE(x) ...), valueless guards (#define HEADER_H), and bodies that are not constant expressions (#define NOW time(0)) - are reported rather than silently dropped.

Macros are taken from any header the import pulled in, not only the file named in the #include line: a library's entry header is typically an umbrella of #includes that defines nothing itself. The compiler's own predefined macros (__STDC__ and friends) live in no header and are excluded.

A header's extern globals bind as imported globals under the alias (extern int errno_value; -> c::errno_value), including the loader idiom of exposing each entry point as a global function pointer. A static global is skipped and reported: it has no symbol outside the translation unit that defines it.

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 17.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.

17.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).

17.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.

17.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 anything that is a compile-time integer in an array size (section 2.4) - integer/boolean literals, named constants, enum variants, integer casts, and the arithmetic, comparison, logical, and bitwise operators - plus sizeof(T) and alignof(T), which only this position can see. 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.)

const CAPACITY: i64 = 512;
static_assert(CAPACITY == 512, "the wire format fixes the capacity");
static_assert(sizeof(Grid) == CAPACITY);