Kihagyás

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:

kep

Az is lehet, hogy több object állományt egy archívumba csomagolunk, ezáltal a logikailag összetartozó objecteket közelebb hozzhatjuk egymáshoz:

kep

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

kep

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. -lutilslibutils.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
// utils.h
#ifndef UTILS_H
#define UTILS_H

void hello();

#endif
1
2
3
4
5
// utils.c
#include <stdio.h>
void hello() {
    printf("Hello from shared lib!\n");
}
1
2
3
4
5
6
7
// main.c
#include "utils.h"

int main() {
    hello();
    return 0;
}

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
gcc -fPIC -c utils.c
gcc -shared -o libutils.so utils.o

gcc main.c -L. -lutils -o main

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
LD_LIBRARY_PATH=. 
./main

Vagy eleve beégethetjük fordításkor is ezt az útvonalat (ekkor nem kell beállítani az LD_LIBRARY_PATH-t):

1
gcc main.c -L. -Wl,-rpath=. -lutils -o main

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
//hello_en.c
#include <stdio.h>

void hello() {
    printf("Hello!\n");
}
1
2
3
4
5
6
//hello_hu.c
#include <stdio.h>

void hello() {
    printf("Szia!\n");
}

Mindkét állományból készítsünk egy-egy shared objectet:

1
2
gcc -fPIC -shared -o libhello_en.so hello_en.c
gcc -fPIC -shared -o libhello_hu.so hello_hu.c

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
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Használat: %s <nyelv>\n", argv[0]);
        return 1;
    }

    char libnev[100];
    snprintf(libnev, sizeof(libnev), "./libhello_%s.so", argv[1]);

    void *modul = dlopen(libnev, RTLD_NOW);
    if (!modul) {
        fprintf(stderr, "Nem sikerült betölteni: %s\n", dlerror());
        return 1;
    }

    void (*hello)();
    *(void **)(&hello) = dlsym(modul, "hello");

    if (!hello) {
        fprintf(stderr, "Nem található a függvény: %s\n", dlerror());
        dlclose(modul);
        return 1;
    }

    hello();

    dlclose(modul);
    return 0;
}

A fő programban pár új dologgal találkozhatunk:

  • A dlopen() megnyit egy .so fájlt,
  • a dlsym() visszaad egy pointert a hello 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
gcc -o main main.c -ldl

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.