11.1 GDB assembly szinten

11.1.1 Motiváció: hol romlott el az érték?

Egy assemblyfüggvény helyesnek látszik, mégis hibás eredményt ad vissza a C-hívónak. A forrás átolvasása nem dönti el, hogy rossz adatot töltöttünk-e be, túl keskeny regiszternézetet használtunk-e, vagy a visszatéréskor sérült meg a veremállapot. Ehhez a ténylegesen végrehajtott utasítást, a regisztereket és a memóriát együtt kell megfigyelni.

Az utasításszintű hibakeresés (instruction-level debugging) során a programot gépi utasítások határán állítjuk meg, és a végrehajtási állapot változását vizsgáljuk. A GDB programszámlálója, a PC ilyenkor rendszerint a következőként végrehajtandó utasításra mutat. A töréspontnál látható állapot tehát még nem tartalmazza az ott álló utasítás hatását.

Az alfejezet a Szerkesztő és debug-eszközök: GDB, objdump, readelf első look, valamint a Prológus és epilógus: regisztermentés és keretkezelés ismereteire épül. Minden laborpélda A64 ISA, ARMv8-A, little-endian Linux ELF, qemu-aarch64 user-mode céllal. A GDB parancsai nem ISA-utasítások; a példákban az A64 célállapotot vizsgálják.

11.1.2 Lépés, regiszternézet és memórianézet

11.1.2.1 Forrássor vagy gépi utasítás?

A GDB két különböző léptetési szintet kínál:

GDB-parancs Megállási egység Függvényhívás kezelése
step Következő forráshely, a hibakeresési információ alapján Megpróbál belépni a hívott függvénybe
next Következő forráshely ugyanabban a veremkeretben Átlépi a hívást
stepi, röviden si Egy gépi utasítás Belép a hívásba
nexti, röviden ni Egy gépi utasítás, hívásnál annak visszatérési helye Átlépi a hívást

A64 célon, egy BL utasításon állva a stepi végrehajtja a hívást: beállítja az X30 visszatérési címet, majd a hívott függvény elején áll meg. A nexti ehelyett normál visszatérés esetén a BL utáni utasításnál áll meg.

Az „átlépés” nem azt jelenti, hogy a függvény kimarad. Minden mellékhatása megtörténik. Egy közben elért töréspont vagy jelzés azonban korábban is megszakíthatja a futást.

A stepi 5 öt gépi utasítást hajt végre. Ezzel szemben a nexti 5 öt léptetési műveletet jelent: az átlépett függvények miatt ténylegesen jóval több utasítás is lefuthat.

Assemblyforrásban gyakran egy sorhoz egy utasítás tartozik, de ez nem teszi azonossá a két léptetési szintet. A direktívák nem végrehajtható utasítások, optimalizált C-kódnál pedig különösen összetett lehet a forrássorok és az utasítások kapcsolata.

11.1.2.2 Regiszterek lekérdezése

A következő GDB-parancsok A64 célon értendők:

Parancs Jelentés
info registers Az általános regiszterállapot megjelenítése
info registers x0 x1 sp pc Kiválasztott regiszterek megjelenítése
p/x $x0 Az X0 értéke hexadecimálisan
p/u $x1 Az X1 értéke előjel nélküli decimális olvasatban
x/i $pc A következő utasítás megjelenítése

Az info registers után a regiszternevek elé nem kell $; GDB-kifejezésben viszont kell. A p a print rövidítése.

A64-en az X0 64 bites, a W0 ugyanannak a regiszternek a 32 bites nézete. A GDB által közvetlenül elérhető alternatív regiszternevek célleírás-függők. Az alsó 32 bitet biztosan megvizsgálhatjuk a p/x ($x0 & 0xffffffff) kifejezéssel. Ez csak olvasás; nem modellezi a W0-ba írás felső 32 bitet törlő hatását.

11.1.2.3 Az x parancs felépítése

A memóriavizsgálat (memory examination) alapformája:

x/Nfu cím

A GDB w jelölése tehát négy bájt, AArch64 célon is.

GDB-parancs, A64 cél Megjelenített tartomány
x/4xw $x0 Négy, egyenként 4 bájtos érték hexadecimálisan
x/2gx $x0 Két, egyenként 8 bájtos érték hexadecimálisan
x/16xb $x0 Tizenhat különálló bájt
x/2gx $sp Két 8 bájtos egység az aktuális verem tetejétől

A formátum- és méretbetű sorrendje felcserélhető: az x/2gx és az x/2xg ugyanazt jelenti. A GDB megjegyez korábbi megjelenítési beállításokat, ezért reprodukálható vizsgálatban célszerű mindent explicit megadni.

11.1.3 Kidolgozott példa: összegzés és veremellenőrzés

