5.2 C objektumok a folyamat virtuális címterében

A C-objektum tárolási ideje nem a virtuális címterület egyik tartományának neve. Az „automatikus objektum” nyelvi fogalom; a „vermen elhelyezett lokális változó” egy gyakori megvalósítás leírása. Ez a különbség hibakereséskor döntő: attól, hogy egy cím továbbra is olvasható memórialapra mutat, az ott korábban létező objektum élettartama már véget érhetett.

A Scope, linkage, storage duration és lifetime részben bevezetett különbségekre építünk: a hatókör a név használhatóságáról, a nyelvi kapcsolás a deklarációk közötti azonosságról, a tárolási idő és az élettartam pedig az objektum létezéséről szól. Most ezeket kapcsoljuk össze egy Linux-folyamat megfigyelhető memóriatérképével, anélkül, hogy a térképet a C nyelv definíciójának tekintenénk.

5.2.1 A virtuális cím nem fizikai hely

ISO C17 — nyelvi modell. Az objektum olyan adattárolási terület, amelynek tartalma értékeket reprezentálhat. A C nem ír elő Linux-folyamatot, lapozást, vermet vagy egy „heap” nevű memóriatartományt. Egy pointer sem általánosan hordozható fizikai memóriacím.

Linux, x86-64 — megvalósítási modell. A fejezet laborprofiljában az objektumpointerek a folyamat virtuális címteréhez kapcsolódnak. A virtuális cím egy folyamat címfordítási környezetében értelmezett cím; a fizikai cím a hardver fizikai címterének egy helyét azonosítja. Ugyanaz a virtuális cím két különböző folyamatban rendszerint nem ugyanahhoz a fizikai tárolóhoz tartozik.

A címfordítást a hardver és a kernel lapokra bontva kezeli. Egyszerű, azonos méretű lapokat feltételező modellben, vv virtuális cím és PP lapméret mellett:

q=⌊vP⌋,r=vmodP,v=qP+r. q=\left\lfloor\frac{v}{P}\right\rfloor,\qquad r=v\bmod P,\qquad v=qP+r.

Itt qq a virtuális lapszám, rr a lapon belüli eltolás. Ha a lap jelen van, és a fordítás ff fizikai lapkeretre mutat, akkor a fizikai cím fP+rfP+r. A folyamat memóriatérképe azonban nem árulja el önmagában ff értékét.

Egy létező leképezéshez nem szükségképpen tartozik azonnal saját, rezidens fizikai lap. A demand paging, a megosztott lapok és a másolás íráskor (copy-on-write) tovább bontják a „lefoglalt memória” hétköznapi fogalmát.

A leképezés olvasási, írási és végrehajtási jogosultságokat is hordoz. Ezek nem helyettesítik a C szabályait: egy írható lapra történő írás is lehet nem definiált viselkedés, például ha az érintett objektum már nem él.

5.2.2 Gyakori memóriaterületek és a nyelvi objektumok

Az alábbi megfeleltetések Linux hosted, x86-64 System V ABI, LP64 profilban gyakoriak, de nem ISO C-elhelyezési garanciák.

Nyelvi objektum vagy tároló Gyakori megvalósítás Fontos korlát
Automatikus lokális objektum Verem, regiszter, esetleg külön tároló nélkül Az optimalizáló megszüntetheti vagy átalakíthatja
Módosítható statikus objektum, nem nulla kezdőértékkel Írható adatleképezés, gyakran .data A szekciónév nem nyelvi tulajdonság
Nullakezdőértékű statikus objektum Gyakran .bss A fájlban rendszerint nem szerepel minden kezdeti nulla bájt
Statikus, nem módosítható adat Gyakran .rodata vagy más csak olvasható tartomány A const önmagában nem lapvédelmi utasítás
Szálankénti tárolási idejű objektum Thread-local storage, TLS Nem azonos az adott szál automatikus objektumaival
Allokált tároló [heap] vagy más, például anonim leképezés A malloc nem köteles a [heap] tartományból dolgozni

Az ELF-betöltés, PLT/GOT és dinamikus szimbólumok részhez kapcsolódó fontos különbség: az ELF szekciói és a folyamat virtuálismemória-leképezései nem egy-egy arányban felelnek meg egymásnak. A betöltés szegmensekhez kapcsolódik; több szekció kerülhet azonos leképezésbe. A /proc térképében ezért nem feltétlenül találunk .rodata vagy .bss feliratú sort.

A „mapping” sem kizárólag a heapen kívüli területeket jelenti. A programkód, a verem, a megosztott könyvtárak és az allocator által használt tartományok egyaránt leképezésekből állnak.

