Mob ships NIFs statically linked into the main app binary. This is non-negotiable on mobile:

  • iOS App Store rejects bundled .dylib files outright. A dlopen'd NIF can't pass review.
  • Android RTLD_LOCAL hides the parent process's enif_* symbols from a child library loaded by System.loadLibrary or dlopen. A NIF that looks for enif_make_atom at 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.

ArtifactWhere it livesWho writes it
Elixir stub modulelib/<app>/nifs/<name>.exmob.add_nif scaffolds; you fill in function signatures
Native sourcec_src/<name>.c, native/<name>/src/lib.rs, or ~Z block in the stubdepends on backend
Static archivelib<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 inside erl_nif.h. Without it the ERL_NIF_INIT macro emits the dynamic nif_init symbol, 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 to Elixir.<...>_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:

  1. Invokes Cargo at compile time
  2. Copies the produced .so to priv/native/<crate>.so
  3. Wires :erlang.load_nif/2 to 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 .rs files in native/<name>/src/ as you want, organized via standard mod foo; / mod bar; declarations.
  • Add any Cargo dependency to [dependencies]serde, tokio, bytes, anything. Cargo handles resolution and linking.
  • Write unit tests with cargo test and 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.toml

Whatever 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 …
end

That'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 their b13 build revision).
  • Distribution shape: an Python.xcframework containing 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.PythonAppleSupport in 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.so plus libcrypto_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.PythonAndroidSupport in 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.md for 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 ABI

See 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:

BackendiOS deviceiOS simAndroid arm64Android 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:

  1. mix compile on the Mac builds libexla.dylib for macOS arm64 only — nothing for iOS or Android targets.
  2. Mob's cross-compile pipeline doesn't touch EXLA's NIF (it only cross-compiles entries in mob.exs :static_nifs or recognised Rustler/Zigler crates; EXLA isn't structured as either).
  3. Even if we did cross-compile EXLA's NIF, the runtime would then dlopen libxla.so from the xla Hex package — which only ships prebuilts for desktop/server (x86_64-linux, arm64-darwin, etc.). No arm64-android or arm64-ios prebuilt 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 in mob.exs :static_nifs that the existing cross-compile pipeline can drive, mirroring the C entry type we already support. Then NxEigen becomes a regular mob.enable toggle 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:

BackendWorkaroundDrop when
Rustler[patch.crates-io] block in scaffold's Cargo.tomlupstream rustler ships the dladdr+dlopen(NOLOAD) fix for Android (tracker: mob#7)
Ziglergithub: "GenericJam/zigler", branch: "zig-016-port" pinupstream Zigler ships Zig 0.16 support AND honors a per-NIF nif_init_alias flag (tracker: upstream #578 / #579)
Python (Android)Chaquopy as the sourceBeeWare'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.