5.5 Élettartam- és memóriahibák vizsgálati laborja

A memóriahiba vizsgálatának első kérdése nem az, hogy „melyik memórialapon található ez a cím?”, hanem az, hogy melyik élő objektumhoz, milyen típusú és milyen előfeltételekkel megengedett hozzáférés történik. Egy olvasható virtuális memórialap nem tesz érvényessé egy lejárt élettartamú objektumra mutató pointert. Ugyanígy a sanitizer csendje sem bizonyítja a program helyességét.

A labor a virtuális címtér és a C objektumok különbségére, az effective type és az objektumreprezentáció szabályaira, valamint a reprodukálható diagnosztikai környezetre épít. Minden esetben három bizonyítékot kapcsolunk össze: a forráskódból levezetett szabályt, a fordítás feltételeit és a tényleges eszközmegfigyelést.

5.5.1 Laborprofil és bizonyítási módszer

A példák minimumszabványa ISO C17, explicit -std=c17 kapcsolóval. A választott konkrét laborprofil Linux hosted környezet, x86-64 System V ABI, LP64 adatmodell és nyolcbites C bájt. A reprodukcióhoz kijelölt eszközverziók GCC 14.2.0, illetve Clang és compiler-rt 19.1.7, glibc 2.40 mellett. Ez előírt vizsgálati profil, nem elvégzett teszt: a helyi telepítés pontos verzióit és a kernel kiadását külön rögzíteni kell.

Az alábbi parancsokban a gcc és clang ezekre a kiadásokra utal. Más verzió használata megengedett, de eltérésként kerüljön a hibajegybe. A kapcsolók és a sanitizer-runtime elérhetősége implementációfüggő.

ISO C17 — alapfogalmak. Az objektum élettartama (lifetime) az az időszak, amelyben az objektum létezik, és a rá vonatkozó hozzáféréseknek van élettartam szempontjából érvényes célpontjuk. Az élettartamon kívüli objektumhivatkozás nem definiált viselkedés, azaz UB. Az inicializálatlan automatikus objektum értéke meghatározatlan (indeterminate); ez nem egyszerűen „valamilyen véletlen, de használható szám”.

A vizsgálat menete:

  1. Azonosítsd az objektumot, a tárolóját és a kölcsönzött hozzáféréseket.
  2. Határozd meg az élettartam végét, illetve az inicializálás és a típushelyes hozzáférés feltételeit.
  3. Keresd meg az első szabálysértő kiértékelést.
  4. Csak ezután értelmezd a warningot, sanitizer-leletet vagy futási tünetet.

5.5.2 Négy elkülönített hibás eset

Az alábbi faults.c teljes fordítási egység. Szándékosan hibás tanítási példa: minden kiválasztható eset UB-t tartalmaz, ezért egyikhez sem tartozik garantált kimenet. A CASE fordítási makró egyetlen esetet választ ki.

#include <stdio.h>

#ifndef CASE
#define CASE 1
#endif

#if CASE == 1

static int *expired(void)
{
    int local = 42;
    return &local;
}

int main(void)
{
    int *p = expired();
    printf("%d\n", *p);
    return 0;
}

#elif CASE == 2

int main(void)
{
    int *p;
    {
        int local = 42;
        p = &local;
    }
    printf("%d\n", *p);
    return 0;
}

#elif CASE == 3

int main(void)
{
    int retries;
    printf("%d\n", retries);
    return 0;
}

#elif CASE == 4

int main(void)
{
    _Static_assert(sizeof(int) == sizeof(float),
                   "This case requires equal sizes.");
    _Static_assert(_Alignof(int) >= _Alignof(float),
                   "This case requires sufficient alignment.");

    _Alignas(int) float value = 1.0f;
    printf("%d\n", *(int *)&value);
    return 0;
}

#else
#error Unsupported CASE
#endif

Az első három eset nem támaszkodik konkrét típusszélességre. A negyedik feltételeit a kijelölt target teljesíti; az ellenőrzések és az igazítás azért szerepelnek, hogy a példában ne a méret vagy az igazítás legyen a hozzáférési hiba magyarázata.

5.5.2.1 Lokális objektumra mutató pointer visszaadása

Az első esetben a visszaadott pointer nem viszi magával az objektumot. A cím előállításakor a local még él; a függvényből való visszatéréskor az élettartama véget ér. A hívó ezért nem kap érvényes hozzáférést egy int objektumhoz.

