Chapter 34: libc, dynamic linking, and the loader¶
What: the C library that user-space programs link against (glibc, musl, uClibc-ng), the ELF dynamic-linker that resolves shared-library references at runtime (
/lib/ld-linux-armhf.so.3), and the bookkeeping (PLT, GOT,LD_LIBRARY_PATH,RPATH) that makeshello-worldactually findprintf. ELF: Executable and Linkable Format, the standard Linux object and executable file format.Why: every dynamically-linked program on the target depends on this machinery. When it works it’s invisible. When it breaks you get
No such file or directoryon a file that does exist. Knowing what the loader does demystifies these failures.Focus: ld-linux’s job. When the kernel
execs a dynamically-linked program, the first thing that runs is notmain(), it’s the dynamic linker, which loads every required shared library, fixes up addresses, and only then jumps to your code. Once you’ve traced this sequence you can debug mostlibfoo.so.X: cannot open shared object fileproblems.
34.1 Three C libraries¶
Embedded Linux lets you pick the C library. On a desktop you get glibc and nothing else.
glibc |
musl |
uClibc-ng |
|
|---|---|---|---|
Origin |
GNU project, ~1988 |
Rich Felker, ~2011 |
Fork of uClibc, 2014 |
License |
LGPL-2.1 |
MIT |
LGPL-2.1 |
Static linking |
discouraged (NSS won’t work) |
first-class |
first-class |
Static-linked “hello world” |
~700 KB |
~30 KB |
~50 KB |
Dynamic-linked “hello world” |
8 KB + ~2 MB libs |
8 KB + 600 KB libs |
8 KB + 700 KB libs |
Feature completeness |
most complete |
very complete (POSIX, most GNU exts) |
complete enough for embedded |
Performance |
optimized aggressively |
conservative, predictable |
adequate |
Used in mainstream |
Debian, Ubuntu, Fedora |
Alpine, Void |
OpenWrt, Buildroot |
For embedded Linux musl is the default. Reasons:
One sixth the size of glibc for the same program.
Static linking actually works (no NSS / dlopen surprises).
Tighter, more predictable behavior (no surprise heuristics).
MIT-licensed (LGPL is fine for most people but not all).
glibc remains right for: anything that ships large dynamic apps (Qt, Python, Java), or any rootfs that’s a distro derivative (Ubuntu-base / Debian).
MCU bridge: Think of the rootfs as the firmware image’s file-backed runtime environment. On an MCU you link everything into flash. On Linux, programs and config live in this mounted tree. rootfs: root filesystem, the directory tree mounted at / that contains /bin, /etc, /dev, and libraries.
34.2 Anatomy of a dynamically-linked ELF¶
Compile a trivial program against glibc:
$ cat > hello.c <<'EOF'
#include <stdio.h>
int main(void) { puts("hello"); return 0; }
EOF
$ arm-none-linux-gnueabihf-gcc -o hello hello.c
$ file hello
hello: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux-armhf.so.3, ...
Two things to notice from file:
dynamically linked: references shared libraries that aren’t part of the binary.
interpreter /lib/ld-linux-armhf.so.3: when the kernel
execs this binary, it actually runs the interpreter first, passing the binary as an argument.
That second point is the heart of dynamic linking and worth understanding precisely.
The ELF program headers¶
$ arm-none-linux-gnueabihf-readelf -l hello | head -25
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x4c0
There are 9 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x00000034 0x00000034 0x00120 0x00120 R 0x4
INTERP 0x000154 0x00000154 0x00000154 0x00019 0x00019 R 0x1
[Requesting program interpreter: /lib/ld-linux-armhf.so.3]
LOAD 0x000000 0x00000000 0x00000000 0x004f4 0x004f4 R 0x10000
LOAD 0x000ed4 0x00010ed4 0x00010ed4 0x0011c 0x0011c RW 0x10000
DYNAMIC 0x000eec 0x00010eec 0x00010eec 0x000d0 0x000d0 RW 0x4
...
The INTERP segment is a single string: /lib/ld-linux-armhf.so.3. The kernel reads this string out of the ELF, opens that file, and execs it, passing your hello as a regular argument to the linker. The dynamic linker is the real program PID-wise. Your hello is its workload.
What the linker does¶
ld-linux-armhf.so.3 runs and:
Reads
hello’s DYNAMIC segment. This contains a table of needed libraries:$ arm-none-linux-gnueabihf-readelf -d hello | head 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000001d (RUNPATH) Library runpath: [/path/to/runtime/libs] (if set) ...
Resolves each NEEDED library. Search order:
LD_LIBRARY_PATHenvironment variable (colon-separated)RPATH/RUNPATHfrom the ELF/etc/ld.so.cache(built byldconfig)/lib,/usr/lib(default fallback)
mmaps each.sointo memory at addresses chosen for that process (ASLR shuffles them if enabled).Repeats recursively: Libc may need other libraries (pthread, ld, …).
Performs symbol resolution. For every undefined symbol in
helloand the loaded libraries, find the defining library. Rewrite the address tables (PLT / GOT) so that calls go to the right place.Calls the program’s entry point (
_start, which eventually callsmain).
All of that happens before your main() runs. On embedded i.MX6ULL hardware, dynamic linking adds ~5-50 ms to startup depending on how many libraries are pulled in.
34.3 PLT and GOT¶
The PLT and GOT are the two tables that make dynamic linking efficient.
GOT, Global Offset Table¶
The GOT holds runtime addresses of every external symbol the program references. The compiler emits LOAD r0, [GOT+offset] instead of a fixed address.
$ arm-none-linux-gnueabihf-readelf -r hello
Relocation section '.rel.dyn' at offset 0x444 contains 4 entries:
Offset Info Type Sym.Value Sym. Name
00010eec 00000017 R_ARM_RELATIVE
00010ef0 00000017 R_ARM_RELATIVE
00010ff0 00000115 R_ARM_GLOB_DAT 00000000 __libc_start_main@GLIBC_2.34
00010ff4 00000215 R_ARM_GLOB_DAT 00000000 __cxa_finalize@GLIBC_2.4
Relocation section '.rel.plt' at offset 0x464 contains 1 entry:
Offset Info Type Sym.Value Sym. Name
00011000 00000316 R_ARM_JUMP_SLOT 00000000 puts@GLIBC_2.4
R_ARM_GLOB_DAT entries are data-section relocations. The loader writes the resolved address of __libc_start_main into the GOT slot at 0x10ff0.
PLT, Procedure Linkage Table¶
The PLT is a tiny stub per function that jumps via the GOT. The first time you call puts():
puts@plt:
ldr r12, [pc, #plt_offset_to_GOT_entry]
bx r12 @ → resolved address of puts, or to the resolver stub
The first time, the GOT slot still points at a “resolver” routine inside ld-linux. The resolver looks up puts for real, writes the real address into the GOT slot, and jumps there. Subsequent calls go directly via the GOT, fast.
This trick is called lazy binding. The dynamic linker doesn’t resolve every function at startup. It resolves them on first use. You can disable lazy binding with LD_BIND_NOW=1, useful for security-hardened systems (no resolver stub at runtime).
34.4 Where libraries actually come from¶
On the target:
[root@pa-mini:~]# ls /lib/
ld-2.31.so libnss_files.so.2
ld-linux-armhf.so.3 libnss_files-2.31.so
libc-2.31.so libpthread-2.31.so
libc.so.6 libpthread.so.0
libdl-2.31.so libresolv-2.31.so
libdl.so.2 libresolv.so.2
libm-2.31.so librt-2.31.so
libm.so.6 librt.so.1
Notice:
libc.so.6is a symlink tolibc-2.31.so. The “6” is the ABI version. “2.31” is the implementation version.
ABI: Application Binary Interface: the calling convention, register use, binary format, and library contract that let separately built code run together.
ld-linux-armhf.so.3is the dynamic linker, a real file, not a symlink. (See Ch 31 §31.10’s gotcha.)The
nss_fileslibrary is for Name Service Switch, loaded dynamically by glibc when you callgethostbyname,getpwuid, etc. This is the part that breaks under static linking.
The dynamic linker also consults /etc/ld.so.cache, a pre-indexed map of library-name → file-path:
$ ldconfig -p | head
21 libs found in cache `/etc/ld.so.cache'
libz.so.1 (libc6,hard-float) => /lib/libz.so.1
libuClibc-1.0.so (libc6) => /lib/libuClibc-1.0.so
...
ldconfig rebuilds this cache from /etc/ld.so.conf and /etc/ld.so.conf.d/*.conf. On embedded systems running BusyBox, ldconfig is often skipped, the search defaults (/lib, /usr/lib) cover everything.
34.5 Looking inside a real program¶
ldd shows what a binary depends on:
[root@pa-mini:~]# ldd /bin/busybox
not a dynamic executable
(Static, no dependencies.) But:
[root@pa-mini:~]# ldd /usr/bin/some-dynamic-binary
libpthread.so.0 => /lib/libpthread.so.0 (0xb6f00000)
libm.so.6 => /lib/libm.so.6 (0xb6e80000)
libc.so.6 => /lib/libc.so.6 (0xb6d80000)
/lib/ld-linux-armhf.so.3 (0xb6f80000)
Right column is load addresses, where each .so was mmap’d into the process’s address space.
For a deeper look:
[root@pa-mini:~]# LD_DEBUG=libs my-binary 2>&1 | head -20
6: find library=libpthread.so.0 [0]; searching
6: search cache=/etc/ld.so.cache
6: trying file=/lib/libpthread.so.0
6:
6: find library=libc.so.6 [0]; searching
6: search cache=/etc/ld.so.cache
6: trying file=/lib/libc.so.6
6:
6: calling init: /lib/libpthread.so.0
6: calling init: /lib/libc.so.6
6: initialize program: my-binary
6: transferring control: my-binary
LD_DEBUG=libs (LD_DEBUG=help for the full list of categories) is the diagnostic tool for “why isn’t this library being found?” Use LD_DEBUG=libs before guessing.
34.6 RPATH and friends¶
Sometimes you ship a binary that depends on libraries not in /lib. Three common ways to point the loader at them:
LD_LIBRARY_PATH¶
[root@pa-mini:~]# LD_LIBRARY_PATH=/opt/myapp/lib /opt/myapp/bin/my-binary
Most general. User can override per-invocation. Downside: easy to forget. Security-sensitive binaries (setuid) ignore it for safety.
RPATH baked into the binary¶
$ arm-none-linux-gnueabihf-gcc -Wl,-rpath,/opt/myapp/lib -o my-binary my.c
The binary now searches /opt/myapp/lib/ automatically. Visible in readelf -d as a RUNPATH entry. Best for self-contained apps that bundle their own libs.
$ORIGIN is a special token that expands to the binary’s own directory:
$ arm-none-linux-gnueabihf-gcc -Wl,-rpath,'$ORIGIN/../lib' -o my-binary my.c
Binary in /opt/myapp/bin/ searches /opt/myapp/lib/, regardless of where the package is installed.
/etc/ld.so.conf.d/myapp.conf¶
$ echo /opt/myapp/lib > /etc/ld.so.conf.d/myapp.conf
$ ldconfig
System-wide. Affects all binaries. Use sparingly.
34.7 Static vs dynamic for embedded, the real trade-off¶
Static |
Dynamic |
|
|---|---|---|
Binary size |
Big (KB to MB per program) |
Small (KB) |
RAM per process |
High (no library sharing) |
Low (shared mmaps) |
Disk for whole image |
Variable; if you have 1 binary, smaller. 10 binaries: larger. |
One copy of each lib regardless of binary count |
Update granularity |
Replace binary |
Replace library; binaries pick it up |
|
No (in practice for glibc) |
Yes |
DNS / NSS works |
No (glibc) / Yes (musl) |
Yes |
Boot time |
Slightly faster (no linker startup) |
Slightly slower |
For embedded with N small binaries, dynamic linking is almost always smaller in total. For embedded with 1 large binary, static can win.
busybox, our biggest single binary, is the canonical case for static: 580 KB static vs ~580 KB binary + ~2 MB glibc dynamic. Choosing static for busybox saves 2 MB. Choosing dynamic means dozens of other small dynamic binaries can also be on the system without adding more libc copies.
The decision is per-binary, not per-system. Mix as needed.
34.8 Lab¶
Inspect a binary’s dependencies.
arm-none-linux-gnueabihf-readelf -d /bin/my-binaryfrom the host (or from the target after copying). Note each NEEDED entry. Verify each exists in/lib/or/usr/lib/.lddon the target. Runldd /usr/bin/...on something dynamic and read each line. Identify the dynamic linker. Verify it matches the INTERP fromreadelf.Break a dynamic binary on purpose. Move
/lib/libm.so.6somewhere. Try to run a math-dependent binary. Observe the “cannot open shared object file” error. Restore.Trace library loading.
LD_DEBUG=libs LD_DEBUG_OUTPUT=/tmp/ldlog my-binary. cat /tmp/ldlog. Identify every library load.Build the same program against musl. If you have a musl-targeting cross toolchain, build
hello.cagainst musl and against glibc. Compare sizes statically:arm-linux-musleabihf-gcc -static -o hello-musl hello.cversusarm-none-linux-gnueabihf-gcc -static -o hello-glibc hello.c.Set up
$ORIGINrpath. Build a binary with-Wl,-rpath,'$ORIGIN/../lib', place it under/opt/myapp/bin/, place a custom.sounder/opt/myapp/lib/, verify the binary finds it withoutLD_LIBRARY_PATH.
34.9 Pitfalls¶
libfoo.so.X: cannot open shared object file: the dynamic linker can’t find a NEEDED library. Diagnose withLD_DEBUG=libs. Fix by copying the library into/libor adding toLD_LIBRARY_PATH/RPATH.relocation error: undefined symbol: the library was found but doesn’t have a symbol the binary needs. Usually means library version mismatch. The binary was built against newer libc. The runtime has older.Mixed glibc and musl on one rootfs. Mixing glibc and musl on one rootfs is possible but easy to get wrong. Glibc uses SONAME
libc.so.6and loader/lib/ld-linux-armhf.so.3. Musl uses its own loader/lib/ld-musl-armhf.so.1and its own libc. They can live in separate prefixes. The failure mode is when both want to own the same/lib/libc.so.6symlink. Pick one libc per rootfs, or put musl binaries under their own prefix with the loader path baked in viaRPATH.Static glibc + getaddrinfo. Returns “Temporary failure in name resolution” with no obvious cause. NSS modules are dlopen’d at runtime even for “static” binaries. If the .so files aren’t on disk you lose DNS. Either ship the NSS .so files alongside your “static” binary or switch to musl.
LD_LIBRARY_PATHand setuid binaries. Ignored for setuid binaries (security). Don’t rely on it for system binaries.ldconfignot run after copying libraries. Some glibc setups won’t find a library until youldconfig. Symptom: works afterldconfig, breaks on fresh boot. Fix: runldconfigfromrcSor include the library path in/etc/ld.so.conf.d/.$ORIGINrpath doesn’t work in shell scripts. It’s resolved by the dynamic linker, not the shell. Only matters for the ELF binary itself. If you invoke the binary via a wrapper script,$ORIGINis relative to the wrapper’s location only if the wrapper itself is dynamic.
34.10 Going deeper¶
man ld.so: the canonical loader reference.man ldd,man ldconfig,man dlopen.Documentation/admin-guide/dynamic-debug-howto.rstfor kernel-side dlopen-like patterns.Drepper, "How to Write Shared Libraries": long PDF, gold standard.musl’s website,
musl.libc.org, design rationale, comparison tables.The ELF Specification(System V ABI, ARM supplement) for the gritty details of dynamic sections.
Next chapter: Chapter 35: Buildroot, after you can do it by hand. With BusyBox + libc + init understood, we adopt the tool that automates all of it. Buildroot: a configuration-driven build system that produces a complete root filesystem and related images.