Mob ships NIFs statically linked into the main app binary. This is non-negotiable on mobile:
- iOS App Store rejects bundled
.dylibfiles outright. A dlopen'd NIF can't pass review. - Android
RTLD_LOCALhides the parent process'senif_*symbols from a child library loaded bySystem.loadLibraryordlopen. A NIF that looks forenif_make_atomat load time doesn't find it.
Both platforms point at the same answer: link the NIF's init function
into the main binary alongside libbeam.a, and register it in a
static NIF table so load_nif/2 resolves to the embedded code
instead of opening a shared library.
mix mob.add_nif <name> is the single entry point. The four backends
(c, rustler, zigler, elixir-only) all produce the same shape
of artifact for the linker — a lib<name>.a (or <name>.o, for C)
exporting <name>_nif_init. What differs is the language you write
the NIF body in and the toolchain that produces the archive.
This guide is the contract per backend: how each upstream library normally works, and what Mob changes (or leaves alone) to make on-device static linking work. The aim is that a user — or an agent acting on their behalf — never has to read the build code to answer "what does Mob do with my Rust crate?"
For app-level integration of Python specifically (wheel handling,
asset extraction, the host-dev fallback), see
python_embedding.md — this guide covers only
the NIF-layer story.
Anatomy of a static NIF in Mob
Every backend ends up at the same three artifacts in the same three places. The differences below are about how each gets generated, not what it is at link time.
| Artifact | Where it lives | Who writes it |
|---|---|---|
| Elixir stub module | lib/<app>/nifs/<name>.ex | mob.add_nif scaffolds; you fill in function signatures |
| Native source | c_src/<name>.c, native/<name>/src/lib.rs, or ~Z block in the stub | depends on backend |
| Static archive | lib<name>.a (cross-compiled per arch) | mob_dev invokes the backend's toolchain |
The link-time dispatch table — priv/generated/driver_tab_ios.zig
and priv/generated/driver_tab_android.zig — declares
<name>_nif_init as extern fn and adds it to
erts_static_nif_tab[], which the BEAM consults instead of dlopen
when the Elixir stub calls :erlang.load_nif/2. The table is
regenerated from mob.exs's :static_nifs list by
mix mob.regen_driver_tab (which mob.add_nif composes
automatically, so you rarely call it directly).
MobDev.StaticNifs is the schema reference for
the manifest entries.
C
How erl_nif normally works
A C NIF is a .c file that includes <erl_nif.h>, defines a few
functions matching the ErlNifFunc table, and ends with the
ERL_NIF_INIT macro. The Erlang VM compiles it as a shared library,
the Elixir module's :erlang.load_nif/2 dlopens that library, and
the BEAM looks up the init symbol — which is hardcoded as plain
nif_init in dynamic mode.
Upstream reference: the erl_nif man page
and the User's Guide tutorial.
How Mob handles it
Mob compiles the same .c source as a regular .o file and links it
straight into the app binary alongside libbeam.a. Two compile-time
flags switch <erl_nif.h> from "dlopen mode" into "static mode":
-DSTATIC_ERLANG_NIF— selects the static-link dispatch path insideerl_nif.h. Without it theERL_NIF_INITmacro emits the dynamicnif_initsymbol, which collides across multiple NIFs.-DSTATIC_ERLANG_NIF_LIBNAME=<name>— overrides the init symbol to<name>_nif_init. The static table declares it by that exact name, so they have to match. Without the override, the macro mangles toElixir.<...>_nif_init, which isn't a valid C identifier and fails to compile.
That's all. The C code itself is identical to a portable NIF — no
Mob-specific includes, no special prologue. You can prototype a NIF
against any vanilla BEAM via mix compile then drop it into Mob
unchanged.
Scaffold:
mix mob.add_nif audio_engine --type c
Drops c_src/audio_engine.c with the macro pre-wired, generates the
Elixir stub, appends %{module: :audio_engine, archs: [:all]} to
mob.exs's :static_nifs, and re-runs mob.regen_driver_tab.
Rust via Rustler
How Rustler normally works
A standard Rustler project has a Cargo crate (typically at
native/<crate>/) declared with crate-type = ["cdylib"]. Cargo
builds a .so containing #[rustler::nif]-annotated functions
registered via the rustler::init!(...) macro. The Elixir side calls
use Rustler, otp_app: :app, crate: "name", which:
- Invokes Cargo at compile time
- Copies the produced
.sotopriv/native/<crate>.so - Wires
:erlang.load_nif/2to that path so the BEAM dlopens it
Upstream reference: the Rustler crate and the Rustler GitHub README.
How Mob handles it
Three differences from the standard flow. None of them touch the Rust source code.
1. Dual crate-type. Mob's scaffolded Cargo.toml has:
crate-type = ["staticlib", "cdylib"]The cdylib keeps host-dev's mix compile working — same
dlopen-the-.so path Rustler users know. The staticlib is what
mob_dev's cross-compile actually consumes: cargo rustc --crate-type staticlib --target <arch> produces a lib<name>.a that's linked
into the main app binary on-device.
2. Symbol convention requires rustler 0.37+. Rustler 0.37 changed
the static-NIF init symbol to derive from CARGO_CRATE_NAME as
<crate>_nif_init. Earlier versions hardcoded plain nif_init,
which would collide if you had multiple Rust NIFs in one app (linker
errors on duplicate symbols). Mob's driver_tab declares each NIF
by <crate>_nif_init, so the pin to 0.37 is load-bearing — don't
silently downgrade it.
3. Android dlsym workaround (transient). Rustler 0.37's
nif_filler uses dlopen(NULL) to locate enif_* symbols at NIF
init. On Bionic that handle resolves to a namespace which doesn't
include the app's own .so siblings, even when they're marked
RTLD_GLOBAL. Every NIF init panics with undefined symbol: enif_priv_data.
The scaffolded Cargo.toml carries a [patch.crates-io] block
pointing at GenericJam/rustler:genericjam-android-rtld-default,
which patches the Android branch to do dladdr + dlopen(self, RTLD_NOLOAD) for an explicit self-handle. iOS/macOS/Linux paths in
the fork are unchanged. Tracker: mob#7.
Drop the patch block (and bump the version pin) once upstream
rustler merges the fix.
What stays standard
Everything in the Rust source. You can:
- Put as many
.rsfiles innative/<name>/src/as you want, organized via standardmod foo;/mod bar;declarations. - Add any Cargo dependency to
[dependencies]—serde,tokio,bytes, anything. Cargo handles resolution and linking. - Write unit tests with
cargo testand run them on the host like any other crate. - Use Rustler's full surface —
Term,Atom,Encoder/Decoder,ResourceArc,NifTuple, etc. — without modification.
Multiple Rust NIFs per app
Each mob.exs :static_nifs entry whose name matches a
native/<name>/Cargo.toml is cross-compiled to its own
lib<name>.a and linked. No workspace required. nif_combo ships
three NIFs (greet_c + greet_rust + greet_zig) in one app as
the canonical example.
If you want Cargo workspaces for shared deps across multiple Rust
NIFs, the scaffold doesn't generate one but Cargo's
workspace-detection is transparent to cargo rustc --manifest-path native/<name>/Cargo.toml. Add a native/Cargo.toml workspace
yourself and Cargo wires it up.
Scaffold:
mix mob.add_nif audio_engine --type rustler
Bringing in an existing Rust crate
mob.add_nif is for scaffolding a new NIF from scratch. If you already
have a Rust crate written elsewhere — your own work, a multi-crate
project authored against vanilla Rustler, anything that produces NIFs
the standard way — Mob doesn't auto-import it, but the manual hookup
is short. The four steps below are the whole list.
1. Drop the crate(s) into native/<name>/. Each crate gets its own
directory with its own Cargo.toml and src/ tree. Mob doesn't care
about Rust-internal structure — files, submodules, build.rs, bench
targets, external deps, subdirectory module trees — that's all cargo's
business. Mob compiles each crate via:
cargo rustc --release --target <arch> --crate-type staticlib \
--manifest-path native/<name>/Cargo.tomlWhatever cargo can build, Mob can ship.
2. Per Cargo.toml: add staticlib to crate-type. A standard
Rustler crate has:
[lib]
crate-type = ["cdylib"]Mob needs:
[lib]
crate-type = ["staticlib", "cdylib"]The cdylib keeps host-dev (mix compile on Mac/Linux) working
through Rustler's normal dlopen path. The staticlib is what Mob's
cross-compile consumes for the on-device link. One-line edit per
crate.
3. Per Cargo.toml: add the Android dlsym patch. Until upstream rustler ships the dladdr+dlopen(NOLOAD) fix (see "When the upstream lands its own fix" below), each crate's Cargo.toml needs:
[patch.crates-io]
rustler = { git = "https://github.com/GenericJam/rustler.git",
branch = "genericjam-android-rtld-default" }Without it, NIF init panics on Android with undefined symbol: enif_priv_data. iOS/macOS/Linux are unaffected.
4. Register each crate in mob.exs. Open mob.exs and add one
entry per crate to :static_nifs:
config :mob_dev,
static_nifs: [
%{module: :dsp_utils, archs: [:all]},
%{module: :phy_modem, archs: [:all]},
%{module: :melpe, archs: [:all]}
]The module: atom matches the directory name under native/ and
the [lib] name = "..." in that Cargo.toml. Then run:
mix mob.regen_driver_tab
which rewrites priv/generated/driver_tab_{ios,android}.zig to
declare and dispatch each new init function. mob.add_nif calls this
for you; when bringing in crates by hand, run it explicitly.
The Elixir-side stubs (lib/<app>/nifs/<name>.ex) are also up to you
to write. The pattern is:
defmodule MyApp.Nifs.PhyModem do
use Rustler, otp_app: :my_app, crate: "phy_modem"
def modulate(_input), do: :erlang.nif_error(:nif_not_loaded)
# … one stub per #[rustler::nif] fn in the crate …
endThat's the full list. No further wiring is needed. mix mob.deploy --native cross-compiles every registered crate, links each
lib<name>.a into the app binary, and :erlang.load_nif/2 resolves
to the static dispatch table at startup.
Where to run mob commands from. Mob resolves native/<name>/
relative to the current working directory. For a standalone project
that's the project root. For an umbrella project, run mob commands
from the child app directory (the one whose apps/<app>/ contains
mob.exs + ios/ + android/ + native/), not from the umbrella
root. There is no umbrella-aware app selection (yet); running from
the wrong directory silently finds no native/ entries and emits no
NIFs.
One-time toolchain prerequisites — same as any cross-compiled Rust project, not Mob-specific:
rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-linux-android armv7-linux-androideabi
mix mob.doctor verifies these are installed and flags missing ones.
Caveat for external Cargo deps. Pure-Rust dependencies
(rustfft, serde, tokio, etc.) cross-compile cleanly to all four
Mob targets and need no special handling. Crates that pull in C via
build.rs or bindgen may need the corresponding C library
available for the target — that's upstream's concern, same as in any
non-Mob cross-compile. If cargo rustc --target aarch64-linux-android
fails for a transitive C dep, that's not a Mob issue; check the dep's
own cross-compile instructions.
Zig via Zigler
How Zigler normally works
Zigler lets you embed Zig directly
in an Elixir module via the ~Z sigil, compiles it through Zig's
build system at mix compile time, and exposes each pub fn as a
NIF function on the module. Standard usage produces a dynamically
loaded .so — same dlopen-at-load model as Rustler's default.
Upstream reference: the Zigler hex docs and the Zigler GitHub README.
How Mob handles it
Mob uses a fork of Zigler pinned via the scaffolded mix.exs:
{:zigler, github: "GenericJam/zigler", branch: "zig-016-port"}Two reasons the fork exists, both invisible to Zig source code:
1. macOS 26 (Sequoia/Tahoe) compatibility. Upstream Zigler pins
Zig 0.15.x, whose bundled compiler_rt references
__availability_version_check and friends that aren't present in
the macOS 26 SDK. Compiles fail with a cascade of POSIX
symbol-undefined errors on developer machines that have upgraded
past the SDK boundary. The fork ports priv/beam/ to Zig 0.16.0,
which works against the macOS 26 SDK.
2. Static-NIF init symbol collision. Zigler 0.16's emitted NIF
init function was a bare nif_init (same name Rustler ≤0.36 used).
When you statically link a Zig NIF alongside any other NIF in
mob's driver_tab, the linker merges them into one symbol and
silently corrupts per-module dispatch. The fork honors a
-Dnif_init_alias=<name>_nif_init flag so each Zig NIF gets a
unique exported init symbol, matching what driver_tab declares.
Once upstream Zigler ships Zig 0.16 support natively and accepts a per-NIF init alias, the fork dissolves. Track upstream issue #578 and PR #579.
The mob.add_nif --type zigler scaffold also runs mix zig.get
afterward so Zigler's executable_path lookup finds the cached Zig
0.16.0 before falling through to PATH (which on most mob
developer machines points at Zig 0.17-dev — wrong stdlib for the
port).
What stays standard
The Zig source itself. Any pub fn becomes a NIF; Zigler's
type-mapping table works as documented upstream; resource types,
beam.send, etc. all behave identically to non-Mob Zigler projects.
Scaffold:
mix mob.add_nif audio_engine --type zigler
Python via Pythonx
Python is a different beast from C/Rust/Zig — it's not a single NIF crate you compile, it's a whole interpreter that ships inside the app. The static-link mechanics still apply (the Pythonx NIF is statically linked, just like any other Rust NIF), but the Python runtime + standard library + C extensions need to come from somewhere, and there's no upstream that ships one binary distribution covering iOS and Android.
How Pythonx normally works
Pythonx embeds CPython into the
BEAM via a NIF (written in Rust, using
PyO3). On a developer machine, it
fetches CPython through uv on
first run, installs it under a project-local cache, and dlopens
libpython from there. Pythonx.eval/2 runs Python code in that
in-process interpreter.
Upstream reference: Pythonx hex docs and the Pythonx GitHub README.
How Mob handles it
The Pythonx NIF itself is treated like any other Rust NIF —
cross-compiled to libpythonx.a, linked into the main binary,
registered in driver_tab. Same static-link story as Rustler above.
The Python runtime is the part that differs. There's no
uv install on iOS or Android (no shell, no PATH, sandboxed
filesystem), and Pythonx's normal fetch flow can't run on-device. Mob
ships a pre-built CPython distribution for each platform, bundled
into the app artifact and extracted on first launch.
Both platforms target Python 3.13 so user code is portable. The sources differ because no single upstream ships both:
iOS — BeeWare's Python-Apple-support.
- Version pinned to
3.13-b13(BeeWare's release tag — Python 3.13 plus theirb13build revision). - Distribution shape: an
Python.xcframeworkcontaining the arch slices for iOS device + iOS simulator, plus the stdlib and standard C extensions (_ssl,_ctypes,_hashlib, etc.). - Tarball URL pattern:
https://github.com/beeware/Python-Apple-support/releases/download/3.13-b13/Python-3.13-iOS-support.b13.tar.gz - Implementation:
MobDev.PythonAppleSupportin mob_dev. Cached at~/.mob/cache/python-apple-support-<version>/.
Android — Chaquopy's target distribution.
- Version pinned to
3.13.9-0(Chaquopy's<python>.<patch>-<chaquopy-rev>versioning — Python 3.13.9 plus Chaquopy revision 0). - Distribution shape: per-ABI zips (arm64-v8a + x86_64) containing
libpython3.13.sopluslibcrypto_python.so,libssl_python.so,libsqlite3_python.so, the lib-dynload C extensions, and a separate stdlib zip. - Maven Central URL pattern:
https://repo1.maven.org/maven2/com/chaquo/python/target/... - License: Apache 2.0 (as of 2025).
- Implementation:
MobDev.PythonAndroidSupportin mob_dev. Cached at~/.mob/cache/python-android-support-<version>/.
Why two sources? BeeWare's Python-Android-support (the natural
sibling of Python-Apple-support) hasn't shipped a release since
Python 3.10. Chaquopy is currently the only actively-maintained
source of pre-built CPython binaries for Android 3.11+. We use it
for the binaries only — Chaquopy's Java↔Python bridge is bypassed
entirely; Pythonx talks to libpython3.13.so through the same FFI
contract on both platforms.
What this means in practice:
- Your Python code runs against CPython 3.13 on both platforms. The stdlib is BeeWare's pure-Python copy on iOS and Chaquopy's pure- Python copy on Android — both come straight from python.org's 3.13 source tree, so they're functionally identical for stdlib surface.
- Standard C extensions (
_ssl,socket,_hashlib, …) are present on both. Build flags differ slightly between BeeWare and Chaquopy, so a determined user could find a corner case where e.g. SSL cipher suites differ — but for nearly all code, the platforms are interchangeable. - Patch versions can drift mildly. Today iOS is on Python 3.13.x (BeeWare b13's underlying CPython tag) and Android is on Python 3.13.9 (Chaquopy's pin). Both move together in lockstep with our manual end-to-end validation when either upstream cuts a new release.
- Third-party wheels are out of scope. See
python_embedding.mdfor the "build your own wheel" guidance per platform.
What stays standard
Pythonx itself is unchanged. Pythonx.eval/2, Pythonx.encode/1,
Pythonx.decode/1, the Pythonx.Object resource — all behave
identically to a host-dev project. Your Python code never sees
that the interpreter came from a different upstream.
Scaffold:
mix mob.enable pythonx # not `mob.add_nif` — Pythonx is an enable, not a generic NIF
For the app-integration story (when Pythonx initializes, where
extracted files live, how to ship third-party wheels, host-dev
fallback), see python_embedding.md.
Multiple NIFs per app, generally
mob.exs's :static_nifs is a list. Each entry follows the schema:
%{module: :nif_name, archs: [:all]} # link on all targets
%{module: :nif_name, archs: [:ios]} # link only on iOS targets
%{module: :nif_name, archs: [:android_arm64]} # narrow to one Android ABISee MobDev.StaticNifs for the full schema,
arch atoms, and the per-arch _nif_init symbol-name mapping. The
order in the list determines link order, which matters if NIFs have
inter-symbol dependencies (rare) but is otherwise cosmetic.
mob.add_nif always appends with archs: [:all] and assumes you'll
narrow that manually if needed. Hand-editing mob.exs and re-running
mix mob.regen_driver_tab is the supported way to adjust arch
guards after the fact.
Nx backends on mobile — what works, what doesn't
Nx applications often ask "which backend should I use on phone?" The short answer per platform, based on what actually compiles and runs:
| Backend | iOS device | iOS sim | Android arm64 | Android arm32 |
|---|---|---|---|---|
Nx.BinaryBackend (pure Elixir) | ✓ | ✓ | ✓ | ✓ |
| EMLX (Apple Accelerate, CPU) | ✓ | ✓ | — | — |
| NxEigen (Eigen C++, CPU) | ✗ (shallow — one cdylib to build) | (same) | ✗ (shallow — one .so to build) | (same) |
| EXLA (Google XLA, JIT) | ✗ (deep — XLA + EXLA cross-builds) | (same) | (same) | (same) |
EMLX is Mob's current supported path on iOS — see the mlx section
of mix mob.enable.
Apple-Silicon-CPU only in the current bundle; Metal-on-iOS is a v2
follow-up.
EXLA sounds attractive (XLA's JIT compiler is genuinely fast) but
isn't realistic on mobile today. Empirically verified failure: EXLA's
BEAM modules ship and the Elixir layer loads, but EXLA.NIF is unloadable
because:
mix compileon the Mac buildslibexla.dylibfor macOS arm64 only — nothing for iOS or Android targets.- Mob's cross-compile pipeline doesn't touch EXLA's NIF (it only
cross-compiles entries in
mob.exs :static_nifsor recognised Rustler/Zigler crates; EXLA isn't structured as either). - Even if we did cross-compile EXLA's NIF, the runtime would then
dlopen libxla.sofrom thexlaHex package — which only ships prebuilts for desktop/server (x86_64-linux,arm64-darwin, etc.). Noarm64-androidorarm64-iosprebuilt exists.
Getting EXLA to work on either mobile platform is two missing cross-compiles deep:
- Step 1: Build XLA (Google's C++ library) itself for arm64-ios and arm64-android via Bazel. No published recipe. Multi-day to multi-week project. Produced binary is hundreds of MB.
- Step 2: Cross-compile EXLA's NIF wrapper against that.
- Step 3: Wire those binaries into mob_dev's deploy pipeline.
If a user wants ML model inference on mobile via Elixir, TFLite (TensorFlow Lite) is Google's blessed mobile path. It has a different API surface from XLA but supports iOS + Android natively. Would require writing a TFLite-bindings NIF; not in mob today.
NxEigen (github.com/cocoa-xu/nx_eigen)
is a CPU-only Nx backend over the Eigen
C++ template library. Eigen is header-only-ish, vectorised via
SSE/NEON, and famously portable — Android NDK builds work upstream,
and iOS arm64 builds work too (any Clang with a Darwin target will
compile it). For Mob's Android case this is the closest peer to "what
EMLX gives you on iOS." Empirically verified failure mode on both iOS
arm64 (physical device) and Android arm64 (emulator): the BEAM modules
ship and Nx.tensor(..., backend: NxEigen.Backend) runs the Elixir
path until it hits NxEigen.NIF.from_binary/3 — which is :undef
because no libnx_eigen.{dylib,so} was cross-compiled for the target.
The peer NIF (Rustler / Zigler / :static_nifs) infrastructure would
catch this case; NxEigen's elixir_make cdylib build doesn't plug
into it.
Crucially, the failure is shallow. Unlike EXLA (which would
further dlopen libxla.so even if its own NIF cross-built — a second
unbuilt dep), NxEigen has no transitive prebuilt-binary dependency.
The integration path is a single .cpp file + Eigen headers + fine.hpp
erl_nif.h, compiled per target. The most natural place is a new C++ entry type inmob.exs :static_nifsthat the existing cross-compile pipeline can drive, mirroring the C entry type we already support. Then NxEigen becomes a regularmob.enabletoggle on both platforms.
If your app's Nx code is mostly stdlib operations and the perf hit of
Nx.BinaryBackend is acceptable, that path Just Works on both
platforms today with zero setup. For real numerics work — particularly
on Android where EMLX isn't an option — file an issue and we'll
prioritise NxEigen integration; the shallow failure mode above means
it's a realistic next addition, not a research project.
Inspecting what got linked
After mix mob.deploy --native finishes:
# iOS sim binary, list every static NIF init exported
nm -gU ios/MobApp.app/MobApp | grep _nif_init
# Android arm64 .so, same
llvm-readelf --dyn-syms android/app/build/intermediates/stripped_native_libs/debug/out/lib/arm64-v8a/lib<app>.so | grep _nif_init
Each :static_nifs entry should appear exactly once. A missing
symbol means the link step didn't see the archive (check
-Dproject_rust_libs in the zig invocation); a duplicate means
two scaffolds collided (rename one).
When the upstream lands its own fix
The three transient items above will each have a clean exit:
| Backend | Workaround | Drop when |
|---|---|---|
| Rustler | [patch.crates-io] block in scaffold's Cargo.toml | upstream rustler ships the dladdr+dlopen(NOLOAD) fix for Android (tracker: mob#7) |
| Zigler | github: "GenericJam/zigler", branch: "zig-016-port" pin | upstream Zigler ships Zig 0.16 support AND honors a per-NIF nif_init_alias flag (tracker: upstream #578 / #579) |
| Python (Android) | Chaquopy as the source | BeeWare's Python-Android-support returns to active maintenance with a 3.11+ release |
Until then these stay where they are — the scaffold writes them out loudly, with comments pointing at this guide.