Skip to content
davthecoder
The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK
tech

The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK

By David Cruz Anaya, Independent Senior Mobile Engineer (Android, Kotlin Multiplatform, Rust)

Updated 23 July 2026
AndroidRustNDKcargo-ndkToolchainCross-Compilation
Share:

Rust + NDK on Android, Part 1 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Next: Calling Rust from Kotlin: JNI Basics for Android Developers (coming soon).

The complete Rust Android NDK setup is four installs and one command. rustup brings the compiler and the four Android targets, sdkmanager installs the NDK, and cargo install cargo-ndk adds the glue between them. After that, a single cargo ndk build cross-compiles your crate straight into the folder the Android Gradle Plugin packages into the APK. You never touch CMake or write a linker script.

By the end of this post you’ll have a Rust function inside a real APK, called from Kotlin, with the resulting .so verified down to its exported symbols and its 16KB page alignment. The only prerequisites are an ordinary Android project and the Android SDK. You don’t need to have written a line of Rust before. If you’re still deciding whether Rust deserves a slot in your stack, the series hub makes that case with Google’s published numbers. This post is purely the how.

Key Takeaways

  • The whole setup is four pieces: rustup with four Android targets, the NDK for its linker and sysroot, cargo-ndk to join them, and a cdylib crate. Nothing else is required to get a working .so.
  • Plain cargo build --target aarch64-linux-android fails on a laptop because rustc hands ELF linker flags to the host linker. cargo-ndk exists to point cargo at the NDK’s clang for each target.
  • One cargo ndk invocation builds all four ABIs and copies the libraries into app/src/main/jniLibs, exactly where the Android Gradle Plugin already looks, with no build.gradle changes.
  • NDK r28 aligns .so files to 16KB pages by default, which matters because Google Play requires 16KB support for new apps and updates targeting Android 15+ (Android Developers).

What does a Rust Android NDK setup actually involve?

Four pieces, from two vendors who never talk to each other. Rust ships Android support as a Tier 2 platform with six targets (rustc book), so rustup covers the compiler side out of the box. What rustc cannot do alone is link the final shared library: an Android .so is an ELF binary linked against bionic, Android’s libc, and that linker and sysroot live inside the NDK. Google’s NDK team has said first-party Rust support for apps is not planned; the rustup toolchain is the recommended path, as covered in the hub post.

The division of labour: rustup provides rustc and the Android standard libraries, the NDK provides clang and the sysroot, and cargo-ndk wires the right NDK binary to the right Rust target. The fourth piece is your own crate, compiled as a cdylib, Rust’s crate type for a C-compatible shared library.

One Rust target exists per Android ABI, and the names don’t match, which trips everyone up exactly once:

Rust target triple Android ABI Where it runs
aarch64-linux-android arm64-v8a effectively every modern phone
armv7-linux-androideabi armeabi-v7a older 32-bit devices
x86_64-linux-android x86_64 the emulator on Intel and AMD hosts
i686-linux-android x86 old 32-bit emulator images

cargo-ndk accepts the Android ABI names and translates them, so after this table you can mostly think in ABIs again.

Installing rustup, the Android targets, and the NDK

If Rust isn’t on your machine yet, the official installer is one command (rustup.rs):

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Then add the four Android targets, which downloads a prebuilt Rust standard library for each, not a new compiler:

rustup target add aarch64-linux-android armv7-linux-androideabi \
  x86_64-linux-android i686-linux-android

The NDK comes from sdkmanager (Android Developers). Pin an exact version rather than installing “the NDK”: different releases link binaries differently, and every machine on the team should build with the same one. I’m using r28.2 throughout this series:

sdkmanager --install "ndk;28.2.13676358"
export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/28.2.13676358

On macOS the SDK lives at ~/Library/Android/sdk and sdkmanager under cmdline-tools/latest/bin; on Linux the SDK path is usually ~/Android/Sdk. The ANDROID_NDK_HOME export is how cargo-ndk finds the toolchain. Finally, cargo-ndk installs like any cargo tool:

cargo install cargo-ndk

That’s the entire toolchain. Everything from here on is a normal Rust project.

The first crate: one exported function in a cdylib

The layout for the whole series is one Android app module plus a cargo workspace of Rust crates next to it:

project/
  app/                          the Android app module
    src/main/java/dev/davthecoder/rustseries/
    src/main/jniLibs/           cargo-ndk output lands here
  rust/
    Cargo.toml                  workspace root
    hello/
      Cargo.toml
      src/lib.rs

The workspace root at rust/Cargo.toml just lists its members:

[workspace]
members = ["hello"]
resolver = "2"

The crate manifest at rust/hello/Cargo.toml is where the Android-specific decision lives:

[package]
name = "hello"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]

[dependencies]
jni = "0.21"

cdylib tells cargo to produce a C-compatible shared library, libhello.so, named after the package. The extra lib keeps the ordinary Rust library around too, which is what lets cargo test link against the crate on your laptop. The jni crate is the bridge to the JVM; I pin 0.21 because it’s the API style virtually all existing examples and production code use.

