Linker¶
Mint látható, eddig a pontig minden C forrásnak "egyenes'" az útja: a header fájlokat nem számolva egy C forrásból egy object keletkezik. Eddig a pontig kivétel nélkül bármelyik helyes C forrás eljuttatható.
Futtatható program azonban csak olyan forrásból készíthető, amelyben van main()
függvény.
A linker feladata, hogy az object fájl(ok)ból -- amely csak a C forrásfájlban szereplő deklarációkat és definíciókat tartalmazza -- előállítsa a futtatható programot.
Első megközelítésben a linker olyan object fájlból tud futtatható programot csinálni, amiben van main()
függvény.
De miért kell ehhez linker? És mire jó a többi object (amiben nincs main
?)
Ahhoz, hogy ezt megértsük, meg kell ismerkednünk a modulok fogalmával.
Feladat
Mi történik akkor, ha egy olyan állományt szeretnénk lefordítani, amelynek nincs main
metódusa? Írjunk egy ilyen c programot, majd fordítsuk le!
Megoldás
A program linkelése során a következő hibát kapjuk:
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64- linux-gnu/Scrt1.o: in function `_start':
(.text+0x17): undefined reference to `main'
collect2: error: ld returned 1 exit status
Modulok¶
A korszerű programozási nyelvek lehetővé teszik programok mellett programrendszerek nyelvi szinten történő kezelését is. A programrendszer nem más, mint modulok hierarchiája. A modul olyan programozási egység, amely programelemek (konstans, típus, változó, függvény) együtteséből áll.
A modul két részre osztható:
- Interfészre, ami a modul más modulok (programok) által is látható és felhasználható közösségi része, és
- Implementációra, ami a modul privát, kívülről nem látható része.
A modulnak e két részre osztása biztosítja a felhasznált modulok stabilitását és a privát elemek védelmét.
A modulok természetes egységei a fordításnak, tehát minden modul külön fordítható. Fontos hangsúlyozni, hogy a külön fordítás nem független fordítást jelent. Következésképpen a modulok két formában léteznek:
- Forrásnyelvi forma.
- Lefordított forma.
C-ben alapvető egységként adódik az egy C forrás/object, mint legkisebb lehetséges modul.
.o
kiterjesztésű object fájl, ekkor a modul lefordított formája lesz,.c
kiterjesztésű forrás a modul privát részének nyelvi formája,- a modul közösségi részét pedig a már említett
.h
kiterjesztésű header fájlok valósítják meg.
A header és forrás fájlok tehát általában párosan léteznek:
A .h
kiterjesztésű header fájl tartalmazza az összes olyan változó és függvény deklarációját, illetve konstans és típus definícióját, ami a modul interfészét, közösségi részét képezi.
Ha valaki használni akarja a modult, csak include-olnia kell ezt a header fájlt, és máris használhatja az ebben deklarált dolgokat.
A.c
kiterjesztésű forrás pedig tartalmazza a közösségi részben deklarált függvények definícióit, illetve a modul teljes privát részét, tehát az implementációt.
Ahhoz, hogy olyan programot (vagy másik modult) írjunk, ami a mi modulunkat használja, elegendő a modulunk header fájlját ismerni, azaz a programban include-olni.
Az adott program forrásába a megfelelő #include
helyére a preprocesszor bemásolja a mi header fájlunk tartalmát, így a program ismerni fogja az abban szereplő programelemeket, és object szintig lefordítható lesz.
Egy-egy programot vagy modult le tudunk fordítani object szintig, függetlenül attól, hogy milyen más modulokat használ.
A main()
nélküli objectekkel többet már nem nagyon tehetünk, maximum egy archiver segítségével össze tudjuk őket gyűjteni egy úgynevezett függvénykönyvtárban, ami valójában egy olyan fájl, ami objecteket tartalmaz.
A main()
függvénnyel rendelkező objectekből viszont programok készíthetők.
De egy-egy ilyen object önmagában eléggé hiányos lehet, hiszen a program a modul felhasznált függvényeinek csak a deklarációját ismeri, a függvény megvalósítása a modul object fájljában van. A linker feladata, hogy az ilyen objecteken átívelő hivatkozásokat feloldja, és egyetlen futtatható programot állítson elő sok .o
fájlból.
A sok .o
közül pontosan egynek tartalmaznia kell a main()
függvényt, hiszen az operációs rendszer majd ezt "hívja meg" a program indításakor.
Komplexebb programok fordítását a következő ábra szemlélteti:
Az is lehet, hogy több object állományt egy archívumba csomagolunk, ezáltal a logikailag összetartozó objecteket közelebb hozzhatjuk egymáshoz:
Amikor fordítunk, nem kell mindig minden forrást újrafordítani. A korábban már elkészült (és azóta nem változtatott források) object állományai felhasználhatóak a fordításhoz, elég, ha ezeket a végső linkelésnél szerkesztjük hozzá a futtatható programhoz. Ahhoz, hogy az újonnan fordítandó elemet fordítani tudjuk, elegendőek a header állományok, a linkelésnél szükséges a többi object (vagy az objectek archivált változata):
Látható tehát, hogy egy-egy modul több programhoz is felhasználható, gondoljunk csak például a math.h
-ra.
Az ebben deklarált függvények (pl.: sin()
) megvalósítása egy libm.a
függvénykönyvtárban van tárolva.
Az #include <math.h>
"csak" a függvények deklarációit tartalmazza, amellyel már fordítható a forráskód, de önmagában még nem készíthető belőle futtatható program.
A fordításkor megadott -lm
kapcsoló mondja meg a linkernek, hogy a m.a
fájlban is keresgéljen függvények után.
Meg kell jegyeznünk, hogy a modul kifejezés általában nem csupán egyetlen .o
fájlt, hanem egy vagy akár több függvénykönyvtárat is takarhat.
A függvénykönyvtárakból csak azok az eljárások kerülnek bele a programba, amiket valóban meg is hívunk.
Megvalósítás elrejtése¶
Programrendszer készítésekor az egyes modulokat célszerű úgy tervezni, hogy a header-ben ne legyenek láthatók azok a programelemek, amelyek csak a megvalósításhoz kellenek. Ennek két oka is van:
- Egyrészt ezzel biztosítani tudjuk, hogy a modult felhasználó csak azokhoz a programelemekhez tud hozzáférni, amit a műveletek szabályos használata megenged, ezzel védettséget biztosítunk a lokális elemek számára.
- Másrészt a megvalósításhoz használt elemek elrejtése az implementációs részben azt eredményezi, hogy a megvalósítás módosítása, megváltoztatása esetén nem kell a programrendszer más modulját változtatni, pl. újrafordítani.
Nyilvánvalónak tűnik, hogyha egy függvény deklarációja nem szerepel a modul header fájljában, akkor az a függvény más modulok által nem használható (közvetlenül nem hívható).
Ez viszont így nem igaz.
A linker nem tudja, honnan származik a deklaráció.
Ha valaki saját magának deklarálja a függvényt, a linker akkor is megtalálja azt a modul object fájljában.
Ha viszont egy függvényt a static
kulcsszóval deklarálunk, akkor azt a linker nem fogja látni, hiába kerül bele az object fájlba.
Vagyis, a headerben .h
nem szereplő, kizárólag a megvalósításhoz tartozó függvényeket a modul megvalósításában.c
ajánlott a static
kulcsszóval deklarálni, hogy valóban el legyenek rejtve a "külvilág" elől.
A többféle programelem közül az adattípusok elrejtése okozhat még problémát.
Azoknak az eljárásoknak a deklarációjában ugyanis, amelyek a probléma megoldását adják -- tehát a headerben kell lenniük -- meg kell adni a paraméterek típusát, jóllehet a típus definícióját csak a modul .c
forrásában kellene megadni, mert ezek megadása már a megvalósításhoz tartozik. Adattípus megadásának elhalasztása, vagyis elrejtése a void*
pointer felhasználásával megoldható.
Az elrejtés technikája lehetővé teszi, hogy modul tervezésekor meg tudjuk adni az interfész végleges alakját, anélkül, hogy a megvalósítás bármely részletét is ismernénk. Ez különösen hasznos absztrakt adattípusok tervezése és megvalósítása esetén.
Optimalizálás¶
A linkelés fázisban is van mód optimalizálásra.
A külön fordított modulok hátránya, hogy bizonyos észszerű optimalizálásokat (mint például a function inlining) nem lehet fordítási időben elvégezni, ha azok több modult érintenek.
A-flto
kapcsoló hatására a linker utólag lefuttatja ezeket az optimalizáló algoritmusokat.
A kapcsolót már fordításkor is meg kell adni, különben a fordító nem teszi bele a link-time optimalizáláshoz szükséges információt az object fájlokba.
Statikus és dinamikus linkelés¶
A modern operációs rendszereken a linkelés kétféleképpen oldható meg: statikusan és dinamikusan (illetve vegyesen, mint látni fogjuk).
A statikus linkelés azt jelenti, hogy a linker a megfelelő függvényeket tartalmazó object-eket fizikailag hozzáragasztja a programhoz, így a program tulajdonképpen tartalmazza ezen függvények megvalósítását. Így amikor futtatás előtt a program kódja betöltődik a memóriába, ezek a függvények is betöltődnek, vagyis (ezek miatt legalábbis) semilyen extra dologra nincs szükség a futtatáshoz.
A dinamikus linkelés során ezzel szemben a linker nem pakolja bele a függvények kódját a programba. Csak azt az infót teszi bele, hogy melyik függvények vannak meghívva, és azok melyik (shared) object fájlban találhatók. Amikor az operációs rendszer betölti a programot a memóriába, akkor a linker által berakott információ alapján megkeresi a megfelelő (shared) object fájlokat, és azokat is ugyanúgy betölti, és a memóriában hozzáköti a programhoz. Így válik betöltés után teljessé és futtathatóvá a program.
Jó, de akkor mi a dinamikus linkelés előnye?
Egyelőre röviden: ha sokan használják ugyanazokat a függvényeket, akkor nincs mindenkinek "saját" példánya, hanem rendszer szinten osztoznak egy példányon. Ez helyfoglalás szempontjából eléggé előnyös tud lenni, és nem csak a háttértáron tárolt programok mérete, de az egész rendszer memóriafelhasználása is kedvezőbb lesz (erről később).
A modern operációs rendszerekre, és így az ezekre programot készítő linkerekre a dinamikus linkelés jellemző (bár verziózási és kompatibilitási problémák miatt van egy ezzel ellentétes tendencia is).
Vagyis amit nem mi írtunk, csak felhasználtunk a programban, azt alapvetően dinamikusan teszi hozzá, a saját kódot pedig statikusan szerkeszti össze.
(Vagyis valójában vegyes linkelés folyik.)
A linkert viszont rá lehet venni, hogy teljesen statikusan linkeljen -static
kapcsoló használatával.
A dinamikus linkeléshez a lib-et is kicsit másként kell fordítani. Statikus linkelés esetén a linker fordítási időben egymás mellé tudja pakolni a függvényeket, így fordítási időben tudni fogja nem csak azt, hogy melyik függvény milyen memóriacímen lesz elérhető, de azt is, hogy az egyes utasításainak, változóinak mi lesz a címe. Ezért a fordító nyugodtan generálhat olyan kódot, amelyben abszolút címek szerepelnek, ezeket majd a linker beírja a gépi kódú utasítássorozat megfelelő helyeire. A dinamikus linkelésnél viszont csak a betöltésnél derülnek ki ezek a címek, ezért olyan kódot (Position Independent Code) kell generálni, ami bárhová betöltve működik, nincsenek benne abszolút címek.
A dinamikus lib betöltésekor nem lehetne korrigálni a címeket?
Jó kérdés, és részben meg is történik. A dinamikus linkelés viszont nem ennyire egyszerű, a virtuális memória, amit a modern operációs rendszerek használnak, bekavar.
A virtuális memória azt jelenti, hogy minden program úgy látja a memóriát, mintha egyedül használná azt, és az operációs rendszer fogja a fizikai memória darabjait a virtuális memória darabjaihoz hozzárendelni. Így fordulhat elő az, hogy a dinamikusan linkelt függvények fizikailag valóban csak egy példányban vannak a memóriában, de az operációs rendszer ezt a fizikai tartományt az egyik programnak az A virtuális címéhez, a másiknak a B virtuális címéhez rendeli. Mivel mindegyik program a saját virtuális címtartományában dolgozik, és az objektum kódja mindkettőben ugyanaz, ha ide abszolút címek lennének beírva, akkor vagy az egyik program működne jól, vagy a másik (esetleg egyik sem). A függvények címeinél ez meg van oldva, de "tetszőleges" címre megoldani nem lenne praktikus.
Fontosabb gcc kapcsolók linkeléshez¶
Kapcsoló | Jelentés |
---|---|
-static |
statikus linkelés (pl. glibc statikus verzióját is beépíti) |
-fPIC |
Position Independent Code – dinamikus könyvtárhoz kötelező |
-shared |
megosztható könyvtár készítése |
-L<útvonal> |
könyvtár keresési útvonal megadása |
-l<név> |
könyvtár hivatkozása (pl. -lutils → libutils.so vagy libutils.a ) |
-Wl,-rpath,<útvonal> |
futásidejű könyvtárkeresési útvonal beégetése |
-ldl |
dinamikus betöltéshez szükséges könyvtár (dlopen , dlsym ) |
Példa dinamikus linkelésre¶
Legyen adott az alábbi példánk!
1 2 3 4 5 6 7 |
|
1 2 3 4 5 |
|
1 2 3 4 5 6 7 |
|
Az utils.c állományban megvalósított hello
függvényt dinamikusan szeretnénk linkelni a fő programunkhoz! Ahhoz, hogy ezt megtegyük, egy olyan shared object állományt kell készítenünk, amelyet majd a futáskor be tud tölteni a programunk:
1 2 3 4 |
|
Ahhoz, hogy a main
programot futtatni is tudjuk, meg kell adjuk, hogy mely útvonalon éri el a libutils.so
-t a rendszer, és csak ezután tudjuk futtatni a programot:
1 2 |
|
Vagy eleve beégethetjük fordításkor is ezt az útvonalat (ekkor nem kell beállítani az LD_LIBRARY_PATH
-t):
1 |
|
Pluginek¶
A program egyes komponenseinek betöltésére futásidőben is lehetőség van, ha tudjuk, hogy az adott metódus mely library-ben van.
Minimálisan módosítsuk az előző példát! A cél legyen az, hogy szeretnénk elkészíteni a hello
metódus magyar és angol verzióját is, és természetesen csak azt szeretnénk használni (betölteni), amely valóban szükséges lesz számunkra.
1 2 3 4 5 6 |
|
1 2 3 4 5 6 |
|
Mindkét állományból készítsünk egy-egy shared objectet:
1 2 |
|
A fő programban a parancssori argumentumok által adjuk meg, hogy a magyar, vagy az angol nyelvű verziót szeretnénk használni az adott programfuttatáskor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
A fő programban pár új dologgal találkozhatunk:
- A
dlopen()
megnyit egy.so
fájlt, - a
dlsym()
visszaad egy pointert ahello
nevű szimbólumhoz, dlclose()
-szal felszabadítjuk a könyvtárat.
A fordításkor most nem a majd használni kívánt library-kat kell betölteni, hanem azon library-t, ami az előbb felsorolt, betöltést végző metódusokat tartalmazza:
1 |
|
A program futtatása ezután vagy ./main en
, vagy ./main hu
módon történik (ettől eltérő használat esetén hibajelzést kapunk). Előbbi esetben a program a Hello!
szöveget, utóbbinál a Szia!
szöveget írja ki.
Ez a technika szuper hasznos lehet például:
- plugin rendszerek építéséhez
- lokalizációhoz (mint itt)
- vagy dinamikusan bővíthető architektúrák esetében.