11.3 Hibakeresési esettanulmány: szegmentálási hiba gyökérokának megtalálása

11.3.1 Motiváció: a hibás utasítás nem feltétlenül a hiba oka

Egy C-ből hívott assembly függvény tömbelemet olvas. Az első elemmel működik, a másodikkal szegmentálási hibát kapunk. A hibakereső egy betöltőutasításnál áll meg, de ebből még nem következik, hogy maga a betöltés kódolása rossz: hibás lehet a cím előállítása, egy korábban felülírt regiszter vagy a hívási szerződés megsértése is.

A szegmentálási hiba (segmentation fault, Linux alatt jellemzően SIGSEGV) azt jelzi, hogy a folyamat olyan memória-hozzáférést kísérelt meg, amelyet a virtuálismemória-leképezés vagy annak jogosultságai nem engednek meg. Ez operációsrendszer-szintű esemény, nem egyetlen assembly programozási hibafajta neve.

Két címet különböztessünk meg:

A labor a Disassembly objdump/readelf-fel munkafolyamatára épít. Minden assembly utasítás és regiszternév A64 ISA, AArch64, ARMv8-A környezetre vonatkozik. A direktívák GNU as direktívák ehhez a célhoz. A célprofil little-endian Linux, az elsődleges futtató qemu-aarch64.

Az alábbi eredmények levezetett, várt eredmények; a kódot itt nem futtattuk.

11.3.2 Kontrollált hibafeltétel: tömb egy védőlap előtt

Egy hibás indexelés nem szükségképpen okoz azonnali összeomlást. Ha a rossz cím továbbra is olvasható lapon van, a program egyszerűen más adatot olvas. Ezért a laborban egy védőlapot (guard page) hozunk létre: egy leképezett, de a program számára nem olvasható és nem írható memórialapot.

A címeket az mmap szolgáltatja. Nem használunk rögzített vagy találomra választott címet. A lapméretet futás közben kérdezzük le.

Legyen:

Az első lap olvasható és írható; a következő lap hozzáférhetetlen. A lap végére helyezett uint32_t tömb kezdete:

A=B+P−4n. A = B + P - 4n.

A helyes elemcím A+4iA+4i. A hibás függvény ehelyett A+8iA+8i címet képez: összekeveri a 64 bites mutató méretét a tömbelem 4 bájtos méretével.

Levezetés az alapteszthez: n=2n=2, i=1i=1 esetén:

Mennyiség Levezetett érték
Tömb kezdete B+P−8B+P-8
Helyes második elem címe B+P−4B+P-4
Hibásan képzett cím B+PB+P
Hibás olvasás célterülete A védőlap első 4 bájtja

Így a hiba nem a környező memória véletlen tartalmától függ.

11.3.3 Kidolgozott példa: hibás elemskálázás

Munkakönyvtár: ~/arm-labor/a64/a042/ — az alfejezet minden laborfájlja ide kerül, és minden parancs innen futtatandó (a könyvtárrendet a Labor-alapkörnyezet alfejezet írja le).

mkdir -p ~/arm-labor/a64/a042
cd ~/arm-labor/a64/a042

A függvény C-interfésze:

uint32_t lab_get(const uint32_t *p, size_t i);

Előfeltétele, hogy p megfelelően igazított, legalább i+1 olvasható elemet tartalmazó tömbre mutasson. A függvény nem végez határellenőrzést.

Az AAPCS64 szerint a mutató az A64 X0, az index az A64 X1 regiszterben érkezik; a 32 bites eredmény az A64 W0 regiszterben tér vissza.

Mentsük a hibás függvényt lab-offset.s néven:

// A64 ISA, ARMv8-A; GNU as; little-endian laborprofil.
.arch armv8-a
.text
.p2align 2
.global lab_get
.type lab_get, %function
lab_get:
    .cfi_startproc
    add x2, x0, x1, lsl #3     // HIBA: az indexet 8-cal szorozza.
    ldr w0, [x2]              // 4 bájtos elem betöltése.
    ret
    .cfi_endproc
.size lab_get, .-lab_get