ISO C17: amikor a mutatott objektum élettartama véget ér, a rá mutató pointer értéke indeterminate lesz. A hibát ezért nem helyes úgy modellezni, hogy „a pointer biztosan megmarad, csak a mögötte lévő adat változhat”. A későbbi dereferencia mindenképpen érvénytelen objektumhozzáférést kísérel meg.

Nem az okozza a hibát, hogy egy másik függvény „felülírta a stacket”. Az ilyen felülírás legfeljebb látványossá teszi a már korábban bekövetkezett érvénytelenséget.

5.5.2.2 Use-after-scope

A második esetben a függvény még nem tért vissza, de a belső blokk objektumának élettartama már lejárt. Ez a use-after-scope: hozzáférési kísérlet a blokkhoz kötött objektum élettartamának vége után.

Eset Objektum és tároló Élettartam kezelése Kölcsönzött hozzáférés Érvénytelenítő esemény Következmény
CASE=1 local, automatikus tároló A meghívott blokkhoz kötött; nincs kézi felszabadítás A visszaadott pointer Visszatérés az expired függvényből Későbbi hozzáférés: UB
CASE=2 local, automatikus tároló A belső blokkhoz kötött A külső p Kilépés a belső blokkból Későbbi hozzáférés: UB
CASE=4 value, automatikus tároló A main blokkjához kötött A konvertált int * A vizsgált ponton nincs élettartamvége Inkompatibilis típusú olvasás: UB

A második esetben a p pointerobjektum maga továbbra is él. Ebből nem következik a mutatott objektum fennmaradása.

Bizonyításvázlat. Legyen a belső blokkba lépés időpontja t0t_0, a kilépésé t1t_1, a későbbi olvasásé t2t_2. A nem VLA local élettartama itt [t0,t1)[t_0,t_1), miközben t2>t1t_2>t_1. Az olvasás tehát kívül esik az élettartamon. A levezetésben sem memóriacím, sem stackelrendezés nem szerepel.

5.5.2.3 Inicializálatlan és inkompatibilis típusú olvasás

A harmadik eset retries változója nem kap értéket a használata előtt. Ebben a konkrét esetben egy inicializálatlan, automatikus, register tárolási osztállyal is deklarálható objektum értékét olvassuk, amelynek a címét sem képeztük: az olvasás UB. A javításnak a program jelentéséből kell következnie. Ha a kezdeti próbálkozásszám nulla, legyen int retries = 0;; ha bemenetből származik, az olvasást csak sikeres bemenetfeldolgozás után szabad elvégezni.

A negyedik esetben a value él és inicializált, mégis hibás a hozzáférés.

ISO C17 — típushozzáférés: a deklarált float objektum tárolt értékét nem olvashatjuk tetszőlegesen int típusú lvalue-n keresztül. A pointerkonverzió nem alakítja át az objektumot. Az egyenlő méret és a megfelelő igazítás szükséges lehet bizonyos műveletekhez, de nem helyettesíti az aliasing szabályait.

5.5.3 Helyes ellenpélda: a hívó tárolója és karakteres reprezentáció

A következő fixed.c teljes C17 program. Az eredmény tárolóját a hívó biztosítja; a függvény csak a hívás idejére kölcsönzi.

#include <stdio.h>
#include <string.h>

static int produce(int *out)
{
    if (out == NULL) {
        return -1;
    }

    *out = 42;
    return 0;
}

int main(void)
{
    int result = 0;

    if (produce(&result) != 0) {
        return 1;
    }

    float value = 1.0f;
    unsigned char bytes[sizeof value];
    memcpy(bytes, &value, sizeof value);

    printf("result=%d, representation_size=%zu\n",
           result, sizeof bytes);
    return 0;
}
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic fixed.c -o fixed

A produce szerződése szerint a nem null out egy élő, írható, megfelelően igazított int objektumra mutat. A null pointerhez -1 hibakód tartozik; az egyébként érvénytelen pointert a függvény nem képes általánosan felismerni.

A result tárolóját és élettartamát a main blokkja biztosítja; a kölcsönzött out nem kerül eltárolásra. A hozzáférési jogosultságot a result élettartamának vége szüntetné meg, de ez a hívás után történik.

A memcpy alatt a forrás value és a cél bytes is élő, nem átfedő helyi objektum. A könyvtári művelet nem tartja meg a pointereket, és az előfeltételek teljesülése esetén nincs külön hibakódja. A másolt mennyiség sizeof value C bájt. A másolás objektumreprezentációt őriz meg, nem numerikus float–int konverziót végez.

5.5.4 Padding és strukturált értékek összehasonlítása