5.2.2.1 Miért nem következik az automatikus tárolási időből veremelhelyezés?

Indoklásvázlat. Az ISO C17 az objektum nyelvi tulajdonságait és a program megfigyelhető viselkedését írja elő, nem minden objektum gépi tárolóhelyét. Ha egy lokális objektum értéke mindig egy konstans, a fordító helyettesítheti a felhasználásokat ezzel a konstanssal. Ha az érték csak számításokhoz kell, regiszterben tarthatja. Ha szükségtelen, elhagyhatja.

A cím megfigyelhetővé tétele korlátozza ezeket az átalakításokat, de abból sem következik általános nyelvi tételként, hogy az objektum „a stacken van”. A fordítónak a megfigyelhető viselkedést kell megőriznie.

5.2.3 Címmérési labor a saját folyamatban

Kijelölt, itt nem futtatott laborprofil: ISO C17, GCC 14.2.0, glibc 2.40, GDB 15.2, Linux x86-64, System V ABI, LP64, CHAR_BIT == 8. A tényleges reprodukcióban a kernel és az eszközök pontos verzióját is rögzíteni kell. Az alábbiak laborutasítások és értelmezési szempontok, nem dokumentált mérési eredmények.

A program többféle objektum címét és saját memóriatérképét írja ki. A 4096 itt az allokáció kért mérete C bájtban; nem a lapméretre vonatkozó állítás.

/* address_map.c — ISO C17; a /proc elerese Linux-specifikus. */
#include <stdio.h>
#include <stdlib.h>

static int initialized = 7;
static int zeroed;
static const char label[] = "address probe";

static void frames(unsigned depth)
{
    unsigned marker = depth;

    printf("depth=%u marker=%p\n", depth, (void *)&marker);

    if (depth != 0) {
        frames(depth - 1);
    }

    printf("return marker=%p value=%u\n",
           (void *)&marker, marker);
}

int main(void)
{
    int automatic = 11;
    void *owner = malloc(4096);

    if (owner == NULL) {
        fputs("allocation failed\n", stderr);
        return EXIT_FAILURE;
    }

    FILE *maps = fopen("/proc/self/maps", "r");
    if (maps == NULL) {
        perror("fopen /proc/self/maps");
        free(owner);
        return EXIT_FAILURE;
    }

    printf("initialized=%p\nzeroed=%p\nlabel=%p\n",
           (void *)&initialized, (void *)&zeroed,
           (void *)label);
    printf("automatic=%p\nowner-slot=%p\nallocation=%p\n",
           (void *)&automatic, (void *)&owner, owner);

    frames(2);

    char line[512];
    while (fgets(line, sizeof line, maps) != NULL) {
        fputs(line, stdout);
    }

    int failed = ferror(maps) != 0;

    if (fclose(maps) != 0) {
        failed = 1;
    }

    free(owner);

    if (fflush(stdout) == EOF || ferror(stdout)) {
        failed = 1;
    }

    return failed ? EXIT_FAILURE : EXIT_SUCCESS;
}

Fordítás a külön laborban:

gcc -std=c17 -Wall -Wextra -Wpedantic \
    -O0 -g3 -fno-omit-frame-pointer -fPIE -pie \
    address_map.c -o address-map

A %p formátumhoz void * argumentumot adunk. A kiírás alakja implementáció által meghatározott; ebben a Linux-profilban hexadecimális címalak várható.

Különösen fontos az owner-slot és az allocation különbsége. Az előbbi magának az automatikus pointerobjektumnak a címe, az utóbbi a benne tárolt pointerérték által jelölt allokált terület kezdete. A pointer tárolási ideje nem azonos a célobjektum tárolási idejével.

5.2.3.1 Tulajdonlás és élettartam a példában

A tulajdonos itt programtervezési szerep: a C típusrendszere nem kényszeríti ki.

Objektum vagy erőforrás Tulajdonos, felelős Kölcsönzött hozzáférés Élettartamhatár, hibakimenet
Allokált 4096 C bájt A main, az owner pointeren keresztül A cím kiírása; nincs tartós alias free(owner); sikertelen kéréskor null pointer
owner pointerobjektum, automatic A main blokkhoz kötött automatikus objektumok Címük átadása a printf hívásnak A blokk elhagyása
Rekurzív marker objektumok Az egyes függvényaktivációk Címük átadása a printf hívásnak Az adott hívás visszatérése
initialized, zeroed, label Statikus tárolási idejű objektumok Csak címmegfigyelés A program teljes végrehajtása alatt élnek
Megnyitott FILE adatfolyam A main, a maps handle révén Könyvtári hívások a lezárásig fclose; megnyitási, olvasási vagy lezárási hiba lehetséges