Munkakönyvtár: ~/arm-labor/a64/a040/ — 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/a040
cd ~/arm-labor/a64/a040

A laborfüggvény két 64 bites, előjel nélküli elemet és egy eltolási értéket ad össze. C-szemantikával a művelet p[0] + p[1] + bias, uint64_t operandusokkal. Az A64 bemenet az X0 mutató és az X1 eltolás; a visszatérési érték az X0.

Mentsük a következőt lab-gdb.s néven:

// A64 ISA, ARMv8-A, little-endian Linux.
// A direktívák GNU as / AArch64 ELF környezethez tartoznak.

.arch armv8-a
.text
.p2align 2
.global _start, lab_call, lab_after, lab_stored
.type _start, %function

_start:
    adrp x0, lab_values
    add  x0, x0, :lo12:lab_values
    mov  w1, #7

lab_call:
    bl   lab_sum

lab_after:
    adrp x2, lab_result
    add  x2, x2, :lo12:lab_result
    str  x0, [x2]

lab_stored:
    mov  w0, #0
    mov  w8, #93
    svc  #0
.size _start, .-_start

.global lab_sum
.type lab_sum, %function
lab_sum:
    .cfi_startproc
    stp  x29, x30, [sp, #-16]!
    .cfi_def_cfa_offset 16
    .cfi_offset 29, -16
    .cfi_offset 30, -8
    mov  x29, sp
    ldr  x2, [x0]
    ldr  x3, [x0, #8]
    add  x0, x2, x3
    add  x0, x0, x1
    ldp  x29, x30, [sp], #16
    .cfi_restore 29
    .cfi_restore 30
    .cfi_def_cfa_offset 0
    ret
    .cfi_endproc
.size lab_sum, .-lab_sum

.data
.balign 8
lab_values:
    .xword 0x0000000200000001
    .xword 0x0000000400000003
lab_result:
    .xword 0

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

A függvény levélfüggvényként nem igényelné ezt a veremkeretet; itt szándékosan tartjuk meg, hogy vizsgálható legyen a mentés és visszaállítás. A kilépés Linux/AArch64 rendszerhívás-konvenció: az X8 értéke 93, az X0 a kilépési állapot. Ez nem önmagában az A64 ISA szolgáltatása.

Immediate-ok: a példa minden konstansa és eltolása kódolható (lásd az A64 immediate-korlátok összefoglalóját). Az ADRP + ADD …, :lo12: pár együtt állítja elő a szimbólum teljes címét.

11.1.3.1 Összeállítás és indítás

Ubuntu 24.04 x86_64 hoston, A64 qemu-user célra:

# Target: qemu-user / AArch64 Linux, little-endian.
aarch64-linux-gnu-as -g -EL -march=armv8-a \
    -o lab-gdb.o lab-gdb.s
aarch64-linux-gnu-ld -EL -e _start \
    -o lab-gdb.elf lab-gdb.o

# Első terminál: várakozás a GDB kapcsolódására.
qemu-aarch64 -g 12345 ./lab-gdb.elf
printf 'QEMU kilepesi allapot: %d\n' "$?"

A -g után megadott szám TCP-port, nem memóriaérték. A várakozás alatt a program látszólag nem csinál semmit: ez most helyes működés. A hibakereső portot csak megbízható, elkülönített laborhálózatban használjuk.

A második terminálban készítsünk GDB-parancsfájlt. A következő bash blokk csak létrehozza azt és elindítja a hibakeresőt; a GDB határolók közötti sorok GDB-parancsok.

# Target: a fenti qemu-user / AArch64 folyamat.
cat > lab-step.gdb <<'GDB'
set pagination off
target remote localhost:12345
break *lab_call
continue

x/i $pc
info registers x0 x1 sp pc
x/4xw $x0
x/2gx $x0
set $entry_sp = $sp
set $entry_fp = $x29

stepi
info registers x30 sp pc
x/i $pc

stepi
x/2gx $sp
p $sp == $entry_sp - 16

stepi 5
p/x $x0
x/i $pc

stepi
p $sp == $entry_sp
p $x29 == $entry_fp

stepi
x/i $pc

break *lab_stored
continue
x/1gx &lab_result
continue
quit
GDB

gdb-multiarch -q ./lab-gdb.elf -x lab-step.gdb

Kézi gyakorláshoz a GDB-t a -x lab-step.gdb rész nélkül indítsuk, majd a fájl parancsait egyenként adjuk ki.

11.1.4 A megállások értelmezése

A következő állapotok levezetett, várt eredmények, nem itt végrehajtott mérésből származnak.

A break *lab_call a BL végrehajtása előtt állít meg. Ekkor az A64 X0 a tömb címét, az X1 a 7 értéket tartalmazza. Nem kell előre ismernünk a numerikus címet: a GDB az ELF szimbólumából határozza meg.

A két memórianézet ugyanazt a 16 bájtot mutatja:

Nézet Várt értékek, növekvő címsorrendben
x/4xw $x0 0x00000001, 0x00000002, 0x00000003, 0x00000004
x/2gx $x0 0x0000000200000001, 0x0000000400000003

Little-endian elrendezésben egy 64 bites érték alsó 32 bitje található az alacsonyabb címen. A két megjelenítés tehát nem eltérő adatot, hanem eltérő csoportosítást jelent.

Az első stepi végrehajtja az A64 BL utasítást. A PC a lab_sum elejére, az X30 a lab_after címére kerül. A következő stepi végrehajtja az A64 STP utasítást:

Hely vagy feltétel Várt tartalom/eredmény
SP Belépési SP − 16
[SP], 8 bájt A belépési X29 értéke
[SP+8], 8 bájt A lab_after visszatérési címe
p $sp == $entry_sp - 16 Igaz, jellemzően 1 alakban

A $entry_sp és $entry_fp GDB-segédváltozók (convenience variables), nem célprocesszorbeli regiszterek. Létrehozásuk nem módosítja a programot.

Az öt további lépés a keretmutató beállítását, két betöltést és két összeadást hajt végre. A várt eredmény:

0x0000000200000001 + 0x0000000400000003 + 7 = 0x000000060000000b

Ekkor a következő A64 utasítás még az LDP. Annak végrehajtása után mindkét állapot-összehasonlítás igaz. Az A64 RET után a PC a lab_after helyre mutat.

A lab_stored töréspontnál az eredménytárolás már megtörtént, ezért a lab_result várt tartalma 0x000000060000000b. A program nem ír szöveget; normál, 0 állapotú kilépést várunk.

11.1.4.1 Ugyanez nexti használatával

Indítsuk újra az első terminál QEMU-parancsát, majd kapcsolódjunk új GDB-munkamenettel:

# Target: újraindított qemu-user / AArch64 folyamat.
gdb-multiarch -q ./lab-gdb.elf \
    -ex 'target remote localhost:12345' \
    -ex 'break *lab_call' \
    -ex 'continue' \
    -ex 'nexti' \
    -ex 'info registers x0 pc' \
    -ex 'continue' \
    -ex 'quit'

A nexti után közvetlenül a lab_after helyen kell állnunk, az A64 X0 regiszterben ugyanazzal az összeggel. A függvény belső állapotai ezúttal nem láthatók.

11.1.5 Töréspont szimbólumra vagy pontos címre

A break lab_sum szimbólumhoz kapcsolódó töréspontot kér. Függvényszimbólumnál a GDB a rendelkezésre álló információ alapján a prológus utáni helyet is választhatja. A break *lab_sum ezzel szemben kifejezetten a függvény kezdőcímét jelöli ki.

A * azt jelzi, hogy a megadott kifejezést utasításcímként értelmezzük. Így címre törhetünk beégetett numerikus cím nélkül is, például break *$pc használatával. A töréspontokat az info breakpoints listázza; a delete N egy adott sorszámút töröl.

Mindig a futó programhoz tartozó ELF-et nyissuk meg. Újrafordítás után a korábban feljegyzett numerikus címek és a régi GDB-munkamenet szimbólumai elavulhatnak.

11.1.6 QEMU user-mode és system-mode: előzetes különbség

A jelenlegi user-mode emulációban egy Linux-folyamatot vizsgálunk. A qemu-aarch64 -g 12345 a program indulása előtt várja a kapcsolatot. A megnyitott ELF helyi példánya szolgáltatja a szimbólumokat; a target remote létrehozza a kapcsolatot, a continue elindítja a már betöltött célt. Ebben a munkafolyamatban nem kell GDB load, és nem a run indítja a célt.

A Cortex-M futási modelljében, Thumb-2/Armv7-M kontextusban, a qemu-system-arm teljes gépet emulál. A későbbi mps2-an385 laborban a -S az induló processzort állítja meg, a -gdb tcp::12345 pedig a hibakereső kiszolgálót engedélyezi. A kiszolgáló engedélyezése önmagában nem helyettesíti a -S kapcsolót.

Ott a firmware, a resetállapot és a gép memóriatérképe is a vizsgálat része lesz. Ez most csak működésbeli előzetes, nem Cortex-M firmware-indítási recept.

11.1.7 Tipikus hibák és ellenpróbák

Tünet Hipotézis Adatgyűjtés és egyetlen következő változtatás
Nem jön létre a kapcsolat Eltérő port vagy nem várakozó QEMU Ellenőrizzük a két portszámot; indítsuk újra csak a QEMU-folyamatot
Váratlan utasításnál állunk Régi ELF vagy forrásszintű léptetés Nézzük meg az x/i $pc eredményét; váltsunk stepi használatára
A 64 bites adat „szétesett” Négybájtos nézetet használunk Ugyanazon címen váltsunk x/4xw alakról x/2gx alakra
A számítás jó, de a visszatérés hibás Sérült mentés vagy epilógus Vizsgáljuk a mentett A64 X30 értéket és az SP visszaállását
„Cannot access memory” Nem mutatót vagy már megváltozott regisztert vizsgálunk Ellenőrizzük a PC-t és a regiszter szerepét; ne próbálgassunk véletlen címeket

Különösen fontos: a függvény visszatérése után az A64 X0 már eredmény, nem a bemeneti mutató. Ekkor a tömb vizsgálatához az x/2gx &lab_values parancsot használjuk.

11.1.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért különbözik az x/4xw és az x/2gx kimenete, miközben mindkettő 16 bájtot olvas? Mit látunk, ha az A64 STR utasítás címére teszünk töréspontot: a tárolás előtti vagy utáni állapotot?

  2. (implementációs, alapszint — cél: rutinépítés) Módosítsuk az eltolást 7-ről 8-ra. Építsük újra a programot, indítsunk új QEMU/GDB-munkamenetet, és nexti segítségével ellenőrizzük a visszatérési értéket, majd a tárolt eredményt.

  3. (hibakeresési, középszint — cél: rutinépítés) Cseréljük az A64 ldr x3, [x0, #8] utasítást ldr w3, [x0, #8] alakra, az eltolást pedig állítsuk vissza 7-re. Hol jelenik meg először az eltérés? Törjünk a lab_sum első utasítására, lépjünk négyet, és hasonlítsuk össze az X3 értékét a memória 8 bájtos nézetével.

11.1.9 Megoldások

1. megoldás — Segítség. Ugyanaz a 16 bájt két KÜLÖNBÖZŐ csoportosításban jelenik meg a két parancsnál — nem az adat változik. A töréspont-szemantikára gondolj: a GDB mindig a KÖVETKEZŐ, még végre nem hajtott utasításnál áll meg.

1. megoldás — Teljes megoldás. Az x parancs eltérő méretű egységekre bontja ugyanazt a bájtsorozatot. Little-endian esetben az alacsonyabb címen a 64 bites szám alsó része található. Az A64 STR címére helyezett töréspont a tárolás előtt állít meg; egy stepi után vizsgálható az új memóriaérték.

2. megoldás — Segítség. Az A64 mov w1, #8 ugyanabba a 16 bites immediate-mezőbe fér, mint a #7; csak a végeredmény változik — számold ki fejben az új összeget az eredeti példa alapján.

2. megoldás — Teljes megoldás. A módosítás A64 alakja mov w1, #8. A várt eredmény 0x000000060000000c. A nexti után ezt az X0, a lab_stored helyen pedig a lab_result tartalmazza. Az új munkamenet biztosítja, hogy a betöltött program és a szimbólumok ugyanahhoz az új buildhez tartozzanak.

3. megoldás — Segítség. A W3-ba írás csak 4 bájtot olvas, és nullázza X3 felső felét — a memória második 64 bites eleme ettől még a helyén marad, csak X3 nem fogja tükrözni.

3. megoldás — Teljes megoldás. A hibás betöltés csak négy bájtot olvas. Az A64 W3-ba írás nullázza az X3 felső felét, ezért az X3 értéke 0x0000000000000003, miközben a memória második 64 bites eleme változatlan. A hibás végeredmény 0x000000020000000b. Négy lépés után már látszik az eltérés; a következő utasítás az első összeadás. A javítás kizárólag a 64 bites betöltés visszaállítása.

11.1.10 Rövid összefoglaló

A GDB-ben a megfigyelt értéket mindig a következő utasítással együtt értelmezzük. A stepi belép a függvényhívásba, a nexti rendszerint annak visszatérése után áll meg, de mindkettő végrehajtja a program megfelelő részét. A regisztervizsgálat megmutatja a számítás állapotát, az x parancs pedig explicit méretben és formátumban jeleníti meg a memóriát. Ugyanaz a bájtsorozat többféleképpen csoportosítható; ettől az adat nem változik. A reprodukálható vizsgálathoz azonos ELF, szimbolikusan meghatározott helyek és előre rögzített ellenőrzési feltételek szükségesek.

11.1.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 kódot és parancsokat itt nem futtattuk. A várt állapotok az utasítások szemantikájából levezetettek. Az Ubuntu 24.04-ben telepített konkrét eszközverziókkal ellenőrizendő a távoli kapcsolat, a GDB regiszterneveinek elérhetősége és a megállási üzenetek pontos formája. A labor nem tartalmaz időmérést vagy hardverhű ciklusszámra vonatkozó állítást.