Skip to content
Rust & NativeRust y nativo

The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APKLa configuración completa de la cadena de herramientas de Rust para Android: rustup, cargo-ndk y tu primer .so en un APK

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 23 July 2026Actualizado el 23 de julio de 202610 min read10 min de lectura
AndroidRustNDKcargo-ndkToolchainCross-Compilation
The Complete Rust Toolchain Setup for Android: rustup, cargo-ndk, and Your First .so in an APK
Share:Compartir:

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.

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.

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).

The four pieces

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. 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 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

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 small. 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 JNI lookup.

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.

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.

Everything from Part 2 onwards assumes exactly this setup and nothing more. Part 2, Calling Rust from Kotlin: JNI Basics for Android Developers, covers the long Java_dev_davthecoder_rustseries_RustHello_greeting name, the JNIEnv argument, and what happens to a Kotlin String when it crosses into Rust. 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

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.

La configuración completa de Rust con el NDK de Android son cuatro instalaciones y un comando. rustup trae el compilador y los cuatro targets de Android, sdkmanager instala el NDK, y cargo install cargo-ndk añade el pegamento entre ellos. A partir de ahí, un solo cargo ndk build compila tu crate de forma cruzada directo a la carpeta que el Android Gradle Plugin empaqueta en el APK.

Al terminar este artículo tendrás una función de Rust dentro de un APK real, llamada desde Kotlin, con el .so resultante verificado hasta sus símbolos exportados y su alineación de página de 16 KB. Los únicos requisitos previos son un proyecto de Android normal y el SDK de Android. No hace falta que hayas escrito una línea de Rust antes. Si todavía estás decidiendo si Rust merece un sitio en tu pila, el artículo del hub de la serie plantea ese caso con las cifras publicadas por Google. Este artículo es solo el cómo.

Ideas clave

  • Toda la configuración son cuatro piezas: rustup con cuatro targets de Android, el NDK por su enlazador y su sysroot, cargo-ndk para unirlos, y un crate cdylib. No hace falta nada más para conseguir un .so que funcione.
  • Un cargo build --target aarch64-linux-android a secas falla en un portátil porque rustc entrega flags de enlazador ELF al enlazador del host. cargo-ndk existe para apuntar cargo al clang del NDK de cada target.
  • Una sola invocación de cargo ndk compila las cuatro ABI y copia las bibliotecas a app/src/main/jniLibs, exactamente donde ya mira el Android Gradle Plugin, sin cambios en el build.gradle.
  • El NDK r28 alinea los ficheros .so a páginas de 16 KB por defecto, y eso importa porque Google Play exige soporte de 16 KB en apps nuevas y actualizaciones que apunten a Android 15 o superior (Android Developers).

Las cuatro piezas

Cuatro piezas, de dos fabricantes que no se hablan entre sí. Rust publica el soporte de Android como plataforma de Tier 2 con seis targets (rustc book), así que rustup cubre el lado del compilador de serie. Lo que rustc no puede hacer solo es enlazar la biblioteca compartida final: un .so de Android es un binario ELF enlazado contra bionic, la libc de Android, y ese enlazador y ese sysroot viven dentro del NDK. El equipo del NDK de Google ha dicho que no hay previsto soporte de Rust de primera parte para apps; la cadena de herramientas de rustup es la vía recomendada, como cuento en el artículo del hub.

El reparto: rustup aporta rustc y las bibliotecas estándar de Android, el NDK aporta clang y el sysroot, y cargo-ndk conecta el binario correcto del NDK con el target de Rust correcto. La cuarta pieza es tu propio crate, compilado como cdylib, el tipo de crate de Rust para una biblioteca compartida compatible con C.

Hay un target de Rust por cada ABI de Android, y los nombres no coinciden, lo que confunde a todo el mundo exactamente una vez:

Triple de target de Rust ABI de Android Dónde corre
aarch64-linux-android arm64-v8a prácticamente todos los móviles actuales
armv7-linux-androideabi armeabi-v7a dispositivos de 32 bits más antiguos
x86_64-linux-android x86_64 el emulador en hosts Intel y AMD
i686-linux-android x86 imágenes antiguas de emulador de 32 bits

cargo-ndk acepta los nombres de ABI de Android y los traduce, así que después de esta tabla puedes volver a pensar casi siempre en ABI.

Instalar rustup, los targets de Android y el NDK

Si Rust todavía no está en tu máquina, el instalador oficial es un comando (rustup.rs):

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

Luego añade los cuatro targets de Android, lo que descarga una biblioteca estándar de Rust precompilada para cada uno, no un compilador nuevo:

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

El NDK sale de sdkmanager (Android Developers). Fija una versión exacta en lugar de instalar «el NDK»: cada release enlaza los binarios de otra forma, y todas las máquinas del equipo deberían compilar con la misma. En toda esta serie uso r28.2:

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

