How to Target Android from Rust

Add the Android target with rustup and specify it in the cargo build command to compile Rust code for Android.

When your laptop binary won't run on your phone

You wrote a Rust function that compresses images in half the time of the Java equivalent. It runs perfectly on your development machine. You want to ship it to an Android app. You plug in your phone, hit build, and get a binary that crashes immediately. Or worse, the build fails with a wall of linker errors complaining about missing libraries.

Android is not just "Linux with a different UI." It is a distinct environment with its own system libraries, its own linker expectations, and strict rules about how native code gets loaded. Rust handles this by treating Android as just another target platform. You don't rewrite your code. You tell the compiler to generate instructions for the Android world instead of your laptop's world.

The core mechanism is cross-compilation. Rust's compiler, rustc, can produce code for almost any architecture and OS combination, regardless of what machine you are typing on. You specify the destination using a target triple, and the toolchain generates a binary that speaks Android's language.

The target triple is a shipping label

Every Rust target is identified by a string called a target triple. It follows the pattern arch-vendor-os-env. This string tells the compiler exactly what CPU to generate instructions for and what system libraries to link against.

For Android, the triples look like this:

  • aarch64-linux-android: 64-bit ARM processors, Linux kernel, Android environment. This is the standard for modern phones.
  • x86_64-linux-android: 64-bit Intel/AMD processors. Used primarily for the Android Studio emulator.
  • armv7-linux-androideabi: 32-bit ARM. Legacy support for very old devices. Rarely needed today.

Think of the target triple as a shipping label on a package. The compiler is the factory. If the label says aarch64-linux-android, the factory packs the code in a format that fits the Android shelf and uses parts that work with Android machinery. If you leave the label blank, the factory packs it for your local warehouse. The result is a binary that runs on your laptop but is useless on your phone.

You must install the standard library for each target you want to build. The standard library contains the core Rust types and functions compiled for that specific environment. Without it, the compiler has no way to produce code for the target.

Minimal setup: rustup and cargo

The most direct way to target Android is using rustup to add the target and cargo to build for it. This approach gives you full control but requires you to manage the Android NDK and linker paths manually on some systems.

First, add the target to your toolchain. This downloads the rust-std component for Android.

# Install the standard library for 64-bit ARM Android devices
rustup target add aarch64-linux-android

# Install the standard library for the x86_64 emulator
rustup target add x86_64-linux-android

Once the target is installed, you can build your project by passing the --target flag to cargo.

# Build a release binary for ARM64 Android
cargo build --target aarch64-linux-android --release

The output lands in target/aarch64-linux-android/release/. You will find an executable or library file there, depending on your crate type.

This works for simple binaries, but Android apps rarely run raw executables. Android expects native code to be packaged as shared libraries (.so files) that the app loads at runtime. To produce a shared library, you need to adjust your Cargo.toml.

Realistic workflow: cdylib and cargo-ndk

In the real world, you are building a native library that your Android app loads via JNI (Java Native Interface) or NDK. Rust produces these libraries using the cdylib crate type. This tells the compiler to output a C-compatible dynamic library.

Update your Cargo.toml to specify the crate type.

[lib]
# Produce a C-compatible dynamic library for FFI
crate-type = ["cdylib"]

Now write a function that Android can call. You need to use extern "C" to ensure the function uses the C calling convention, and #[no_mangle] to prevent Rust from renaming the symbol.

// lib.rs

// Prevent name mangling so Java/Kotlin can find this symbol
#[no_mangle]
pub extern "C" fn rust_compression_ratio() -> i32 {
    // Return a value the Java side can read
    42
}

Building this with plain cargo works, but you run into friction quickly. You need the Android NDK installed. You need to set environment variables so the linker knows where to find Android headers and libraries. You need to handle different architectures. The community standard for solving this is cargo-ndk.

cargo-ndk is a cargo subcommand that wraps the build process. It detects the NDK, sets up the correct linker flags, and handles the sysroot automatically. It is the convention for Rust Android development.

Install it once:

cargo install cargo-ndk

Then build with a single command:

# cargo-ndk handles the NDK, sysroot, and target setup
cargo ndk --target aarch64-linux-android --build --release

This produces a .so file in the target directory that you can copy into your Android project's jniLibs folder. The workflow is cleaner, and you avoid the maze of manual environment configuration.

Convention aside: cargo-ndk is preferred over cargo-apk for most use cases. cargo-apk tries to package the entire APK, which can conflict with existing Android build tools like Gradle. cargo-ndk focuses on building the native library, letting Gradle handle the rest. This separation of concerns keeps your build pipeline stable.

What happens under the hood

When you run cargo ndk --target aarch64-linux-android, several things happen in sequence.

First, cargo-ndk locates the Android NDK on your system. It reads the ANDROID_NDK_HOME environment variable or searches common paths. If it cannot find the NDK, it fails immediately. The NDK provides the C/C++ toolchain and headers that Rust's linker relies on for Android-specific symbols.

Next, cargo invokes rustc with the target triple. The compiler generates machine code for the ARM64 architecture. It also switches the standard library to the Android version. The Android standard library is built against bionic, Android's C library, instead of glibc or musl. This ensures that system calls and low-level functions match what the Android kernel expects.

Finally, the linker combines your object files with the standard library and any dependencies. It produces a shared object file. The file format is ELF, but the dynamic section points to Android libraries. If you try to load this .so on a Linux desktop, it will fail because the desktop lacks bionic and the Android-specific symbols. The binary is tightly coupled to the target triple you specified.

Pitfalls and compiler errors

Cross-compiling for Android introduces specific failure modes. The compiler and linker will tell you what is wrong, but the messages can be cryptic if you are not expecting them.

Missing the target is the most common error. If you try to build for a target you haven't installed, rustc rejects the build.

The compiler rejects this with a "failed to run custom build command" or a direct error about the target not being installed. You must run rustup target add first.

Linker errors are frequent when the NDK is misconfigured. If cargo-ndk cannot find the NDK, or if the NDK version is too old for your Rust toolchain, the linker fails to resolve symbols.

You will see errors like ld: cannot find -llog or undefined reference to __android_log_print. This means the linker cannot find the Android logging library. Ensure the NDK is installed and cargo-ndk can locate it.

Another trap is stack size. Android threads have a default stack size of 1MB, which is smaller than the default on desktop Linux. Recursive Rust code or functions with large local variables can overflow the stack and crash the app at runtime. The compiler cannot catch this. You must profile your code or increase the stack size in your Android thread configuration.

Debug builds also behave differently. Android's debug environment is stricter about symbols and optimization levels. Sometimes a debug build crashes due to missing unwind information or aggressive inlining issues that only appear on ARM. If your code works in release but crashes in debug, try building with --release and stripping debug symbols, or check your rustflags for target-specific settings.

Convention aside: Always test on a physical device when possible. Emulators can mask architecture-specific bugs. An x86_64 emulator might run code that fails on aarch64 due to alignment differences or atomic operation support. Trust the device.

Decision: when to use this vs alternatives

Use rustup target add when you need to install the standard library for a specific Android architecture before building. Use cargo build --target when you want manual control over the build flags and are comfortable configuring the NDK and linker paths yourself. Use cargo-ndk when you want a streamlined workflow that handles NDK detection, sysroot setup, and target configuration automatically. Use aarch64-linux-android when targeting modern physical Android devices, as this covers the vast majority of current hardware. Use x86_64-linux-android when testing on the Android Studio emulator, which runs on Intel or AMD hosts. Use armv7-linux-androideabi only when you must support legacy 32-bit devices, which is rare in modern app development. Reach for cdylib when you are building a library to be loaded by an Android app via JNI or NDK. Reach for bin when you are building a standalone executable for an Android environment that supports running binaries directly, such as a Linux-on-Android container.

Treat the target triple as a contract. If you get one character wrong, the binary is garbage. Verify your triple matches your device architecture before shipping.

Where to go next