Co dzieje się zanim odpali main()
Wpisujesz ./program, ale pierwszą wykonaną instrukcją nie jest Twój main(). Dla typowego dynamicznie linkowanego programu ELF kernel mapuje segmenty i przekazuje sterowanie interpreterowi wskazanemu w pliku. Dynamiczny linker ładuje zależności i wykonuje relokacje, zanim runtime dojdzie do kodu aplikacji.
Większość deweloperów traktuje ./program jak czarną skrzynkę. Inżynier rozumiejący ELF i dynamic linking wie dlaczego pierwszy call do funkcji z biblioteki jest wolniejszy, jak LD_PRELOAD pozwala przechwycić wiele funkcji bez rekompilacji, dlaczego ldd nie pokazuje całego obrazu i jak symbol resolution może być wektorem ataku.
Ten artykuł rozkłada format ELF od nagłówka przez segmenty, mechanikę ld.so, struktury GOT/PLT, lazy binding, aż po praktyczne zastosowania interpozycji. To wiedza, która zamienia „linker errors” w przewidywalny, debugowalny proces.
Anatomia pliku ELF
ELF (Executable and Linkable Format) to format binarny używany przez Linux i wiele innych systemów uniksowych dla plików wykonywalnych, bibliotek współdzielonych (.so), plików obiektowych (.o) i core dumpów. Struktura ma dwie komplementarne perspektywy: linking view (sekcje, dla linkera) i execution view (segmenty, dla loadera).
# Nagłówek ELF — pierwsze 64 bajty determinują wszystko
$ readelf -h ./program
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 14688 (bytes into file)Magic number 7f 45 4c 46 identyfikuje ELF. ET_DYN występuje zarówno w bibliotekach współdzielonych, jak i w plikach wykonywalnych PIE — samo pole typu ich nie odróżnia. Entry point typowego programu C prowadzi do kodu startowego, zwykle _start, a nie bezpośrednio do main().
Sekcje vs segmenty — dwie perspektywy tego samego pliku
| Aspekt | Sekcje (linking view) | Segmenty (execution view) |
|---|---|---|
| Odbiorca | Linker (ld) | Loader (kernel + ld.so) |
| Granularność | Drobna (.text, .data, .bss, .rodata) | Gruba (LOAD, DYNAMIC, INTERP) |
| Cel | Relokacja, symbol resolution | Mapowanie do pamięci z uprawnieniami |
| Tablica | Section Header Table | Program Header Table |
# Segmenty — to co loader faktycznie mapuje do pamięci
$ readelf -l ./program
Program Headers:
Type Offset VirtAddr Flags Align
INTERP 0x000318 0x0000000000000318 R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 R 0x1000 # nagłówki
LOAD 0x001000 0x0000000000001000 R E 0x1000 # .text (kod)
LOAD 0x002000 0x0000000000002000 R 0x1000 # .rodata
LOAD 0x002db8 0x0000000000003db8 RW 0x1000 # .data + .bss
DYNAMIC 0x002dc8 0x0000000000003dc8 RW 0x8Kluczowy jest segment INTERP — zawiera ścieżkę do dynamicznego linkera (/lib64/ld-linux-x86-64.so.2). Kernel czyta tę ścieżkę i to linker, nie kernel, dokończy ładowanie programu. Każdy segment LOAD ma flagi uprawnień (R/W/E) mapowane bezpośrednio na bity stron w MMU — segment z kodem jest R E (read+execute, ale nie write), co blokuje samomodyfikujący się kod. Te same flagi stron decydują o tym, czy dostęp do pamięci jest legalny — szczegóły w anatomii segfaulta.
Rola dynamicznego linkera ld.so
Gdy uruchamiasz dynamicznie linkowany program, sekwencja jest następująca:
- Kernel parsuje ELF, mapuje segmenty LOAD, czyta segment INTERP
- Kernel ładuje
ld.so(sam będący plikiem ELF) i przekazuje mu sterowanie — nie do_startprogramu ld.soparsuje sekcję.dynamic, znajduje listę potrzebnych bibliotek (DT_NEEDED)- Dla każdej biblioteki: lokalizacja na dysku, mapowanie, rekurencyjne rozwiązanie jej zależności
- Relokacje i symbol resolution — wypełnienie GOT
- Wywołanie konstruktorów (
.init_array) - Skok do
_startprogramu, który ostatecznie wywołamain()
# Prześledź pełen proces ładowania bibliotek przez ld.so
$ LD_DEBUG=libs ./program 2>&1 | head -20
find library=libc.so.6 [0]; searching
search path=/lib/x86_64-linux-gnu/tls/haswell/x86_64
trying file=/lib/x86_64-linux-gnu/libc.so.6
calling init: /lib/x86_64-linux-gnu/libc.so.6
# Wszystkie dostępne kategorie debugowania ld.so
$ LD_DEBUG=help ./program
libs wyświetl przeszukiwanie bibliotek
reloc wyświetl przetwarzanie relokacji
symbols wyświetl przeszukiwanie tablicy symboli
bindings wyświetl wiązanie symboli
statistics wyświetl statystyki relokacjiCo pokazuje ldd i kiedy mu nie wystarczy
ldd zwykle uruchamia dynamiczny linker w trybie śledzenia zależności. Nie pokazuje przyszłych wywołań dlopen(), a wynik zależy od środowiska uruchomienia. Dokumentacja odradza używanie go na niezaufanych plikach wykonywalnych. objdump -p lub readelf -d pozwalają odczytać wpisy DT_NEEDED bez uruchamiania programu, ale pokazują tylko bezpośrednie zależności.
# Bezpieczna inspekcja zależności — bez uruchamiania binarna
$ objdump -p ./program | grep NEEDED
NEEDED libssl.so.3
NEEDED libcrypto.so.3
NEEDED libc.so.6
# Alternatywnie przez readelf
$ readelf -d ./program | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libssl.so.3]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]GOT i PLT — mechanika wywołań funkcji zewnętrznych
Tu zaczyna się sedno. Skoro biblioteki są ładowane pod losowe adresy (ASLR), jak kod może wywołać printf(), skoro jego adres nie jest znany w czasie kompilacji? Odpowiedź to dwie struktury: GOT (Global Offset Table) i PLT (Procedure Linkage Table).
- GOT — tablica wskaźników. Wpis dla każdego zewnętrznego symbolu, wypełniany przez
ld.sofaktycznym adresem runtime. Zapisywalna podczas relokacji; RELRO może później chronić ją przed zapisem. - PLT — tablica małych stubów kodu. Funkcja wywoływana tym mechanizmem ma wpis PLT, który wykonuje skok pośredni przez GOT.
W uproszczonym modelu opartym na PLT wywołanie printf() kompiluje się do call printf@plt — skoku do stuba PLT, nie do samej funkcji:
/* Twój kod */
printf("%s", message);
/* Kompiluje się do (uproszczone): */
call printf@plt /* skok do stuba PLT */
/* printf@plt (uproszczony asembler): */
printf@plt:
jmp *printf@got /* skok pośredni przez GOT */
/* jeśli GOT niewypełniony — fallback do resolvera */
push $printf_index
jmp _dl_runtime_resolveLazy binding — dlaczego pierwszy call jest wolniejszy
Przy włączonym lazy binding adres funkcji wywoływanej przez PLT może zostać rozwiązany dopiero przy pierwszym wywołaniu. Opcje linkowania i LD_BIND_NOW mogą wymusić wcześniejsze wiązanie, więc koszt pierwszego wywołania zależy od sposobu zbudowania programu.
Mechanika: początkowo wpis GOT dla funkcji wskazuje z powrotem na kod PLT, który wywołuje _dl_runtime_resolve. Ten lokalizuje faktyczny adres funkcji, wpisuje go do GOT i skacze do funkcji. Każde kolejne wywołanie idzie już bezpośrednio przez wypełniony GOT — stąd narzut tylko za pierwszym razem.
# Wymuszenie eager binding — wszystkie symbole rozwiązane przy starcie
$ LD_BIND_NOW=1 ./program
# Wolniejszy start, ale brak narzutu przy pierwszym call każdej funkcji
# Również: cała GOT może być read-only (RELRO) — hardening security
# Sprawdzenie czy binarka ma Full RELRO (GOT read-only po starcie)
$ readelf -l ./program | grep GNU_RELRO
GNU_RELRO 0x002db8 0x0000000000003db8 R 0x1
$ readelf -d ./program | grep BIND_NOW
0x000000000000001e (FLAGS) BIND_NOW # Full RELRO aktywneLazy binding wymaga możliwości aktualizacji odpowiednich wpisów GOT. Typowe pełne RELRO uzyskuje się podczas linkowania przez -Wl,-z,relro,-z,now: relokacje są wykonywane przed ochroną tego obszaru przed zapisem. Samo ustawienie LD_BIND_NOW nie zmienia układu segmentów już zbudowanego pliku.
LD_PRELOAD — interpozycja jako supermoc
LD_PRELOAD ładuje dodatkowe biblioteki przed zwykłymi zależnościami i umożliwia interpozycję wielu symboli. Nie podmieni jednak dowolnego wywołania: ograniczają ją m.in. widoczność symboli, statyczne linkowanie, optymalizacje kompilatora i tryb secure-execution.
/* puts_tracker.c — prosty przykład interpozycji, Linux/glibc */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>
int puts(const char *text) {
int (*next_puts)(const char *) = dlsym(RTLD_NEXT, "puts");
if (!next_puts) return EOF;
const char notice[] = "[puts] wywolanie przechwycone\n";
(void)write(STDERR_FILENO, notice, sizeof notice - 1);
return next_puts(text);
}gcc -shared -fPIC -o puts_tracker.so puts_tracker.c -ldl
# Własny program testowy powinien wywoływać puts("hello").
LD_PRELOAD=./puts_tracker.so ./helloRTLD_NEXT szuka następnego symbolu w kolejności wyszukiwania, dzięki czemu wrapper może przekazać wywołanie dalej. Przechwytywanie malloc() jest trudniejsze: dlsym() lub logowanie przez stdio mogą same alokować pamięć i wywołać rekursję. Prosty wrapper z fprintf() nie jest więc bezpiecznym profilerem alokacji.
Statyczne vs dynamiczne linkowanie — trade-off
| Wymiar | Statyczne (-static) | Dynamiczne (domyślne) |
|---|---|---|
| Rozmiar binarna | Większy; wymagany kod bibliotek jest dołączony | Zwykle mniejszy; pozostają zależności od bibliotek |
| Zużycie RAM (N procesów) | Procesy tego samego pliku mogą współdzielić czyste strony kodu | Biblioteki mogą współdzielić czyste strony między różnymi programami |
| Czas startu | Szybki (brak resolution) | Wolniejszy (ld.so + relokacje) |
| Aktualizacje bibliotek | Wymaga rekompilacji | Wymagana zgodność ABI; działające procesy mogą potrzebować restartu |
| Patche bezpieczeństwa | Rebuild każdego binarna | Aktualizacja biblioteki i odświeżenie korzystających z niej procesów |
| Przenośność | Mniej zależności od bibliotek; pozostają wymagania systemu i ABI | Zależna od wersji .so |
| Przypadek użycia | Kontenery scratch, statyczne CLI | Standardowy deployment systemowy |
Renesans statycznego linkowania widać w Go (domyślnie statyczne) i Rust (musl target) — głównie dla kontenerów scratch (zero-dependency images) i dystrybucji pojedynczych binarów. Dynamiczne pozostaje standardem systemowym ze względu na współdzielenie pamięci i centralne patche bezpieczeństwa (jeden CVE w OpenSSL = jeden update libssl.so, nie rebuild tysiąca aplikacji). Samo współdzielenie stron kodu między procesami opiera się na copy-on-write — tym samym mechanizmie, co wywołanie fork().
Praktyczna diagnostyka — narzędzia
| Narzędzie | Zastosowanie |
|---|---|
readelf -h/-l/-d | Inspekcja nagłówka, segmentów, sekcji dynamicznej |
objdump -d/-p | Disasembler + nagłówki programu (bezpieczny dla zależności) |
nm -D | Symbole dynamiczne eksportowane/importowane przez .so |
LD_DEBUG=all | Pełny trace procesu ładowania i resolution w runtime |
LD_TRACE_LOADED_OBJECTS=1 | Lista zależności (to co robi ldd, ale świadomie) |
patchelf | Modyfikacja RPATH, interpretera, SONAME istniejącego binarna |
strace -e trace=openat | Które pliki .so faktycznie są otwierane przy starcie |
Podsumowanie: linker jako część kontraktu wykonania
Dynamic linking to nie magia, która „po prostu działa” — to precyzyjnie zdefiniowany protokół między kompilatorem, linkerem, kernelem i ld.so. Zrozumienie formatu ELF, mechaniki GOT/PLT i lazy binding zamienia całą klasę problemów (symbol not found, version mismatch, wrong library loaded, wolny start) z tajemniczych w deterministyczne i debugowalne.
Rozróżniaj brak deklarowanej zależności od braku ścieżki jej wyszukiwania. Dopisanie RPATH nie tworzy wpisu DT_NEEDED. Najpierw sprawdź, czego program wymaga i gdzie linker tego szuka; dopiero potem zmieniaj konfigurację.
Mechanika ładowania programu jest fundamentem, na którym stoi wszystko inne. Warto wiedzieć co dzieje się, zanim odpali main().
Powiązane artykuły
- Anatomia segfaulta — od MMU przez kernel po core dump w gdb — mapowanie segmentów i uprawnienia stron
- malloc internals — dlaczego free() nie oddaje pamięci systemowi — LD_PRELOAD jako mechanizm podmiany allocatora
- fork() Linux — co naprawdę robi pod spodem — współdzielenie bibliotek przez copy-on-write
- strace — czytanie syscalli, kiedy stack trace nie wystarcza — LD_DEBUG i strace jako narzędzia analizy ładowania
Weryfikacja techniczna: ELF, ld.so, ldd.


