8.3 Prológus és epilógus: regisztermentés és keretkezelés

8.3.1 Miért kell a függvénynek belépési és kilépési rend?

Egy assemblyben megírt függvény helyes eredményt számolhat, mégis elronthatja a hívóját: felülírhat egy megőrzendő regisztert, elveszítheti a visszatérési címet, vagy rossz helyen hagyhatja a veremmutatót. Ezek a hibák gyakran csak a következő függvényhíváskor jelentkeznek.

A prológus (function prologue) a függvény belépési műveletsora: létrehozza a szükséges veremkeretet, és elmenti a később visszaállítandó állapotot. Az epilógus (function epilogue) helyreállítja ezt az állapotot, felszabadítja a keretet, majd visszatér.

A nem-levél függvény (non-leaf function) másik függvényt is hív. A64-ben a BL felülírja az X30 kapcsolatregisztert, vagyis az LR-t (link register). Ezért a hagyományos, hívás után tovább dolgozó nem-levél függvénynek meg kell őriznie saját visszatérési címét.

Az előfeltétel Az AAPCS64 regiszterszerződés ismerete. Itt nem ismételjük meg a teljes regisztertáblázatot: azt mutatjuk meg, hogyan lesz a szerződésből működő mentési és visszaállítási kód.

Architektúrajelölés: az alfejezet minden utasítása és regisztere az A64 ISA, AArch64, I. rész kontextusához tartozik. A direktívák GNU assembler-, AArch64 ELF-környezetben értendők. A labor little-endian profilt használ; Cortex-M-kód nem szerepel benne.

8.3.2 Páros mentés és visszaállítás: STP és LDP

Az A64 STP (store pair) két regisztert tárol egymást követő memóriahelyekre. Az A64 LDP (load pair) két regisztert tölt vissza. Két 64 bites regiszternél összesen 16 bájt adat mozog.

A következő részlet 32 bájtos keretet foglal:

// A64 ISA, AArch64; GNU as szintaxis.
// Szemléltető részlet, a teljes program lejjebb következik.
STP X29, X30, [SP, #-32]!
MOV X29, SP

A ! előindexelt, visszaíró címzést (pre-indexed addressing with writeback) jelöl. Ha a belépési veremmutató értékét S jelöli:

  1. az új veremmutató S − 32;
  2. az X29 korábbi értéke az új veremmutatóhoz képest 0 bájtos eltolásra kerül;
  3. az X30 korábbi értéke 8 bájtos eltolásra kerül;
  4. az X29 felveszi az új keret alapcímét.

Az S itt szimbolikus cím, nem konkrét memóriatartomány.

A hozzá tartozó felszabadító részlet:

// A64 ISA, AArch64; GNU as szintaxis.
LDP X29, X30, [SP], #32
RET

Ez utóindexelt címzés (post-indexed addressing): előbb megtörténik a betöltés a régi SP alapján, majd a veremmutató 32 bájttal nő. Az operandus nélkül írt A64 RET az X30 által megadott címre tér vissza.

Kódolási korlát: az itt használt, két 64 bites általános célú regisztert mozgató A64 STP/LDP eltolása előjeles, 7 bites, 8 bájttal skálázott mező. A tartomány −512…504 bájt, 8 bájtos lépéssel. A negatív és pozitív szélsőérték tehát nem szimmetrikus.

A STP X29, X30, [SP, #-n]! és LDP X29, X30, [SP], #n egylépéses keretkezelő mintában, pozitív és 16-tal osztható n mellett, a közös kódolható tartomány 16…496 bájt. Nagyobb kerethez külön veremmutató-módosítás és külön mentési művelet szükséges.

A páros utasítás csökkentheti a mentési kód utasításszámát. Ebből azonban nem következik garantált ciklusszám vagy kétszeres sebesség: a mikroarchitekturális végrehajtás külön kérdés.

8.3.3 Mit kell ténylegesen megőrizni?

Az AAPCS64 megőrzési követelménye a megőrzött, azaz callee-saved regiszterek módosítására vonatkozik.

Nem érdemes minden megőrzött regisztert automatikusan elmenteni. Ez felesleges memóriaforgalmat és nagyobb keretet okozna. A szükséges mentések a tényleges regiszterhasználatból következnek.

A labor a szokásos, két 64 bites elemből álló keretrekordot (frame record) használja: előző keretmutató, majd mentett visszatérési cím. Ez áttekinthető mintázat, de nem állítjuk, hogy minden optimalizált függvénynek pontosan ilyen kerete van. A platform keretlánc-szabályai és a fordító beállításai is számítanak.

A veremigazításról részletesebben A stack frame felépítése és a verem iránya szól. A példában a veremmutató végig 16 bájtra igazított; különösen minden hívási határon és minden veremmutatón keresztüli memóriaeléréskor.

8.3.4 Kidolgozott példa: számítás és ABI-ellenőrzés

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

A megvalósítandó számítás C-modellje:

/* Szemantikai modell; a laborban nem külön fordítási egység. */
#include <stdint.h>

static uint64_t lab_double_model(uint64_t a)
{
    return a + a;
}

uint64_t lab_eval_model(uint64_t a, uint64_t b)
{
    return lab_double_model(a) + a + b;
}

Az értékeket előjel nélküli, 64 bites számként kezeljük. Az összeadás modulo 2642^{64} értendő; a tesztadatoknál nincs körbefordulás.

Az A64 assemblyváltozat:

A segédfüggvény szándékosan felülírja az A64 X1 regisztert. Ezt megteheti: a hívó nem támaszkodhat annak megőrzésére.

Mentsd a következő teljes programot lab-frame.s néven:

// A64 ISA, AArch64, ARMv8-A; GNU as, little-endian ELF.
.section .text
.global _start
.global lab_eval

.type _start, %function
_start:
    // Magas biteket is tartalmazó megőrzési tesztminták.
    MOVZ X19, #0x1234, LSL #48
    MOVZ X20, #0x5678, LSL #48
    MOV  X29, XZR
    MOV  X21, SP

    MOV  X0, #7
    MOV  X1, #5
    BL   lab_eval

    CMP  X0, #26
    B.NE .Lbad_value

    MOV  X9, SP
    CMP  X9, X21
    B.NE .Lbad_stack

    MOVZ X9, #0x1234, LSL #48
    CMP  X19, X9
    B.NE .Lbad_saved

    MOVZ X9, #0x5678, LSL #48
    CMP  X20, X9
    B.NE .Lbad_saved

    CBNZ X29, .Lbad_saved

    MOV  X0, #0
    B    .Lexit

.Lbad_value:
    MOV  X0, #1
    B    .Lexit
.Lbad_stack:
    MOV  X0, #2
    B    .Lexit
.Lbad_saved:
    MOV  X0, #3
.Lexit:
    // AArch64 Linux exit rendszerhívás.
    MOV  X8, #93
    SVC  #0
.size _start, .-_start

.type lab_eval, %function
lab_eval:
    .cfi_startproc
    STP  X29, X30, [SP, #-32]!
    .cfi_def_cfa_offset 32          // CFA = SP + 32 (a belépési SP)
    .cfi_offset 29, -32             // X29 a CFA − 32 címen
    .cfi_offset 30, -24             // X30 a CFA − 24 címen
    MOV  X29, SP
    STP  X19, X20, [SP, #16]
    .cfi_offset 19, -16
    .cfi_offset 20, -8

    MOV  X19, X0
    MOV  X20, X1
    BL   lab_double

    ADD  X0, X0, X19
    ADD  X0, X0, X20

lab_eval_epilogue:
    LDP  X19, X20, [SP, #16]
    .cfi_restore 19
    .cfi_restore 20
    LDP  X29, X30, [SP], #32
    .cfi_restore 29
    .cfi_restore 30
    .cfi_def_cfa_offset 0
    RET
    .cfi_endproc
.size lab_eval, .-lab_eval

.type lab_double, %function
lab_double:
    // levélfüggvény: a kezdeti szabály (CFA = SP) végig érvényes
    .cfi_startproc
    ADD  X0, X0, X0
    MOV  X1, #0
    RET
    .cfi_endproc
.size lab_double, .-lab_double

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

A GNU .type és .size direktívák függvényszimbólumokat írnak le, nem hajtanak végre mentést. A .note.GNU-stack szekció a GNU ELF-eszközláncnak jelzi, hogy a program nem igényel végrehajtható vermet.

A további immediate-ok: a MOV, CMP és SVC konstansai kódolhatók (lásd az A64 immediate-korlátok összefoglalóját); ez a kis pozitív konstansokra igaz, nem általános garancia tetszőleges 64 bites értékre.

8.3.4.1 A keret levezetése

Legyen S a lab_eval belépési veremmutatója. A prológus után az A64 SP és X29 értéke egyaránt S − 32.

Eltolás az új A64 SP-hez képest Méret Tartalom
0 bájt 8 bájt A hívó mentett X29 értéke
8 bájt 8 bájt A függvény belépési X30 értéke
16 bájt 8 bájt A hívó mentett X19 értéke
24 bájt 8 bájt A hívó mentett X20 értéke

A középső STP nem módosítja a veremmutatót: nincs sem !, sem utóindexelt operandus. A már lefoglalt keret felső felét tölti ki.

A segédfüggvény visszatérésekor az A64 X0 értéke 14, az X19 értéke 7, az X20 értéke 5. A két összeadás eredménye 26. Ezután a munkára használt megőrzött regiszterek visszakapják a hívó értékeit, nem a 7 és 5 argumentumot.

8.3.4.2 Assemble–link–run

Host: Ubuntu 24.04 LTS x86_64, a könyv cross-eszközláncával.
Target: AArch64 Linux, little-endian, qemu-aarch64 user-mode.

# Target: AArch64 Linux, qemu-user.
aarch64-linux-gnu-as -EL -g -march=armv8-a \
  -o lab-frame.o lab-frame.s

aarch64-linux-gnu-ld -EL -e _start \
  -o lab-frame lab-frame.o

qemu-aarch64 ./lab-frame
lab_status=$?
printf 'kilepesi_allapot=%s\n' "$lab_status"

aarch64-linux-gnu-objdump -d ./lab-frame

Várt, levezetett kimenet: kilepesi_allapot=0. A program maga nem ír a terminálra.

Kilépési állapot Jelentés
0 A beépített eredmény- és megőrzési ellenőrzések sikeresek
1 Hibás számítási eredmény
2 Elcsúszott veremmutató
3 Hibásan visszaállított X19, X20 vagy X29

Az A64 X21 a tesztelő kódban őrzi a belépési veremmutatót; a vizsgált függvény nem módosítja. A magas biteket tartalmazó tesztminták a véletlen 32 bites visszaállítást is kimutathatják.

Ez célzott laborellenőrzés, nem általános ABI-validátor.

8.3.5 CFI: a keret leírása a hibakereső számára

A lab_eval listájában a .cfi_* sorok nem processzorutasítások, hanem az assemblernek szóló direktívák. Belőlük készül az ELF .eh_frame szekciójának hívási keretinformációja (Call Frame Information, CFI): egy táblázat, amely a függvény minden utasításcímére megmondja, hol van a hívó keretének kezdete (CFA, Canonical Frame Address — itt a belépési SP), és hová mentettük a hívó regisztereit. A GDB bt parancsa, a C++-kivételkezelés és a profilozók ebből lépnek vissza a hívóhoz.

Direktíva Jelentés
.cfi_startproc / .cfi_endproc a függvény CFI-leírásának kezdete és vége
.cfi_def_cfa_offset 32 ettől az utasítástól CFA = SP + 32
.cfi_offset 30, -24 a 30-as DWARF-regiszter (X30) a CFA − 24 címen van mentve
.cfi_restore 30 az X30 újra a saját regiszterében van

A DWARF-sorszámok AArch64-en: X0–X30 = 0–30, SP = 31, V0–V31 (így a D8–D15 is) = 64–95.

CFI nélkül a hibakereső csak a keretláncból (X29 → mentett X29 → …) vagy heurisztikából tud visszalépni. Ez a prológus közepén, keretmutató nélküli függvényben vagy sérült veremben megbízhatatlan. A könyv szabálya ezért a következő:

A CFI helyességét nem a futás, hanem a táblázat igazolja:

# Hostoldali vizsgálat; a fájl célja AArch64 Linux, qemu-user.
aarch64-linux-gnu-readelf --debug-dump=frames-interp lab-frame

A lab_eval FDE-jében négy sor várható: a belépéskor CFA = sp+0; az első STP után sp+32, x29 és x30 mentett helyével (c-32, c-24); a második STP után x19, x20 is (c-16, c-8); az epilógus végén ismét sp+0. Ha a táblázat egy mentést nem mutat, a bt az adott ponton rossz hívót jelezhet, a program futása viszont ettől még hibátlannak látszik.

8.3.6 Közös ABI-teszthívó: lab_abi_call

A lab-frame.s saját _start-ja csak a lab_eval két megőrzött regiszterét ellenőrzi. A könyv további C-ből hívható assembly függvényeihez közös teszthívót használunk, amely a teljes AAPCS64 megőrzési szerződést ellenőrzi:

A horgony miatt a teszthívó nem reentráns és nem szálbiztos: egyszerre csak egy lab_abi_call futhat. Ezt nem csak dokumentáljuk, hanem ki is mutatjuk: ha a hívott maga is lab_abi_call-t hív, a belső hívás nem indítja el a függvényt, hanem LAB_ABI_BUSY hibabitet ad vissza. Ugyanezt a megoldást használja a II. rész Thumb-2-es párja, a lab_abi_call32 is.

A teszthívó maga is a fenti szabályt követi: teljes prológus–epilógus és CFI. A fájlok ebben a könyvtárban (~/arm-labor/a64/a029/) készülnek; a későbbi alfejezetek innen másolják át őket.

A lab-abi-call.h tartalma:

/* lab-abi-call.h — közös AAPCS64 ABI-teszthívó; cél: AArch64 Linux, LP64. */
#ifndef LAB_ABI_CALL_H
#define LAB_ABI_CALL_H

#include <stdint.h>

/* Tetszőleges, legfeljebb hat egész/mutató argumentumú függvény címe.
 * A hívás az assemblyben történik, ezért a C oldalon csak a címet adjuk át. */
typedef void (*lab_abi_fn)(void);

/* A hibamaszk bitjei; 0 = a hívott betartotta a megőrzési szerződést. */
#define LAB_ABI_X19      (1u << 0)   /* bit 0..9: X19..X28 */
#define LAB_ABI_D8       (1u << 10)  /* bit 10..17: D8..D15 */
#define LAB_ABI_SP       (1u << 18)  /* SP nem a hívás előtti */
#define LAB_ABI_FP       (1u << 19)  /* X29 nem a hívás előtti */
#define LAB_ABI_SP_FP    (LAB_ABI_SP | LAB_ABI_FP)
#define LAB_ABI_BUSY     (1u << 20)  /* újrabelépés: fn nem hívódott meg */

/* Meghívja fn(a0, ..., a5)-öt úgy, hogy előtte X19–X28 és D8–D15 ismert,
 * magas biteket is tartalmazó mintát kap; visszatérés után ellenőrzi
 * a mintákat, valamint SP és X29 értékét. *result = a hívott X0-ja.
 *
 * NEM reentráns és nem szálbiztos: a hívás előtti keretcímet egyetlen
 * statikus horgony őrzi. Egymásba ágyazott hívásnál a belső hívás nem
 * hívja meg fn-t, LAB_ABI_BUSY-t ad vissza, *result = 0. */
uint64_t lab_abi_call(lab_abi_fn fn,
                      uint64_t a0, uint64_t a1, uint64_t a2,
                      uint64_t a3, uint64_t a4, uint64_t a5,
                      uint64_t *result);

#endif

A lab-abi-call.S tartalma (nagybetűs .S: a GCC előbb a C-preprocesszort futtatja rajta):

// lab-abi-call.S — A64 ISA, AArch64 Linux; GNU as, AAPCS64.
// Közös ABI-teszthívó: uint64_t lab_abi_call(fn, a0..a5, *result)

// Minta: [63:48] = 0xa5a5, [47:32] = 0x5a5a, [15:0] = a regiszter sorszáma.
// A felső fél nem nulla, így egy W-regiszteres írás is lebukik.
.macro LAB_PATTERN reg, n
    movz    \reg, #\n
    movk    \reg, #0x5a5a, lsl #32
    movk    \reg, #0xa5a5, lsl #48
.endm

// Ha \reg nem egyezik a mintával, a \bit sorszámú bit 1 lesz X10-ben.
.macro LAB_CHECK reg, n, bit
    LAB_PATTERN x12, \n
    cmp     \reg, x12
    cset    x13, ne
    orr     x10, x10, x13, lsl #\bit
.endm

.macro LAB_CHECK_D dreg, n, bit
    fmov    x14, \dreg
    LAB_CHECK x14, \n, \bit
.endm

    .text
    .p2align 2
    .global lab_abi_call
    .type   lab_abi_call, %function
lab_abi_call:
    .cfi_startproc
    // Keret: X29/X30 (16) + X19–X28 (80) + D8–D15 (64) + result-mutató (8) -> 176
    stp     x29, x30, [sp, #-176]!
    .cfi_def_cfa_offset 176
    .cfi_offset 29, -176
    .cfi_offset 30, -168
    mov     x29, sp
    stp     x19, x20, [sp, #16]
    .cfi_offset 19, -160
    .cfi_offset 20, -152
    stp     x21, x22, [sp, #32]
    .cfi_offset 21, -144
    .cfi_offset 22, -136
    stp     x23, x24, [sp, #48]
    .cfi_offset 23, -128
    .cfi_offset 24, -120
    stp     x25, x26, [sp, #64]
    .cfi_offset 25, -112
    .cfi_offset 26, -104
    stp     x27, x28, [sp, #80]
    .cfi_offset 27, -96
    .cfi_offset 28, -88
    stp     d8,  d9,  [sp, #96]
    .cfi_offset 72, -80           // DWARF 72..79 = D8..D15
    .cfi_offset 73, -72
    stp     d10, d11, [sp, #112]
    .cfi_offset 74, -64
    .cfi_offset 75, -56
    stp     d12, d13, [sp, #128]
    .cfi_offset 76, -48
    .cfi_offset 77, -40
    stp     d14, d15, [sp, #144]
    .cfi_offset 78, -32
    .cfi_offset 79, -24
    str     x7, [sp, #160]        // result-mutató

    // Horgony: a hívás előtti SP (= X29) egy statikus szóba kerül, amelyhez
    // a hívott nem fér hozzá. 0 = szabad; nem nulla = egy hívás már fut.
    adrp    x11, lab_abi_anchor
    add     x11, x11, :lo12:lab_abi_anchor
    ldr     x12, [x11]
    cbnz    x12, 1f               // újrabelépés: fn nem hívható
    mov     x12, sp
    str     x12, [x11]

    // Argumentumok átrendezése: X0 = fn, X1..X6 = a0..a5.
    mov     x9, x0
    mov     x0, x1
    mov     x1, x2
    mov     x2, x3
    mov     x3, x4
    mov     x4, x5
    mov     x5, x6

    // Megőrzendő regiszterek feltöltése mintával.
    LAB_PATTERN x19, 19
    LAB_PATTERN x20, 20
    LAB_PATTERN x21, 21
    LAB_PATTERN x22, 22
    LAB_PATTERN x23, 23
    LAB_PATTERN x24, 24
    LAB_PATTERN x25, 25
    LAB_PATTERN x26, 26
    LAB_PATTERN x27, 27
    LAB_PATTERN x28, 28
    LAB_PATTERN x10, 0x108
    fmov    d8, x10
    LAB_PATTERN x10, 0x109
    fmov    d9, x10
    LAB_PATTERN x10, 0x10a
    fmov    d10, x10
    LAB_PATTERN x10, 0x10b
    fmov    d11, x10
    LAB_PATTERN x10, 0x10c
    fmov    d12, x10
    LAB_PATTERN x10, 0x10d
    fmov    d13, x10
    LAB_PATTERN x10, 0x10e
    fmov    d14, x10
    LAB_PATTERN x10, 0x10f
    fmov    d15, x10

    blr     x9

    // Ellenőrzés; X10 = hibamaszk. Az SP-t és az X29-et külön-külön a
    // horgonyhoz hasonlítjuk, nem egymáshoz: azonos eltolásuk is lebukik.
    mov     x10, xzr
    adrp    x11, lab_abi_anchor
    add     x11, x11, :lo12:lab_abi_anchor
    ldr     x12, [x11]            // a hívás előtti keretcím
    mov     x13, sp
    cmp     x13, x12
    cset    x13, ne
    orr     x10, x10, x13, lsl #18
    cmp     x29, x12
    cset    x13, ne
    orr     x10, x10, x13, lsl #19
    mov     sp, x12               // helyreállítás csak a horgonyból:
    mov     x29, x12              // sérült SP/X29 értékét nem használjuk
    str     xzr, [x11]            // a horgony újra szabad

    LAB_CHECK x19, 19, 0
    LAB_CHECK x20, 20, 1
    LAB_CHECK x21, 21, 2
    LAB_CHECK x22, 22, 3
    LAB_CHECK x23, 23, 4
    LAB_CHECK x24, 24, 5
    LAB_CHECK x25, 25, 6
    LAB_CHECK x26, 26, 7
    LAB_CHECK x27, 27, 8
    LAB_CHECK x28, 28, 9
    LAB_CHECK_D d8,  0x108, 10
    LAB_CHECK_D d9,  0x109, 11
    LAB_CHECK_D d10, 0x10a, 12
    LAB_CHECK_D d11, 0x10b, 13
    LAB_CHECK_D d12, 0x10c, 14
    LAB_CHECK_D d13, 0x10d, 15
    LAB_CHECK_D d14, 0x10e, 16
    LAB_CHECK_D d15, 0x10f, 17

    b       2f

1:  mov     x10, #(1 << 20)       // LAB_ABI_BUSY; a horgonyt nem írjuk felül
    mov     x0, xzr

2:  ldr     x7, [sp, #160]
    str     x0, [x7]              // a hívott eredménye
    mov     x0, x10               // visszatérési érték: hibamaszk

    ldp     d14, d15, [sp, #144]
    ldp     d12, d13, [sp, #128]
    ldp     d10, d11, [sp, #112]
    ldp     d8,  d9,  [sp, #96]
    ldp     x27, x28, [sp, #80]
    ldp     x25, x26, [sp, #64]
    ldp     x23, x24, [sp, #48]
    ldp     x21, x22, [sp, #32]
    ldp     x19, x20, [sp, #16]
    .cfi_restore 72
    .cfi_restore 73
    .cfi_restore 74
    .cfi_restore 75
    .cfi_restore 76
    .cfi_restore 77
    .cfi_restore 78
    .cfi_restore 79
    .cfi_restore 19
    .cfi_restore 20
    .cfi_restore 21
    .cfi_restore 22
    .cfi_restore 23
    .cfi_restore 24
    .cfi_restore 25
    .cfi_restore 26
    .cfi_restore 27
    .cfi_restore 28
    ldp     x29, x30, [sp], #176
    .cfi_restore 29
    .cfi_restore 30
    .cfi_def_cfa_offset 0
    ret
    .cfi_endproc
    .size   lab_abi_call, .-lab_abi_call

    .bss
    .p2align 3
lab_abi_anchor:                   // helyi szimbólum: csak a teszthívó látja
    .space  8

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

A teszthívó önellenőrzéséhez egy helyes és hat szándékosan hibás hívottat készítünk. Az SP/X29-szerződéshez három külön változat tartozik: csak az SP-t, csak az X29-et, illetve mindkettőt azonos mértékben eltoló hívott. A lab-abi-demo.S tartalma:

// lab-abi-demo.S — A64 ISA, AArch64; hívottak az ABI-teszthívó kipróbálásához.
    .text
    .p2align 2

// Helyes: X19-et használja, de menti és visszaállítja. Eredmény: a0 + a1 + a2.
    .global lab_demo_good
    .type   lab_demo_good, %function
lab_demo_good:
    .cfi_startproc
    stp     x29, x30, [sp, #-32]!
    .cfi_def_cfa_offset 32
    .cfi_offset 29, -32
    .cfi_offset 30, -24
    mov     x29, sp
    str     x19, [sp, #16]
    .cfi_offset 19, -16
    add     x19, x0, x1
    add     x0, x19, x2
    ldr     x19, [sp, #16]
    .cfi_restore 19
    ldp     x29, x30, [sp], #32
    .cfi_restore 29
    .cfi_restore 30
    .cfi_def_cfa_offset 0
    ret
    .cfi_endproc
    .size   lab_demo_good, .-lab_demo_good

// HIBÁS: mentés nélkül felülírja X19-et.
    .global lab_demo_bad_x19
    .type   lab_demo_bad_x19, %function
lab_demo_bad_x19:
    .cfi_startproc
    mov     x19, x0
    add     x0, x19, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_x19, .-lab_demo_bad_x19

// HIBÁS: "ártalmatlannak" látszó W-írás; nullázza X20 felső 32 bitjét.
    .global lab_demo_bad_w20
    .type   lab_demo_bad_w20, %function
lab_demo_bad_w20:
    .cfi_startproc
    mov     w20, w20
    add     x0, x0, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_w20, .-lab_demo_bad_w20

// HIBÁS: D8 csak az alsó 64 bitjén megőrzendő, itt mégis felülíródik.
    .global lab_demo_bad_d8
    .type   lab_demo_bad_d8, %function
lab_demo_bad_d8:
    .cfi_startproc
    fmov    d8, xzr
    add     x0, x0, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_d8, .-lab_demo_bad_d8

// HIBÁS: 16 bájttal elállított SP-vel tér vissza.
    .global lab_demo_bad_sp
    .type   lab_demo_bad_sp, %function
lab_demo_bad_sp:
    .cfi_startproc
    sub     sp, sp, #16
    .cfi_def_cfa_offset 16
    add     x0, x0, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_sp, .-lab_demo_bad_sp

// HIBÁS: az SP rendben van, de az X29-et 16 bájttal elállítja.
    .global lab_demo_bad_fp
    .type   lab_demo_bad_fp, %function
lab_demo_bad_fp:
    .cfi_startproc
    sub     x29, x29, #16
    add     x0, x0, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_fp, .-lab_demo_bad_fp

// HIBÁS: az SP-t és az X29-et azonos mértékben tolja el. Az SP == X29
// összehasonlítás itt átmenne; csak a független horgony mutatja ki.
    .global lab_demo_bad_spfp
    .type   lab_demo_bad_spfp, %function
lab_demo_bad_spfp:
    .cfi_startproc
    sub     sp, sp, #16
    .cfi_def_cfa_offset 16
    sub     x29, x29, #16
    add     x0, x0, x1
    ret
    .cfi_endproc
    .size   lab_demo_bad_spfp, .-lab_demo_bad_spfp

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

A lab-abi-test.c tartalma:

/* lab-abi-test.c — az ABI-teszthívó önellenőrzése; cél: AArch64 Linux. */
#include <inttypes.h>
#include <stdio.h>
#include "lab-abi-call.h"

extern void lab_demo_good(void);
extern void lab_demo_bad_x19(void);
extern void lab_demo_bad_w20(void);
extern void lab_demo_bad_d8(void);
extern void lab_demo_bad_sp(void);
extern void lab_demo_bad_fp(void);
extern void lab_demo_bad_spfp(void);

/* Szerződéstartó C-hívott, amely maga is meghívja a teszthívót: a belső
 * hívásnak LAB_ABI_BUSY-t kell adnia (a horgony foglalt). */
static uint64_t lab_demo_reenter(void)
{
    uint64_t inner = 0;
    return lab_abi_call(lab_demo_good, 1, 2, 3, 0, 0, 0, &inner);
}

static int lab_expect(const char *name, lab_abi_fn fn,
                      uint64_t want_mask, uint64_t want_result)
{
    uint64_t result = 0;
    uint64_t mask = lab_abi_call(fn, 1, 2, 3, 0, 0, 0, &result);
    int ok = (mask == want_mask) && (result == want_result);

    printf("%-18s maszk=0x%05" PRIx64 " eredmeny=%" PRIu64 " %s\n",
           name, mask, result, ok ? "OK" : "HIBA");
    return ok;
}

int main(void)
{
    int ok = 1;

    ok &= lab_expect("lab_demo_good",    lab_demo_good,    0,             6);
    ok &= lab_expect("lab_demo_bad_x19", lab_demo_bad_x19, LAB_ABI_X19,   3);
    ok &= lab_expect("lab_demo_bad_w20", lab_demo_bad_w20, LAB_ABI_X19 << 1, 3);
    ok &= lab_expect("lab_demo_bad_d8",  lab_demo_bad_d8,  LAB_ABI_D8,    3);
    ok &= lab_expect("lab_demo_bad_sp",  lab_demo_bad_sp,  LAB_ABI_SP,    3);
    ok &= lab_expect("lab_demo_bad_fp",  lab_demo_bad_fp,  LAB_ABI_FP,    3);
    ok &= lab_expect("lab_demo_bad_spfp", lab_demo_bad_spfp, LAB_ABI_SP_FP, 3);
    ok &= lab_expect("lab_demo_reenter", (lab_abi_fn)lab_demo_reenter, 0, LAB_ABI_BUSY);
    ok &= lab_expect("lab_demo_good",    lab_demo_good,    0,             6);
    return ok ? 0 : 1;
}

Build és futtatás — target: AArch64 Linux, qemu-user:

aarch64-linux-gnu-gcc -std=c17 -O2 -Wall -Wextra -march=armv8-a -static \
    lab-abi-test.c lab-abi-call.S lab-abi-demo.S -o lab-abi-test
qemu-aarch64 ./lab-abi-test
printf 'kilepesi_allapot=%d\n' "$?"

Várt kimenet:

lab_demo_good      maszk=0x00000 eredmeny=6 OK
lab_demo_bad_x19   maszk=0x00001 eredmeny=3 OK
lab_demo_bad_w20   maszk=0x00002 eredmeny=3 OK
lab_demo_bad_d8    maszk=0x00400 eredmeny=3 OK
lab_demo_bad_sp    maszk=0x40000 eredmeny=3 OK
lab_demo_bad_fp    maszk=0x80000 eredmeny=3 OK
lab_demo_bad_spfp  maszk=0xc0000 eredmeny=3 OK
lab_demo_reenter   maszk=0x00000 eredmeny=1048576 OK
lab_demo_good      maszk=0x00000 eredmeny=6 OK
kilepesi_allapot=0

A legtanulságosabb sor a lab_demo_bad_w20: a mov w20, w20 „semmit sem csináló” utasításnak tűnik, és kis értékekkel tesztelve valóban nem okoz eltérést. A 64 bites minta felső fele azonban nullázódik, és a teszthívó ezt kimutatja. Az SP/X29-sorok azt mutatják, miért kell független horgony. Egy korábbi változat az SP-t az X29-hez hasonlította, és mov sp, x29-cel állt vissza. Ez csak addig működik, amíg a hívott az X29-et megőrzi. A lab_demo_bad_spfp mindkettőt 16 bájttal tolja el, ezért az SP == X29 vizsgálat átmenne. A lab_demo_bad_fp esetén pedig a mov sp, x29 egy 16 bájttal arrébb fekvő „keretből” töltené vissza a mentett regisztereket és a visszatérési címet. A horgonyos változat mindhárom hibát külön bittel jelzi (LAB_ABI_SP, LAB_ABI_FP, illetve mindkettő), és a horgonyból mindig ép kerettel tér vissza, így a hiba jelentés lesz, nem összeomlás. A lab_demo_reenter sor eredménye 0x100000 = LAB_ABI_BUSY. Ez a belső, egymásba ágyazott hívás maszkja, amelyet a C-hívott továbbad. Az utána következő lab_demo_good sor azt igazolja, hogy a horgony a külső hívás végén felszabadult.

Korlátok. A teszthívó legfeljebb hat egész vagy mutató argumentumot ad át, a hívott X0-ját olvassa vissza, és nem ellenőrzi a lebegőpontos argumentumokat, a veremben átadott argumentumokat, a V8–V15 felső 64 bitjét (ezt az AAPCS64 nem is írja elő megőrzendőnek), sem a memóriabeli mellékhatásokat. Ezeket a hívott függvény saját tesztje vizsgálja. Az SP/X29-ellenőrzés csak a visszatéréskori regiszterértékekre vonatkozik. Ha a hívott a hívó keretét is felülírja (például a saját keretén túl ír, vagy előbb megemeli, majd használja az SP-t), a teszthívó a mentett regisztereket már sérült memóriából tölti vissza, és ezt nem ismeri fel. A rossz X30-cal visszatérő hívott pedig el sem jut az ellenőrzésig. Ezekre a debugger (watch a hívó keretére, illetve töréspont a visszatérési címen) a megfelelő eszköz. Egyszerre csak egy lab_abi_call futhat (statikus horgony); többszálú vagy szignálkezelőből hívott tesztben ez a teszthívó nem használható.

8.3.7 Miért kell szimmetria?

A szimmetria állapotbeli inverzt jelent, nem egyszerűen azonos számú mentő és betöltő utasítást.

A keretméretre vonatkozó levezetés:

SPkilépés=S−nfoglalás+nfelszabadítás. SP_{\text{kilépés}} = S - n_{\text{foglalás}} + n_{\text{felszabadítás}}.

Itt S bájtcím, a két n pedig bájtban megadott keretméret. A hívó veremmutatója akkor áll helyre, ha a foglalás és felszabadítás mérete azonos. Ez szükséges, de önmagában nem elég: a regisztereket is a megfelelő rekeszekből kell visszatölteni.

Különösen alattomos módosítás:

// A64 ISA; szándékosan hibás epilógusrészlet.
LDP X19, X20, [SP, #16]
LDP X29, X30, [SP], #16
RET

A visszatérési cím helyes helyről töltődik be, ezért a függvény visszatérhet. A veremmutató viszont S − 16 marad. Még az igazítása is helyes, tehát az igazításellenőrzés nem feltétlenül leplezi le a hibát. A labor várt kilépési állapota ekkor 2.

8.3.8 Hibakeresés: tünettől az ellenőrzött hipotézisig

Tünet Hipotézis Ellenőrzés
Az eredmény helyes, később hibázik a program Elmaradt megőrzöttregiszter-visszaállítás Belépési és kilépési regiszterértékek összevetése
A visszatérés a függvény belsejébe vezet A belépési LR elveszett A64 X30 és a mentett visszatérési cím vizsgálata
Minden visszatérés után változik a veremhelyzet Eltérő foglalási és felszabadítási méret A64 SP különbségének kiszámítása
Csak a felső 32 bit hibás Téves Wn művelet A teljes Xn érték és a disassembly ellenőrzése

A következő GDB-menettel közvetlenül az epilógus előtt állunk meg.

Első terminál, target: AArch64 Linux, qemu-user:

qemu-aarch64 -g 1234 ./lab-frame

A program a debugger kapcsolódására vár.

Második terminál, debugger: hoston futó gdb-multiarch, target: qemu-user:

gdb-multiarch -q ./lab-frame \
  -ex 'target remote localhost:1234' \
  -ex 'break *lab_eval' \
  -ex 'continue' \
  -ex 'set $lab_sp0 = $sp' \
  -ex 'break *lab_eval_epilogue' \
  -ex 'continue' \
  -ex 'p/d $lab_sp0 - $sp' \
  -ex 'x/4gx $sp' \
  -ex 'si' \
  -ex 'si' \
  -ex 'p/d $sp == $lab_sp0' \
  -ex 'info registers x19 x20 x29 x30 sp' \
  -ex 'continue' \
  -ex 'quit'

Várt állapot: az első különbség 32; a két A64 LDP után az egyenlőségvizsgálat eredménye 1. A memória négy nyolcbájtos rekesze a fenti táblázatot követi. A mentett visszatérési cím futásfüggő, ezért nem adunk hozzá számszerű címpéldát.

Hibakereséskor egyszerre egy dolgot módosíts: például csak a felszabadítás méretét. Ezután ismételd meg ugyanazt a regiszter- és veremellenőrzést. Ne következtess ABI-helyességre pusztán a számított eredményből.

8.3.9 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért kell a példában az A64 X19, X20, X29 és X30 értékét megőrizni? Miért nem mentjük az X0 belépési értékét visszaállítás céljából?

  2. (implementációs, középszint — cél: rutinépítés) Alakítsd át a lab_eval függvényt úgy, hogy két segédfüggvény-hívással számítsa ki a 2*a + 2*b értéket. Az első hívás eredményét őrizd meg a második hívás idejére. Maradjon 32 bájtos keret, és frissítsd az eredményellenőrzést.

  3. (hibakeresési, középszint — cél: megértés-ellenőrzés) Az eredeti példában változtasd az utolsó A64 LDP utóindexét 32-ről 16-ra. Fordítsd újra a korábbi assemble–link–run paranccsal. Jegyezd fel a kilépési állapotot, majd GDB-ben ellenőrizd a veremmutató eltérését. Végül állítsd vissza az eredeti kódot.

8.3.10 Megoldások

1. megoldás — Segítség. Két külön kérdést tegyél fel. (1) Melyik regiszterek callee-saved-ek az AAPCS64 szerint, és ezek közül melyiket írja felül a lab_eval? Ezeket minden hívó felé vissza kell állítania, akkor is, ha az éppen bemutatott _start némelyiküket nem olvassa: a függvény nem ismerheti a jövőbeli hívóit. (2) Mit veszélyeztet a lab_eval saját belső BL-je? Gondolj az X30-ra. A „csak azt, amit a hívó a hívás után még használ” szempont a hívó saját döntésére való: a caller-saved regisztereiből (X0–X18) azt kell a hívás előtt elmentenie, amire a hívás után is szüksége van.

1. megoldás — Teljes megoldás. Az A64 X19 és X20 megőrzött regiszter, és a függvény felülírja őket. Az X29 szintén megőrzendő, és új keretmutatót kap. Az X30 belépési értékét a belső BL felülírná, pedig arra kell majd visszatérni. Az X0 eredményregiszter: kilépéskor az eredményt, nem a belépési argumentumot kell tartalmaznia.

2. megoldás — Segítség. A két hívás eredményét nem tarthatod egyszerre X0-ban — az egyiket a hívások között egy megőrzött regiszterbe kell átmenteni, mielőtt a második BL felülírja X0-t.

2. megoldás — Teljes megoldás. A teljes lab_eval függvény helyére:

// A64 ISA, AArch64; a lab-frame.s cserefüggvénye.
.type lab_eval, %function
lab_eval:
    STP X29, X30, [SP, #-32]!
    MOV X29, SP
    STP X19, X20, [SP, #16]

    MOV X19, X1
    BL  lab_double
    MOV X20, X0

    MOV X0, X19
    BL  lab_double
    ADD X0, X0, X20

lab_eval_epilogue:
    LDP X19, X20, [SP, #16]
    LDP X29, X30, [SP], #32
    RET
.size lab_eval, .-lab_eval

Az A64 X19 a második argumentumot, az X20 az első hívás eredményét őrzi. A tesztben a CMP X0, #26 helyett CMP X0, #24 szükséges. Az előző buildparancsokkal újrafordítva a várt kilépési állapot ismét 0.

3. megoldás — Segítség. A veremmutató-eltérés és a visszatérési cím helyessége két külön dolog — gondold végig, hogy a LDP X29, X30, [SP], #16 melyiket állítja helyre, és melyiket nem.

3. megoldás — Teljes megoldás. A várt kilépési állapot 2. Az epilógus előtti különbség továbbra is 32 bájt; a hibás felszabadítás után 16 bájt marad. A GDB egyenlőségvizsgálata 0-t ad. A visszatérési cím helyreállítása sikerült, a veremkereté nem. Ezért nincs szükség azonnali összeomlásra ahhoz, hogy a kód hibás legyen.

8.3.11 Rövid összefoglaló

A nem-levél A64 függvénynek meg kell őriznie a további hívások által veszélyeztetett visszatérési címét, valamint minden általa módosított megőrzött regiszter belépési értékét. Az STP és LDP tömör eszközt ad a páros mentéshez, de címzési és immediate-korlátaikat figyelembe kell venni. A helyes epilógus nemcsak a regisztereket, hanem a belépési veremmutatót is visszaállítja. A szimmetria ezért állapotbeli azonosságot jelent. Egy függvény helyes számítás mellett is megsértheti az ABI-t; ezt célzott regisztermintákkal és veremmutató-ellenőrzéssel vizsgáljuk. A könyv C-ből hívható assembly függvényeit a közös lab_abi_call teszthívó ellenőrzi; a végleges függvények CFI-t is kapnak, amelyet a readelf --debug-dump=frames-interp táblázata igazol.

8.3.12 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önyv referencia-eszközláncával (GNU GCC/binutils, qemu-aarch64) a kódot itt nem futtattuk. A lab-frame.s kilépési állapotát (0), a lab-abi-test fenti kimenetét és a két függvény CFI-táblázatát helyettesítő eszközlánccal mértük: zig cc (Clang, integrált assembler, musl C-könyvtár, aarch64-linux-musl cél), a futtatás pedig egy Unicorn-alapú AArch64 user-mode emulátorban történt. Ugyanezzel a környezettel ellenőriztük a horgony szükségességét is: a korábbi, SP == X29 összehasonlítású és mov sp, x29-cel visszaálló teszthívó a lab_demo_bad_fp és a lab_demo_bad_spfp hívottnál a sérült keretből olvasott result-mutató miatt szegmentálási hibával (139) állt le, a horgonyos változat mindkettőnél a várt maszkot adta. A GNU assembler és a glibc alatti eredmény a hallgatói laborban ellenőrizendő, csakúgy mint a GDB-menet. A CFI a szinkron visszalépést (bt, kivételkezelés hívási pontokon) írja le; az epilógus utasításai közötti aszinkron visszafejtés pontosságát külön nem vizsgáltuk.