AOSP Expert & Production Engineering
3 min read

ELF & Dynamic Linking

ELF File Structure: Sections vs Segments

The Executable and Linkable Format (ELF) is the standard file format for executables, object code, shared libraries, and core dumps in Android. An ELF file has two parallel views:

  1. Linking View (Sections): Used by the compiler and linker. Sections categorize data logically (e.g., .text for code, .rodata for read-only data, .bss for uninitialized data).
  2. Execution View (Segments): Used by the OS loader. Multiple sections with the same permissions are grouped into a single segment (Program Header) to minimize the number of mmap calls required during loading. For instance, .text and .rodata might be packed into a single read-execute segment.

You can inspect ELF files in the Android NDK using readelf:

readelf -l <binary> # View segments
readelf -S <binary> # View sections

PLT and GOT

Shared libraries (.so) are loaded at unpredictable memory addresses due to ASLR (Address Space Layout Randomization). To resolve external function calls without modifying the read-only code segment, ELF uses the Procedure Linkage Table (PLT) and Global Offset Table (GOT).

  • GOT (Global Offset Table): A data section containing absolute addresses of global variables and functions.
  • PLT (Procedure Linkage Table): A code section consisting of small trampolines.

When a program calls an external function (e.g., printf), it actually calls the printf@PLT stub. The stub jumps to the address stored in the GOT. Initially, the GOT points back to the dynamic linker. The linker resolves the true address of printf, overwrites the GOT entry, and calls the function. Subsequent calls jump directly to the resolved address, bypassing the linker.

Dynamic Linking Process

When an Android app starts, the kernel maps the executable into memory and transfers control to the dynamic linker (/system/bin/linker or linker64).

The dynamic linker performs several crucial steps:

  1. Parses the executable's DT_NEEDED entries to find required shared libraries (e.g., libc.so, liblog.so).
  2. Locates these libraries in the filesystem (resolving paths via LD_LIBRARY_PATH and linker namespaces).
  3. Maps the libraries into memory using mmap.
  4. Performs relocations, populating the GOT with correct memory addresses.
  5. Executes the .init and .init_array constructors of the libraries.
  6. Transfers control to the executable's entry point (main or _start).

Symbol Visibility and Versioning

In large projects like AOSP, leaking internal symbols from shared libraries causes namespace pollution, increases load times, and creates ABI compatibility nightmares.

Android enforces strict symbol visibility. By default, developers use compiler flags (-fvisibility=hidden) to hide all symbols. Only symbols explicitly marked with __attribute__((visibility("default"))) are exported.

Furthermore, AOSP utilizes symbol versioning. Libraries define map files (e.g., libfoo.map) that specify exactly which symbols are exposed and assign them version tags. This ensures that system updates do not break apps compiled against older library ABIs.

// Explicitly exporting a symbol in C++
__attribute__((visibility("default"))) void public_api() {
    // ...
}