A struktúra értéke és teljes tárolási reprezentációja nem azonos fogalom. A tagok között vagy a struktúra végén kitöltés, azaz padding lehet; ezek a bájtok nem külön adatmezők.

A következő compare.c C17 fordítási egység két összehasonlítást mutat:

#include <stdbool.h>
#include <string.h>

struct state {
    unsigned char tag;
    unsigned int count;
};

bool state_equal(struct state a, struct state b)
{
    return a.tag == b.tag && a.count == b.count;
}

/* Hibás, ha a cél a tagértékek egyenlősége. */
bool state_equal_wrong(struct state a, struct state b)
{
    return memcmp(&a, &b, sizeof a) == 0;
}
gcc -std=c17 -O2 -Wall -Wextra -Wpedantic -c compare.c -o compare.o

Előfeltétel, hogy mindkét bemeneti struktúra tagjai érvényes, inicializált értéket tartalmazzanak. A memcmp a függvény élő paraméterobjektumait kölcsönzi a hívás idejére; nincs eltárolt pointer.

ISO C17: a padding bájtjainak értékére nem építhető hordozható értékegyenlőségi szerződés. Két struktúra tagjai lehetnek egyenlők eltérő reprezentáció mellett. A memcmp ezért reprezentáció-egyenlőséget vizsgál, nem a kívánt strukturált értékegyenlőséget.

Ez nem automatikusan ugyanolyan típusú UB, mint az inicializálatlan int közvetlen olvasása. A karakteres reprezentáció-hozzáférést és a típusos értékolvasást külön kell kezelni. Az inicializálatlanságot követő eszköz ugyanakkor jelezheti, ha az összehasonlítás eredménye inicializálatlannak követett paddingtől függ.

A teljes tároló előzetes nullázása nem általános helyettesítője a tagonkénti összehasonlításnak: későbbi struktúratárolások után sem alapozhatunk hordozható szerződést a padding rögzített tartalmára.

5.5.5 Optimalizálás és sanitizer-diagnosztika

5.5.5.1 O0 és O2: kísérlet, nem UB-garancia

Azonos esetet külön binárisokba fordíts:

gcc -std=c17 -O0 -g -Wall -Wextra -Wpedantic -DCASE=2 faults.c -o scope-O0
gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -DCASE=2 faults.c -o scope-O2

A külön laborfuttatásoknál jegyezd fel a kimenetet, a kilépési állapotot és a diagnosztikát. Lehetséges, hogy a két változat azonosan látszik működni; az is, hogy eltérően. Egyik sem változtatja meg a nyelvi hibát.

A -O0 nem kapcsolja ki az UB-t, nem jelent minden részletében közvetlen forráskód-végrehajtást, és nem garantálja egy hibás olvasás megőrzését. Az optimalizáló különbség csak megfigyelés, nem önmagában fordítóhiba.

5.5.5.2 ASan és UBSan

Az AddressSanitizer, röviden ASan, instrumentált memóriahozzáférésekkel többek között objektumhatár- és élettartamhibákat keres. Az UndefinedBehaviorSanitizer, röviden UBSan, kiválasztott nem definiált műveletekhez ad futásidejű ellenőrzéseket.

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
  -fsanitize=address,undefined \
  -fsanitize-address-use-after-scope \
  -fno-omit-frame-pointer \
  -DCASE=2 faults.c -o scope-san

A visszatérés utáni hozzáféréshez a kijelölt Clang-profilban külön mód is kérhető:

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
  -fsanitize=address \
  -fsanitize-address-use-after-return=always \
  -fno-omit-frame-pointer \
  -DCASE=1 faults.c -o return-san

Eszközfüggő korlát: a kapcsoló, az instrumentált útvonal és az optimalizálás is befolyásolja a leletet. A fordító már a visszaadott lokális cím miatt is figyelmeztethet, illetve úgy alakíthatja át a hibás programot, hogy nem a várt sanitizer-kategória jelenik meg.

A UBSan nem „minden UB detektora”. Különösen nem általános effective-type-ellenőrző, és nem teljes inicializálatlanság-detektor.

5.5.5.3 MSan és az instrumentálás határa

A MemorySanitizer, röviden MSan, az inicializáltsági állapotot követi. A harmadik esethez külön build szükséges:

clang -std=c17 -O1 -g -Wall -Wextra -Wpedantic \
  -fsanitize=memory -fsanitize-memory-track-origins=2 \
  -fno-omit-frame-pointer -fPIE -pie \
  -DCASE=3 faults.c -o uninit-msan

