Linkerele unesc mai multe fișiere compilate (obiect) pentru a genera un singur fișier executabil.
Loaderele încarcă un program executabil la o anumită adresă în memorie.
Acestea permit construirea programelor din module, în locul unui program mare și monolitic. Astfel, una din principalele sarcini ale linkerelor și loaderelor este să lege numele abstracte folosite de programatori (ex: numele funcțiilor din biblioteci) și să le transforme în nume concrete (ex: locația deplasată cu 512 octeți de la începutul secvenței codului executabil din memorie sau o adresă numerică).
La începuturile erei calculatoarelor, programarea se făcea în totalitate în cod mașină. Programatorii scriau programele simbolice pe hârtie și le transformau de mână în cod mașină. Dacă programatorul folosea nume simbolice (de exemplu un nume de procedură) acestea trebuiau transformate de mână în adrese și, dacă apoi se descoperea că o instrucțiune trebuie adăugată într-un anumit loc în program, acesta trebuia verificat pentru a fi modificate toate adresele afectate de acea instrucțiune.
Problema în aceasta abordare era că numele erau legate de o adresă fixă prea devreme în procesul de dezvoltare. Această problemă a fost rezolvată cu ajutorul asambloarelor ce permiteau folosirea de nume simbolice. Dacă programul trebuia schimbat, programatorul trebuia să îl reasambleze, dar această muncă de a atribui adresele fizice nu mai era făcută de el.
Bibliotecile complică această problemă (existau biblioteci de cod încă înaintea apariției asambloarelor). Aceste biblioteci ar trebui să fie încărcate în memorie și să poată fi accesate din programul care le apelează (eventual să fie încărcate în același loc cu celelalte metode ale programului). În general aceste biblioteci vin sub o formă binară (compilată) și ar trebui ca adresele lor sa poată fi atribuite în funcție de adresa efectivă la care sunt încărcate.
Pentru a rezolva această problemă a fost introdusă noțiunea de cod relocabil. Programatorii și asamblorul scriau module care începeau atribuirea adreselor relative de la 0 și amânau atribuirea adreselor absolute (adrese fizice) până în momentul linkeditării.
Odată cu apariția sistemelor de operare, a devenit necesară o separare a linkerelor de loadere. Înainte programele puteau fi asamblate și link-ate cu adrese fixe pentru că programele aveau toată memoria la dispoziție, dar în contextul sistemelor de operare, în momentul în care modulele erau legate, programele nu știau la ce adresă vor fi încărcate.
Astfel linkerele făceau partea de agregare a modulelor și foloseau adrese relative, în timp ce loaderele le transformau în adrese absolute odată cu încărcarea programelor în memorie.
Această evoluție a continuat și a mai parcurs o serie de stagii marcate în principal de apariția blocurilor de date partajate de mai multe programe (linkerul trebuia să asigure suportul pentru accesul la aceste date), împărțirea programelor în secțiuni (linkerul trebuie să combine toate secțiunile de fiecare tip din fiecare modul), apariția bibliotecilor dinamice etc.
Atât linkerele cât și loaderele lucrează (sau modifică) cod obiect și sunt cam singurele instrumente de largă răspândire care lucrează cu acest tip de cod (în afara debuggerelor).
Există trei mari acțiuni pe care le îndeplinesc:
Este o acțiune făcută și de linker și de loader.
Compilatoarele și asambloarele creează, în general, fișiere obiect cu adrese relative (pornind de la 0). Relocarea este procesul prin care se atribuie adrese de încărcare diferitelor părți din program, ajustând codul și datele în program pentru a reflecta adresele atribuite. Un program poate fi relocat de mai multe ori. De regulă, un linker creează un program mare cu adresele pornind de la zero din mai multe subprograme (fiecare fiind creat cu adrese pornind de la zero) care vor fi introduse în zone diferite în programul mare. La încărcare, loaderul va reloca executabilul produs de linker la o adresă efectivă în memorie.
Este o acțiune făcută de linker.
Într-un program compus din mai multe subprograme se pot face referințe între subprograme prin simboluri (de ex. proceduri sau variabile externe: un apel către sqrt() din biblioteca matematică). Rezolvarea unui simbol se face înlocuind toate utilizările simbolului cu adresa la care acesta este definit.
Este o acțiune făcută de loader.
Încărcarea este procesul de aducere a unui program de pe un mediu de stocare secundar (hard-disk, SSD, flash etc.) în memoria principală astfel încât programul să fie gata de execuție. Această acțiune poate varia de la simpla copiere a datelor până la alocarea spațiului, setarea unor biți de protecție ai memoriei (de ex. paginile de cod sunt read-only) sau maparea unor zone de memorie virtuală pe spații de pe disk/flash/SSD/etc.
Un singur program poate face toate cele trei funcții amintite mai sus. Limita dintre relocare și identificarea simbolurilor poate fi destul de confuză. Din moment ce linkerele pot rezolva referințe la simboluri, un mod de a trata relocarea este de a atribui un simbol adresei de start a fiecărei părți din program și apoi de a trata relocarea adreselor ca referințe la adresa simbolului de bază.
Fișierele ELF pot fi de trei tipuri (puțin diferite):
Compilatoarele, asambloarele și linkerele tratează fișierele ELF ca un set de secțiuni logice descrise de un section header table. Loaderul tratează fișierele ELF ca un set de segmente descrise de un program header table, un segment fiind de obicei compus din mai multe secțiuni.
Un fișier relocabil sau partajat este considerat o colecție de secțiuni definite în header. Fiecare secțiune conține un singur tip de informație (ex: codul programului, date read-only sau read-write, intrări relocabile, simboluri, etc.). Fiecare simbol definit este relativ la o secțiune (ex: punctul de intrare al unei proceduri va fi definit relativ la secțiunea care conține codul programului).
Există două pseudo-secțiuni:
Un executabil relocabil are în jur de 12 secțiuni. Numele secțiunilor au semnificație pentru linker, care caută anumite secțiuni pentru prelucrări specifice. Tipurile de secțiuni includ:
Se pot defini și secțiuni proprii, însă vom enumera câteva dintre secțiunile tipice (standard):
Fișierele executabile au același format, dar datele sunt aranjate astfel încât fișierul poate fi mapat direct în memorie și rulat. Conține un header de program care urmează headerului ELF din fișier și definește segmentele care trebuie mapate.
Un executabil are în general doar câteva segmente: unul read-only pentru cod și datele read-only și unul read-write pentru datele read-write. Toate secțiunile sunt împachetate în segmentele corespunzătoare astfel încât sistemul poate mapa simplu fișierul în memorie.
Utilitarul ld
poate fi configurat atât din linia de comandă, cât și printr-un limbaj specific: Linker Command Language. Fișierele de configurare au extensia .lds
(ld script) și constituie o înșiruire de comenzi:
Secțiuniile de cod sunt definite folosind comanda SECTIONS
, urmată de diverse atribute. Exemplu:
SECTIONS { /* * „.” (contorul de locație) este setat la 0x10000 * implicit, la începutul comenzii SECTIONS are valoarea 0 * este incrementat automat la includerea unei secțiuni cu dimensiunea acesteia */ . = 0x10000; /* * adună toate secțiunile .text din fișierele de intrare * și pune-le în secțiunea .text în fișierul de ieșiere * * dacă nu există nici o secțiune .text în nici un fișier de intrare * nu se generează în fișierul de ieșire secțiunea .text */ .text : { *(.text) } /* adresa de la care se scriu date este 0x8000000 */ . = 0x8000000; /* similar text */ .data : { *(.data) } /* similar text */ .bss : { *(.bss) } }
Comanda ENTRY
setează entry-pointul programului (prima instrucțiune de executat). Dacă comanda lipsește, atunci se va utiliza valoarea simbolului .start
(dacă este definit), sau valoarea simbolului .text
(dacă este definit), sau, dacă nu, valoarea 0.
Pentru a defini un simbol nou se poate folosi comanda PROVIDE
în cadrul comenzii SECTIONS
. Exemplu:
SECTIONS { .text : { *(.text) } PROVIDE(__data_start = .); .data : { *(.data) } PROVIDE(__data_stop = .); PROVIDE(__bss_start = .); .bss : { *(.bss) } PROVIDE(__bss_stop = .); }
Pentru a folosi simbolurile definite în exemplul anterior se poate proceda ca în exemplul următor:
#include <stdio.h> extern void * __data_start, * __data_stop, * __bss_start, * __bss_stop; int main(void) { printf(".data starts at %p and ends at %p\n", &__data_start, &__data_stop); printf(".bss starts at %p and ends at %p\n", &__bss_start, &__bss_stop); return 0; }
În GCC se pot specifica pentru anumite tipuri de simboluri comportamente speciale. Sintaxa prin care se decorează un element cu un atribut este următoarea:
type_of_element element_name __attribute__ ( attribute_goes_here );
Unul din atributele pe care GCC le suportă este specificarea secțiunii în care va fi inclus simbolul generat:
// definim variabila x, o inițializăm cu 7 // și specificăm compilatorului să o pună în secținea ".cpl" int x __attribute__ ((section (".cpl"))) = 7;
LD_LIBRARY_PATH - o listă de directoare separate prin :
ce specifică directoarele în care linkerul să caute biblioteci, înainte de a se uita în directoarele standard
LD_PRELOAD - calea către o bibliotecă dinamică ce va fi încărcată în locul unei alte biblioteci; spre exemplu astfel se poate înlocui biblioteca standard C
În rezolvarea laboratorului folosiți arhiva de sarcini lab12-tasks.zip.
Pe măsura ce rezolvați exercițiile, nu uitați că modificarea optiunilor compilării implică și recompilarea fișierelor sursă.
Linkerul este responsabil de rezolvarea numelor. Fiecare fișier obiect conține o tabelă de simboluri. În continuare vom analiza cum arată aceste tabele pentru diverse tipuri de funcții în C și în C++.
Pentru a vizualiza tabela de simboluri vom utiliza utilitarul nm
.
Intrați în directorul 1-name/c
. Vom analiza pe rând fiecare din cele 3 fișiere.
Pentru a genera fișierele obiect, rulați comanda make obj
. Vom avea nevoie de acestea pentru a vizualiza tabelele de simboluri. Tabela de simboluri poate fi vizualizată rulând comanda:
nm -s $FIȘIER_OBIECT
Deschideți fișierul main-only.c
. Programul apelează funcția printf
, fără a include niciun header specific. Vizualizați tabela de simboluri. Ce observați? Ce înseamnă T
? Dar U
? Consultați pagina de manual a utilitarului nm.
Deschideți fișierul simple-functions.c
. Programul definește mai multe funcții având diverse tipuri de argumente. Vizualizați tabela de simboluri. Ce observați? În ce ordine sunt listate funcțiile?
Deschideți fișierul static-function.c
. Programul definește o funcție statică și o apelează din funcția main. Vizualizați tabela de simboluri. Ce observați? Ce înseamnă t
? Consultați pagina de manual a utilitarului nm.
Intrați în directorul 1-name/cpp
. Vom analiza pe rând fiecare din cele 5 fișiere sursă.
Pentru a genera fișierele obiect, rulați comanda make obj
. Vom avea nevoie de acestea pentru a vizualiza tabelele de simboluri. Tabela de simboluri poate fi vizualizată rulând comanda:
nm -s $FIȘIER_OBIECT
Deschideți fișierul main-only.cpp
. Programul include header-ul iostream
și importă în cadrul namespace-ului curent streamul cout
. Vizualizați tabela de simboluri. Ce observați? Folosiți parametrul C
al utilitarului nm
pentru a înțelege mai bine simbolistica outputului. Consultați pagina de manual a utilitarului nm.
Deschideți fișierul simple-functions.cpp
. Programul definește mai multe funcții având diverse tipuri de argumente. Vizualizați tabela de simboluri.
Deschideți fișierul static-function.cpp
. Programul definește o funcție statică și o apelează din funcția main. Vizualizați tabela de simboluri.
Deschideți fișierele myClass.hpp
și myClass.cpp
. Cele două definesc clasa myClass
. Vizualizați tabela de simboluri. De ce sunt două simboluri diferite pentru constructorul fără argumente? Citiți explicația de aici.
Deschideți fișierul useClass.cpp
. Programul folosește clasa definită anterior. Vizualizați tabela de simboluri.
Rezolvarea simbolurilor de către linker se face în următoarea ordine:
Intrați în directorul 2-symbols
. Vizualizați conținutul fișierelor sursă c. O să găsiți trei implementări diferite ale funcției function
.
Rulați comanda make
pentru a genera:
libA.so
ce implementează funcția function
în fișierul a.c
libB.so
ce implementează funcția function
în fișierul b.c
exe12
compilat astfel gcc main.o -lA -lB -L. -o exe12
exe21
compilat astfel gcc main.o -lB -lA -L. -o exe21
exe-static
compilat astfel gcc main.o c.o -lA -lB -L. -o exe-static
Setați variabila de mediu LD_LIBRARY_PATH corespunzător, pentru ca linkerul să găsească bibliotecile generate în directorul curent.
Executabilul exe12
a fost generat linkând 2 biblioteci dinamice ce implementează funcția function
. Care este ordinea bibliotecilor dinamice în linia de comandă? Rulați binarul pentru a observa outputul produs.
Executabilul exe21
a fost generat linkând 2 biblioteci dinamice ce implementează funcția function
. Care este ordinea bibliotecilor dinamice în linia de comandă? Rulați binarul pentru a observa outputul produs.
Executabilul exe-static
a fost generat linkând 2 biblioteci dinamice și un fișier obiect ce implementează funcția function
. În ce ordine au fost specificate în linia de comandă? Rulați binarul pentru a observa outputul produs.
Presupunem că avem un set de binare ale unei aplicații/biblioteci ale cărei surse nu le mai avem (de ex. am cumpărat licența pentru o bibliotecă proprietară și compania care producea acea bibliotecă s-a închis). Dorim să modificăm aplicația/biblioteca, fără a modifica acele binare. :)
Neavând acces la surse putem spune linkerului să routeze toate apelurile către un anumit simbol către o funcție specială aflată sub controlul nostru. Dacă se trimite argumentul –wrap=symbol
linkerului acesta:
symbol
cu referințe către __wrap_symbol
__real_symbol
care va trimite către varianta originală a lui symbol
Intrați în directorul 3-wrap
. Vizualizați fișierul main.c
. Programul încearcă să scrie un mesaj în fișierul /bad/path
și eșuează din lipsa permisiunilor și a existenței directoarelor superioare. Pentru a compila fișierul, rulați comanda make default
și rulați executabilul exe_default
.
Fără a modifica fișierul main.c
, veți suprascrie funcția open
așa cum am descris mai sus, astfel încât să obțineți un file descriptor valid. Implementarea o veți realiza în cadrul fișierului wrap.c
. Pentru a compila, rulați comanda make wrapped
, apoi rulați executabilul exe_wrapped
.
Consultați secțiunea Linker Command Language din laborator.
Intrați în directorul 4-lcl
. Aveți definit în cadrul fișierului def.h
un macro care primește numele unei funcții și generează un pointer la funcție și-l pune în secținuea .cpl
:
// def.h #define MODULE_INIT(X) \ static void * __ptr_init_cpl_##X __attribute__ ((section (".cpl"))) = &X;
De asemenea, în director veți găsi un set de fișiere cu funcții de inițializare pentru care s-a apelat macro-ul de mai sus.
Practic, în secțiunea .cpl
se vor adăuga pointeri către toate funcțiile.
Modificați scriptul de linker din cadrul directorului pentru a introduce o secțiune de date specială .cpl
care să adune secțiunile .cpl
din fiecare fișier obiect de intrare, conform TODO-ului din script (revedeți secțiunea PROVIDE). Apoi, în cadrul fișierului main.c
, exportați două simboluri pentru începutul și sfârșitul acestei secțiuni și iterați prin toți pointerii, apelând pe rând funcția către care aceștia trimit (puteți pasa orice întreg ca parametru).
Scopul acestui exercițiu este de a suprascrie o funcție de bibliotecă (similar ca la exercițiul 3).
Implementați acest lucru folosindu-vă de variabila de mediu LD_PRELOAD
.
Refaceți exercițiul 2 fără a folosi variabila de mediu LD_LIBRARY_PATH
.
Folosiți-vă de run-time search path.
Citiți secțiunea GNU ld.so
pentru a înțelege algoritmul aplicat de linker pentru a căuta biblioteci dinamice.