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.
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:
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
#endifAz 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.
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.
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
,
a kilépésé
,
a későbbi olvasásé
.
A nem VLA local élettartama itt
,
miközben
.
Az olvasás tehát kívül esik az élettartamon. A levezetésben sem
memóriacím, sem stackelrendezés nem szerepel.
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.
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 fixedA 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.
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.oElő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.
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-O2A 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.
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-sanA 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-sanEszkö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.
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-msanA 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 |
A második esethez tartozó hibajegyben különüljön el:
CASE=2, compiler, runtime, target és kapcsolók.local élettartama; a későbbi hozzáférés érvénytelen.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.
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.
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.
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.
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.
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.
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.
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.