FFI and low level
Goal: call C with
@cimport, drop down to inline assembly when needed, and understand why the unsafe boundary is contained, not spread out.
C comes in through @cimport
Section titled “C comes in through @cimport”@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.gccsize: c.gcc.size_t = n // size_t AS GCC defines itC’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).
Hermetic builds with embedded musl
Section titled “Hermetic builds with embedded musl”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:
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 libcmk cc: a drop-in C/C++ compiler
Section titled “mk cc: a drop-in C/C++ compiler”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:
mk cc server.c -o server // a fully static C binarymk cc -c util.c -o util.o // compile one translation unit, drop-in in a Makefile as CCThat 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.
Exporting Makoto to C and C++
Section titled “Exporting Makoto to C and C++”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 }mk build --emit=lib mathlib.mko // -> libmathlib.a + libmathlib.hThe 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 42Because 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 .hppThe 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.
Calling C is unsafe
Section titled “Calling C is unsafe”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 containable
Section titled “unsafe is containable”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.
Inline assembly
Section titled “Inline assembly”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 archfn 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