A sikeres allokációkat lefedő teszt még nem bizonyítja, hogy a program erőforrás-kezelése helyes. A valódi kérdés az, hogy minden megszakított felépítési folyamat után helyreállnak-e az invariánsok: felszabadulnak-e a már megszerzett erőforrások, érvényes marad-e a korábbi állapot, és nem marad-e használatban érvénytelen pointer.
A realloc és dinamikusan növekvő buffer rész alapján sikeres átméretezés után a régi allokációból származó pointereket nem használhatjuk tovább. A tulajdonlási szerződés és cleanup ágak adja a hibautak szerkezetét; az élettartam- és memóriahibák vizsgálati laborja pedig a diagnosztikai szemléletet. Most ezekhez determinisztikus hibákat és ellenőrizhető erőforrásmérleget kapcsolunk.
Az allokációs hiba-injektálás (allocation failure injection) szándékosan sikertelenre változtat egy kiválasztott foglalási kísérletet. Nem szükséges hozzá tényleges memóriahiány: egy tesztadapter az -edik hívásnál közvetlenül null pointert ad vissza.
A számlálás foglalási kísérleteket jelent, nem csak
sikeres foglalásokat. A pozitív méretű realloc szintén
kísérlet, akkor is, ha az implementáció egyébként helyben tudná
végrehajtani.
Ha egy adott bemenet sikeres útján foglalási kísérlet történik, célszerű külön tesztesetben megvizsgálni:
Minden eset friss állapotból indul. Ez az adott út hibapontjait fedi le; más bemenetekhez tartozó elágazásokat, újrapróbálkozásokat és a hibakezelés közbeni további foglalásokat nem automatikusan.
Az alábbi adapter szerződése szűk:
errno értékét.Ez az utóbbi fontos különbség: itt ISO C17 szerinti, null pointerrel jelzett sikertelenséget tesztelünk, nem egy POSIX-specifikus hibakód-szerződést.
Legyen az élő, általunk nyilvántartott allokációk száma. Ekkor:
ahol az új, sikeres foglalások, pedig a nem null pointerrel végzett, érvényes felszabadítások száma.
A nem null pointeren végzett, pozitív méretű realloc ezt
a logikai darabszámot nem változtatja meg. Sikertelenségkor megmarad a
régi allokáció; siker esetén annak helyébe lép az új.
Bizonyításvázlat. Kezdetben
.
Minden sikeres malloc vagy calloc eggyel
növeli, minden érvényes felszabadítás eggyel csökkenti az értéket. A
fenti szerződésű átméretezés nettó változása nulla. A műveletsor
hosszára végzett indukcióval az összefüggés minden prefixre fennáll.
A nulla végösszeg szükséges, de önmagában nem elégséges bizonyíték: egy puszta számláló nem azonosítja az allokációkat, és nem ellenőrzi a pointerek eredetét.
A példában egy eredmény két erőforrást birtokol: egy
egészérték-tömböt és egy címkét. A felépítés három foglalási kísérletből
áll: malloc, calloc, majd
realloc.
Szerződés: a build üres kimenetet kap.
Siker esetén átadja a két allokáció tulajdonlását; hibánál a kimenet
üres marad. A destroy üres objektumra is meghívható.
| Szerep | Tulajdonos | Élettartamhatár és érvénytelenítés |
|---|---|---|
| Felépítés alatti tömb és címke | A build cleanup-logikája |
Hiba esetén a közös takarító ág szabadítja fel |
| Sikeres eredmény két allokációja | A hívó result objektuma |
A destroy felszabadítja őket |
| A tömbből képzett kölcsönzött pointer | Nem tulajdonos | Sikeres átméretezés vagy felszabadítás érvényteleníti |
Az eredményt leíró result objektum |
A teszteset automatikus objektuma | Hatókörének elhagyása önmagában nem szabadítja fel a dinamikus tárolót |
Laborprofil: ISO C17, Linux hosted környezet, x86-64 System V ABI, LP64. Rögzíthető kiinduló verziók: GCC 14.2.0 vagy Clang 19.1.7, glibc 2.39; Clang esetén illeszkedő compiler-rt 19.1.7. A helyi elérhetőséget és párosítást ellenőrizni kell. A következő példákhoz nem tartozik dokumentált fordítási vagy futtatási eredmény.
A teljes fi.c:
#include <assert.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
struct fi {
size_t calls;
size_t fail_at; /* 0: nincs injektalt hiba */
size_t live;
};
struct result {
int *data;
char *label;
size_t len;
};
static int reject(struct fi *f)
{
++f->calls;
return f->fail_at != 0 && f->calls == f->fail_at;
}
static void *fi_malloc(struct fi *f, size_t bytes)
{
assert(bytes > 0);
if (reject(f))
return NULL;
void *p = malloc(bytes);
if (p != NULL)
++f->live;
return p;
}
static void *fi_calloc(struct fi *f, size_t count, size_t size)
{
assert(count > 0 && size > 0);
if (reject(f))
return NULL;
void *p = calloc(count, size);
if (p != NULL)
++f->live;
return p;
}
static void *fi_resize(struct fi *f, void *p, size_t bytes)
{
assert(p != NULL && bytes > 0);
if (reject(f))
return NULL; /* p allokacioja valtozatlanul el */
return realloc(p, bytes);
}
static void fi_free(struct fi *f, void *p)
{
if (p != NULL) {
assert(f->live > 0);
free(p);
--f->live;
}
}
static int build(struct fi *f, struct result *out)
{
int *data = NULL;
char *label = NULL;
int status = -1;
assert(out->data == NULL);
assert(out->label == NULL && out->len == 0);
data = fi_malloc(f, 4 * sizeof *data);
if (data == NULL)
goto done;
for (size_t i = 0; i < 4; ++i)
data[i] = (int)i;
label = fi_calloc(f, 8, sizeof *label);
if (label == NULL)
goto done;
memcpy(label, "probe", sizeof "probe");
int *next = fi_resize(f, data, 8 * sizeof *data);
if (next == NULL)
goto done;
data = next;
for (size_t i = 4; i < 8; ++i)
data[i] = (int)i;
out->data = data;
out->label = label;
out->len = 8;
data = NULL; /* tulajdonlas atadva */
label = NULL;
status = 0;
done:
fi_free(f, label);
fi_free(f, data);
return status;
}
static void destroy(struct fi *f, struct result *r)
{
fi_free(f, r->label);
fi_free(f, r->data);
r->label = NULL;
r->data = NULL;
r->len = 0;
}
static size_t run_case(size_t fail_at)
{
struct fi f = {0, fail_at, 0};
struct result r = {NULL, NULL, 0};
int status = build(&f, &r);
if (fail_at == 0) {
assert(status == 0);
assert(f.calls == 3 && f.live == 2);
assert(r.len == 8);
assert(strcmp(r.label, "probe") == 0);
for (size_t i = 0; i < r.len; ++i)
assert(r.data[i] == (int)i);
} else {
assert(status == -1);
assert(f.calls == fail_at);
assert(r.data == NULL && r.label == NULL);
assert(r.len == 0 && f.live == 0);
}
destroy(&f, &r);
assert(f.live == 0);
assert(r.data == NULL && r.label == NULL && r.len == 0);
return f.calls;
}
int main(void)
{
size_t count = run_case(0);
for (size_t n = 1; n <= count; ++n)
(void)run_case(n);
return EXIT_SUCCESS;
}A méretek itt kis, rögzített értékek, amelyek a megadott profilban reprezentálhatók. Változó elemszámnál a korábban tanult szorzási túlcsordulás-ellenőrzés továbbra is szükséges.
A várt állapotok:
| Injektált hiba | Elért kísérletek száma | Élő allokáció a build
visszatérésekor |
Élő allokáció a teszt végén |
|---|---|---|---|
| Nincs | 3 | 2 | 0 |
| Első kísérlet | 1 | 0 | 0 |
| Második kísérlet | 2 | 0 | 0 |
| Harmadik kísérlet | 3 | 0 | 0 |
A sikeres ágon a teszt az eredeti négy elem megőrzését is ellenőrzi. A harmadik kísérlet kudarcakor pedig a régi tömb még él, ezért a cleanup jogosan felszabadítja.
A nem injektált foglalások ténylegesen is meghiúsulhatnak. Ilyenkor a teszt korábbi hibát észlelhet a tervezettnél: ez nem feltétlenül cleanup-hiba, hanem a tesztkörnyezet elégtelen erőforrása.
A következő bad.c szándékosan hibás, negatív
teszt. Mindig csak egy hibamódot válasszunk: az első hiba után
a program további viselkedésére már nincs nyelvi garancia.
#include <stdlib.h>
int main(int argc, char **argv)
{
if (argc != 2)
return 2;
int *p = malloc(2 * sizeof *p);
if (p == NULL)
return EXIT_FAILURE;
p[0] = 42;
p[1] = 7;
switch (argv[1][0]) {
case 'u':
free(p);
return p[0]; /* UB: use-after-free */
case 'd':
free(p);
free(p); /* UB: double-free */
return EXIT_SUCCESS;
case 'r': {
int *slice = p + 1;
int *next = realloc(p, 4 * sizeof *p);
if (next == NULL) {
free(p);
return EXIT_FAILURE;
}
p = next;
int value = *slice; /* UB: stale slice */
free(p);
return value;
}
default:
free(p);
return 2;
}
}Az allokáció tulajdonosa mindhárom ágban a main; a
slice csak kölcsönzött hozzáférés. A kezdeti foglalás
hibája szabályos hibakilépés. Az r ág sikertelen
átméretezéskor szabályosan felszabadítja a régi tárolót.
A hibák azonban különböznek:
ISO C17 szabály: sikeres realloc után a
régi allokáció élettartama véget ér. Ez akkor is igaz, ha a visszaadott
pointer ugyanazt a numerikus címet reprezentálná. Ne a régi és az új
pointer összehasonlításával próbáljuk eldönteni az érvényességet.
A helyes megoldás az index megtartása és a pointer újraképzése. Az
alábbi részlet az r ág helyes változata, az előző
inicializálással együtt értendő:
size_t index = 1; /* az eredeti ket inicializalt elem egyike */
int *next = realloc(p, 4 * sizeof *p);
if (next == NULL) {
free(p);
return EXIT_FAILURE;
}
p = next;
int *slice = p + index;
int value = *slice;
free(p); /* slice ettol kezdve ervenytelen */
return value;Zsugorításnál az index és a nézet teljes hosszának érvényességét külön ellenőrizni kell. A régi kapacitás nem bizonyíték az új tartomány elérhetőségére.
A fordítási parancsok külön laborhoz tartoznak. Az ellenőrzésekhez ne
legyen definiálva az NDEBUG, mert az kikapcsolná az
assert vizsgálatait.
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 fi.c -o fi
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 bad.c -o bad
clang -std=c17 -Wall -Wextra -Wpedantic -g -O1 \
-fno-omit-frame-pointer -fsanitize=address fi.c -o fi-asan
clang -std=c17 -Wall -Wextra -Wpedantic -g -O0 \
-fno-omit-frame-pointer -fsanitize=address bad.c -o bad-asanA hibás fájlnál fordítói figyelmeztetés is megjelenhet; ez nem garantált diagnózis.
| Eszköz | Elsődleges szerep | Fontos korlát |
|---|---|---|
| AddressSanitizer, ASan | Többek között felszabadítás utáni hozzáférés és érvénytelen felszabadítás felismerése | Csak a végrehajtott utakat látja; nem teljes nyelvi érvényességvizsgáló |
| LeakSanitizer, LSan | Elérhetőségi vizsgálat alapján potenciális szivárgások keresése | Az elérhető, de szükségtelenül megtartott memóriát rendszerint nem tekinti leaknek |
| Valgrind Memcheck | Futás közbeni memóriahozzáférés- és leakdiagnosztika | Jelentős futási többletköltség; platform- és verziófüggő támogatás |
A megadott Linux-profilban az ASan runtime leakellenőrzést is
támogathat; ezt az ASAN_OPTIONS változó
detect_leaks=1 beállításával lehet kérni. Az integrációt a
tényleges compiler/runtime pároson ellenőrizni kell. Az önálló
-fsanitize=leak másik lehetséges profil, nem az ASan
hozzáférés-ellenőrzésének helyettesítője.
Valgrindhoz például a 3.24.0 kiadás rögzíthető kiinduló verzióként. A
szokásos összehasonlítást a sanitizer nélküli, debug információt
tartalmazó binárison végezzük. A Memcheck --leak-check=full
és --show-leak-kinds=all kapcsolója részletesebb jelentést
kér; CI-ben külön döntés, mely leakkategóriák okozzanak
hibakilépést.
Az u, d és r esetet külön
folyamatban vizsgáljuk. Nincs garantált UB-kimenet vagy garantált
szövegű jelentés.
Különösen fontos: ha a realloc helyben dolgozik, a stale
pointer címe továbbra is címezhető memóriára mutathat. Az ASan
hallgatása ilyenkor nem igazolja a C-kód helyességét.
Mozgatásos diagnosztikai esetet úgy kényszeríthetünk ki, hogy még az élő
régi tömb mellett külön új tömböt foglalunk, átmásoljuk a két
inicializált elemet, majd felszabadítjuk a régit. Ez célzott
tesztmodell, nem a valódi realloc implementációjának
mérése.
A memóriaszivárgás tulajdonlási probléma: a felszabadítási kötelezettség nem teljesül, például mert elveszítjük az utolsó használható tulajdonosi hivatkozást.
Egy élő cache más helyzet. Ha szándékosan birtokolja az objektumokat, és van ürítési vagy kilakoltatási szabálya, a memória megtartása lehet helyes. A korlátlanul növekvő cache ugyanakkor súlyos üzemeltetési hiba lehet, még ha a leakdetektor mindent elérhetőnek lát is.
Az RSS (resident set size) az operációs rendszer által
elszámolt rezidens memória mérőszáma, nem az alkalmazás élő
malloc-allokációinak összege. Linuxon többek között az
allocator visszatartott területei, töredezettség, stackek és leképezések
is befolyásolják. A free nem ígér azonnali
RSS-csökkenést.
| Megfigyelés | Amit nem bizonyít |
|---|---|
live == 0, de magas RSS |
Önmagában nem bizonyít leaket |
| „Still reachable” jelentés | Nem bizonyítja a cache méretpolitikájának helyességét |
| Nincs leakjelentés | Nem zárja ki az elérhető memória indokolatlan megtartását |
| Stabil RSS | Nem zárja ki kis mennyiségű memória folyamatos elvesztését |
Ezért együtt vizsgáljuk a tulajdonosi állapotot, az allokációk és kért C bájtok mérlegét, a cache elemszámát és az OS-mutatókat.
Gyakori hibás javítás, hogy free(p) után csak
p = NULL kerül a kódba. Ez hasznos állapotfrissítés, de más
aliasokat nem módosít, és korábbi hibás hozzáférést nem tesz
helyessé.
Írd le a második és harmadik foglalás kudarcakor élő erőforrásokat,
majd a cleanup hatását. Miért marad változatlan a live
értéke sikertelen átméretezéskor?
Egészítsd ki a build függvényt egy 16 C bájtos
ideiglenes munkaterülettel. Foglalása az átméretezés után, a tulajdonlás
átadása előtt történjen. A munkaterület siker esetén sem kerülhet a
kimenetbe. Bővítsd az ellenőrzéseket.
Egy feltételezett vizsgálatban az r eset nem ad
ASan-jelentést, a teljes cleanup után pedig az RSS nem csökken.
Bizonyítja-e bármelyik megfigyelés a helyességet vagy a leaket? Adj
célzott javítást és ellenőrzési tervet.
A második kísérlet előtt csak a tömb él: . A címke foglalása sikertelen, ezért a null címke felszabadítása nem csinál semmit; a tömb felszabadítása után .
A harmadik kísérlet előtt a tömb és a címke is él:
.
Sikertelen, pozitív méretű realloc esetén a régi tömb
megmarad. A két cleanup-művelet sorrendben
változást eredményez.
Ellenőrzési feltétel: hibás visszatéréskor üres kimenet és nulla élő allokáció.
A build elején, minden lehetséges ugrás előtt adjuk
hozzá:
unsigned char *scratch = NULL;A sikeres átméretezés és az elemek inicializálása után, de a kimenet kitöltése előtt:
scratch = fi_malloc(f, 16);
if (scratch == NULL)
goto done;A közös cleanup elején:
fi_free(f, scratch);Ezek a korábbi C17-fájlba illesztendő részletek. A
scratch tulajdonosa végig a build; minden
visszatérési úton felszabadul, kölcsönzött nézet nem kerül ki
belőle.
A sikeres ág elvárt hívásszáma négyre változik. A main a
mért hívásszámból képezi a hibapontokat, ezért az új negyedik esetet is
lefedi.
Ellenőrzési feltétel: sikeres visszatéréskor továbbra is csak két allokáció él; minden injektált hibánál nulla.
Egyik megfigyelés sem elegendő. Az ASan nem feltétlenül látja a helyben végzett átméretezéshez tartozó nyelvi élettartamsértést. A változatlan RSS pedig lehet allocator-retenció következménye.
A javítás: az elemindexet őrizzük meg, majd sikeres átméretezés után az új pointerből képezzük újra a nézetet. A vizsgálati terv külön tartalmazza a sikertelen átméretezést, a sikeres tartalommegőrzést és a mozgatást kényszerítő negatív tesztet.
Ellenőrzési feltétel: a javított út nem használ régi aliast, minden hibakimenet rendezett, és az erőforrásmérleg visszaáll. A sanitizer és a leakdetektor ezt kiegészítő bizonyíték, nem teljes helyességi bizonyítás.
Az allokációs hibák teszteléséhez nem tényleges memóriahiány, hanem
pontosan kiválasztott hibapont kell. Az
-edik
foglalás elutasítása ellenőrizhetővé teszi a részleges felépítés
cleanup-ját. Az erőforrásmérleg a tulajdonlási szerződést vizsgálja; az
ASan, LSan és Memcheck végrehajtott hibákat keres. Egyik sem
helyettesíti az élettartam-szabályokat: sikeres realloc
után a régi nézeteket újra kell képezni, az RSS pedig nem tekinthető
leakszámlálónak.