ABI
What the ABI is (and what the FFI already solved)
Section titled “What the ABI is (and what the FFI already solved)”The FFI with C (section 17) solves the consumption side: you call C. The ABI is the opposite side, how others talk to your language in binary. It covers two things: (a) your own compiled modules linking to each other, and (b) other languages (C, and by extension everything that speaks C) consuming yours as a pre-compiled library. It is the low-level contract: type layout, symbol names, how calls happen.
The decision that cuts across everything is that the ABI is a boundary of data, not of runtime. What crosses are concrete types with a defined layout; your runtime’s concepts (processes, channels, @mm) do not cross raw, the runtime operates them underneath. This mirrors the FFI with C, and keeps the boundary small and stable.
Layout: not stable by default, @repr to freeze
Section titled “Layout: not stable by default, @repr to freeze”The compiler optimizes the layout of the structs (reorders fields to minimize padding, section 2), and by default that layout is not stable, which has a precise meaning: the ordering is deterministic (the same code, the same compiler, the same flags always give the same layout), but it is not a promise that crosses boundaries you do not control. Three things can change it: the compiler version (v1.1 can do better packing than v1.0), the optimization flags, or a change in an embedded type (one more field deep down changes the padding of everything that contains it).
The practical consequence is calm: within a single build, everything links (your modules were compiled together, with the same rule). What the non-stable forbids is taking a separately-compiled lib (another version, another moment, or to be consumed by C) and expecting the layouts to match. For that case, and only for it, you use @repr(c) (section 17), which freezes the layout field by field in that type.
And this is opt-in per type, on purpose: there is no global flag --abi=stable. A global flag would freeze the layout of the whole program to gain stability you only need in some types (the ones that cross the boundary), killing the reordering optimization in everything. The @repr(c) is the inverse: you pay stability only where you need it (the struct that goes outside), and the rest of the program stays optimized. Stable is the marked exception, not the expensive default.
The runtime interface: the one boundary that is frozen
Section titled “The runtime interface: the one boundary that is frozen”The rule above — unstable inside, stability as a marked exception — has one counterpart, and it is the host interface the runtime is written against (section 5). There the default flips: it is external ABI, and it is frozen.
The reason is what the boundary is for. A struct’s layout is internal until you mark it otherwise, because almost no struct crosses. The host interface is the opposite: it exists only to be crossed, and by code we did not write and cannot recompile — a kernel, an embedded target, somebody’s simulator. Something whose entire purpose is to be implemented by other people cannot also be free to change.
So write(stream, buf, len) does not acquire a parameter, and map(bytes) does not start returning a status. And the discipline is not charity toward whoever implements a host: the compiler emits calls against those same signatures, so changing one would break Makoto’s compatibility with its own programs. The constraint already existed; naming the boundary only makes it visible.
There is deliberately no version field and no capability negotiation. Those are mechanisms for an interface that changes, and paying their ceremony in every implementation to insure against an event we are committing not to cause would be the same mistake as a global --abi=stable: a cost on everyone for a guarantee needed nowhere. And if the core ever has to break ground here, the answer is the one the language already gives everywhere else (section 22): the core moves slowly, and when it moves incompatibly, it comes with the migration tool.
The distinction is worth stating plainly, because the two live one line apart in the same program:
- Internal ABI — your structs, your calls between modules of one build. Optimized, reordered, not promised across compiler versions. Freezing is opt-in per type, with
@repr(c). - External ABI — the host interface, and what
@extern/@repr(c)expose to C. Frozen, because someone else compiles against it.
Name mangling: internal mangled, boundary raw
Section titled “Name mangling: internal mangled, boundary raw”Functions become symbols in the binary, and symbols cannot collide (two foo in different modules, or List[int] vs List[string], need distinct names). This requires mangling: encoding the module and the types into the name. But mangling only matters in one place:
- Internal symbols (your modules calling your modules): mangled, and no one types them, because the compiler generates and resolves them, machine talking to machine. The name can be whatever.
- Boundary symbols (
@extern, section 17): raw name, no mangling, because C needs to writeextern void my_api();and find the symbol. The@externturns off the mangling at the boundary by definition. And@externdoes not accept a generic parameter: a C symbol is concrete and pre-compiled, and C does not instantiate a generic, so an@extern fn f[T]is rejected (generics cross as code to monomorphize, not as a binary symbol).
Since the internal symbol is never typed by hand, the mangling style is a build optimization option, in the same bucket as size, speed and debug, with the default optimized:
optimized(default): compact mangling, smaller symbols, smaller binary.readable: legible mangling (something like_auto_core_int_fmtinstead of_ZN4core3fmt...), which makes stack traces, profiling and debugging legible (you read the symbol and understand, without decoding). It combines with the profiler and the observer (sections 19 and 22).
Neither of the two affects the boundary; there the @extern is always raw. It is a quality-of-life choice for the tools, not an architectural one.
Generics: monomorphization at comptime, do not cross pre-compiled
Section titled “Generics: monomorphization at comptime, do not cross pre-compiled”Generics do not cross the ABI boundary as a pre-compiled binary, and the reason is the nature of monomorphization. A List[T] only becomes code when you write List[int]: the compiler monomorphizes at comptime (section 10), generating the code specific to that type, in your build. There is no way to pre-compile List[T] in a lib, because the T does not exist until the use.
This is not a limitation, it is what keeps the layouts consistent: the List[int] of your lib and the List[int] of the program that consumes it are the same layout, because both are monomorphized in the same build, by the same deterministic rule. The generic travels as what it is (a form to instantiate), and it is instantiated where it is used, the same on both sides. So only concrete types (@repr when they cross the boundary) cross as a binary; generics cross as code to monomorphize.
Calling convention: LLVM/C’s for now
Section titled “Calling convention: LLVM/C’s for now”When one function calls another, the calling convention decides where the arguments go (registers or stack), where the return goes, and who saves the registers. For now, the language inherits the platform’s convention via LLVM (System V and the like), the same one C uses. The advantage is direct: interop with C is free (your symbols already speak C’s language), and there are not two conventions for the compiler to maintain and convert. The @extern (section 17) is already the boundary, and it coincides with the internal convention, so there is no conversion.
An own internal convention (more arguments in registers, fewer saves, optimized for your code calling yours) would bring a gain, but an incremental one, at the cost of real complexity (maintaining two conventions, converting at the boundary). So it is left for later, together with the own backend (see Implementation notes), the same logic as the “LLVM now, own later” of the rest.
Runtime concepts at the boundary: the runtime is the middle-man
Section titled “Runtime concepts at the boundary: the runtime is the middle-man”What happens when something external needs to interact with a process or channel of yours (sections 3 and 4)? They do not cross raw, they are concepts of your runtime, and C does not know what a process is. Instead, the runtime is the middle-man: the ABI exposes the minimum to send and receive data, and your runtime, on your side, creates the process, attaches the channel and does the transmission. The external one never “holds a channel”, it exchanges bytes with your runtime, which operates the channel underneath.
This is identical to what the FFI with C already does (the callback that does spawn is routed by the runtime, section 17), and to the @mm(c) principle (when you receive something from C, the free comes along to manage that object’s memory). The symmetry closes the boundary: data crosses; runtime concepts do not, because the runtime operates them underneath, on both sides.