Linking & FFI

Extern functions (calling native code)

tape
@tape {
    if (@target == "win64") {
        @emit @link("kernel32.dll");
        @emit extern fn GetTickCount() -> u32;
        @emit extern fn Sleep(ms: u32);
    }
}

extern fn declares a function that lives in a native library. A library of one platform is linked in that target’s branch of a @tape block (see Platform libraries). Calls to it use the platform C calling convention (Win64, SysV x86-64, AAPCS64 or Apple arm64), in the VM and natively, so no glue code is needed: not for scalars and pointers, nor for structs by value, variadic functions or callbacks (below). A narrow result (bool, u16, i32, …) is taken at its declared width: C leaves the bits above it undefined, and tape extends it.

Custom symbol names

If the native symbol differs from the tape function name:

tape
@tape {
    if (@target == "win64") {
        @emit @link("user32.dll");
        @emit extern("MessageBoxA") fn message_box(hwnd: *u8, text: *const u8, caption: *const u8, flags: u32) -> i32;
    }
}

The extern("symbol") form specifies the exact linker symbol to resolve.

Several declarations may name the same symbol, each with its own signature. That is how a C function whose type depends on the call is used, such as objc_msgSend, typed once per selector:

tape
@tape {
    if (@target == "macos-arm64") {
        @emit @link("/usr/lib/libobjc.A.dylib");
        @emit extern("objc_msgSend") fn msg_id(obj: *u8, sel: *u8) -> *u8;
        @emit extern("objc_msgSend") fn msg_frame(obj: *u8, sel: *u8) -> NSRect;
    }
}

C strings

A tape string is not a C string: its bytes have no terminating NUL. It never converts to a pointer. Declare a parameter C only reads as *const u8; a string literal goes there as is (the compiler NUL-terminates it in read-only data), and any other string goes through cstr.from_view, which makes a NUL-terminated copy you release with cstr.free:

tape
import cstr from "cstr";
extern fn puts(s: *const u8) -> i32;

puts("hello");                          // a literal
let c: *u8 = cstr.from_view(name);      // name: string
defer cstr.free(c);
puts(c);
puts(name);                             // error: a 'string' is not a C string

A literal never goes where *u8 is declared: C may write through it, and the literal’s bytes are read-only.

Variadic functions

A C function with a ... parameter list is declared with ... after its fixed parameters:

tape
@tape {
    if (@target == "win64") {
        @emit @link("msvcrt");                       // the C runtime
    } else if (@target == "linux" || @target == "linux-arm64") {
        @emit @link("libc.so.6");
    } else {
        @emit @link("/usr/lib/libSystem.B.dylib");
    }
    @emit extern fn printf(fmt: *const u8, ...) -> i32;   // from the one the branch linked
}

printf("%s has %d items (%.1f%%)\n", "cart", count as i32, ratio * 100.0);

Each extra argument goes with C’s default promotions: f32 as f64, i8/i16/bool as i32, u8/u16 as u32; 32- and 64-bit integers, f64 and pointers as they are; a string literal as a pointer to its NUL-terminated bytes (any other string through cstr.from_view). An integer literal is an i64 (write x as i32 for a %d to be exact). Structs, string values, enums, slices and optionals cannot go through ..., and only an extern fn can be variadic.

Structs by value

A struct parameter or result of an extern fn is passed the way C passes it, in registers, on the stack or through a pointer, as the platform’s ABI says:

tape
struct NSRect { x: f64; y: f64; w: f64; h: f64; }
extern fn CGRectInset(r: NSRect, dx: f64, dy: f64) -> NSRect;

let r = CGRectInset(NSRect { x: 0.0; y: 0.0; w: 100.0; h: 50.0; }, 4.0, 4.0);

The struct has C’s layout (@packed and @align(N) as in C). Its fields must be C types: integers, floats, bool, pointers, enums, fixed arrays and structs of these, not strings or slices.

Callbacks: C calling tape

extern fn(...) is the type of a function pointer with the C calling convention. Name a tape function where one is expected and the compiler passes its C entry, a wrapper C can call:

tape
type WndProc = extern fn(i64, u32, i64, i64) -> i64;
struct WndClass { size: u32; style: u32; proc: WndProc; /* ... */ }

fn window_proc(hwnd: i64, msg: u32, wp: i64, lp: i64) -> i64 { ... }

let wc = WndClass { size: 80; style: 3; proc: window_proc; };

// in the C runtime's @tape block above:
@emit extern fn qsort(base: *u8, n: u64, size: u64, cmp: extern fn(*const u8, *const u8) -> i32);
fn by_value(a: *const u8, b: *const u8) -> i32 { ... }
qsort(&items[0] as *u8, n, 8, by_value);
  • The signature must match exactly; structs and floats work in both directions.
  • Another module’s pub function works too: qsort(base, n, 8, cmp.by_value). A function has one C entry in the program, so taking it in two modules gives the same address.
  • A plain fn(...) pointer is tape’s own convention and never converts to extern fn(...) (nor back); only a function named directly does. f as extern fn(...) spells the conversion out.
  • Calling through an extern fn pointer (one C gave you, or a C entry) uses the C convention: let inset: extern fn(NSRect, f64) -> NSRect = get_inset(); inset(r, 2.0);
  • An address from GetProcAddress or dlsym becomes one with as: addr as extern fn(i32) -> i32.
  • In the VM this works too: the C entry is a native stub that re-enters the interpreter. C calls it on the program’s thread, from inside a call the program made (as a window procedure, a comparator or an Objective-C method is called), or on a thread of its own when the function is @any_thread (below). The VM holds up to 64 such functions per run.

Callbacks on C’s own threads

A library with threads of its own (a Rust core on tokio, an audio driver, a network stack) calls back on them. Mark a function that may be called there @any_thread:

tape
@thread_local var calls: i64 = 0;
let total: sync.Atomic = sync.atomic(0);

@any_thread
fn work(arg: *u8) -> i64 {
    calls = calls + 1;              // this call's own copy on C's thread
    total.add(calls * 10);
    return 0;
}

os_thread.run_on_thread(work, null);   // CreateThread or pthread_create, then wait
  • It is checked like a thread’s function: neither it nor anything it calls uses a module var (“@any_thread function ‘work’ uses module variable ‘hits’”), except through a @thread_safe function. It can use module lets, @thread_local vars, and sync types, which are how it shares with the rest of the program.
  • On a thread tape did not start, each call joins that thread for the call. Its @thread_local vars are a copy of its own, made from their initializers and dropped when the call returns. The main thread’s copy is untouched. Called on the main thread or on a worker, it runs as any function does.
  • That thread has no drain point. Starting a task, or waiting for one, reaches the scheduler’s state, so the check refuses it. Hand work to the main thread through sync, and read it from a ui.Timer.
  • A trap there ends the program with exit code 101 and its line, as a trap on a worker does.
  • A callback of a function without the mark, called by C on such a thread, traps before it runs: “C called ‘f’ on a thread tape did not start (mark it @any_thread)”. The main thread, the program’s workers, and a thread an @any_thread call is running on all pass. An @export function is not checked: it is a library’s API, and its host calls it on the host’s threads.
  • It is for t1 and t2 programs; t0 has no thread runtime. It goes on a function, not on an extern fn or a co fn.

Declares which native library to link against:

tape
@link("uwm100");             // a portable name: uwm100.dll, uwm100.so, uwm100.dylib

@tape {
    if (@target == "win64") {
        @emit @link("user32.dll");                  // Windows DLL
        @emit @link("opengl32.dll");                // Windows DLL
    } else if (@target == "linux" || @target == "linux-arm64") {
        @emit @link("libc.so.6");                   // Linux shared library
    } else {
        @emit @link("/usr/lib/libSystem.B.dylib");  // macOS system library (dyld finds it only by its full path)
    }
}

All extern fn declarations following a @link are resolved from that library. Multiple @link directives can appear in one file.

Platform libraries