En macOS el SDK vive en ~/Library/Android/sdk y sdkmanager bajo cmdline-tools/latest/bin; en Linux la ruta del SDK suele ser ~/Android/Sdk. El export de ANDROID_NDK_HOME es cómo cargo-ndk encuentra la cadena de herramientas. Por último, cargo-ndk se instala como cualquier herramienta de cargo:

cargo install cargo-ndk

Esa es toda la cadena de herramientas. A partir de aquí todo es un proyecto de Rust normal.

El primer crate: una función exportada en un cdylib

La disposición de toda la serie es un módulo de app Android más un workspace de cargo con crates de Rust al lado:

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

La raíz del workspace, en rust/Cargo.toml, solo lista sus miembros:

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

El manifiesto del crate, en rust/hello/Cargo.toml, es donde vive la decisión específica de Android:

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

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

[dependencies]
jni = "0.21"

cdylib le dice a cargo que produzca una biblioteca compartida compatible con C, libhello.so, con el nombre del paquete. El lib extra mantiene también la biblioteca de Rust ordinaria, que es lo que permite que cargo test enlace contra el crate en tu portátil. El crate jni es el puente con la JVM; fijo la 0.21 porque es el estilo de API que usan prácticamente todos los ejemplos y el código de producción que hay.

El rust/hello/src/lib.rs completo:

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!");
    }
}

Dos funciones, y el corte entre ellas es el patrón sobre el que se construye toda la serie. greeting es Rust a secas: sin tipos de JNI, testeable en cualquier sitio. La función larga Java_dev_davthecoder_rustseries_RustHello_greeting es el adaptador JNI: su nombre codifica el paquete, la clase y el método de Kotlin con los que se va a casar, #[no_mangle] impide que el compilador renombre el símbolo, y extern "system" le da la convención de llamada que espera la JVM. Cómo se resuelve ese nombre, y qué te entrega JNIEnv, es toda la parte 2. Por hoy, trata el adaptador como ceremonia. La lógica vive en una función que puedes probar sin Android a la vista:

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

Esa prueba corrió de forma nativa en mi portátil, en milisegundos, sin arrancar un emulador. Mantener el núcleo testeable en el host es la diferencia entre este flujo y el desarrollo clásico en C++ con el NDK.

Por qué cargo a secas falla, y qué hace de verdad cargo-ndk

Es razonable preguntarse por qué cargo no puede compilar para Android directamente, si el target ya está instalado. Pruébalo:

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

En un Mac, la compilación sale bien y luego el enlace revienta:

  = 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

rustc compiló tu código a objetos ARM64 sin quejarse, luego invocó cc para enlazarlos, y cc en macOS es el enlazador de Apple. Flags como --version-script y --eh-frame-hdr son flags de enlazador ELF; el ld de Apple enlaza Mach-O y no ha oído hablar de ellas. El compilador nunca fue el problema. Lo que falta es un enlazador que produzca ELF para Android, y ese enlazador viene dentro del NDK como un conjunto de wrappers de clang específicos de cada target.

Podrías arreglarlo a mano, definiendo CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER y compañía para cada target, y manteniendo las rutas al día con cada actualización del NDK. cargo-ndk existe para que nadie tenga que hacer eso. Localiza el NDK mediante ANDROID_NDK_HOME (y si no, cae al directorio de tu SDK), mapea cada nombre de ABI a su triple de Rust, apunta cargo al wrapper de clang del NDK que corresponde a tu nivel de API (21 por defecto, se cambia con --platform), define CC y AR para los crates con scripts de build en C, ejecuta la compilación y copia cada .so a un subdirectorio con el nombre de la ABI dentro de la ruta de salida (cargo-ndk).

Compilar las cuatro ABI en jniLibs

Un comando, lanzado desde el directorio rust/, compila cada ABI y deja las bibliotecas donde Gradle las espera:

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

La cola de la salida real:

   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

Eso produce exactamente cuatro ficheros: app/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}/libhello.so. El directorio jniLibs es especial: el Android Gradle Plugin empaqueta todo lo que hay dentro en la sección de bibliotecas nativas del APK, sin configuración alguna. Compila tu app ahora y el Rust ya está dentro. Enganchar este comando a Gradle es el trabajo de la parte 4; mientras iteras, lanzarlo a mano está bien.

Cargar la biblioteca desde Kotlin

El lado Kotlin es pequeño. El app/src/main/java/dev/davthecoder/rustseries/RustHello.kt completo:

package dev.davthecoder.rustseries

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

    external fun greeting(name: String): String
}

System.loadLibrary("hello") encuentra el libhello.so de la ABI del dispositivo dentro del APK; el prefijo lib y el sufijo .so se añaden solos. Como la llamada está en el bloque init de un object, se ejecuta exactamente una vez, la primera vez que algo toca RustHello. La palabra clave external es el native de Kotlin: una declaración sin cuerpo, que se resuelve contra las bibliotecas nativas cargadas en la primera llamada. Fíjate en que el paquete y el nombre del object coinciden con el símbolo exportado en lib.rs. Esa coincidencia es la búsqueda de JNI.

