16.2 ASan, UBSan, MSan, TSan és Valgrind

A memóriahiba helye és a látható meghibásodás helye gyakran eltér. Egy határon túli írás után a folyamat még sokáig működhet; egy felszabadított objektum olvasása pedig csak más allokációs mintázat mellett okozhat összeomlást. A dinamikus diagnosztikai eszközök célja, hogy a hibás művelethez közelebb adjanak értelmezhető jelzést.

Az allokációs hiba-injektálás a hibautak kezelését vizsgálta; itt elsősorban a végrehajtott memóriaműveleteket ellenőrizzük. A konkurens élettartam szabályaihoz a TSan, a debuggeres vizsgálathoz pedig a szimbolizált hívási lánc kapcsolódik. Egyetlen hibamentes futás sem igazolja a teljes program helyességét: csak adott bemenetek, végrehajtási utak és szálütemezések kerültek vizsgálatra.

16.2.1 Eszközválasztás a hibatípus alapján

A sanitizer fordítói és futtatókörnyezeti diagnosztikai mechanizmus. A fordító ellenőrzésekkel egészíti ki, azaz instrumentálja a programot; a kapcsolódó futtatókönyvtár nyilvántartásokat vezet és jelentéseket készít. A Valgrind/Memcheck ezzel szemben a gépi program végrehajtását felügyeli, ezért nem igényel sanitizeres újrafordítást.

Eszköz Elsődleges cél Lényeges korlát
AddressSanitizer, ASan Határon túli hozzáférés, use-after-free, egyes élettartam- és felszabadítási hibák Nem ismeri minden alkalmazásszintű objektumhatár jelentését
UndefinedBehaviorSanitizer, UBSan Kiválasztott nem definiált műveletek Nem ellenőrzi a C összes UB-szabályát
MemorySanitizer, MSan Inicializálatlan értékek felhasználása Különösen érzékeny a függőségek instrumentálására
ThreadSanitizer, TSan Adatversenyek Nem általános logikai race- vagy holtpontdetektor
LeakSanitizer, LSan Elvesztett dinamikus allokációk Az elérhető memória is lehet alkalmazásszintű szivárgás
Valgrind/Memcheck Érvénytelen hozzáférés, definiálatlan értékhasználat, leak Jelentős futási többletköltség; nem általános UB-ellenőrző

16.2.1.1 ASan és UBSan: egymást kiegészítő ellenőrzések

Az ASan a programmemóriához kiegészítő nyilvántartást, árnyékmemóriát (shadow memory) rendel. Többek között jelöli az allokációk körüli tiltott területeket és a felszabadított tárolót. Így észlelhet heap-, stack- és globális bufferhatár-sértést, kétszeres felszabadítást vagy nem megfelelő cím átadását a free számára.

A hatókör elhagyása utáni használathoz (use-after-scope) a Clang profiljában explicit megadható a -fsanitize-address-use-after-scope. A függvényből visszatérés utáni használat külön mechanizmus; támogatása és beállítása platform- és verziófüggő. Az ASan nem teljes lifetime-ellenőrző.

Fontos vakfolt az objektumon belüli határátlépés: egy struktúratag tömbjéből a következő tagba történő hibás hozzáférés nem feltétlenül érint tiltott területet. Ugyanígy nem feltétlenül jelez, ha egy nagy allokáción belül túllépjük az alkalmazás által érvényesnek tekintett adathosszt.

ISO C17-szabály és eszközellenőrzés különbsége: a signed egész túlcsordulás nem definiált viselkedés; a UBSan megfelelő ellenőrzése ezt a végrehajtott műveletnél jelezheti. A -fsanitize=undefined csoport többek között igazítási, null pointerrel kapcsolatos, eltolási, aritmetikai és bizonyos tömbhatár-ellenőrzéseket kapcsol be. Nem ad általános effective type-, aliasing- vagy élettartam-ellenőrzést.

Az unsigned aritmetika modulo viselkedése önmagában nem UB, ezért az unsigned méretszámítás körbefordulásának felismerésére ez a csoport nem elegendő. A Clang külön unsigned-overflow ellenőrzése hasznos hibakeresési kiegészítés lehet, de nem szabad nyelvi tilalomként értelmezni.

