Later stages kept these 1.1.0 rules and added one that this chapter's examples did not hit: a file-scope alias does not parse. Constants in the generated ABI are functions such as abi_dirty_cpu(). The GPU paragraph below is the Stage 0 observation. Stage 8 later ran a MAX kernel; see stages/s8.md.
Learning objectives
- Read Mojo 1.1.0 as a compiled language with a C ABI escape hatch.
- Notice which syntax from older Mojo write-ups this compiler rejects.
- Follow a function from
defto LLVM.
Why this matters
The NIF is a few dozen lines, but those lines only work because of specific 1.1.0 rules: def instead of fn, abi("C") on every export, RegisterPassable structs for C layout, and no mutable globals. Guessing the previous year's syntax wastes a day and produces a file that never reaches the BEAM.
Mental model
def add(...) abi("C")
|
v
Mojo types + origins (compile time; erased from the ABI)
|
v
LLVM i64 / ptr on arm64 (what the CPU and dyld see)
|
v
Mach-O in libpoc.soOrigins and traits do not appear in ErlNifEntry. They stop the compiler from accepting some unsafe programs. Once the object file exists, the CPU sees integers and addresses.
Theory
Verified on Mojo 1.1.0 by compiling the files in this repo:
fnis removed. The diagnostic says to usedef.@export("name")plusabi("C")emits a C symbol. Withoutabi, the compiler rejects the export.defparameters are passed by value unless marked otherwise.Int64in the small add experiment became LLVMi64.- Structs that are
RegisterPassableand hold only C-compatible fields use C field order and C padding.@fieldwise_initgenerates a constructor. This was checked by printingfield_offset, not assumed from the trait name. from std.ffi import c_int, c_long, c_ulong, c_char, c_size_tis the width-correct set for this target.c_longis 64 bits here. The Mojo manual says every platform it supports today is LP64, and this Mac matches that (long=8in the diagnostic).Pointer[T, origin]is non-nullable. Null isOptional[Pointer], which is 8 bytes on this target, and the empty value is all zero bits.- A function value with
abi("C")is described by the compiler asthin. Stored in a struct it is 8 bytes. That is the code pointer. - File-scope
aliasand file-scopevarare rejected.comptimeis the compile-time binding used infits_int64. external_call["symbol", ReturnType](args...)emits a direct call to that C symbol.mojo buildresolves it at link time.mojo runcan resolve libc from the process even when a later link would fail. That split is why the NIF is always built withmojo build, never exercised only undermojo run.- Traits such as
RegisterPassableandWritableare compile-time constraints. They are not C++ vtables in the NIF struct. - Overflow: the small
Int64add in experiment 01 is an LLVMaddwith no check. The NIF does not use that wrapping add on the term path. It rejects out-of-range sums before theadd. - SIMD, GPU, and
parallelizeare not used.from std.gpu import parallelizefails and the note says those modules moved to themaxpackage, which is not installed. - The pipeline that was observed is Mojo source to LLVM IR (
--emit llvm) to a linked Mach-O (--emit shared-lib). MLIR is not printed by this CLI. Claiming a particular MLIR pass ran would be invention.
initialize_runtime exists at std.runtime. Calling it does not require a Mojo main. See chapter 08 for the BEAM experiment. The add NIF never calls it.
Hands-on exercise
.venv/bin/mojo build experiments/abi/01_export_add/add.mojo --emit llvm -o /tmp/add.ll
The IR should contain a function @mojoler_add that takes two i64s and returns an i64, and the module triple should contain arm64-apple-darwin. The numeric address and the exact feature string can change with the CPU. The add instruction is the whole body of that experiment.
Then change def to fn and rebuild. The diagnostic should name def as the replacement.
Line-by-line walkthrough
experiments/abi/01_export_add/add.mojo:
@export("mojoler_add")
def mojoler_add(a: Int64, b: Int64) abi("C") -> Int64:
return a + b@export("mojoler_add")is the linker name, without Darwin's underscore.nmprints_mojoler_add.defdefines the function.Int64is Mojo's fixed 64-bit integer, not Elixir's arbitrary-precision integer.abi("C")selects the platform C calling convention. On this Mac that is the arm64 AAPCS variant: integer arguments inx0andx1, result inx0.return a + bbecameadd i64in the IR. No Mojo runtime call appears in that function.
Under the hood
The IR target is arm64-apple-darwin25.5.0 with target-cpu=apple-m1. Darwin 25 is the kernel version corresponding to this macOS 26 install. The function is dso_local, which is why nm shows a defined text symbol rather than an undefined one.
The shared library still depends on libKGENCompilerRTShared.dylib even for this one-line function. The compiler links its runtime into every --emit shared-lib output tried here. The add function does not call it, but the image loads it.
Failure modes
- Using a blog post that starts with
fn main(). This compiler will not parse it. - Passing a Mojo
Stringtoexternal_call. The manual, and this compiler's diagnostics for related APIs, wantas_c_string_span(). - Trusting
mojo runas proof a symbol will link. Build the.so. - Treating
Int64wraparound as Elixir's+. They disagree past2^63-1.
Rustler and Zigler
Rustler's exported functions are Rust extern "C" and Zigler's are export with a C calling convention. The role of abi("C") is that same choice. Neither of those toolchains needs a vendor runtime dylib for an integer add; Mojo 1.1.0 still links libKGENCompilerRTShared.dylib. That difference shows up in otool -L and in deployment: the BEAM must be able to open that rpath.
Checkpoint
- What keyword replaced
fn?def. - Why does
nmshow a leading underscore that the source does not? Darwin's C ABI prefix.dlsymand@exportuse the unprefixed name. - Stretch: add a second exported function that multiplies, rebuild, and confirm both symbols are
Tinnm -gU.