Skip to content

The book · 13

FFI and low level

book.md · 179 lines · 5 min read

Goal: call C with @cimport, drop down to inline assembly when needed, and understand why the unsafe boundary is contained, not spread out.

@cimport("header.h") runs at comptime, invokes an embedded C compiler, translates the whole header, and returns a module under the root namespace c. You redeclare no prototype:

@cimport("stdio.h") // → c.stdio
@cimport("SDL2/SDL.h") as gfx // → c.gfx (renames the submodule)
unsafe c.stdio.printf("%d\n", count) // everything qualified under 'c.'

Everything from C stays under c.: the dot screams “this is C” on every contact, and your namespace stays clean (FFI does not pollute whoever does no FFI). The fundamental types come from an imported runtime, because they are compiler/platform dependent:

@cimport("gcc") // → c.gcc
size: c.gcc.size_t = n // size_t AS GCC defines it

C’s NULL becomes Optional[*c.gcc.char] at the boundary (chapter 01: absence is Optional); your structs going to C need @repr(c) (faithful layout). C deps are declared in cdeps in the mk.project (the .c compiles along with it).

The toolchain ships with its own C compiler AND its own C library (musl), the way Zig does, so a build never depends on whatever libc happens to be installed on the machine. --libc=musl links your program statically against that embedded musl, producing a binary that depends on nothing:

Terminal window
mk build --libc=musl server.mko // a fully static binary; `ldd` says "not a dynamic executable"

The default (--libc=system) links the host’s C library dynamically, as usual. musl is also importable as a runtime, @cimport("musl") c.musl.*, carrying musl’s own ABI the same way c.gcc/c.clang carry theirs; importing it selects the hermetic build automatically:

@cimport("musl")
n: i32 = unsafe c.musl.abs(-7) assume "pure" // links the embedded musl, statically, with no system libc

The same embedded toolchain is exposed directly as mk cc, a cc replacement in the spirit of zig cc. It forwards its arguments to the compiler but links against the embedded musl, so it compiles C anywhere and emits a static binary, no system libc required:

Terminal window
mk cc server.c -o server // a fully static C binary
mk cc -c util.c -o util.o // compile one translation unit, drop-in in a Makefile as CC

That makes it usable as the CC of an existing C project, for incremental adoption alongside Makoto. C++ compiles too (mk cc foo.cpp); linking a C++ program against the C++ standard library needs a C++ runtime that the toolchain does not yet bundle.

The boundary runs both ways. A function marked @extern is exported under its raw C name, so C code can call it. mk build --emit=lib builds a Makoto file (no main required) into a static library plus a generated C header:

@extern fn triple(x: i32) -> i32 { return x * 3 }
Terminal window
mk build --emit=lib mathlib.mko // -> libmathlib.a + libmathlib.h

The header declares each export (wrapped in extern "C", so C++ can call it too) and makoto_init, which the caller runs once at startup to arm the Makoto runtime before calling any export that allocates:

#include "libmathlib.h"
int main(void) { makoto_init(); return triple(14); } // links libmathlib.a; returns 42

Because the calling convention is already C’s (System V), an export is a plain C symbol with no shim. This is how a C or C++ codebase adopts Makoto incrementally: write a module in Makoto, link it as a library, call it.

@cimport of a C++ header (.hpp/.hh/.hxx) parses it as C++: functions resolve to their Itanium-mangled symbols, so Makoto calls a C++ free function (in a namespace or not) and passes POD structs the same way it calls C:

@cimport("mathpp.hpp") // namespace mathpp { int add(int, int); }
n := unsafe c.mathpp.add(20, 22) assume "c++"

A header whose extension does not reveal its language (a .h that is actually C++, as some libraries ship) takes an explicit language override as an optional second argument, "c" or "c++":

@cimport("legacy.h", "c++") // a .h that is really C++
@cimport("weird.hpp", "c") // force plain C on a .hpp

The hard parts of C++ (templates, virtual dispatch, exceptions, RAII, the STL) are reached the way every language reaches them: the C++ compiler owns the C++ semantics, and Makoto calls a C-ABI entry point. You write a thin extern "C" shim in C++ and compile it with mk cc; inside it you instantiate templates, call virtual methods, try/catch, and use std:: freely:

// bridge.cpp — mk cc compiles this; the C++ compiler handles the C++.
extern "C" int total(const int* xs, int n) {
std::vector<int> v(xs, xs + n); // STL, RAII
return std::accumulate(v.begin(), v.end(), 0);
}

mk cc and mk build --libc=musl link the C++ runtime automatically: the host’s libstdc++ for a system build, the embedded (musl-targeted) libc++ for a hermetic one, so a Makoto program that calls C++ still becomes a single fully static binary. An overloaded C++ function (two symbols, one name) is a compile error at the boundary rather than a silent pick – give the overload you want its own extern "C" shim.

Every call to C is a black box that can violate any invariant, so it is an unsafe operation, knocked down with assert/assume or grouped in a block:

n := unsafe c.unistd.read(fd, buf, len) assume "fd valid and buf holds len bytes"
unsafe {
c.SDL.SDL_Init(c.SDL.SDL_INIT_VIDEO)
win := c.SDL.SDL_CreateWindow(...)
}

And the C return validates at the boundary: a pointer that can be NULL becomes Optional, bytes that become a string go through Result. After validation, you operate with the normal guarantees.

unsafe is not a region where everything is permitted and silent (the Rust model). It is an obligation to handle: each dangerous operation requires a handler (assert/assume) or is passed on (the function becomes an unsafe fn). Any function that meets the preconditions absorbs the danger and exposes a safe interface on top. That is why unsafe lives in small islands with safe boundaries, and not in a whole call stack painted over.

The lowest extension of FFI: raw machine instructions, for a syscall, an rdtsc, a SIMD that the compiler does not emit. Register binding is via @asm:

@asm(x86, intel)
unsafe fn add10(value: u32 @asm(in, rax)) -> u32 @asm(out, rbx) {
mov rbx, rax
add rbx, 10
}

Selection by architecture is comptime (via the arch package); an arch mismatch is a compile error (there is no magic fallback, because a mov rax does not exist on ARM).

use arch
fn timestamp() -> u64 {
comptime if arch.current == .x86_64 {
return unsafe rdtsc_x86() assume "rdtsc available on this target"
}
return portable_clock()
}

Next: 14 · UI extension