16.2.1.2 MSan, TSan és a leak-ellenőrzések

Az MSan nem csupán azt követi, hogy egy cím elérhető-e, hanem azt is, hogy az ott tárolt érték inicializált-e. Egy inicializálatlan érték másolása továbbviheti a jelölést; a jelentés például akkor születhet meg, amikor az érték vezérlési döntést befolyásol. Az origin tracking az eredet visszakeresését segíti.

MSan esetén a nem instrumentált függőség különösen veszélyes határ: hibák maradhatnak rejtve, illetve helyesen előállított adatok is definiálatlannak látszhatnak. A saját kód mellett a releváns függőségeket is instrumentálni kell. A rendszerkönyvtári hívások egy részét speciális elfogó függvények, interceptorok kezelik; ez nem általános felmentés minden külső könyvtárra.

A TSan a memóriahozzáféréseket és az általa ismert szinkronizációt követi. Az adatverseny meghatározásához a korábban tárgyalt happens-before viszony szükséges. A két külön zárolás között végrehajtott „ellenőrzés, majd módosítás” például lehet logikailag hibás akkor is, ha minden hozzáférés adatversenymentes. A saját szinkronizációs megoldásokat a TSan nem feltétlenül érti.

Clang-eszközszabály: az ASan, MSan és TSan egymással nem kombinálható sanitizerprofilok. A TSanhoz ezért külön bináris és külön futtatás készüljön; ne próbáljuk az összes ellenőrzést egyetlen kapcsolólistába zsúfolni.

A LSan jellemzően a leak-vizsgálat időpontjában elérhetőségi elemzést végez. Linuxon gyakran az ASan runtime részeként használható, de az integráció platformfüggő. Az önálló -fsanitize=leak profil elsősorban runtime-linkelést igényel, nem ASan-szerű hozzáférés-instrumentálást. Korai összeomlás vagy ASan-leállás esetén a szokásos kilépéskori leak-vizsgálat elmaradhat.

16.2.2 A teljes build instrumentálása

A következő parancsok nem futtatott laborutasítások. Választott mintaprofiljuk: ISO C17, Linux x86-64 System V ABI, Clang/LLVM és compiler-rt 18.1.8, glibc 2.39; a Memcheck-példához Valgrind 3.23.0. A telepített csomagokat, disztribúciós javításokat és támogatást a laborban ellenőrizni kell. A clang és llvm-symbolizer itt a rögzített LLVM-telepítés eszközeit jelenti.

A teljes instrumentálási lánc három részből áll:

  1. A saját fordítási egységeket — a tesztelt könyvtárakat is — a megfelelő sanitizerkapcsolókkal fordítjuk.
  2. A végső linket a compiler driverrel, itt a clang paranccsal végezzük, a szükséges sanitizerkapcsolókkal.
  3. Futáskor a megfelelő runtime és ténylegesen a kívánt instrumentált megosztott könyvtárak töltődnek be.

A nyers ld meghívása nem helyettesíti automatikusan a driver runtime-választását és linkbeállításait. A csak tesztprogramon bekapcsolt sanitizer nem instrumentálja utólag a korábban elkészített statikus archívumot.

Profil Fordításkor Végső driveres linkkor
ASan + UBSan -fsanitize=address,undefined Ugyanez
MSan -fsanitize=memory -fsanitize-memory-track-origins=2 -fPIE -fsanitize=memory -fsanitize-memory-track-origins=2 -pie
TSan -fsanitize=thread -fPIE -fsanitize=thread -pie
Memcheck -g, sanitizer nélkül Sanitizer nélkül

Minden profilnak külön buildkönyvtár kell. Ez az objektumfájlok és függőségek véletlen keverését is megelőzi. Az instrumentálatlan külső kódot lefedettségi korlátként kell nyilvántartani, nem „ellenőrzött” komponensként.

16.2.3 Kidolgozott példa: olvasás az allokáció után

A következő három fájl önálló C17-példát alkot. A checksum.c szándékosan hibás, UB-t tartalmazó tanítási példa; nincs garantált kimenete.