The full rust/hello/src/lib.rs:

use jni::objects::{JClass, JString};
use jni::sys::jstring;
use jni::JNIEnv;

pub fn greeting(name: &str) -> String {
    format!("Hello from Rust, {name}!")
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_RustHello_greeting(
    mut env: JNIEnv,
    _class: JClass,
    name: JString,
) -> jstring {
    let name: String = env
        .get_string(&name)
        .map(|s| s.into())
        .unwrap_or_else(|_| "unknown".to_string());
    let output = greeting(&name);
    env.new_string(output)
        .expect("failed to build Java string")
        .into_raw()
}

#[cfg(test)]
mod tests {
    use super::greeting;

    #[test]
    fn greeting_includes_name() {
        assert_eq!(greeting("Android"), "Hello from Rust, Android!");
    }
}

Two functions, and the split between them is the pattern the entire series builds on. greeting is plain Rust: no JNI types, testable anywhere. The long Java_dev_davthecoder_rustseries_RustHello_greeting function is the JNI shim: its name encodes the Kotlin package, class and method it will be matched to, #[no_mangle] stops the compiler from renaming the symbol, and extern "system" gives it the calling convention the JVM expects. How that name resolves, and what JNIEnv hands you, is the whole of Part 2. For today, treat the shim as ceremony and notice that the logic lives in a function you can test without any Android in sight:

cargo test -p hello
running 1 test
test tests::greeting_includes_name ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

That test ran natively on my laptop in milliseconds without starting an emulator. Keeping the core testable on the host is the single biggest quality-of-life difference between this workflow and classic NDK C++ development.

Why plain cargo fails, and what cargo-ndk actually does

You might reasonably ask why cargo can’t just build for Android directly, since the target is installed. Try it:

cargo build -p hello --target aarch64-linux-android

On a Mac, compilation succeeds and then linking explodes:

  = note: ld: unknown options: --version-script=... --no-undefined-version --as-needed -Bstatic -Bdynamic --eh-frame-hdr -z --gc-sections -z -z
          clang: error: linker command failed with exit code 1 (use -v to see invocation)

error: could not compile `hello` (lib) due to 1 previous error

Read the first line closely, because this error teaches the whole lesson. rustc compiled your code to ARM64 objects without complaint, then invoked cc to link them, and cc on macOS is Apple’s linker. Flags like --version-script and --eh-frame-hdr are ELF linker flags; Apple’s ld links Mach-O and has never heard of them. The compiler was never the problem. The missing piece is a linker that produces ELF for Android, and that linker ships inside the NDK as a set of target-specific clang wrappers.

You could fix this by hand, setting CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER and friends for every target, and keeping the paths current across NDK upgrades. cargo-ndk exists so nobody does that. It locates the NDK through ANDROID_NDK_HOME (falling back to your SDK directory), maps each ABI name to its Rust triple, points cargo at the matching NDK clang wrapper for your API level (21 by default, override with --platform), sets CC and AR for crates with C build scripts, runs the build, and copies each .so into an ABI-named subdirectory of the output path (cargo-ndk).

Building all four ABIs into jniLibs

One command, run from the rust/ directory, builds every ABI and drops the libraries where Gradle expects them:

ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/28.2.13676358 \
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \
  -o ../app/src/main/jniLibs build --release

The tail of the real output:

   Compiling jni-sys v0.4.1
   Compiling combine v4.6.7
   Compiling jni v0.21.1
   Compiling hello v0.1.0 (rust/hello)
    Finished `release` profile [optimized] target(s) in 3.99s
     Copying libraries to app/src/main/jniLibs

That produces exactly four files: app/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}/libhello.so. The jniLibs directory is special: the Android Gradle Plugin packages anything in it into the APK’s native library section with no configuration at all. Build your app now and the Rust is already inside it. Wiring this command into Gradle is Part 4’s job; while iterating, running it by hand is fine.

Loading the library from Kotlin

The Kotlin side is deliberately tiny. The full app/src/main/java/dev/davthecoder/rustseries/RustHello.kt:

package dev.davthecoder.rustseries

object RustHello {
    init {
        System.loadLibrary("hello")
    }

    external fun greeting(name: String): String
}

System.loadLibrary("hello") finds libhello.so for the device’s ABI inside the APK; the lib prefix and .so suffix are added for you. Because the call sits in the init block of an object, it runs exactly once, the first time anything touches RustHello. The external keyword is Kotlin’s native: a declaration with no body, resolved against loaded native libraries at first call. Note how the package and object name match the exported symbol in lib.rs. That match is the actual lookup mechanism, not a naming convention someone picked for readability.

Proving it works takes one line of logging. The full app/src/main/java/dev/davthecoder/rustseries/MainActivity.kt:

package dev.davthecoder.rustseries

import android.app.Activity
import android.os.Bundle
import android.util.Log

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.d("RustSeries", RustHello.greeting("Android"))
    }
}