A platform library is one whose name ties it to one platform:

  • a path (/usr/lib/libobjc.A.dylib, a framework, D:\libs\raylib.dll);
  • a file name with a platform’s extension: .dll (Windows), .so or .so.6 (Linux), .dylib (macOS), .sto (Uusi), .framework;
  • an operating system’s own library, by its bare name too (any case): Windows’ kernel32, user32, gdi32, gdiplus, ole32, oleaut32, shell32, shlwapi, advapi32, comctl32, comdlg32, combase, ws2_32, wsock32, mswsock, msvcrt, ucrtbase, vcruntime140, ntdll, opengl32, glu32, winmm, dwmapi, shcore, bcrypt, ncrypt, crypt32, secur32, iphlpapi, winhttp, wininet, urlmon, d3d9, d3d11, d3d12, dxgi, d2d1, dwrite, dinput8, xinput1_4, xinput1_3, xinput9_1_0, setupapi, version, psapi, dbghelp, userenv, uxtheme, imm32, winspool, mf, mfplat, mfreadwrite, avrt, propsys, runtimeobject, hid, cfgmgr32, powrprof, wtsapi32, netapi32, rpcrt4, dnsapi, credui, wlanapi, mpr; Linux’s and macOS’s libc, libm, libdl, libpthread, librt, libX11, libGL, libasound, libSystem, libobjc; Uusi’s uusikernel, uusiwm, uusigfx.

Any other name has no extension and is portable: the compiler gives it each target’s (.dll, .so, .dylib, .sto), so it names a library on every target. tape’s own libraries are named so (tapert100, uwm100, image100), and so is any library built for every target under one name (@link("mylib") for mylib.dll, mylib.so and mylib.dylib).

In a t1 or t2 module a platform library is linked inside a @tape branch of the targets that have it, with the extern fns it binds emitted there too (or after the branches, for functions every branch’s library has). A bare @link of one, or an @emit @link of one outside a target branch, is a compile error at the @link:

plaintext
error: 'ole32.dll' is a platform library: link it inside `@tape { if (@target == "win64") { @emit @link("ole32.dll"); } }` and @emit the extern fns it binds there too; a bare @link in t1 or t2 is for a library every target has, named without an extension

so a module compiled for Linux or macOS never asks for a Windows DLL at run time. t0 code (a kernel, a boot loader) is single-platform by nature and links any library bare. The tape runtime’s own modules (@runtime;, one per platform) are exempt.

A library built under different names per target (cargo makes kuori.dll, libkuori.so and libkuori.dylib) is linked per branch for that reason, though the compiler doesn’t require it.

A file with @link is a link module: an FFI binding that declares extern fns and wraps them in pub fns, with no vars, components or generators, importing no other link module. An extern fn outside a link module is an error. See Platform-specific code § Link modules for the full rules.

A @link with no extern fn after it still loads the library with the program. That is how an Objective-C framework is used whose classes you look up by name (objc_getClass("NSWindow") finds nothing unless AppKit is loaded):

tape
@tape {
    if (@target == "macos" || @target == "macos-arm64") {
        @emit @link("/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit");
    }
}

On macOS and Linux the program or library gets a load command for it (LC_LOAD_DYLIB, DT_NEEDED) and imports nothing from it; tape run loads it before main, and stops if it does not load, as the system loader would. Windows has no such use and links nothing for it.

An extern fn binds to the @link written last before it. One with none before it (written above the first @link, or at the top level of a module whose only @link is emitted in a @tape block) is a compile error: extern fn 'f' has no library.

A C function that exists on one target only is declared in a @tape block with @emit extern fn (or @emit extern("symbol") fn). It binds to the last @emit @link("...") the block ran before it, else to the last @link written before the block; with neither, it is a compile error. “Ran” is the point: the branch taken decides, so functions both targets have can be declared once, after the branches:

tape
@tape {
    if (@target == "linux" || @target == "linux-arm64") {
        @emit @link("libc.so.6");
    } else {
        @emit @link("libSystem.B.dylib");
    }
    @emit extern fn write(fd: i32, buf: *const u8, count: u64) -> i64;   // libc or libSystem
}