.section .note.GNU-stack,"",%progbits

Az A64 ADD itt eltolt regiszteres operandust használ. A 64 bites változat LSL eltolási értéke 0…63 lehet; a #3 tehát szabályosan kódolható. Ez szemantikai hiba, nem assemblerhiba. A helyes eltolás #2, mert 22=42^2=4.

Az A64 X2 hívó által mentendő ideiglenes regiszter, ezért használata nem sérti a hívási szerződést. A függvény nem hív másik függvényt, és nem módosítja a vermet. A .cfi_startproc és .cfi_endproc a veremvisszafejtéshez használható metaadatot jelöli; nem hajt végre regisztermentést.

A teljes tesztprogram legyen lab-offset.c:

#define _GNU_SOURCE
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

extern uint32_t lab_get(const uint32_t *p, size_t i);

static void fail(const char *what)
{
    perror(what);
    exit(2);
}

__attribute__((noinline))
static void run_case(size_t n, size_t i, int tail)
{
    long raw_page = sysconf(_SC_PAGESIZE);
    if (raw_page <= 0) {
        fputs("Nem kerdezheto le a lapmeret.\n", stderr);
        exit(2);
    }

    size_t page = (size_t)raw_page;
    if (page > SIZE_MAX / 2 || n == 0 ||
        n > page / sizeof(uint32_t) || i >= n) {
        fputs("Ervenytelen teszteset.\n", stderr);
        exit(2);
    }

    unsigned char *map = mmap(NULL, 2 * page, PROT_NONE,
                             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (map == MAP_FAILED)
        fail("mmap");

    if (mprotect(map, page, PROT_READ | PROT_WRITE) != 0)
        fail("mprotect");

    size_t offset = tail ? page - n * sizeof(uint32_t) : 0;
    uint32_t *p = (uint32_t *)(map + offset);

    for (size_t j = 0; j < n; ++j)
        p[j] = 1000u + 17u * (uint32_t)j;

    uint32_t expected = p[i];
    uint32_t got = lab_get(p, i);

    if (got != expected) {
        fprintf(stderr,
                "FAIL: n=%zu i=%zu tail=%d got=%u expected=%u\n",
                n, i, tail, (unsigned)got, (unsigned)expected);
        exit(1);
    }

    if (munmap(map, 2 * page) != 0)
        fail("munmap");
}

int main(int argc, char **argv)
{
    unsigned cases = 0;

    if (argc == 1) {
        run_case(2, 1, 1);
        cases = 1;
    } else if (argc == 2 && strcmp(argv[1], "--all") == 0) {
        const size_t lengths[] = {1, 2, 3, 16};

        for (size_t k = 0; k < sizeof lengths / sizeof lengths[0]; ++k) {
            for (int tail = 0; tail <= 1; ++tail) {
                for (size_t i = 0; i < lengths[k]; ++i) {
                    run_case(lengths[k], i, tail);
                    ++cases;
                }
            }
        }
    } else {
        fputs("Hasznalat: lab-offset [--all]\n", stderr);
        return 2;
    }

    printf("OK: %u eset\n", cases);
    return 0;
}

A tail=0 esetek a lap elejére, a tail=1 esetek közvetlenül a védőlap elé helyezik a tömböt. Így egyszerre teszteljük a helyes értéket és a határközeli címzést.

11.3.3.1 Összeszerelés, linkelés, reprodukció

Ubuntu 24.04 cross-toolchainnel, qemu-user célra:

# Target: qemu-user / AArch64 Linux, ARMv8-A, little-endian.
aarch64-linux-gnu-as -g -EL -march=armv8-a \
    -o lab-offset.o lab-offset.s

aarch64-linux-gnu-gcc -std=c11 -g3 -O0 -Wall -Wextra \
    -march=armv8-a -mlittle-endian \
    -fno-omit-frame-pointer -fno-optimize-sibling-calls \
    -c lab-offset.c -o lab-driver.o

aarch64-linux-gnu-gcc -static -no-pie \
    -o lab-offset lab-driver.o lab-offset.o

aarch64-linux-gnu-objdump -d --disassemble=lab_get lab-offset

# Szándékos összeomlás: ne keletkezzen core fájl.
ulimit -c 0
lab_status=0
qemu-aarch64 ./lab-offset || lab_status=$?
printf 'status=%s\n' "$lab_status"

A statikus linkeléshez a célarchitektúra statikus C-könyvtára is szükséges. Ha a linker például a céloldali libc.a hiányát jelzi, az környezeti probléma, nem a vizsgált címzési hiba.

Várt eredmény: SIGSEGV; Bash alatt jellemzően status=139, azaz 128+11128+11. A QEMU és a shell diagnosztikai szövege verziófüggő. Az OK sor nem jelenhet meg.

11.3.4 A hibapont és a hívási lánc felderítése

Első terminálban indítsuk el a célt a QEMU távoli hibakereső-felületével:

# Target: qemu-user / AArch64; várakozás a GDB kapcsolatára.
ulimit -c 0
qemu-aarch64 -g 12345 ./lab-offset

Második terminálban:

# Debugger: x86_64 host; target: qemu-user / AArch64.
gdb-multiarch -q ./lab-offset \
    -ex 'set pagination off' \
    -ex 'target remote 127.0.0.1:12345' \
    -ex 'break lab_get' \
    -ex 'continue' \
    -ex 'info registers x0 x1 x2 sp x29 x30 pc' \
    -ex 'set $base = $x0' \
    -ex 'x/2uw $x0' \
    -ex 'si' \
    -ex 'p/d $x2 - $base' \
    -ex 'continue' \
    -ex 'x/i $pc' \
    -ex 'info registers x0 x1 x2 sp x29 x30 pc' \
    -ex 'bt'

A lépések jelentése:

  1. A töréspontnál ellenőrizzük a bemeneti regisztereket.
  2. A x/2uw $x0 két 32 bites, előjel nélküli decimális értéket olvas: várhatóan 1000 és 1017.
  3. A si egyetlen gépi utasítást léptet: végrehajtja a hibás címképzést.
  4. A címkülönbség várhatóan 8, holott az index 1, az elemméret pedig 4 bájt.
  5. A folytatás végrehajtja a betöltési kísérletet; a GDB várhatóan SIGSEGV miatt megáll.

A hiba pillanatában:

A64 állapot Várt értelmezés
PC Az A64 LDR W0, [X2] utasítás címe
X0 A tömb kezdőcíme; a sikertelen betöltés még nem adott eredményt
X1 1, előjel nélküli index
X2 A védőlap kezdőcíme
SP A függvénybelépéshez képest változatlan

A GDB-ben a p $_siginfo paranccsal megpróbálhatjuk lekérni a jel részletes adatait. Ha a QEMU–GDB kapcsolat támogatja ezt, a si_addr mező a hozzáférési hibacímet adja. A távoli cél nem minden változata szolgáltat ilyen adatot. Hiánya esetén az utasításból és regiszterekből vezetjük le a címet: ennél az A64 betöltésnél az effektív cím pontosan az X2 tartalma.

A bt által megjelenített hívási lánc (backtrace) várt szerkezete:

Keret Függvény
#0 lab_get
#1 run_case, n=2, i=1, tail=1
#2 main

A frame 1, majd az info args és info locals parancsokkal ellenőrizhetjük a C-oldali előfeltételeket és a lapméretet. Ezután a frame 0 visszavisz a hibázó függvényhez.

A hívási lánc nem önmagában bizonyíték: sérült verem vagy hibás visszafejtési metaadat esetén téves vagy hiányos lehet. Ugyanígy a debugger esetleges sikeres memóriaolvasása sem bizonyítja, hogy a program ugyanazt a címet jogosultan olvashatja: a hibakereső memóriaelérése eltérhet a célszálétól.

11.3.5 Hipotézis, egy változtatás, ellenőrzés

A hipotézisünk konkrét:

A tömbelem címe azért kerül a védőlapra, mert a függvény az indexet 4 helyett 8 bájttal skálázza.

Ez ellenőrizhető előrejelzést ad: kizárólag az eltolás javítása után a címkülönbség 4, a visszatérési érték 1017, a kilépési állapot 0 lesz.

Ne módosítsuk egyszerre a teszttömböt, a lapvédelmet és az assembly kódot. Hozzunk létre külön javított változatot:

# Target: qemu-user / AArch64; egyetlen forrásváltoztatás.
sed 's/lsl #3/lsl #2/' lab-offset.s > lab-offset-fixed.s

aarch64-linux-gnu-as -g -EL -march=armv8-a \
    -o lab-offset-fixed.o lab-offset-fixed.s

aarch64-linux-gnu-gcc -static -no-pie \
    -o lab-offset-fixed lab-driver.o lab-offset-fixed.o

aarch64-linux-gnu-objdump -d --disassemble=lab_get lab-offset-fixed

qemu-aarch64 ./lab-offset-fixed
printf 'status=%s\n' "$?"

qemu-aarch64 ./lab-offset-fixed --all
printf 'status=%s\n' "$?"

Várt kimenet: először OK: 1 eset, majd OK: 44 eset; mindkét futás kilépési állapota 0. A teljes tesztszám:

2(1+2+3+16)=44, 2(1+2+3+16)=44,

ahol a kettes szorzó a két elhelyezést jelenti.

A javított binárist a korábbi GDB-lépéssorral is vizsgáljuk meg: a címképzés után 4 legyen az eltérés, a betöltés után az A64 W0 értéke 1017. Ez összekapcsolja a forrásváltozást, a tényleges gépi kódot és a megfigyelt eredményt.

11.3.6 Más hibaforrások elkülönítése

Tünet Hipotézis Célzott adatgyűjtés és ellenpróba
Már az első betöltés címe rossz Hibás offset vagy elemskálázás Bemeneti mutató, index, elemméret és effektív cím összevetése
Egy belső hívás után romlik el a mutató Élő érték maradt egy felülírható regiszterben Regiszterérték rögzítése a hívás előtt és után; kizárólag a megőrzés javítása
A hívó állapota változik meg váratlanul A függvény nem állít helyre megőrzendő regisztert Az A64 X19–X29 érintett regisztereinek összevetése belépéskor és kilépéskor
Visszatéréskor vagy később történik a hiba Aszimmetrikus prológus/epilógus Veremmutató és mentési helyek követése; a foglalás és felszabadítás párosítása

Az A64 X30 visszatérési címét egy újabb A64 BL felülírja. Egy további függvényt hívó rutin ezért nem támaszkodhat feltétel nélkül az eredeti regisztertartalomra. A jelenlegi levélfüggvény viszont nem végez ilyen hívást.

A veremhiba sem mindig egyszerű igazítási hiba. Ha a prológus 16 bájtot foglal, az epilógus pedig 32 bájtot szabadít fel, az A64 SP továbbra is 16 bájtra igazított lehet, mégsem áll helyre. Az igazítás ellenőrzése szükséges, de nem helyettesíti a belépési és kilépési érték összevetését.

11.3.7 Mit jelent a hiba tényleges megszűnése?

A „most lefutott” gyenge bizonyíték. Ebben a laborban négy külön ellenőrzés kell:

Az érvénytelen, például i == n index nem része a függvény szerződésének. Annak tesztelése nem helyettesíti az érvényes bemenetek ellenőrzését.

A hibajegyzőkönyvet például lab-report.md néven rögzíthetjük:

Mező Tartalom
Tünet SIGSEGV a második elem olvasásakor
Gyökérok (root cause) Hibás, nyolcbájtos elemskálázás
Közreható tényező (contributing factor) A mutatóméret és az elemméret összekeverése
Felismerést késleltető tényező A nulladik index tesztje mindkét skálázással helyes címet ad
Bizonyíték X1=1, címkülönbség 8, betöltési cím a védőlapon
Javítás Kizárólag az eltolás módosítása
Elfogadási feltétel Helyes címkülönbség, helyes eredmény, 44 sikeres eset

A védőlap nem a programhiba gyökéroka: a meglévő hibát tette determinisztikusan láthatóvá.

11.3.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért nem különbözteti meg a helyes és hibás függvényt az i=0 teszt? Lapvégi, 16 elemű tömbnél mi a helyes utolsó elem címe a védőlap kezdetéhez viszonyítva?

  2. (implementációs, alapszint — cél: rutinépítés) Egészítsd ki a hosszlistát a 8 értékkel. Számítsd ki az új esetszámot, majd építsd újra a javított programot azonos fordítási kapcsolókkal.

  3. (hibakeresési, középszint — cél: rutinépítés) Futtasd az eredeti hibás binárist --all kapcsolóval. Miért lehetséges, hogy nem SIGSEGV, hanem FAIL az első eredmény? GDB-ben ellenőrizd az első értékeltérésnél a képzett címet és az olvasott adatot.

11.3.9 Megoldások

1. megoldás — Segítség. Helyettesítsd be i=0i=0-t mindkét képletbe (4i4i és 8i8i) — különböznek-e az eredmények? A lapvégi elhelyezésnél induljon ki abból, hogy a tömb kezdete B+P−4nB+P-4n.

1. megoldás — Teljes megoldás. Nulla index esetén 4i=8i=04i=8i=0, ezért mindkét függvény a tömb elejét olvassa. A 16 elemű lapvégi tömb kezdete B+P−64B+P-64; az utolsó elem helyes címe B+P−64+4⋅15=B+P−4B+P-64+4\cdot15=B+P-4. A teljes négysávos bájttartomány még az olvasható lapon van.

2. megoldás — Segítség. Az esetszám-képlet 2∑n2\sum n minden hosszra; csak egy taggal kell bővíteni az összeget.

2. megoldás — Teljes megoldás. A hosszlista {1, 2, 3, 8, 16} lesz. Az esetszám 2(1+2+3+8+16)=602(1+2+3+8+16)=60. A javított változat várt kimenete OK: 60 eset, kilépési állapota 0. Az új esetek az indexelési tartományt bővítik, nem módosítják a függvény szerződését.

3. megoldás — Segítség. Melyik tail elhelyezésnél (0 vagy 1) esik a hibásan képzett cím MÉG a leképezett, csak épp rossz tartalmú területre, nem a védőlapra?

3. megoldás — Teljes megoldás. A ciklussorrendben az első eltérés várhatóan n=2, i=1, tail=0 esetén jelentkezik. A hibás cím a tömb kezdete után 8 bájttal van: a tömbön kívül, de még az olvasható lapon. Az anonim leképezés érintetlen része kezdetben nulla, ezért a várt diagnózis got=0 expected=1017, a kilépési állapot 1. Ez bizonyítja, hogy a szegmentálási hiba hiánya nem jelent helyes címzést.

11.3.10 Rövid összefoglaló

A szegmentálási hiba vizsgálatakor külön kezeljük a hibázó utasítás címét és az elérni próbált memóriacímet. A regiszterekből rekonstruált címképzést a függvény szerződésével és a C-oldali adatelrendezéssel vetjük össze. A védőlap reprodukálhatóvá teszi a határátlépést, az értékellenőrzés pedig az olvasható memórián belüli téves hozzáférést is felismeri. Egyetlen célzott változtatást tesztelünk, majd regressziós és negatív ellenpróbával ellenőrizzük a javítást. A hívási láncot mindig a verem és a visszafejtési információ megbízhatóságával együtt értelmezzük.

11.3.11 Források és ellenőrzési hiányok

A linkek és a dokumentumverziók közös állapota az Olvasói útmutató forrásállapot-táblájában van; itt csak az alfejezet saját megállapításai szerepelnek.

Ellenőrzési hiányok, amelyek a fenti ellenőrzés után is ténylegesen fennmaradnak: a buildet, a futtatást és a konkrét GDB-kimenetet itt nem validáltuk. A laborban rögzíteni kell a telepített eszközverziókat, ellenőrizni kell a statikus célkönyvtár elérhetőségét, a $_siginfo támogatását és a hívási lánc tényleges megjelenítését. A dokumentációs linkek nem rögzítik automatikusan az Ubuntu 24.04 csomagverzióit.