Demostrar que funciona exige una línea de log. El app/src/main/java/dev/davthecoder/rustseries/MainActivity.kt completo:

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"))
    }
}

Ejecuta la app y logcat imprime Hello from Rust, Android!, la misma cadena que afirmó el test unitario en el host. La misma función, ahora ejecutándose como código ARM64 nativo dentro de tu proceso.

Verificar el .so antes de fiarte de él

Nunca publico una biblioteca nativa que no haya inspeccionado, y el NDK trae las binutils de LLVM para hacerlo, bajo $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin en macOS (linux-x86_64 en Linux). Dos comprobaciones se pagan solas al momento.

Primero, confirma que el símbolo JNI está de verdad exportado. llvm-nm -gD lista los símbolos dinámicos que otro código puede ver:

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

La T significa que el símbolo vive en la sección de texto y está exportado. Si falta esta línea, System.loadLibrary seguirá teniendo éxito, y la primera llamada a greeting lanzará UnsatisfiedLinkError. La causa es casi siempre un #[no_mangle] olvidado o una errata en el nombre largo de la función, y este comando te lo dice antes incluso de haber instalado el APK.

Segundo, comprueba la alineación de página del ELF:

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

La última columna es la alineación de cada segmento LOAD: 0x4000 son 16384 bytes, así que cada segmento de aquí está alineado a 16 KB. Eso es el valor por defecto del NDK r28 haciendo su trabajo, e importa porque Google Play exige soporte de páginas de 16 KB en apps nuevas y actualizaciones que apunten a Android 15 o superior (Android Developers). Compila con un NDK actual y Rust te da el cumplimiento gratis; compila con uno antiguo y este mismo comando muestra 0x1000. La parte 13, Tamaño del binario y el requisito de páginas de 16 KB, cuenta la historia completa.

Modos de fallo habituales y cómo leerlos

Cuatro fallos explican casi todas las primeras configuraciones rotas que he visto.

rustc informa del error E0463, no encuentra el crate core. El error nombra el target para el que no puede compilar. Significa que la biblioteca estándar de Rust de ese target no está instalada: ejecuta rustup target add con el triple del mensaje. Esto muerde sobre todo a las máquinas de CI recién aprovisionadas.

cargo-ndk no encuentra un NDK. Busca primero ANDROID_NDK_HOME y luego el directorio del SDK. Si hay varias versiones del NDK instaladas, no dejes que adivine: exporta ANDROID_NDK_HOME con la versión fijada, como hace el comando de compilación de arriba.

La compilación sale bien pero no aparece ningún .so. Revisa crate-type en el Cargo.toml. Sin cdylib en la lista, cargo compila encantado un rlib, que es un formato interno de Rust, y no llega nada a jniLibs.

UnsatisfiedLinkError en tiempo de ejecución. Dos formas distintas. Si lo lanza System.loadLibrary, el APK no tiene un libhello.so para la ABI del dispositivo, normalmente porque se saltó un target durante la compilación. Si lo lanza la primera llamada al external fun, la biblioteca cargó pero la búsqueda del símbolo falló: vuelve a lanzar la comprobación de llvm-nm y compara el símbolo con el paquete, la clase y el método de Kotlin, carácter a carácter.

Preguntas frecuentes

¿Necesito Android Studio para compilar Rust para Android?

No. La compilación necesita las herramientas de línea de comandos del SDK, el NDK, rustup y cargo-ndk, y todas corren sin interfaz; esa es también toda la historia de la CI. En la práctica vas a tener Android Studio igualmente para el lado de la app, y la interfaz del SDK Manager instala el NDK igual de bien.

¿Puedo saltarme los targets de Android de 32 bits?

A menudo, sí. Google Play exige soporte de 64 bits desde 2019, y arm64-v8a cubre prácticamente todos los dispositivos actuales (Android Developers). Quédate armeabi-v7a solo si tus datos de instalación muestran dispositivos de 32 bits de verdad, y quédate x86_64 para el emulador. Menos targets también significan un APK más pequeño y compilaciones más rápidas.

Todo a partir de la parte 2 asume exactamente esta configuración y nada más. La parte 2, Llamar a Rust desde Kotlin: fundamentos de JNI para quien desarrolla en Android, cubre el nombre largo Java_dev_davthecoder_rustseries_RustHello_greeting, el argumento JNIEnv y qué le pasa a un String de Kotlin cuando cruza a Rust. Si quieres primero el mapa más amplio, el artículo del hub cubre la depuración y el argumento a favor del propio Rust, y mi guía de C++ en Android es la misma historia de cadena de herramientas para el otro lenguaje nativo.

Fuentes

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…