Why Native Code Works on Android
If you have been developing for Android long enough, you have probably encountered crashes that only occur on specific devices, or wondered “how is this even possible?” when calling C++ code via System.loadLibrary("native-lib").
In this post, we will explore how Java/Kotlin code and C/C++ native code can work together on Android. The answer lies in the ABI (Application Binary Interface).
Note that Google Play has mandated 64-bit support. All apps that include native code must provide a 64-bit version.
Starting with a Simple Experiment
Have you ever seen code like this?
// MainActivity.kt
class MainActivity : AppCompatActivity() {
companion object {
init {
System.loadLibrary("native-lib")
}
}
external fun stringFromJNI(): String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val text = stringFromJNI() // Calling a C++ function!
Log.d("ABI", text)
}
}
// native-lib.cpp
#include <jni.h>
#include <string>
// JNIEXPORT: __attribute__((visibility("default"))) - exposes the symbol externally
// JNICALL: handles platform-specific calling conventions
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_myapp_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
Kotlin code is calling a C++ function. How on earth is this possible?
The answer lies in the ABI.
So What Exactly Is an ABI?
Let us start with something familiar. You have probably heard of API (Application Programming Interface). In simple terms, it is a source-code-level contract that says “call this function like this.”
// A contract at the API level
fun calculateSum(a: Int, b: Int): Int {
return a + b
}
But what happens when this code gets compiled? The CPU has absolutely no knowledge of Kotlin or Java. It only understands machine code made up of 0s and 1s.
; Transformed into ARM64 assembly
add x0, x0, x1 ; x0 = x0 + x1
ret ; return the value in register x0
This raises some important questions:
- Which registers do the parameters
aandbgo into? - Where is the return value stored?
- How is the stack aligned?
The ABI is what defines all of these rules.
Why Do Different Devices Need Different Libraries?
Here is an interesting experiment.
int add(int a, int b) {
return a + b;
}
What happens when we compile this exact same addition function for different Android devices?
ARM32 (armeabi-v7a) - Most older Android devices
; ARM AAPCS: r0-r3 for argument passing, r4-r11 preserved, r12 scratch
; 32-bit integer addition
add r0, r0, r1 ; r0 = r0 + r1 (first argument + second argument)
bx lr ; return via lr (Link Register), r0 is the return value
ARM64 (arm64-v8a) - Most current Android smartphones
; AAPCS64: x0-x7 for arguments/return, x8 indirect result, x9-x15 temporaries
; 32-bit int uses w registers (lower 32 bits of x registers)
add w0, w0, w1 ; w0 = w0 + w1 (32-bit integer addition)
ret ; return to the address in x30 (lr), w0 is the return value
x86 (32-bit) - Older Android tablets, emulators
; System V i386 ABI (cdecl): all arguments passed via the stack
; Stack: [return address][first argument][second argument]...
mov eax, [esp+4] ; load first argument (a) into eax
add eax, [esp+8] ; add second argument (b)
ret ; return via eax, caller cleans up the stack
x86_64 (64-bit) - Modern Android emulators, Chrome OS
; System V AMD64 ABI: first 6 arguments passed via rdi, rsi, rdx, rcx, r8, r9
; 32-bit integers use the lower 32 bits (edi, esi)
add edi, esi ; edi = edi + esi (32-bit addition)
mov eax, edi ; copy result to eax (return value)
ret ; return via the lower 32 bits of rax (eax)
Remarkably, each CPU architecture calls functions in a completely different way!
- ARM: Register-centric (uses r0-r3 or x0-x7)
- x86 (32-bit): Stack-centric (all arguments via the stack)
- x86_64: Registers first (first 6 arguments via registers)
This is exactly why you see folders like lib/armeabi-v7a/, lib/arm64-v8a/, lib/x86/, and lib/x86_64/ inside an Android APK. Because the “machine code” and “calling conventions” understood by each CPU architecture are completely different, you need native libraries (.so files) compiled specifically for each one.
Android’s ABIs
Android provides multiple ABIs to support a wide range of devices. Here is the current status:
| ABI | Architecture | Status | Market Share |
|---|---|---|---|
| arm64-v8a | 64-bit ARM | Required | ~95% |
| armeabi-v7a | 32-bit ARM | Legacy support | ~5% |
| x86_64 | 64-bit Intel | Emulator | <1% |
| Deprecated | - |
1. armeabi-v7a (32-bit ARM)
A 32-bit ARM architecture still used to support legacy devices.
// Checking the characteristics of this ABI in code
fun checkArmeabiV7a() {
// Key characteristics:
// - 32-bit pointers (4 bytes)
// - 16 general-purpose registers (r0-r15)
// - VFPv3 floating-point support
// - NEON SIMD instruction support
val pointerSize = 4 // bytes
val maxMemory = 4_294_967_296L // 4GB (2^32)
}
Actual memory layout (typical example)
[Low Memory]
0x00000000: Reserved
0x08048000: Text Segment (code)
0x10000000: Data Segment (global variables)
0x40000000: Heap (dynamic allocation)
0xBFFFFFFF: Stack (function calls)
[High Memory]
2. arm64-v8a (64-bit ARM)
The architecture used by the vast majority of modern Android devices today.
// Demonstrating the pointer size difference
struct VideoFrame {
void* buffer; // 32-bit: 4 bytes, 64-bit: 8 bytes
size_t size; // 32-bit: 4 bytes, 64-bit: 8 bytes
int64_t timestamp; // 8 bytes on both
};
// Size of this struct on 32-bit: 16 bytes
// Size of this struct on 64-bit: 24 bytes!
Why does this difference occur? It is because of padding.
// 32-bit memory alignment
struct VideoFrame_32 {
void* buffer; // bytes 0-3
size_t size; // bytes 4-7
int64_t timestamp; // bytes 8-15
}; // Total: 16 bytes
// 64-bit memory alignment
struct VideoFrame_64 {
void* buffer; // bytes 0-7
size_t size; // bytes 8-15
int64_t timestamp; // bytes 16-23
}; // Total: 24 bytes
3. x86 / x86_64 (Intel Architecture)
Primarily used in emulators and some tablets.
// What makes x86 special: stack-based calling conventions
external fun processData(a: Int, b: Int, c: Int, d: Int): Int
// ARM: first 4 arguments passed via registers
// x86: all arguments passed via the stack (32-bit)
// x86_64: first 6 arguments passed via registers
Name Mangling
How does C++ support function overloading?
// C++ code
class Calculator {
public:
int add(int a, int b);
float add(float a, float b);
double add(double a, double b);
};
The compiler uses Name Mangling to distinguish between these functions.
// Mangled names (vary by compiler)
_ZN10Calculator3addEii // int add(int, int)
_ZN10Calculator3addEff // float add(float, float)
_ZN10Calculator3addEdd // double add(double, double)
Breaking down each part:
_Z: Start of a C++ mangled nameN: Start of a nested name10Calculator: 10-character class name “Calculator”3add: 3-character method name “add”E: End of nested nameii/ff/dd: Parameter types (int/float/double)
This is exactly why JNI uses extern "C".
// Preventing Name Mangling
extern "C" {
JNIEXPORT jstring JNICALL
Java_com_example_MainActivity_getString(JNIEnv* env, jobject obj) {
// Without extern "C", this function name would be mangled
// and Java would not be able to find it!
}
}
ELF and the System V ABI
Android’s native libraries follow the ELF (Executable and Linkable Format). Let us analyze an actual .so file.
# Analyzing a .so file
$ readelf -h libnative-lib.so
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 # ELF signature
Class: ELF64 # 64-bit
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V # System V ABI!
Machine: AArch64 # ARM64
ELF file structure
+------------------+
| ELF Header | File type, architecture, entry point
+------------------+
| Program Headers | Runtime segment information
+------------------+
| .text | Executable code
+------------------+
| .data | Initialized global variables
+------------------+
| .bss | Uninitialized global variables
+------------------+
| .dynamic | Dynamic linking information
+------------------+
| .symtab | Symbol table
+------------------+
| Section Headers | Section metadata
+------------------+
The System V ABI defines the standards for all of these:
- Data type sizes and alignment
- Function calling conventions
- Register usage rules
- Stack frame layout
- Exception handling mechanisms
In Practice: Supporting Multiple ABIs
Let us look at how to support multiple ABIs in a real project.
1. Gradle Configuration
android {
defaultConfig {
ndk {
// Recommended: remove x86, prioritize 64-bit
abiFilters 'arm64-v8a', 'armeabi-v7a'
}
}
// Per-ABI APK splits (APK size optimization)
splits {
abi {
enable true
reset()
include 'armeabi-v7a', 'arm64-v8a'
universalApk false // Whether to generate an APK containing all ABIs
}
}
}
2. CMake Configuration
# CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project("native-lib")
# Use C++20 standard
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Per-ABI optimization
if(${ANDROID_ABI} STREQUAL "arm64-v8a")
# ARM64: NEON mandatory, advanced optimizations
# Note: +crypto may cause SIGILL on some older ARM64 SoCs (e.g. Snapdragon 410). Consider runtime feature detection.
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -march=armv8-a+crc+crypto")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -ftree-vectorize")
elseif(${ANDROID_ABI} STREQUAL "armeabi-v7a")
# ARM32: NEON optional, compatibility-focused
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O2 -mfpu=neon-vfpv4")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -mfloat-abi=softfp")
endif()
# Security hardening
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fstack-protector-strong")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_FORTIFY_SOURCE=2")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,relro -Wl,-z,now")
add_library(native-lib SHARED native-lib.cpp)
3. Runtime ABI Detection
class AbiManager {
fun getCurrentAbi(): String {
return Build.SUPPORTED_ABIS[0] // Primary ABI
}
fun loadOptimizedLibrary() {
when (getCurrentAbi()) {
"arm64-v8a" -> {
System.loadLibrary("native-lib-arm64")
Log.d("ABI", "Loaded 64-bit optimized library")
}
"armeabi-v7a" -> {
System.loadLibrary("native-lib-arm32")
Log.d("ABI", "Loaded 32-bit library")
}
else -> {
System.loadLibrary("native-lib")
Log.d("ABI", "Loaded default library")
}
}
}
fun checkAbiCompatibility() {
val primaryAbi = Build.SUPPORTED_ABIS[0]
// Available only on API 21+
val all32BitAbis = Build.SUPPORTED_32_BIT_ABIS
val all64BitAbis = Build.SUPPORTED_64_BIT_ABIS
Log.d("ABI", """
Primary ABI: $primaryAbi
32-bit ABIs: ${all32BitAbis.joinToString()}
64-bit ABIs: ${all64BitAbis.joinToString()}
""".trimIndent())
}
}
ABI-Specific Considerations
Leveraging NEON SIMD (ARM)
#ifdef __ARM_NEON__
#include <arm_neon.h>
void processAudio_NEON(float* samples, int count, float gain) {
// Process 4 elements in parallel (128-bit NEON registers)
// Actual performance improvement: 1.5-3x (limited by memory bandwidth)
for (int i = 0; i < count; i += 4) {
// Prefetch to reduce cache misses
__builtin_prefetch(&samples[i + 16], 0, 1);
float32x4_t vec = vld1q_f32(&samples[i]); // Load 4 elements
vec = vmulq_f32(vec, vdupq_n_f32(gain)); // Multiply 4 simultaneously
vst1q_f32(&samples[i], vec); // Store 4 elements
}
}
#else
void processAudio_Standard(float* samples, int count, float gain) {
// Standard processing
for (int i = 0; i < count; i++) {
samples[i] *= gain;
}
}
#endif
64-bit Optimization
// Taking advantage of 64-bit
void processLargeData(uint8_t* data, size_t size) {
#ifdef __LP64__ // 64-bit environment
// Utilize the larger address space
// More registers available (ARM64: 31 64-bit general-purpose registers vs ARM32: 16)
// Process 8 bytes at a time (64-bit registers)
uint64_t* ptr = (uint64_t*)data;
size_t count = size / 8;
for (size_t i = 0; i < count; i++) {
ptr[i] = processChunk(ptr[i]);
}
#else // 32-bit environment
// Process 4 bytes at a time
uint32_t* ptr = (uint32_t*)data;
size_t count = size / 4;
for (size_t i = 0; i < count; i++) {
ptr[i] = processChunk(ptr[i]);
}
#endif
}
Debugging in Practice
1. UnsatisfiedLinkError
// A common error
java.lang.UnsatisfiedLinkError:
dlopen failed: "/data/app/.../lib/arm/libnative.so"
is 32-bit instead of 64-bit
Solution
class NativeLibLoader {
companion object {
fun loadLibrarySafely(libName: String) {
try {
System.loadLibrary(libName)
} catch (e: UnsatisfiedLinkError) {
// Handle ABI mismatch
Log.e("ABI", "Library load failed: ${e.message}")
// Note: This fallback pattern is illustrative. Android's built-in library loading
// already handles ABI selection automatically via the lib/<abi>/ directory structure.
val abi = Build.SUPPORTED_ABIS[0]
System.loadLibrary("$libName-$abi")
}
}
}
}
2. Crash Debugging
// Collecting ABI information per architecture for crash debugging
extern "C" JNIEXPORT void JNICALL
Java_com_example_CrashHandler_collectAbiInfo(JNIEnv* env, jobject obj) {
#ifdef __arm__
__android_log_print(ANDROID_LOG_INFO, "ABI", "ARM32 detected");
#endif
#ifdef __aarch64__
__android_log_print(ANDROID_LOG_INFO, "ABI", "ARM64 detected");
#endif
#ifdef __i386__
__android_log_print(ANDROID_LOG_INFO, "ABI", "x86 detected");
#endif
#ifdef __x86_64__
__android_log_print(ANDROID_LOG_INFO, "ABI", "x86_64 detected");
#endif
// Check pointer size
__android_log_print(ANDROID_LOG_INFO, "ABI",
"Pointer size: %zu bytes", sizeof(void*));
// Check alignment
__android_log_print(ANDROID_LOG_INFO, "ABI",
"Alignment of long: %zu", alignof(long));
}
APK Size Optimization Strategies
1. Using App Bundles
// build.gradle
android {
bundle {
abi {
// Exclude specific ABIs
enableSplit = true
}
}
}
When you use Google Play’s App Bundle, only the ABI matching each user’s device is selectively delivered. In practice, applying this approach reduces the install size of apps containing native libraries by an average of 20-35%, shortens download times, and saves users’ storage space.
2. Per-ABI Resource Optimization
class ResourceOptimizer {
fun loadOptimizedResource() {
val isArm64 = Build.SUPPORTED_ABIS[0] == "arm64-v8a"
if (isArm64) {
// 64-bit devices are typically high-performance
loadHighQualityAssets()
} else {
// 32-bit devices are likely lower-spec
loadOptimizedAssets()
}
}
}
Why Should You Know About Android ABI?
Let us return to the original question. Why should we care about Android ABI?