A megbízható követéshez a releváns programrészeknek és függőségeknek MSan-kompatibilis instrumentálásra vagy megfelelő runtime-interceptorra van szükségük. A rendszer-libc egyes hívásait interceptorok kezelhetik; ebből nem következik, hogy tetszőleges előre fordított külső könyvtár biztonságosan bevonható. Az MSan külön konfiguráció, nem egyszerűen az ASan mellé kapcsolható további ellenőrzés.

Eszköz Hasznos szerep Amit a csendje nem bizonyít
Fordítói warning Statikusan felismerhető hibák jelzése Minden útvonal helyessége
ASan Instrumentált határ- és élettartamhibák keresése Inicializáltság és típushelyesség
UBSan Kiválasztott UB-k ellenőrzése Az összes C szabály teljesülése
MSan Inicializálatlan adatok felhasználásának követése Élettartam-, bounds- és aliasing-helyesség

5.5.6 Hibajegy: tünetből indokolt javítás

A második esethez tartozó hibajegyben különüljön el:

Gyakori látszatjavítás a volatile, az optimalizálás kikapcsolása vagy a sanitizer elnémítása. Ezek nem hosszabbítják meg az objektum életét. A lokális változó static-ká alakítása ugyan módosítja az élettartamot, de közös állapotot és új újrahívhatósági, illetve konkurenciafeltételeket vezet be; ezért nem automatikusan megfelelő API-javítás.

5.5.7 Feladatok

  1. Magyarázat: a CASE=2 mindkét optimalizálási szinten 42-t ír ki. Bizonyítja-e ez a pointer érvényességét? Különítsd el a pointerobjektum és a mutatott objektum élettartamát.

  2. Kódolás: javítsd a CASE=2 esetet úgy, hogy az eredményt a belső blokk után használod, de sem dinamikus, sem statikus tárolót nem vezetsz be.

  3. Diagnosztika: egy inicializálatlan számláló használatánál az ASan nem jelez, az MSan igen. Egy másik tesztben azonos tagértékű struktúrákra a memcmp eltérést mutat. Adj mindkét jelenséghez nyelvi magyarázatot, megfelelő javítást és ellenőrzési feltételt.

5.5.8 Megoldások

5.5.8.1 A változatlan kimenet értékelése

Nem bizonyít helyességet. A p pointerobjektum a main blokkjában tovább él, de a local élettartama véget ért. A szabálysértést a blokkhatár és a későbbi hozzáférés bizonyítja; a kiírt szám csak egy konkrét hibás futás megfigyelése.

Ellenőrzési feltétel: az indoklás akkor is érvényes maradjon, ha megváltozik a kimenet vagy a memóriatérkép.

5.5.8.2 Értékmásolás a blokkhatáron át

Az alábbi C17 részlet a CASE=2 ág teljes main függvényét helyettesíti; ugyanazokkal a parancsokkal fordítható.

int main(void)
{
    int result = 0;
    {
        int local = 42;
        result = local;
    }

    printf("%d\n", result);
    return 0;
}

A másoláskor mindkét objektum él, és a forrás inicializált. A későbbi olvasás már a különálló result objektumot használja.

Ellenőrzési feltétel: minden olvasás élő, inicializált objektumra vonatkozzon; ne maradjon kölcsönzött pointer a lejárt objektumhoz.

5.5.8.3 A különböző diagnosztikai eredmények magyarázata

Az ASan nem általános inicializálatlanság-követő. Az MSan-leletet a forrás megfelelő inicializálást nélkülöző végrehajtási útvonalával kell összekapcsolni. A javítás az alkalmazási jelentésnek megfelelő inicializálás vagy az olvasás előfeltételének biztosítása.

A struktúráknál az eltérő padding lehetséges magyarázat, de konkrét futási bizonyíték nélkül nem kizárólagos diagnózis: a tagokat és a méretet is ellenőrizni kell. A javítás tagonkénti érték-összehasonlítás.

Ellenőrzési feltétel: azonos tagértékek mindig egyenlőséget adjanak, bármely vizsgált tag eltérése pedig egyenlőtlenséget, a padding tartalmától függetlenül.

5.5.9 Összefoglaló

A memóriahiba okát a C objektum-, élettartam-, inicializálási és típushozzáférési szabályai adják; a memóriatérkép csak implementációs környezet. Az optimalizálási különbség és a sanitizer-lelet hasznos bizonyíték, de egyik sem helyettesíti a forrásból levezetett indoklást. A jó javítás a hibás szerződést szünteti meg, nem csupán a megfigyelt tünetet.