A kiírt cím nem hosszabbít meg élettartamot. A program a felszabadítás után nem használja újra az owner régi pointerértékét.

5.2.4 A térkép és a debugger összekapcsolása

Linux — diagnosztikai interfész. A /proc/self/maps sorai többek között a következő adatokat tartalmazzák:

A tartomány fél-nyílt: [a,b)[a,b). A b végcím már nem része a tartománynak. A rw-p jelölés olvasható, írható, privát leképezést jelez; a „privát” nem jelenti azt, hogy minden lap már saját fizikai példányban létezik.

Illusztráció, nem mérés, a megadott x86-64 laborprofilhoz:

Adat Érték
Leképezés kezdete, aa 0x0000555500004000
Leképezés vége, bb 0x0000555500006000
Vizsgált virtuális cím, vv 0x0000555500004120

Először ellenőrizzük:

a≤v<b. a\leq v<b.

Ez teljesül. A cím tartományon belüli eltolása:

v−a=𝟶𝚡𝟷𝟸𝟶=288 C bájt. v-a=\mathtt{0x120}=288\text{ C bájt}.

A tartomány mérete:

b−a=𝟶𝚡𝟸𝟶𝟶𝟶=8192 C bájt. b-a=\mathtt{0x2000}=8192\text{ C bájt}.

Ezzel csak a leképezéshez tartozást állapítottuk meg. Nem bizonyítottuk az objektum méretét, élettartamát vagy a rajta végzett művelet nyelvi helyességét. Az intervallumvizsgálat itt Linux-címadatok számszerű elemzése; nem felhívás különálló C-objektumokra mutató pointerek relációs összehasonlítására.

A debuggerben az address-map betöltése után használható a break frames, majd a run. Megálláskor a print &marker, print &initialized és info proc mappings kapcsolja össze az objektumcímeket a leképezésekkel. Két további continue után a bt a legbelső rekurzív hívásból mutathatja a hívási láncot.

A /proc/self/maps mindig az azt megnyitó folyamatra vonatkozik. Ha egy shellben a cat /proc/self/maps parancsot használjuk, a cat térképét látjuk, nem egy másik vizsgált programét. Ezért olvassa a példaprogram saját maga a fájlt.

5.2.5 Veremkeretek, rekurzió és allocatorok

A veremkeret (stack frame) egy függvényaktivációhoz társított, megvalósításfüggő állapot. Tartalmazhat mentett regisztereket, lokális tárolót és a visszatéréshez szükséges adatokat; az ABI és a fordító döntései befolyásolják.

A frames(2) végrehajtása közben három függvényaktiváció létezik egyszerre, saját marker objektummal. A hívás utáni kiírás azt is megmutatja, hogy a külső aktiváció objektuma a belső hívás alatt tovább él. Az egyszerű farokhívásos átalakítást ez megnehezíti, de általánosan nem tiltja meg az optimalizálást.

Az x86-64 gyakori hívási megvalósításában a verem kisebb címek felé növekszik. Ebből nem következik, hogy tetszőleges lokális változók deklarációs sorrendje kiolvasható a címekből, vagy hogy két rekurziós szint címtávolsága minden buildben állandó. A részletes hívási szerződést a Hívási konvenció, stack frame és visszatérés tárgyalja.

Az allokált tárolónál más határt kell felismerni.

ISO C17 — könyvtári szerződés. A sikeres malloc megfelelő tárolót biztosít; a free megszünteti annak használati jogosultságát a C-program számára.

Linux és libc — megvalósítás. Az allocator használhatja a program break változtatását, amelyhez Linuxon a brk mechanizmus kapcsolódik, illetve kérhet külön mmap leképezéseket. A választás allocator-, verzió-, konfiguráció- és futástörténet-függő.

Ezért nincs általános megfeleltetés:

egy malloc-hívás≢egy rendszerhívás≢egy új leképezés. \text{egy malloc-hívás} \;\not\equiv\; \text{egy rendszerhívás} \;\not\equiv\; \text{egy új leképezés}.

Egy free után a tároló az allocatornál maradhat későbbi felhasználásra. A térkép változatlansága nem őrzi meg a felszabadított objektum élettartamát. A részletes allokációs szerződés a malloc, calloc, free és allokációs szerződés témája.

5.2.6 ASLR, optimalizálás és megfigyelési hibák

Az ASLR (address space layout randomization) a megfelelően konfigurált Linux-rendszerben több címtartomány elhelyezését változtathatja futásonként. A példát PIE-ként fordítjuk, hogy a végrehajtható program betöltési alapja is részt vehessen ebben. Nem minden cím változik szükségképpen minden indításkor.