Branches nest, so one library’s functions can still differ per architecture:

tape
@tape {
    if (@target == "macos" || @target == "macos-arm64") {
        @emit @link("/usr/lib/libobjc.A.dylib");
        if (@target == "macos") {   // x86-64 returns NSRect through memory
            @emit extern("objc_msgSend_stret") fn msg_rect(obj: *u8, sel: *u8) -> NSRect;
        } else {                   // arm64 returns it in registers
            @emit extern("objc_msgSend") fn msg_rect(obj: *u8, sel: *u8) -> NSRect;
        }
    }
}

An @emit extern fn with no @emit @link before it in its block binds to the module-level @link written before the block (in t1 and t2 a portable name’s).

A name without an extension gets the target’s: @link("kuori") is kuori.dll on Windows, kuori.so on Linux, kuori.dylib on macOS and kuori.sto on Uusi. That is how tape’s own libraries are named. A system library keeps its full file name (libc.so.6, /usr/lib/libSystem.B.dylib), and a foreign library built as libkuori.so is named so in its branch.

Exported functions (callable from native code)

tape
@export
pub fn tape_init(config: *u8) -> i32 {
    return 0;
}

@export marks a function for inclusion in the output’s export table (DLL exports on Windows, .sto vtable on Uusi). C calls it with the platform convention; a struct parameter or result is passed by value as for an extern fn (the export is then the function’s C entry). An @export function cannot be variadic.

A library exports the @export functions of its root module and of every module it imports, so its C API can live in the modules that implement it. Each name is exported once: two @export functions of one name in two modules are a compile error that names both.

Custom export symbol:

tape
@export("my_custom_name")
pub fn internal_name() -> i32 {
    return 42;
}

The compiler verifies at build time that @link libraries exist and all extern symbols resolve (on the host platform). Skip with --skip-link-check for cross-compilation.

Cross-module linking

Tape modules link to each other automatically through the import system. The compiler:

  1. Parses all imported modules recursively
  2. Injects extern stubs for all pub declarations with qualified names (module.fn_name)
  3. Resolves cross-module calls at link time

Internal symbol naming uses dot-separated qualified names (io.println, str.join) and double-underscore for methods (Button__paint, Vec2__op_add).

Output formats

FlagOutputDescription
(default).exe / ELF / Mach-OStandalone executable
--dll.dll (PE64)Windows DLL with export table
--dll on Linux.so (ELF64 ET_DYN)Shared object with dynamic symbol table
--dll on macOS.dylib (Mach-O MH_DYLIB)Dynamic library with exports trie, ad-hoc signed
--sto.sto (ELF64)Uusi shared object with vtable exports
--sto on Linux, macOS.oRelocatable object, for another toolchain’s linker

Shared library output

bash
tape build lib.tape --dll -o mylib.dll                        # Windows
tape build lib.tape --dll --target linux -o mylib.so          # Linux (also linux-arm64)
tape build lib.tape --dll --target macos-arm64 -o mylib.dylib # macOS (also macos)

tape links the library itself, on any host: no cc -shared, ld or install_name_tool. Only functions marked @export are exported. Libraries it @links by bare name (another tape library such as tapert100) are found next to it ($ORIGIN, @loader_path); see tape build § Shared libraries.

Uusi shared object

bash
tape build lib.tape --sto --target uusi -o mylib.sto

On Uusi, @link externs are resolved at runtime via syscall 23 (library load). The compiler emits thunks that:

  1. Load the .sto library
  2. Read the vtable pointer from the library handle
  3. Jump to the function pointer at the correct vtable slot

Platform-specific linking

Platform@link resolves toMechanism
WindowsDLL import (IAT)PE64 import table
Linux.so symbolELF64 dynamic linking
macOS.dylib symbolMach-O dynamic linking
Uusi.sto vtable slotThunk + syscall 23

tape’s own runtime libraries are built the same way on every platform:

bash
tape build rt/linux/tapert100.tape --dll -I stdlib --target linux -o tapert100.so

Last modified: