Zajęcia 1: ELF¶
Data: 27.02.2024
Materiały dodatkowe¶
O formacie ELF¶
ELF jest formatem plików używanym w systemie Linux (i wielu innych
systemach) dla programów, bibliotek dzielonych (.so
), plików pośrednich
kompilacji (.o
), oraz plików zrzutu pamięci (core
).
Choć podstawowe cechy formatu ELF są zawsze takie same, jest wiele elementów zależnych od architektury procesora, a czasem i systemu operacyjnego. Tutaj będziemy się zajmować jedynie formatem ELF na architekturze x86 w systemie Linux.
Niestety nie istnieje żadna pełna specyfikacja ELFa. “Bazowy” format ELF jest opisany w dokumentacji System V generic ABI (gABI), zaś części zależne od architektury powinny być opisane w odpowiednim processor-specific ABI (psABI). W praktyce jednak psABI dla wielu architektur bywają cieżkie do zdobycia, bardzo niekompletne, lub w ogóle nie zostały napisane. Sprawę dalej komplikują rozszerzenia ELFa: ELF, jako otwarty i elastyczny format, pozwala systemom operacyjnym na definiowanie własnych typów sekcji, relokacji, symboli itp. Wiele z nich nie jest nigdzie opisane.
Prawdopodobnie najbardziej kompletnym pojedynczym dokumentem opisującym ELFa jest Linker and Libraries Guide Sun’a. Jest to w zasadzie dokumentacja linkera i bibliotek dynamicznych na systemie Solaris, ale zawiera pełny opis formatu ELF dla architektur i386, x86_64, sparc, sparc64, a także kilku rozszerzeń używanych również na systemach Linuxowych (wersjonowanie, TLS).
Innym przydatnym zasobem jest plik nagłówkowy elf.h
z biblioteki glibc
(/usr/include/elf.h
). Zawiera on stałe i struktury ELFa dla architektur
i systemów operacyjnych wspieranych przez glibc. Pliki nagłówkowe
ze wsparciem dla jeszcze większej ilości architektur można znaleźć
w bibliotece libbfd, będącej częścią binutils.
W tym pliku zostaną przedstawiony jedynie szkic formatu ELF - po szczegółowe informacje odsyłam do Linker and Libraries Guide.
ELF - podstawowa struktura¶
Pliki ELF, na pierwszym poziomie, są złożone z 4 obszarów:
nagłówek ELF (na początku pliku): zawiera informacje o parametrach pliku i maszynie, na którą jest przeznaczony, a także informacje o położeniu nagłówków sekcji i programu
nagłówki sekcji: każdy nagłówek opisuje typ i położenie jednej sekcji. Sekcja to spójny blok pamięci o jednolitych atrybutach. Większość sekcji opisuje po prostu obszar pamięci, który powinien zostać stworzony przy uruchamianiu programu i zainicjowany danymi z pliku, ale istnieje wiele specjalnych typów sekcji o bardziej skomplikowanej semantyce.
nagłówki programu: każdy nagłówek opisuje typ i położenie jednego segmentu. Segment to spójny blok pamięci o jednolitym przeznaczeniu i atrybutach z punktu widzenia procesu ładowania i uruchamiania programu. Jeśli plik ma zarówno segmenty, jak i sekcje, segmenty mają relację jeden-do-wielu z sekcjami (bo może być wiele sekcji, które linker musi rozróżniać, ale loader już nie)
zawartość sekcji/segmentów
To, czy nagłówki sekcji/programu mogą lub muszą być obecne zależy od typu pliku ELF. Istnieją 4 typy plików ELF:
ET_REL
(relocatable file)Plik skompilowany, ale jeszcze nie zlinkowany (.o). Zazwyczaj powstaje w wyniku kompilacji pojedynczego pliku źródłowego. Nie da się go bezpośrednio uruchomić - takie pliki są pośrednim etapem kompilacji i są łączone przez linker (program ld) w pliki wykonywalne lub biblioteki dynamiczne.
Istnieje również (niezbyt często używana) możliwość połączenia kilku plików .o w jeden większy, używając
ld -r
.Jako pliki pośrednie,
ET_REL
mogą zawierać niezdefiniowane symbole i nieustalone odwołania - zostaną one ustalone przez dalsze etapy linkowania.W typie
ET_REL
nagłówki sekcji są wymagane, zaś nagłówki programu nie są używane.ET_EXEC
(executable file)Skompilowany i zlinkowany program, powstały zazwyczaj z połączenia plików .o przez linker. Taki plik jest gotowy do uruchomienia - wszystkie segmenty mają już ustalony adres, pod którym będą dostępne w czasie działania programu. Wszystkie odwołania w pliku również są już ustalone - jedynym wyjątkiem są specjalne typy odwołań do bibliotek współdzielonych, ograniczone do jednego segmentu. Powoduje to, że prawie cała zawartość pamięci ładowana z pliku wykonywalnego jest identyczna we wszystkich procesach wykonujących dany program i pozwala na współdzielenie pamięci.
W typie
ET_EXEC
nagłówki programu są wymagane. Nagłówki sekcji nie są potrzebne do działania programu, lecz są używane przez debuggery i są zazwyczaj załączane.ET_DYN
(shared object file)Skompilowana i zlinkowana biblioteka dynamiczna (
.so
). Bardzo podobne doET_EXEC
, ale z następującymi różnicami:choć większość zawartości jest już ustalona (nieustalone odwołania, podobnie jak w
ET_EXEC
, są ograniczone do odwołań zewnętrznych w jednym segmencie), nie jest ustalony ostateczny adres, pod którym ta zawartość będzie załadowana - biblioteka może być załadowana pod dowolne miejsce w pamięciponieważ kod biblioteki nie może zawierać odwołań do jej adresu, używany jest specjalny styl kodu nazywany PIC (Position-Independent Code). Za każdym razam, gdy potrzebny jest adres jakiegoś obiektu w bibliotece, kod typu PIC musi w jakiś sposób ustalić swoją własną pozycję i wyliczyć z niej adres żądanego obiektu. Taki kod jest zazwyczaj większy i wolniejszy niż “zwykły” kod.
dzięki powyższym cechom, program może ładować wiele bibliotek dynamicznych do swojej przestrzeni adresowej, a nawet ładować je w trakcie działania
Należy zauważyć, że choć typ
ET_DYN
jest zazwyczaj używany dla bibliotek, to nic nie szkodzi na przeszkodzie, aby używać go również dla głównego programu - technika ta jest nazywana PIE (Position-Independent Executable) i bywa używana ze względu na możliwość pełnej randomizacji przestrzeni adresowej procesu.Przykładem wykonywalnego pliku
ET_DYN
może być biblioteka libc (/lib/libc.so.6
) - przy uruchomieniu wypisuje informacje o wersji. Również linker dynamiczny jest zaimplementowany jako wykonywalnyET_DYN
(aby nie konfliktować z programem, który ładuje).ET_CORE
(core file)Zrzut pamięci procesu, tworzony w przypadku zabicia procesu przez niektóre sygnały. Zawiera pełny stan procesu w momencie śmierci, pozwalając na otwarcie go w debuggerze i ustalenie przyczyny problemu.
Co ciekawe, moduły jądra Linux (.ko) są typu ET_REL
, a są bezpośrednio
ładowane przez jądro - korzyści z typów ET_EXEC
i ET_DYN
(czyli
współdzielenie pamięci) nie stosują się w trybie jądra, zaś ich wady
(ustalona pozycja ET_EXEC
, nieefektywność PIC) byłyby dość dotkliwe.
Nagłówek ELF¶
Nagłówek ELF zawiera następujące informacje:
identyfikator formatu pliku (
"\x7fELF"
)format pliku: little endian czy big endian, 32-bit czy 64-bit - określa format pozostałych struktur
wersję formatu ELF (na razie istnieje tylko 1.0)
identyfikator systemu operacyjnego (często ignorowany)
typ pliku ELF (
ET_*
)architektura docelowa (
EM_386
,EM_X86_64
,EM_SPARC
, …)położenie i rozmiar nagłówków sekcji i programu
adres punktu wejścia programu (dla
ET_EXEC
i wykonywalnychET_DYN
)
Sekcje¶
Informacje zawarte w nagłówku sekcji to:
nazwa sekcji (sekcje mogą mieć dowolne nazwy, ale dla standardowych sekcji przyjęło się używać nazw zaczynających się od kropki)
typ sekcji
atrybuty sekcji
rozmiar, położenie w pliku, i wyrównanie sekcji
dla
ET_EXEC
iET_DYN
: ostateczny adres sekcji w pamięci (relatywny do bazowego adresu w przypadkuET_DYN
)identyfikatory stowarzyszonych sekcji (w przypadku niektórych typów)
Typ sekcji determinuje większość jej semantyki. Ważniejsze typy to:
SHT_PROGBITS
zwykła sekcja, zawartość ładowana z pliku
SHT_NOBITS
zwykła sekcja, ale zawartość jest wypełniana zerami zamiast być ładowana z pliku
SHT_SYMTAB
tabela symboli - zawiera informacje o obiektach zawartych w pliku i zewnętrznych obiektach, do których ten plik ma odwołania
SHT_STRTAB
tabela ciągów znaków - zawiera nazwy używane przez nagłówki sekcji i wpisy w tabeli symboli
SHT_REL
/SHT_RELA
zawiera informacje o nieustalonych odwołaniach użytych w danej (stowarzyszonej) sekcji
SHT_DYNAMIC
zawiera informacje dla linkera dynamicznego
Ważniejsze atrybuty sekcji to:
SHF_WRITE
sekcja ma prawo do zapisu w czasie wykonania
SHF_EXECINSTR
sekcja zawiera kod wykonywalny
SHF_ALLOC
sekcja będzie ładowana do pamięci w czasie wykonania (sekcje bez tej flagi są używane tylko przez narzędzia kompilacji i debugowania)
Standardowe nazwy sekcji używane dla zwykłego kodu w C to:
.text
sekcja kodu
.rodata
sekcja danych tylko do odczytu (
const int x = 3;
).data
sekcja danych (
int x = 3;
).bss
sekcja danych wyzerowanych (
int x = 0;
)
Segmenty¶
Informacje zawarte w nagłówku programu to:
typ segmentu
atrybuty segmentu
położenie segmentu w pliku i adres w pamięci
rozmiar semgentu w pliku i rozmiar segmentu w pamięci (w przypadku różnicy, pozostała część jest wypełniana zerami - używane dla sekcji typu
SHT_NOBITS
)
Ważniejsze typy segmentów to:
PT_LOAD
“zwykły” segment: ładuje obszar do pamięci
PT_DYNAMIC
wskazuje obszar z informacjami dla linkera dynamicznego
PT_INTERP
wskazuje nazwę pliku z linkerem dynamicznym
Jedyne niezależne od procesora/systemu atrybuty segmentu to jego prawa dostępu (rwx).
W czasie linkowania, segmenty PT_LOAD
są tworzone przez scalenie wszystkich
sekcji z flagą SHF_ALLOC
o zgodnych prawach dostępu. Wszystkie pozostałe
segmenty używane w czasie wykonania są zawarte wewnątrz segmentów PT_LOAD
.
Symbole i odniesienia¶
Jednym z głównych zadań formatu ELF jest przechowywanie informacji
o obiektach zawartych w pliku i o odwołaniach do obiektów zewnętrznych.
Przez obiekt rozumiemy funkcję lub zmienną (globalną). Z punktu widzenia
ELFa, obiekt to po prostu obszar wewnątrz sekcji (ET_REL
) lub przestrzeni
adresowej programu (ET_EXEC
, ET_DYN
).
Symbole to nazwy przypisane obiektom. Symbol może być zdefiniowany (przypisany do obiektu w danym pliku) lub niezdefiniowany (stanie się zdefiniowany w momencie połączenia z plikiem, który go defniuje).
Symbole są przechowywane w tabeli symboli. Informacje o symbolu to:
nazwa
wartość: pozycja w sekcji (
ET_REL
) lub pamięci (ET_EXEC
,ET_DYN
)zawierająca sekcja
rozmiar (czyli rozmiar zmiennej lub rozmiar kodu funkcji); może być zerowy jeśli interesuje nas tylko adres
typ:
STT_OBJECT
zmienna
STT_FUNC
funkcja
STT_SECTION
specjalny symbol reprezentujący początek sekcji w odniesieniach
reguły łączenia:
STB_LOCAL
symbol lokalny (static w C)
STB_GLOBAL
symbol globalny
STB_WEAK
słaby symbol globalny (
__attribute__((weak))
w gcc) - specjalny wariant symbolu globalnego, który automatycznie “przegrywa” ze zwykłym symbolem globalnym o tej samej nazwie, gdy oba są zdefiniowane
reguły widoczności - używane do wiązania symboli między modułami (moduł to program wykonywalny lub biblioteka dynamiczna):
STV_DEFAULT
domyślne reguły - symbol jest widoczny i może zostać przysłonięty przez symbol o tej samej nazwie z innego modułu
STV_PROTECTED
symbol jest widoczny, ale odniesienia do niego z wnętrza zawierającego modułu nie będą przysłonięte
STV_HIDDEN
symbol nie jest widoczny z zewnątrz modułu - jak
STB_LOCAL
, ale na poziomie modułu, a nie pliku źródłowegoSTV_INTERNAL
jak
STV_HIDDEN
, ale gdy symbol jest funkcją, dodatkowo zakładamy, że nigdy nie będzie wywołany z zewnątrz modułu (co byłoby możliwe przez przekazanie wskaźnika). Może być wykorzystana do dodatkowej optymalizacji kodu PIC.
Reguły te można ustawić w gcc przez odpowiedni
__attribute__
.
Symbole mogą być wykorzystywane w kodzie przez odniesienia (zwane relokacjami). Relokacja jest informacją dla linkera, że w danym miejscu sekcji, zamiast ustalonych w czasie kompilacji bajtów, powinien znaleźć się adres symbolu (lub jakaś inna wartość nieznana w czasie kompilacji). Relokacje są przechowywane w tabelach relokacji (po jednej na każdą sekcję, która tego wymaga). Informacja przechowywana dla każdej relokacji to:
indeks wykorzystywanego symbolu w tablicy symboli
pozycja relokacji w sekcji
typ relokacji
addend: dodatkowy składnik do wartości - dokładna interpretacja zależy od typu relokacji, najczęściej jest to po prostu liczba dodawana do relokowanej wartości. Może być użyta np. gdy ktoś prosi o adres
a.y
, gdy mamy definicjęstruct { int x, y; } a;
Istnieją dwa typy tablic relokacji: SHT_REL
i SHT_RELA
. W przypadku
SHT_RELA
, addend jest przechowywany w tablicy relokacji, natomiast
dla SHT_REL
, addend jest przechowywany jako początkowa zawartość
relokowanego miejsca. SHT_REL
pozwala na zmniejszenie rozmiaru pliku,
lecz SHT_RELA
jest wymagany dla architektur ze skomplikowanymi typami
relokacji (np. dwuczęściowe relokacje po 16 bitów każda). Architektura
i386 zawsze używa SHT_REL
, a architektura x86_64 zawsze używa
SHT_RELA
.
Typy relokacji są bardzo zależne od architektury. Większość typów relokacji służy do łączenia dynamicznego. Podstawowe typy relokacji na i386 to:
R_386_32
Relokowane jest 32-bitowe pole, wartość relokowana to adres symbolu + addend. Tzn na przykład następujący kod:
extern struct { int x; int y; } a; a.y = 13;
będzie wyglądał tak w assemblerze:
movl $13, a+4
co tłumaczy się na kod maszynowy następująco:
c7 05 XX XX XX XX 0d 00 00 00
gdzie
XX XX XX XX
powinno być zastąpione adresema + 4
. Assembler w sekcji pliku ELF zapisze to jako:c7 05 04 00 00 00 0d 00 00 00
A w tabeli relokacji dla tej sekcji wpisze relokację typu
R_386_32
symbolua
na pozycji 2 (zakładając, że ten kod znajduje się na samym początku sekcji).R_386_PC32
Relokowane jest 32-bitowe pole, wartość relokowana to adres symbolu - adres pola + addend. Ten typ relokacji jest używany do instrukcji skoków i wywołań (przypominam, że w instrukcjach skoku i wywołań na x86 cel jest określany przez różnicę między adresem końca instrukcji skoku a adresem celu). Kod:
extern void f(void); v();
czyli w assemblerze:
call f
w kodzie maszynowym zostanie zapisany jako:
e8 XX XX XX XX .
gdzie
XX XX XX XX
to (adresf
- adres kropki). W sekcji pliku ELF będzie to zapisane jako:e8 fc ff ff ff
A w tabeli relokacji będzie relokacja typu
R_386_PC32
symboluf
na pozycji 1. Proszę zauważyć, że assembler ustawił addend relokacji na0xfffffffc
(czyli -4) - jest to poprawka na to, żeR_386_PC32
jest zdefiniowane jako offset od początku relokowanego pola, a instrukcja skoku używa offsetu od końca instrukcji skoku, czyli od końca relokowanego pola.
Podstawowe typy relokacji na x86_64 to:
R_X86_64_64
Relokowane jest 64-bitowe pole, analogiczne do
R_386_32
.R_X86_64_32S
Jak
R_X86_64_64
, ale relokowane jest 32-bitowe pole ze znakiem. Jeżeli pełna 64-bitowa wartość nie może być reprezentowana przez to pole, następuje błąd linkowania. Na architekturze x86_64, większość instrukcji pobierających parametry liczbowe mieści tylko 32-bitowe liczby ze znakiem - dopóki więc gotowy program zmieści się w dolnych 2GB przestrzeni adresowej, ten typ relokacji jest używany do większości odwołań z kodu. Jeśli program staje się zbyt duży, należy go skompilować z opcją-mcmodel=large
, która używa do ładowania adresów tylko instrukcjimov
, obsługujących pełny 64-bitowy zakres i relokacjiR_X86_64_64
.R_X86_64_PC32
Analogiczne do
R_386_PC32
.
Konwencja wywołań funkcji na architekturze x86 w systemie Linux¶
(Nie jest to w zasadzie część tematu, ale pewnie się przyda.)
Architektura i386 ma w zasadzie 7 rejestrów ogólnego przeznaczenia: %eax
,
%ecx
, %edx
, %ebx
, %ebp
, %esi
, %edi
. Oprócz tego,
z programów użytkownika jest jeszcze dostępny wskaźnik stosu %esp
i rejestr znaczników %eflags
.
Architektura x86_64 rozszerza wszystkie te rejestry do 64-bitów (%rax
,
%rcx
, %rdx
, %rbx
, %rbp
, %rsi
, %rdi
, %rsp
,
%rflags
), oraz dodaje 8 nowych rejestrów ogólnego przeznaczenia
(%r8
- %r15
).
Standardowe konwencje wywołań dla architektury i386 są w skrócie następujące:
stos rośnie w dół,
%esp
wskazuje szczyt stosu, czyli najmniejszy adres będący w użyciu przez program. Każdy adres na stosie mniejszy niż%esp
może zostać w każdej chwili zniszczony (np. przez wywołanie funkcji obsługi sygnału).w punkcie wejścia do funkcji (czyli zaraz po wywołaniu instrukcji call)
%esp
= -4 (mod 16), a słowo na szczycie stosu (pod%esp
) jest adresem powrotnym z funkcjifunkcja powinna powrócić przez zdjęcie adresu powrotu ze stosu (zwiększając
%esp
o 4) i skoczenie do niego. Zazwyczaj wykonuje się to instrukcjąret
.zawartość rejestrów
%ebx
,%ebp
,%esi
,%edi
po powrocie z funkcji musi być równa ich zawartości z momentu jej wykonania - funkcja musi albo zachować i odtworzyć wartość tych rejestrów, albo ich wcale nie używaćzawartość rejestrów
%eax
,%ecx
,%edx
,%eflags
może zostać zmieniona przez funkcję bez żadnych konsekwencjijeśli funkcja pobiera parametry, zostaną one przekazane na stosie, zaczynając od
%esp+4
(czyli zaraz po adresie powrotu). Funkcja ma je tam pozostawić - zdejmowany ze stosu jest tylko adres powrotujeśli funkcja zwraca wartość, ma ją zostawić w
%eax
.
Natomiast dla x86_64:
stos rośnie w dół,
%rsp
wskazuje szczyt stosu. 128 bajtów poniżej szczytu stosu stanowi tzw. red zone, czyli obszar, którego można używać i nie zostanie nadpisany, pomimo znajdowania się za stosem (obszar poniżej red zone może zostać nadpisany przez obsługę sygnału). Taki obszar jest przydatny w funkcjach nie wywołujących innych funkcji (tzw. leaf function), gdyż pozwala uniknąć przesuwania wskaźnika stosu, jeśli funkcja nie potrzebuje dużo miejsca.w punkcie wejścia do funkcji
%rsp
= 8 (mod 16), a słowo na szczycie stosu (pod%rsp
) jest adresem powrotnym z funkcji.funkcja powinna powrócić przez zdjęcie adresu powrotu ze stosu (zwiększając
%rsp
o 8) i skoczenie do niego. Zazwyczaj wykonuje się to instrukcjąret
.zawartość stosu poniżej
%rsp
przy wejściu do funkcji może być przez nią dowolnie modyfikowana, a stos powyżej nie powinien być modyfikowany.zawartość rejestrów
%rbx
,%rbp
,%r12
-%r15
po powrocie z funkcji musi być równa ich zawartości z momentu jej wykonaniazawartość rejestrów
%rax
,%rcx
,%rdx
,%rsi
,%rdi
,%r8
-%r11
może zostać zmieniona przez funkcję bez żadnych konsekwencjiparametry do funkcji są przekazywane kolejno w rejestrach:
%rdi
,%rsi
,%rdx
,%rcx
,%r8
,%r9
. Jeśli funkcja pobiera więcej niż 6 parametrów, są one przekazywane na stosie zaczynając od%rsp+8
. Funkcja ma je tam pozostawić - zdejmowany ze stosu jest tylko adres powrotu.jeśli funkcja zwraca wartość, zwraca ją w
%rax
.
Powyższa lista nie uwzględnia przekazywania parametrów i zwracania wartości innych niż inty/wskaźniki, ani dziwniejszych rejestrów x86. Po więcej szczegółów odsyłam do psABI-i386 oraz psABI-x86_64.
Biblioteki dynamiczne¶
Global Offset Table¶
Jak zostało wcześniej wspomniane, głównym celem projektowym ELFa dla
ET_EXEC
i ET_DYN
była możliwość współdzielenia kodu i danych między
procesami. Ponieważ odwołania zewnętrzne (czyli relokacje) w oczywisty
sposób wymagają modyfikacji zawartości pamięci w stosunku do “szablonu”
zawartego w pliku, postanowiono je zabrać w jedno miejsce, ograniczając
liczbę stron pamięci, które nie będą mogły być współdzielone.
To miejsce nazywa się GOT (Global Offset Table). Istnieje jeden GOT dla
każdego modułu (czyli biblioteki lub głównego programu), który go
potrzebuje. Jest to po prostu duża tablica adresów symboli zewnętrznych
wymaganych przez dany moduł. Gdy piszemy bibliotekę dynamiczną (i używamy
PIC), kompilator automatycznie generuje kod ładujący odpowiedni adres
z GOT za każdym razem, gdy potrzebuje adresu obiektu zewnętrznego.
W przypadku plików ET_EXEC
, używanych jest kilka trików, aby kompilator
nie musiał jawnie używać GOT, lecz GOT w pewnej formie wciąż jest używany
przy wywołaniach funkcji zewnętrznych.
GOT jest automatycznie tworzony przez linker w momencie linkowania programu
czy biblioteki dynamicznej. Emitowana jest specjalna tablica relokacji
w sekcji .rel.dyn
, w którą wpisywane są relokacje wypełniające GOT (jak
i wszystkie inne relokacje wymagana w trakcie dynamicznego łączenia).
Te relokacje są typu R_<arch>_GLOB_DAT
, który (w przypadku x86) działa
identycznie jak R_386_32
/ R_X86_64_64
, ale dodatkowo identyfikuje
cel relokacji jako slot GOT.
PIC na i386¶
Sekwencje kodu niezależnego od pozycji (PIC) są często trikowe, a stopień trudności zależy od architektury. Architektura i386 jest pod tym względem dość średnia - są dostępne względne instrukcje skoku, ale brak jest innych sposobów adresowania pamięci względem wskaźnika instrukcji. Podstawowe sekwencje kodu używane na architekturze i386 to:
znalezienie pozycji GOT:
call _l1 _l1: popl %ebx addl $_GLOBAL_OFFSET_TABLE_+(.-_l1), %ebx
W tej sekwencji instrukcja
call
jest używana, aby zapisać adres etykiety_l1
(czyli adres “powrotu”) na stos. Ten adres jest następnie zdejmowany ze stosu, a adres GOT jest otrzymywany przez dodanie różnicy adresów między adresem GOT a adresem_l1
.Kropka w instrukcji
addl
(oznaczająca adres obecnej instrukcji) jest spowodowana zaszłością historyczną -_GLOBAL_OFFSET_TABLE_
jest specjalnym symbolem rozumianym przez assembler jako (adres GOT - adres obecnej instrukcji). Użycie tego symbolu powoduje również wyemitowanie specjalnej relokacjiR_386_GOTPC
(działa jakR_386_PC32
, ale zamiast adresu symbolu docelowego używa adresu GOT).Po skończonej sekwencji, adres GOT znajduje się w rejestrze
%ebx
. Jest to standardowy rejestr przeznaczony na adres GOT - według konwencji wywołań, musi być ustawiony na adres GOT zawsze, gdy wykonywane jest wywołanie przez PLT (patrz niżej).Znalezienie adresu zmiennej lokalnej (
static int x;
) (mając już adres GOT):leal x@gotoff(%ebx), %ecx
Ponieważ większość funkcji i tak musi znaleźć GOT, wykorzystywany jest fakt, że zmienne lokalne mają stały offset od GOT - adres zmiennej jest znajdowany po prostu przez dodanie tej różnicy do adresu GOT.
x@gotoff
jest specjalną składnią assemblera oznaczającą tą różnicę. Odpowiada to relokacjiR_386_GOTOFF
(wartość = adres symbolu + addend - adres GOT).Znalezienie adresu zmiennej zewnętrznej (
extern int x;
) (mając już adres GOT):movl x@got(%ebx), %ecx
x@got
jest specjalną składnią oznaczjącą (adres adresux
w got - adres got). Ta instrukcja po prostu ładuje zawartość odopowiedniego slotu GOT.x@got
odpowiada relokacjiR_386_GOT32
. Użycie tej relokacji automatycznie powoduje stworzenie slotu w GOT dla odpowiedniego symbolu.
PIC na x86_64¶
Architektura x86_64 zawsze pozwala na użycie adresowania pamięci względnego do
wskaźnika instrukcji. Dzięki temu można uniknąć trikowej sekwencji kodu
szukającej adresu GOT i adresować sloty w GOT przez offset w stosunku do
instrukcji używającej ich. Na przykład znalezienie adresu zmiennej zewnętrznej
(extern int x;
) wygląda następująco:
movq x@GOTPCREL(%rip), %rax
Odpowiada to relokacji R_X86_64_GOTPCREL
.
Aby natomiast dostać się do zmiennej lokalnej, nie trzeba w żaden sposób
używać GOT - wystarczy zakodować w instrukcji offset między instrukcją
a daną zmienną. Używa to tej samej relokacji R_X86_64_PC32
, co skoki.
PLT¶
Jako optymalizacja w stosunku do powyższych mechanizmów, stworzony został specjalny mechanizm do wywoływania funkcji zewnętrznych: PLT (Procedure Linkage Table), pozwalający na leniwe wiązanie funkcji przez linker dynamiczny.
PLT jest specjalną tabelą, zawierającą (na x86) kod zamiast danych. Każda zewnętrzna funkcja wywoływana przez PLT ma wpis w PLT. Wpis dla funkcji f wygląda tak (i386):
f@plt:
jmp *f_GOT_PLT_OFF(%ebx)
f_unbound:
pushl $f_REL_OFF
jmp plt0
Lub tak (x86_64):
f@plt:
jmpq *f_GOT_PLT(%rip)
f_unbound:
pushq $f_REL_OFF
jmp plt0
A plt0
jest pojedynczym specjalnym wpisem wyglądającym tak:
pushq _GLOBAL_OFFSET_TABLE_+8(%rip)
jmpq *_GLOBAL_OFFSET_TABLE_+16(%rip)
Wywołanie funkcji w kodzie PIC wygląda natomiast tak:
call f@plt
I, w przypadku i386, zakłada, że %ebx
zawiera adres GOT.
Mechanizm działa następująco:
f_GOT_PLT_OFF
jest offsetem w GOT specjalnego slotu dla danego wpisu PLTten slot działa podobnie jak zwykły slot GOT, ale używa
R_<arch>_JMP_SLOT
zamiastR_<arch>_GLOB_DAT
, i jest początkowo ustawiony (przez linker) na offset etykiety f_unbound względem bazy biblioteki. Co więcej, relokacjeR_<arch>_JMP_SLOT
są umieszczane w specjalnej, osobnej tablicy relokacji.rel.plt
dynamiczny linker, widząc ten typ relokacji, wypełni ten slot adresem etykiety
f_unbound
przez dodanie adresu bazowego biblioteki, zamiast od razu szukać symboluf
Kiedy program dojdzie do wywołania
f@plt
po raz pierwszy, zostanie wykonana instrukcja jmp do zawartości slotu, prowadząc do etykietyf_unbound
na stos wrzucany jest offset relokacji
R_<arch>_JMP_SLOT
odpowiadającej temu slotowi wewnątrz sekcji.rel.plt
kod plt0 wrzuca zawartość specjalnego slotu GOT o offsecie 4 (lub 8 na x86_64) na stos - ten slot jest wcześniej wypełniany przez dynamiczny linker i zawiera jakiś uchwyt danego modułu
sterowanie jest przekazywane specjalnej funkcji ze specjalnego slotu GOT o offsecie 8 (lub 16) - ten slot również jest wcześniej wypełniany przez dynamiczny linker i zawiera adres specjalnej funkcji wiążącej symbole
dynamiczny linker, korzystając z dwóch parametrów na stosie, ustala o jaki symbol chodzi, i gdzie należy wpisać jego adres, po czym go wpisuje i przekazuje sterowanie do funkcji
f
kiedy program następnym razem wywoła
f@plt
, slot będzie już wypełniony i sterowanie od razu pójdzie do funkcjif
ET_EXEC - specjalne triki dla wiązania dynamicznego¶
Aby kompilacja głównego programu (plików ET_EXEC
) nie wymagała żadnej
znajomości mechanizmów GOT/PLT ze strony kompilatora, używane są dwa
dodatkowe triki:
jeśli program odwołuje się do zewnętrznego symbolu będącego funkcją, automatycznie jest tworzony wpis PLT dla tej funkcji wewnątrz programu głównego, a adres tego wpisu PLT staje się “oficjalnym” adresem tej funkcji wewnątrz całego procesu (jest to wymagane, aby odniesienia do adresu tej funkcji z kodu programu były wiązane w trakcie linkowania, a
&f
zwracało tą samą wartość w całym programie)jeśli program odwołuje się do zewnętrznego symbolu będącego zmienną, linker automatycznie tworzy kopię tej zmiennej w segmencie danych głównego programu oraz emituje do
.rel.dyn
specjalną relokacjęR_<arch>_COPY
, która spowoduje skopiowanie początkowej zawartości tej zmiennej z modułu, który ją oryginalnie definiował. Tak utworzona kopia zmiennej staje się “oficjalną” lokalizacją tej zmiennej w czasie wykonania, zaś oryginalna zmienna w bibliotece definiującej nie jest już dalej używana.
Struktura _DYNAMIC¶
Struktura _DYNAMIC
jest tabelą par klucz:wartość stanowiącą informację
o zawartości modułu dla linkera dynamicznego. Zawiera przede wszystkim:
adres i rozmiar tabeli symboli biorących udział we wiązaniu dynamicznym
adres i rozmiar tabeli
.rel.dyn
i.rel.plt
adres GOT
listę bibliotek wymaganych przez ten moduł
listę ścieżek poszukiwania bibliotek
Linker znajduje strukturę _DYNAMIC
przez nagłówek programu PT_DYNAMIC
.
Proces uruchamiania programu i linker dynamiczny¶
Uruchamianie programów łączonych statycznie¶
W przypadku programów połączonych statycznie, cały proces inicjalizacji programu jest wykonywany przez jądro. Jądro czyta nagłówek ELF, nagłówki programu, po czym ładuje wszystkie segmenty do pamięci. Następnie tworzy początkowy stan programu:
alokowany jest stos głównego wątku
na stosie głównego wątku są umieszczane:
argumenty programu (
argc
,argv
)zmienne środowiskowe (
environ
)wektor pomocniczy (
auxv
)
wskaźnik instrukcji jest ustawiany na początek programu (z nagłówka ELF). Przy standardowym procesie kompilacji, to pole jest ustawiane przez linker na adres symbolu
_start
program rozpoczyna pracę
Należy zauważyć, że _start
nie jest funkcją - nie używa standardowej
konwencji przekazywania parametrów ani nie może wrócić. Standardowa
implementacja _start
przekazuje parametry funkcji main()
, po czym
wykonuje exit()
z wartością zwróconą przez main()
jako parametrem.
Linker dynamiczny¶
Uruchomienie programu połączonego dynamicznie jest znacznie bardziej
skomplikowane - jądro nie potrafi tego zrobić w całości. Zamiast tego
używany jest specjalny program nazywany linkerem dynamicznym. Program
ten bywa też znany jako ld.so
(od nazwy pliku w którym oryginalnie się
znajdował). Na architekturze i386 w systemie Linux dynamiczny linker
znajduje się w pliku /lib/ld-linux.so.2
, a na architekturze x86_64 -
/lib64/ld-linux-x86-64.so.2
.
Jądro rozpoznaje programy łączone dynamicznie po obecności semgentu typu
PT_INTERP
, który zawiera nazwę pliku zawierającego linker dynamiczny.
Gdy taki znajdzie, zamiast przekazać sterowanie do programu po załadowaniu
go, ładuje dodatkowo i uruchamia wskazany linker dynamiczny (który jest
plikem typu ET_DYN
).
Linker dynamiczny rozpoczyna pracę przez znalezienie własnej sekcji
_DYNAMIC
i wypełnienie własnych relokacji. W następnej fazie, linker
przegląda wektor pomocniczy (auxv
) przekazany przez jądro. Jest to
lista par klucz:wartość opisująca stan procesu i jego środowisko.
Zawiera np. informacje o położeniu nagłówków programu głównego pliku
wykonywalnego w pamięci. Po zlokalizowaniu pliku wykonywalnego, linker
ładuje (rekurencyjnie) jego zależności. Następnie, linker wypełnia
wszystkie relokacje z .rel.dyn
, zapycha stubami relokacje z .rel.plt
,
a na koniec przekazuje sterowanie do głównego programu (przez wykonanie
wskazanego w jego nagłówku ELF punktu wejścia).
Linker dynamiczny pozostaje w pamięci po załadowaniu programu i możliwe
jest dalsze używanie jego funkcji w celu otwarcia dodatkowych bibliotek,
poszukiwania symboli, itp. używając funkcji dlopen
, dlsym
, i innych.
Funkcje te dostępne są przez łączenie z biblioteką libdl
.
Przydatne polecenia¶
Kompilacja pliku źródłowego w trybie PIC:
gcc -c x.c -fPIC
Uwaga: istnieją dwie różne opcje włączające PIC: -fpic
oraz -fPIC
. Ich
dokładne znaczenie zależy od architektury. Jeśli się różnią, -fpic
używa
krótszych sekwencji kodu, ograniczonych jednak do mniejszych programów
(np. limit 1021 wpisów GOT na SPARCu). Na x86 obie wersje generują
taki sam kod.
Kompilacja i linkowanie biblioteki dynamicznej:
gcc x.c -o libx.so -Wl,-soname=libx.so -shared -fPIC
Zrzut kodu, tabeli sekcji, symboli, i innych danych o pliku z kodem:
objdump -xtrds <plik>
Zrzut informacji o strukturach ELF:
readelf -a <plik>
Zrzut tabeli symboli:
nm <plik>
Zrzut tabeli symboli dynamicznych:
nm -D <plik>
Lista bibliotek używanych przez program:
ldd <program>
Thread-Local Storage¶
TLS jest dość skomplikowanym rozszerzeniem ELFa i języka C. Jest to mechanizm dodający nową klasę zmiennych do języka (obok zmiennych lokalnych i globalnych): zmienne wątkowe. Deklaruje się je tak (poza zakresem funkcji):
_Thread_local int x;
Zmienne wątkowe zachowują się podobnie do zmiennych globalnych, lecz każdy działający wątek ma ich własną instancję. Pełna implementacja jest dość skomplikowana (ze względu na możliwość dynamicznego tworzenia zarówno wątków, jak i modułów definiujących zmienne wątkowe), więc ograniczymy się tutaj do ogólnego zarysu.
Zmienne wątkowe przechowywane są na etapie kompilacji w sekcjach .tdata
i .tbss
, mających dodatkową flagę SHF_TLS
. Same zmienne mają zaś typ
STT_TLS
. Przy linkowaniu, wszystkie takie sekcje w module zbierane są
w jedno miejsce i tworzony jest segment typu PT_TLS
opisujący to miejsce.
W trakcie wykonania, zmienna wątkowa może być przechowywana w jednym z dwóch miejsc:
główny blok TLS: zawiera wszystkie segmenty TLS należące do głównego programu i bibliotek załadowanych razem z nim
dodatkowe bloki TLS: zawierają pozostałe segmenty TLS (tzn. te od bibliotek załadowanych przez
dlopen
)
Wskaźnik na listę dodatkowych bloków jest przechowywany w głównym bloku
dla danego wątku. Wskaźnik na główny blok jest przechowywany w jakimś
rejestrze procesora (%gs
na i386, %fs
na x86_64). Bloki dodatkowe
są alokowane leniwie.
Istnieją 4 modele dostępu do zmiennych wątkowych, używane w zależności od sytuacji:
global dynamic: najbardziej ogólny, ładuje z GOT uchwyt biblioteki zawierającej symbol i offset zmiennej w segmencie TLS tej biblioteki, wywołuje
__tls_get_addr
aby pobrać adres tego segmentu (być może go alokując)local dynamic: jak global dynamic, ale zakłada, że jesteśmy w tym samym module co zmienna - w przypadku dostepu do kilku zmiennych adres segmentu TLS jest pobierany tylko raz
initial executable: używany w ogólnym przypadku w programach
ET_EXEC
i innych sytuacjach dających gwarancję, że zmienna jest w głównym bloku TLS - ładuje z GOT offset zmiennej w głównym bloku TLSlocal executable: używany w programach
ET_EXEC
do dostępu do jego własnych zmiennych - po prostu wpisuje offset w głównym bloku TLS do kodu programu
Debugowanie i obsługa wyjątków: DWARF¶
DWARF¶
Formatem blisko związanym z ELFem jest format informacji debuggera DWARF
(Debugging With Attributed Record Formats). Definiuje on wiele specjalnych
sekcji (o nazwach zaczynających się od .debug
), które zawierają informacje
przydatne do debugowania programu, np.:
informacje o numerach linii (
.debug_loc
)informacje o formacie ramek stosu (
.debug_frame
)informacje o typach i lokalizacjach zmiennych (
.debug_info
)informacje o makrach użytych w programie (
.debug_macro
)
Takie informacje są dołączane przez kompilator tylko, gdy zostanie o to
poproszony (-g
).
Obsługa wyjątków, mechanizm unwind¶
Jednym z najbardziej skomplikowanych mechanizmów wymaganych do pełnej implementacji C++ jest obsługa wyjątków. Taki mechanizm ma trzy zadania:
przejść przez listę wszystkich ramek stosu z ustawioną obsługą wyjątków
ustalić, która z nich zawiera właściwą funkcję obsługi
odtworzyć stan rejestrów z odpowiedniej ramki i wywołać w niej kod obsługi wyjątków
Mechanizm stack unwinding (odwijania stosu) powstał, aby rozwiązać
pierwsze i trzecie zadanie. Używa on sekcji .eh_frame
bardzo podobnej
(ale nie identycznej) do .debug_frame
w celu przejścia przez cały stos
i odtworzenia informacji o stanie procesora w momencie każdego wywołania.
Literatura¶
Linker and Libraries Guide, 2004, Sun Microsystems (rozdziały 7 i 8) — http://docs.oracle.com/cd/E19683-01/817-3677/817-3677.pdf
psABI-i386: https://github.com/hjl-tools/x86-psABI/wiki/intel386-psABI-1.1.pdf
psABI-x86_64: https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf
man dlsym, dlopen
ELF handling for thread local storage: www.akkadia.org/drepper/tls.pdf
The DWARF debugging standard: http://www.dwarfstd.org/