A debugger befolyásolhatja a környezetet. GDB-ben a show disable-randomization mutatja az erre vonatkozó beállítást; a set disable-randomization off a debugger által kezdeményezett kikapcsolást tiltja le. Ezt indítás előtt kell beállítani, és a rendszerkorlátozások továbbra is számítanak.

Összehasonlításhoz készíthető optimalizált változat:

gcc -std=c17 -Wall -Wextra -Wpedantic \
    -O2 -g3 -fPIE -pie \
    address_map.c -o address-map-O2

Az -O2 mellett egyes függvényhívások beágyazhatók, a tárolóhelyek megváltozhatnak, és a debugger bizonyos értékekre optimized out jelzést adhat. Ez nem bizonyítja, hogy a forrásnyelvi objektumnak nem volt élettartama.

A mérés maga is alakítja a folyamatot: a stdio és a fájlmegnyitás további allokációkat okozhat. Ezért ne rendeljünk minden új térképsort automatikusan a saját malloc hívásunkhoz.

A legfontosabb hibajavítási szabály: a leképezés érvényességét és az objektum érvényességét külön vizsgáljuk. A segmentation fault hiánya nem helyességi bizonyíték.

5.2.7 Feladatok

5.2.7.1 Magyarázó feladat

Hasonlítsd össze az owner pointerobjektumot és az általa jelölt allokált tárolót. Mi történik, ha a main elhagyja a blokkot anélkül, hogy meghívná a free függvényt? Milyen következtetést nem szabad levonni az owner-slot címéből?

5.2.7.2 Kódolási feladat

Egészítsd ki a frames függvényt egy static unsigned shared; objektummal, és minden belépéskor írasd ki a címét. Hasonlítsd össze a rekurzív hívások shared és marker címeit.

5.2.7.3 Diagnosztikai feladat

Egy mérnök három megállapítást tesz:

Magyarázd meg mindhárom következtetés hibáját, és adj ellenőrzési lépést.

5.2.8 Megoldások

5.2.8.1 A magyarázó feladat megoldása

Az owner automatikus tárolási idejű pointerobjektum; a célterület allokált tárolási idejű. Az automatikus pointer megszűnése nem hívja meg a free függvényt. Ha az utolsó hozzáférési lehetőség is elveszik, memóriafelszabadítási hiba keletkezik. A folyamat végén történő OS-takarítás ettől külön mechanizmus.

Az owner-slot csak a pointerobjektum helyét mutatja, nem az allokált területét. Ellenőrzési feltétel: külön azonosítsuk az &owner és az owner kifejezés jelentését, majd kövessük a felszabadítás minden vezérlési útját.

5.2.8.2 A kódolási feladat megoldása

A korábbi C17-függvény törzsének elejéhez illeszthető részlet:

static unsigned shared;
printf("shared=%p\n", (void *)&shared);

Minden aktiváció ugyanarra a statikus objektumra hivatkozik, miközben saját automatikus marker objektuma van. A megadott laborprofilban a shared címének egy futáson belül azonosnak kell mutatkoznia. A marker címek között nem követelünk meghatározott távolságot vagy sorrendet.

A címátadás kölcsönzött hozzáférés, nem tulajdonátadás; a részlet nem őriz meg pointert az automatikus objektum élettartamán túl.

5.2.8.3 A diagnosztikai feladat megoldása

A [heap] felirat nem foglalja magában az allocator minden lehetséges tárolóját. Először az allokáció címét tartalmazó leképezést kell megkeresni; az allocator döntési mechanizmusa külön vizsgálatot igényel.

A debugger ASLR-beállítását ellenőrizni kell, majd külön, debugger nélkül is megismételni a megfigyelést. Azonos címek önmagukban nem írják le a teljes rendszer házirendjét.

A harmadik állítás nyelvi és OS-szintet kever. A free lezárja az allokált objektum élettartamát akkor is, ha a lapok megmaradnak. Az ellenőrzés alapja a felszabadítási út követése, nem egy próbaolvasás: a hibás hozzáférésnek nincs garantált kimenete.

5.2.9 Összefoglaló

A C az objektumok élettartamát és használati szabályait határozza meg; Linux a virtuális leképezéseket és lapjogosultságokat kezeli; a fordító és az allocator köti össze e két nézőpontot. A stack, heap, .data, .bss és .rodata hasznos megvalósítási fogalmak, de nem helyettesítik a nyelvi szerződést. A memóriatérkép helyet mutat, nem élettartamot; a debugger megfigyelést ad, nem általános nyelvi garanciát.