A kívánt függvényszerződés: pozitív n esetén p legalább n inicializált unsigned char elem olvasását engedje; nulla hossz esetén null pointer is megengedett. A függvény nem tartja meg a címet.

checksum.h:

#ifndef CHECKSUM_H
#define CHECKSUM_H

#include <stddef.h>

unsigned checksum(const unsigned char *p, size_t n);

#endif

checksum.c:

#include "checksum.h"

unsigned checksum(const unsigned char *p, size_t n)
{
    unsigned sum = 0;
    for (size_t i = 0; i <= n; ++i) { /* HIBA: <= */
        sum += p[i];
    }
    return sum;
}

main.c:

#include "checksum.h"

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const size_t n = 4;
    unsigned char *p = malloc(n * sizeof *p);
    if (p == NULL) {
        return EXIT_FAILURE;
    }

    for (size_t i = 0; i < n; ++i) {
        p[i] = (unsigned char)i;
    }

    printf("%u\n", checksum(p, n));
    free(p);
    return EXIT_SUCCESS;
}
Tulajdonos Tároló és kölcsönzés Élettartam, érvénytelenítés Hibakimenet
A main felel a felszabadításért malloc-tároló; a checksum csak olvasásra kölcsönzi Sikeres allokációtól free-ig; a hívás közben nincs felszabadítás Allokációs hibánál EXIT_FAILURE; a hibás indexelésnél UB

A sizeof *p értéke egy C bájt. A foglalás tehát négy C bájt, az érvényes indexek:

0≤i<n. 0 \le i < n.

A hibás ciklus eléri az i=ni=n értéket is. A p + n one-past pointer képzése megengedett, de a p[n], vagyis *(p + n) olvasása nem.

Fordítás és külön laborfuttatás:

mkdir -p build/asan

clang -std=c17 -Wall -Wextra -Wpedantic -O1 -g \
  -fno-omit-frame-pointer -fno-optimize-sibling-calls \
  -fsanitize=address,undefined -fsanitize-address-use-after-scope \
  -fno-sanitize-recover=all \
  -c checksum.c -o build/asan/checksum.o

clang -std=c17 -Wall -Wextra -Wpedantic -O1 -g \
  -fno-omit-frame-pointer -fno-optimize-sibling-calls \
  -fsanitize=address,undefined -fsanitize-address-use-after-scope \
  -fno-sanitize-recover=all \
  -c main.c -o build/asan/main.o

clang -fsanitize=address,undefined \
  build/asan/main.o build/asan/checksum.o -o build/asan/checksum

ASAN_OPTIONS=detect_leaks=1:symbolize=1 build/asan/checksum

A várt diagnosztikai kategória heap-buffer-overflow olvasás. Ez nem rögzített futtatási eredmény; a jelentés pontos alakja eszközfüggő. A -fno-sanitize-recover=all az érintett UBSan-ellenőrzéseknél megakadályozza, hogy a teszt hiba után sikeres kilépésig folytatódjon.

A javítás: i < n.

Helyességi bizonyításvázlat: kezdetben i == 0; a ciklustörzs csak i < n mellett fut. A szerződés ekkor garantálja az adott elem olvashatóságát. A tulajdonos a teljes hívás alatt életben tartja a tárolót. Nulla hossznál nincs dereferálás. A javított példában a várható összeg 0+1+2+3=60+1+2+3=6.

16.2.4 Memcheck, stack trace és suppression policy

Memcheckhez külön, nem sanitizeres bináris készüljön:

mkdir -p build/memcheck

clang -std=c17 -Wall -Wextra -Wpedantic -Og -g \
  main.c checksum.c -o build/memcheck/checksum

valgrind --tool=memcheck --leak-check=full \
  --show-leak-kinds=all --errors-for-leak-kinds=definite,possible \
  --track-origins=yes --error-exitcode=99 \
  build/memcheck/checksum

A Memcheck hasznos instrumentálatlan függőségeknél, valamint definiálatlan értékek és szivárgások vizsgálatakor. A heap-allokációk határát ismeri, de például a stacken belüli egyedi tömbhatárok felismerése korlátozott. Az ASan-bináris Memcheck alatti futtatása nem jó alapértelmezett kombináció.