The truth is, most Android developers can build apps without knowing about ABI. But when you encounter an error like UnsatisfiedLinkError: is 32-bit instead of 64-bit, when you are asked to reduce your app’s size, or when crashes occur only on specific devices, you will be stuck without ABI knowledge.
For example, imagine you are developing a camera filter app. You are using a C++-based filter engine for image processing, and suddenly the operations team reports that filters are slow only on a specific Samsung Galaxy device. After debugging, you discover the device was running in 32-bit mode and the native library had not been compiled with NEON SIMD support enabled for that ABI, causing it to process pixels one by one sequentially. (Note that NEON is available on ARMv7 via #ifdef __ARM_NEON__ – the issue is not that 32-bit cannot use NEON, but that the build configuration must explicitly enable it.) Or what if you are building a voice recognition SDK and audio processing stutters on a low-cost Chinese tablet? It is most likely due to the limitations of the 32-bit x86 architecture.
The APK size issue is also something you cannot ignore. A universal APK that includes all ABIs bundles the native libraries for every architecture, making it larger. However, by leveraging Google Play’s App Bundle, users only download the ABI matching their device, reducing data usage as well.
Above all, the moment you use native libraries, ABI becomes an unavoidable topic. To integrate libraries like OpenCV, FFmpeg, or TensorFlow Lite, you need .so files built for each ABI. Without understanding ABI, even basic questions like “why do we need an arm64-v8a folder” or “why does the same library have different sizes” become difficult to answer.
Wrapping Up
ABI is the key mechanism that makes our apps work correctly behind the scenes. The ability of Java/Kotlin code to communicate with native code, and the ability of apps to run on a wide variety of devices, are all thanks to ABI.
It may seem complex at first, but once you understand it, you unlock a deeper level of optimization and problem-solving. This is especially true in fields that demand high performance, such as games, media processing, and AI inference, where ABI knowledge is essential.
The next time you configure abiFilters or write a JNI function, you will fully understand what is happening under the hood.
Code ultimately runs on the CPU, and ABI defines the rules for that execution.