12. Linkers and Loaders

Nice to read

Acest laborator va abunda de rom-engleză, în special de cuvintele linkăre și loadăre. :)

Prezentare teoretică

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ă).

Asambloare

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.

De ce linkere?

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.

De ce loadere?

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.

Linkere vs. Loadere

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:

  • relocarea
  • rezolvarea simbolurilor
  • încărcarea

Relocarea

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.

Rezolvarea simbolurilor

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.

Încărcarea

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.

Linking loaders

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ă.

Formatul Unix ELF

Fișierele ELF pot fi de trei tipuri (puțin diferite):

  • relocabile - create de compilatoare sau asambloare, dar trebuie procesate de linker înainte de execuție
  • executabile - gata relocate și cu toate simbolurile rezolvate exceptând, poate, simbolurile din bibliotecile partajate care vor fi rezolvate la runtime
  • partajate - biblioteci comune ce conțin informații despre simboluri (folosite de linker) și cod executabil

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.

Fișiere ELF relocabile și partajate

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:

  • SHN_ABS - conține simboluri ne-relocabile
  • SHN_COMMON - conține blocuri de date neinițializate, moștenire din formatul a.out

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:

  • PROGBITS - conținut al programului: cod, date, informații pentru debugger
  • NOBITS - la fel ca și PROGBITS, dar nu alocă spațiu în fișierul propriu zis, ci folosește BSS pentru alocarea datelor la încărcarea programului
  • SYMTAB - tabelă ce conține toate simbolurile
  • DYNSYM - tabelă ce conține simbolurile folosite pentru linkarea dinamică
  • STRTAB - tabelă de stringuri
  • RELA - valorile de bază pentru relocare
  • REL - valorile cu care se fac relocările (valorile sunt adăugate la valoarea de bază)
  • DYNAMIC - informații pentru linkarea dinamică
  • HASH - tabelă de simboluri folosite la runtime

Se pot defini și secțiuni proprii, însă vom enumera câteva dintre secțiunile tipice (standard):

  • .text - de tip PROGBITS (cu modificatori ce permit execuția instrucțiunilor), este echivalentul segmentului text din a.out
  • .data - de tip PROGBITS (cu modificatori ce permit scrierea datelor), este echivalentul segmentului de date din a.out
  • .rodata - date read-only
  • .bss - de tip NOBITS (cu modificatori ce permit scrierea); nu ocupă spațiu în fișier și este alocată la runtime
  • .init și .fini - cod care este executat la inițializarea/finalizarea programului; spre exemplu, compilatoarele de C++ folosesc aceste secțiuni pentru a inițializa datele globale alocate static
  • .symtab și .dynsym - tabele de simboluri
  • .strtab și .dynstr - tabele de stringuri

Fișiere ELF executabile

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.

Linker Command Language

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:

  • cuvânt cheie [argumente]
  • atribuiri unui simbol
  • comentarii

SECTIONS

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) }
}

ENTRY

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.

PROVIDE

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;
}

Atribute GCC

Î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;

Variabile de mediu folosite de linker

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

Exerciții de laborator (15p)

Î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ă.

Exercițiul 1 - name mangling (4p)

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.

Denumirea simbolurilor nu este standard și este strâns dependentă de compilator și de platformă.

Name mangling în C (2p)

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.

Name mangling în C++ (2p)

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.

Exercițiul 2 - rezolvarea simbolurilor (2p)

Rezolvarea simbolurilor de către linker se face în următoarea ordine:

  • simbolul este căutat în fișierul obiect în care este folosit prima oară
  • simbolul este căutat în oricare din fișierele obiect sau bibliotecile statice cu care este compilat (în ordinea în care acestea apar ca argumente ale linkerului)
  • simbolul este căutat în bibliotecile partajate (în ordinea în care acestea apar în linia de comandă)

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:

  • fișierele obiect corespunzătoare fiecărui fișier sursă
  • biblioteca dinamică libA.so ce implementează funcția function în fișierul a.c
  • biblioteca dinamică libB.so ce implementează funcția function în fișierul b.c
  • executabilul exe12 compilat astfel gcc main.o -lA -lB -L. -o exe12
  • executabilul exe21 compilat astfel gcc main.o -lB -lA -L. -o exe21
  • executabilul 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.

Exercițiul 3 - instrumentarea binarelor (3p)

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:

  • va înlocui toate referințele către simbolul symbol cu referințe către __wrap_symbol
  • va genera un simbol __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.

Hint: man ld, apoi căutați –wrap

Exercițiul 4 - Linker Command Language (2p)

Dacă lucrați pe calculatoarele proprii și acestea sunt pe 64 de biți, instalați gcc-multilib pentru a putea compila cross-platform: sudo apt-get install gcc-multilib

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).

BONUS

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.

cpl/labs/12.txt · Last modified: 2017/01/11 01:06 by bogdan.nitulescu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0