A hívási lánc (stack trace) értelmezhetőségéhez kell a megfelelő bináris, megosztott könyvtár, debug információ és szimbolizáló. A -g és a frame pointer megtartása segít, de nem szünteti meg az optimalizálás minden hatását. Az llvm-symbolizer legyen elérhető; az eltérő buildből származó szimbólum félrevezető lehet.

Ne csak a jelentés legfelső sorát olvassuk:

A suppression ismert jelentés célzott elnyomása, nem hibajavítás. Legyen hozzá felelős, indoklás, követhető hibajegy és felülvizsgálati feltétel. Külső függőség ismert hibájához szűk minta indokolható; saját kód teljes könyvtárának elnémítása nem.

A runtime-suppression és a fordításkori instrumentálási kizárás eltérő mechanizmus, eszközönként eltérő támogatással. Utóbbi szükséges követési információt is eltüntethet. Egy MSan-instrumentálási hiányt ezért nem helyes egyszerűen elnyomni.

16.2.5 Feladatok

16.2.5.1 Eszközválasztás

Egy parser inicializálatlan lokális változó alapján választ ágat; egy kapcsolatkezelő felszabadítás után olvas; két szál szinkronizáció nélkül ír ugyanabba a nem atomikus objektumba. Mely profilokat választanád? Mit bizonyít egy tiszta futás?

16.2.5.2 Javítás és határteszt

Javítsd a checksum ciklusát. Ellenőrizd a nulla hosszú bemenetet, egyetlen 9 értékű elemet és a példában szereplő négy elemet. Miért nem kell nulla hossznál érvényes elemet biztosítani?

16.2.5.3 Builddiagnózis

Egy CI-feladat csak a main.c fájlt fordítja ASannal, egy régi libparser.a archívumot használ, ld-vel linkel, majd ugyanebbe a profilba TSan-t is bekapcsol. Mi a javítás sorrendje?

16.2.6 Megoldások

16.2.6.1 Eszközválasztás indoklása

Az inicializálatlan értékhez MSan vagy Memcheck, a use-after-free hibához ASan, az adatversenyhez TSan illik. MSanhoz a függőségi lefedettséget is ellenőrizni kell. A tiszta futás kizárólag azt mutatja, hogy az adott végrehajtásban az eszköz nem jelentett felismert hibát; nem igazolja más bemenetek vagy ütemezések helyességét.

16.2.6.2 Határteszt indoklása

A javítás i < n. A helyes implementáció main függvényébe illeszthető ellenőrzés:

const unsigned char one[] = {9};
const unsigned char four[] = {0, 1, 2, 3};

if (checksum(NULL, 0) != 0 ||
    checksum(one, 1) != 9 ||
    checksum(four, 4) != 6) {
    return EXIT_FAILURE;
}

A tömbök tárolóját a hívó biztosítja; a kölcsönzés a hívásokra korlátozódik, és legkésőbb a blokk elhagyásakor érvénytelenné válna. Nulla hossznál a ciklustörzs nem fut, tehát a null pointert nem dereferáljuk. Ellenőrzési feltétel a három elvárt érték és a diagnosztika nélküli futás, a szerződés kézi ellenőrzésével együtt.

16.2.6.3 A build javítása

Először külön ASan- és TSan-buildkönyvtárat kell létrehozni. Ezután minden saját fordítási egységet, köztük a libparser.a tagjait is újra kell fordítani az adott profillal. A végső linket clang végezze a megfelelő sanitizerkapcsolóval.

Az ellenőrzés tárgya nem pusztán a sikeres link: a buildnaplóban minden érintett fordítás és a végső link parancsát is vizsgálni kell, valamint a futáskor betöltött függőségeket. A két profil külön futtatandó.

16.2.7 Összefoglaló

A diagnosztikai profil hibatípushoz választott végrehajtási környezet. Az ASan és UBSan gyakran közös alap; az MSan és TSan külön buildet és körültekintő függőségkezelést igényel. A LSan és Memcheck további nézőpontot ad. A használható eredmény feltétele a helyes instrumentálási lánc, a driveres linkelés, az értelmezhető jelentés és a szűk suppression policy — nem pusztán a nulla kilépési kód.