Learning objectives
- Walk the eight experiments in order and say what each one allowed the next one to assume.
- Point at the real blocker that did not appear, and at the smaller blockers that did.
Why this matters
The passing mix test is the end of a chain. If a later Mojo release breaks one link, the experiment directory is the place that fails first, before anyone edits the Elixir tests into lying.
Mental model
01 export symbol
02 store a real code pointer
03 match clang's padding
04 keep the struct alive without a Mojo global
05 runtime init is optional for add, and callable from nif_init
06 export nif_init
07 import enif_get_long, not the macro name
08 dlopen from the BEAMTheory
Each directory under experiments/abi/ has a NOTE.md. The commands and the text they printed are in experiments/abi/OBSERVED.txt.
- Opaque pointers and function pointers. Thin
abi("C")functions are 8-byte values and are callable after being stored.Pointer(to=function)is a stack address. OpaqueErlNifEnv*usesPointer[NoneType, MutUntrackedOrigin]. - Struct layout. Mojo
NifFuncis 32 bytes andNifEntryis 96, with the same offsets as clang.Optional[Pointer]None is bit-identical to NULL.Optionalof a thin function is 16 bytes and was rejected for callback fields. - Stable descriptor storage. Module-level
vardoes not exist.callocdoes. String literals haveImmStaticOriginand remain readable. - Exported callback signature.
addinnative/poc.mojois exactly(env, argc, argv) -> term. - Callback through a function pointer. Experiment 02 calls the stored pointer. The BEAM does the same with the pointer
nif_initwrote. nif_initreturns the calloc'd entry. The symbol is global. The callback symbol is local and mangled (_poc::add(...)).enif_*access.external_callplus-undefined dynamic_lookup. The Windows table is not involved.enif_get_int64is a macro here.- Load and dependencies.
otool -Lshows the NIF,libKGENCompilerRTShared.dylibvia rpath into the venv, andlibSystem.B.dylib. ExUnit's fresh OS process completesadd(20, 22) == 42.
No experiment hit a Mojo bug that made the ABI inexpressible. The real constraints were language rules (no globals, non-null pointers, fn removed) and an OTP macro. Both have pure-Mojo expressions that still satisfy the "no C implementation" rule.
Hands-on exercise
Read experiments/abi/03_layout/NOTE.md, then the two layout sections of OBSERVED.txt, side by side. You are looking for a size or offset that differs. On the recorded run there is no such line.
Then read scripts/build-native.sh and confirm the only source file is native/poc.mojo.
Line-by-line walkthrough
fits_int64 is part of meeting the ABI, because the values that survive enif_get_long are C longs and the NIF promised checked Int64 addition:
- If
b > 0anda > max - b, the sum would exceed2^63-1. - If
bisINT64_MIN(detected byb + (2^63-1) == -1) anda < 0, the sum would go below-2^63. - Otherwise if
b < 0andais belowINT64_MIN - b, the sum does not fit. INT64_MINis computed as-(2^63-1)-1so the literal itself fits inc_long.
The tests cover both ends, both orders (max+1 and 1+max, min+(-1) and -1+min), and min+max == -1.
Under the hood
nif_init runs once per successful load. It allocates two blocks and returns the entry pointer in x0. The VM copies what it needs and keeps the pointer. Because unload is NULL, those blocks are not freed. They are process-lifetime leaks of 32 and 96 bytes plus allocator overhead. That is acceptable for this proof and unsafe as a pattern for a NIF that reloads in a long-lived node. OTP currently refuses the reload anyway.
The local callback does not need to be exported. The VM does not look it up by name. Exporting it would only widen the dynamic symbol table. nm -gU showing a single T _nif_init is the intended result.
Failure modes
- A Mojo upgrade that changes
RegisterPassablelayout. Experiment 03 fails the visual diff, andmix testmay fail insideload_nifwithbad_libor may crash. Believe experiment 03 first. - Linking without
-undefined dynamic_lookup. The build fails with an undefined_enif_get_long, or the.sobinds it to nothing anddlopenfails. - Copying the Windows "fill this struct of pointers from the nif_init argument" pattern. On Unix,
nif_inittakes no arguments. An extra parameter would be a calling-convention bug.
Rustler and Zigler
Rustler generates steps 6 and 7 from annotations. Zigler generates the struct and can comptime assert sizes against its own translated header. Mojoler keeps the steps as separate programs so a failure names the step. The cost is that nothing regenerates native/poc.mojo when OTP's minor version moves.
Checkpoint
- Which experiment showed that
Pointer(to=function)is the wrong code pointer? The LLVM probe described in chapter 03; experiment 02 is the version that stores the thin function instead. - Why
calloc? Mojo 1.1.0 has no module-levelvar, and the entry has to outlivenif_init. - Stretch: delete
-Xlinker -undefined -Xlinker dynamic_lookupfrom a copy of the build script and keep the compiler error.