Run the app and logcat prints Hello from Rust, Android!, the same string the unit test asserted on the host. Same function, now executing as native ARM64 code inside your process.

Verifying the .so before you trust it

I never ship a native library I haven’t inspected, and the NDK bundles the LLVM binutils to do it, under $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin on macOS (linux-x86_64 on Linux). Two checks pay for themselves immediately.

First, confirm the JNI symbol is actually exported. llvm-nm -gD lists the dynamic symbols other code can see:

llvm-nm -gD app/src/main/jniLibs/arm64-v8a/libhello.so | grep Java_
000000000001902c T Java_dev_davthecoder_rustseries_RustHello_greeting

The T means the symbol lives in the text section and is exported. If this line is missing, System.loadLibrary will still succeed, and the first call to greeting will throw UnsatisfiedLinkError. The cause is almost always a forgotten #[no_mangle] or a typo in the long function name, and this command tells you before you’ve even installed the APK.

Second, check the ELF page alignment:

llvm-readelf -lW app/src/main/jniLibs/arm64-v8a/libhello.so | grep LOAD
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x014fdc 0x014fdc R   0x4000
  LOAD           0x014fdc 0x0000000000018fdc 0x0000000000018fdc 0x03a7b4 0x03a7b4 R E 0x4000
  LOAD           0x04f790 0x0000000000057790 0x0000000000057790 0x001da0 0x002870 RW  0x4000
  LOAD           0x051530 0x000000000005d530 0x000000000005d530 0x0009a8 0x001290 RW  0x4000

The final column is the alignment of each LOAD segment: 0x4000 is 16384 bytes, so every segment here is 16KB-aligned. That’s the NDK r28 default doing its job, and it matters because Google Play requires 16KB page size support for new apps and updates targeting Android 15+ (Android Developers). Build with a current NDK and Rust gives you compliance for free; build with an old one and this same command shows 0x1000 instead. Part 13, Binary Size and the 16KB Page Requirement, covers the full story.

Common failure modes and how to read them

Four failures account for nearly every broken first setup I’ve seen.

rustc reports error E0463, can’t find crate for core. The error names the target it can’t build for. It means the Rust standard library for that target isn’t installed: run rustup target add for the triple in the message. This bites freshly provisioned CI machines especially.

cargo-ndk can’t find an NDK. It searches ANDROID_NDK_HOME first, then the SDK directory. If several NDK versions are installed, don’t let it guess: export ANDROID_NDK_HOME with the pinned version, as the build command above does.

The build succeeds but no .so appears. Check crate-type in Cargo.toml. Without cdylib in the list, cargo happily builds an rlib, which is a Rust-internal format, and nothing arrives in jniLibs.

UnsatisfiedLinkError at runtime. Two distinct flavours. If it’s thrown by System.loadLibrary, the APK has no libhello.so for the device’s ABI, usually because a target was skipped during the build. If it’s thrown at the first external fun call, the library loaded but the symbol lookup failed: re-run the llvm-nm check and compare the symbol against the Kotlin package, class and method character by character.

Frequently Asked Questions

Do I need Android Studio to build Rust for Android?

No. The build needs the SDK command line tools, the NDK, rustup and cargo-ndk, all of which run headless; that’s the entire CI story too. In practice you’ll have Android Studio anyway for the app side, and its SDK Manager UI installs the NDK just as well.

Which NDK version should I use with cargo-ndk?

A current one, pinned exactly. NDK r28 or newer is the practical floor because it emits 16KB-aligned libraries by default, which Google Play requires for apps targeting Android 15+. This series pins 28.2.13676358 via ANDROID_NDK_HOME so every build, local or CI, links with the same toolchain.

Why does System.loadLibrary throw UnsatisfiedLinkError?

At load time it means the APK contains no matching library for the device’s ABI: confirm all four jniLibs subdirectories were populated. At call time it means the JNI symbol doesn’t match: inspect the exported name with llvm-nm -gD and compare it against the Kotlin package, class and method names.

Can I skip the 32-bit Android targets?

Often, yes. Google Play has required 64-bit support since 2019, and arm64-v8a covers effectively every current device (Android Developers). Keep armeabi-v7a only if your install data shows real 32-bit devices, and keep x86_64 for the emulator. Fewer targets also mean a smaller APK and faster builds.

Where this leaves you

That’s the toolchain done, and it’s the last time this series has to talk about it. Everything from Part 2 onwards assumes exactly this setup and nothing more. The libhello.so you just inspected is trivial, but the workflow that produced it is the same one that ships real crates later on.

What I skated over is the strangest part of the code: that long Java_dev_davthecoder_rustseries_RustHello_greeting name, the JNIEnv argument, and what actually happens to a Kotlin String when it crosses into Rust. Part 2, Calling Rust from Kotlin: JNI Basics for Android Developers, covers all of that. If you want the wider map first, the series hub covers debugging and the case for Rust itself, and my C++ on Android guide is the same toolchain story for the other native language.

Sources

Share:

Comments

Loading comments…