8.5 Több visszatérési érték és struktúra-visszaadás szerződése

8.5.1 Miért nem elég azt mondani, hogy „az eredmény az X0-ban van”?

Egy feldolgozófüggvény gyakran több összetartozó eredményt számít: például összeget és különbséget, minimumot és maximumot, vagy feldolgozott elemszámot és hibakódot. C-ben ezt struktúra-visszaadással vagy kimenő mutatóparaméterekkel (output parameters) fejezhetjük ki. Assemblyben azonban pontosan meg kell állapodnunk arról, melyik eredmény hol jelenik meg.

Az AAPCS64 ezt a visszaadott típus alapján, a fordításkor rögzíti. A hívó és a hívott nem egyeztet futás közben a struktúra méretéről.

Ebben az alfejezetben kizárólag A64 ISA, AArch64 Linux, little-endian, LP64 környezetben dolgozunk. A példák normál elrendezésű, egész mezőkből álló C-struktúrákat használnak. Minden assemblyutasítás és regiszter ebbe az A64-környezetbe tartozik; az assemblydirektívák a GNU assembler AArch64 céltárgyára vonatkoznak. Előfeltétel Az AAPCS64 regiszterszerződés ismerete.

8.5.2 Az eredmény helyét a típus határozza meg

A közvetlen eredményátadás (direct result return) során a függvény eredménye regiszterekben érkezik. A közvetett eredményátadás (indirect result return) esetén a hívó eredménytárolót biztosít, a hívott pedig ebbe ír.

A most vizsgált, egész mezőkből álló struktúrákra:

A visszaadott struktúra sizeof értéke Eredmény helye, A64/AAPCS64
1–8 bájt X0 megfelelő bájtjai
9–16 bájt X0, majd X1 megfelelő bájtjai
16 bájtnál több Memória; az eredménytároló címét a hívó X8-ban adja át

A határ tehát 16 bájt, beleértve az igazítás miatti kitöltést (padding). Nem a mezők száma és nem pusztán a mezőméretek összege dönt.

Fontos hatókörkorlát: a „16 bájt felett memória” nem minden AAPCS64-típusra érvényes általános szabály. A homogén lebegőpontos és vektoraggregátumok (HFA, HVA) külön besorolást kapnak; például egy négy double mezőből álló homogén struktúra 32 bájtos, mégis lebegőpontos regiszterekben térhet vissza. Ezeket később tárgyaljuk. Itt egész típusú aggregátumokra alkalmazzuk a táblázatot.

8.5.2.1 A regiszterek nem feltétlenül mezőket jelentenek

A kis struktúra memóriaképe kerül az A64 eredményregiszterekbe, nem általánosan „egy mező egy regiszterbe”.

Little-endian profilban egy

/* AArch64 Linux, LP64, little-endian. */
struct lab_words {
    uint32_t first;
    uint32_t second;
};

típusú, 8 bájtos eredmény mindkét mezője az A64 X0 regiszterben fér el: first az alsó, second a felső 32 biten. A definíció használatához természetesen <stdint.h> szükséges.

Ezzel szemben két uint64_t mező esetén a struktúra 16 bájtos: az első mező A64 X0-ba, a második A64 X1-be kerül.

A kitöltőbájtokból és a regiszter kihasználatlan részeiből ne következtessünk értékre. Az ellenőrzés a mezőket hasonlítsa össze, ne a teljes struktúrát memcmp segítségével.

8.5.2.2 Mit jelent a rejtett X8-paraméter?

Nagy struktúránál a hívó:

  1. megfelelő méretű és igazítású, írható tárolót biztosít;
  2. annak címét az A64 X8 regiszterbe teszi;
  3. a szokásos argumentumokat a szokásos helyükön adja át;
  4. a visszatérés után az eredményt a tárolóból használja.

Az A64 X8 különleges szerepe miatt egy nagy struktúrát visszaadó, két egész paraméteres függvény első két argumentuma továbbra is X0 és X1. A rejtett eredménymutató nem tolja el őket.

A hívott nem köteles az eredménytároló címét A64 X0-ban visszaadni. Az A64 X8 pedig nem megőrzött regiszter: ha a hívott további függvényt hív, és később még szüksége lesz a címre, saját magának kell megőriznie.

8.5.3 Kidolgozott példa: három interfész ugyanahhoz a számításhoz

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

Három függvényt készítünk:

Minden számítás előjel nélküli, 64 bites; az összeadás, kivonás és a későbbi szorzás eredménye modulo 2642^{64} értendő.

Az interfészek szerződése:

Függvény Bemenet, A64 Eredmény, A64
lab_pair(a, b) X0 = a, X1 = b X0 = a+b, X1 = a-b
lab_triple(a, b) X0 = a, X1 = b, X8 = eredménytároló 24 bájt az eredménytárolóban
lab_pair_out(a, b, out) X0 = a, X1 = b, X2 = out 16 bájt az out által jelölt tárolóban

Az explicit kimenő mutató közönséges harmadik argumentum, ezért A64 X2-ben érkezik, nem X8-ban. Mindkét memóriába író változat érvényes, kellően igazított tárolót igényel; ezt a C-teszt helyi objektumai biztosítják.

8.5.3.1 Assemblymegvalósítás

A lab-return.S fájl teljes tartalma:

// A64 ISA, ARMv8-A; GNU as, AArch64 Linux, little-endian.
.arch armv8-a
.text
.p2align 2

.global lab_pair
.type lab_pair, %function
lab_pair:
    add X2, X0, X1
    sub X1, X0, X1
    mov X0, X2
    ret
.size lab_pair, .-lab_pair

.global lab_triple
.type lab_triple, %function
lab_triple:
    add X2, X0, X1
    sub X3, X0, X1
    eor X4, X0, X1
    stp X2, X3, [X8]
    str X4, [X8, #16]
    ret
.size lab_triple, .-lab_triple

.global lab_pair_out
.type lab_pair_out, %function
lab_pair_out:
    add X3, X0, X1
    sub X4, X0, X1
    stp X3, X4, [X2]
    ret
.size lab_pair_out, .-lab_pair_out

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

Mindhárom levélfüggvény: nem hív további függvényt, nem módosít megőrzendő regisztert, ezért nincs szüksége veremkeretre.

Az első függvényben az A64 X2 ideiglenesen megőrzi az összeget. Ez szükséges, mert a különbséghez még kell az eredeti X0 bemenet. Ha előbb közvetlenül X0-ba írnánk az összeget, elveszítenénk ezt a bemenetet.

Az A64 STP két 64 bites regisztert tárol egymást követő 8 bájtos helyekre. A használt eltolásos alak előjeles, 7 bites, 8-cal skálázott eltolást kódolhat: −512 és 504 bájt között, 8 bájtos lépésekben. Itt az eltolás nulla.

Az A64 STR X4, [X8, #16] előjel nélküli, 12 bites, 8-cal skálázott eltolást használ: 0–32760 bájt között, 8 bájtos lépésekben. A 16 bájtos eltolás tehát kódolható.

A GNU AArch64 .p2align 2 direktíva 22=42^2=4 bájtos igazítást kér. A .size a függvény gépi kódjának méretét rögzíti, nem a C-visszatérési típus méretét.

8.5.3.2 C-interfész és ellenőrzőprogram

A lab-return-main.c fájl:

/* AArch64 Linux, LP64, little-endian. */
#include <stddef.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>

struct lab_pair_value {
    uint64_t sum;
    uint64_t difference;
};

struct lab_triple_value {
    uint64_t sum;
    uint64_t difference;
    uint64_t xor_value;
};

extern struct lab_pair_value
lab_pair(uint64_t a, uint64_t b);

extern struct lab_triple_value
lab_triple(uint64_t a, uint64_t b);

extern void
lab_pair_out(uint64_t a, uint64_t b, struct lab_pair_value *out);

_Static_assert(sizeof(struct lab_pair_value) == 16, "pair size");
_Static_assert(offsetof(struct lab_pair_value, sum) == 0, "sum offset");
_Static_assert(offsetof(struct lab_pair_value, difference) == 8,
               "difference offset");

_Static_assert(sizeof(struct lab_triple_value) == 24, "triple size");
_Static_assert(offsetof(struct lab_triple_value, sum) == 0, "sum offset");
_Static_assert(offsetof(struct lab_triple_value, difference) == 8,
               "difference offset");
_Static_assert(offsetof(struct lab_triple_value, xor_value) == 16,
               "xor offset");

int main(void)
{
    const uint64_t inputs[][2] = {
        {20, 7},
        {0, 1},
        {UINT64_MAX, 1}
    };

    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; ++i) {
        uint64_t a = inputs[i][0];
        uint64_t b = inputs[i][1];

        struct lab_pair_value p = lab_pair(a, b);
        struct lab_triple_value t = lab_triple(a, b);
        struct lab_pair_value out = {0, 0};
        lab_pair_out(a, b, &out);

        if (p.sum != a + b || p.difference != a - b ||
            t.sum != a + b || t.difference != a - b ||
            t.xor_value != (a ^ b) ||
            out.sum != a + b || out.difference != a - b) {
            fprintf(stderr, "hiba: eset %zu\n", i);
            return 1;
        }

        if (i == 0) {
            printf("sum=%" PRIu64 " difference=%" PRIu64
                   " xor=%" PRIu64 "\n",
                   t.sum, t.difference, t.xor_value);
        }
    }

    puts("ok: 3 eset, 3 visszaadasi mod");
    return 0;
}

A statikus állítások az assembly által feltételezett méreteket és mezőeltolásokat ellenőrzik. Nem helyettesítik a közös típusdeklarációt, de megakadályozzák, hogy egy észrevétlen struktúramódosítás hibás bináris szerződést hozzon létre.

8.5.3.3 Fordítás és futtatás

Cél: qemu-user, qemu-aarch64; host: Ubuntu 24.04 x86_64. A statikus linkeléshez az AArch64 cross libc fejlesztői állományai is szükségesek, az alapkörnyezet részeként.

# Assemble: A64 objektum.
aarch64-linux-gnu-gcc -march=armv8-a -g \
    -c lab-return.S -o lab-return.o

# Compile: AArch64 Linux C-objektum.
aarch64-linux-gnu-gcc -march=armv8-a -std=c11 -O0 -g \
    -Wall -Wextra -Werror -fno-pie \
    -c lab-return-main.c -o lab-return-main.o

# Link: statikus AArch64 Linux ELF.
aarch64-linux-gnu-gcc -static -no-pie \
    lab-return-main.o lab-return.o -o lab-return

# Run: qemu-user.
qemu-aarch64 ./lab-return
printf 'kilepesi allapot=%d\n' "$?"

Levezetett, várt kimenet; itt nem végrehajtott mérés:

Az első hívássorozat után:

Ellenőrzési pont Várt állapot, A64
lab_pair, közvetlenül visszatéréskor X0 = 27, X1 = 13
lab_triple, az eredménytároló 0 bájtos eltolásán 64 bites előjel nélküli 27
Ugyanott, 8 bájtos eltoláson 64 bites előjel nélküli 13
Ugyanott, 16 bájtos eltoláson 64 bites előjel nélküli 19

A másik két teszt az előjel nélküli alulcsordulást és körbefordulást is ellenőrzi. Ezek C-ben definiált műveletek; nem szabad előjeles túlcsordulási példaként értelmezni őket.

8.5.4 Hibák és célzott ellenpróbák

8.5.4.1 A két fordítási egység más visszatérési típust feltételez

Ha a C-deklaráció 24 bájtos eredményt ír elő, de az assembly csak A64 X0 és X1 értékét állítja be, a hívó által kijelölt tároló nem kapja meg az eredményt.

Fordított esetben a hívott A64 X8-on keresztül próbálhat írni, miközben a hívó nem adott át érvényes eredménycímet. Ennek következménye lehet memóriahiba vagy memóriarongálás.

A linker rendszerint csak a szimbólumnevet egyezteti; ettől a C-típusok még nem lesznek kompatibilisek. A sikeres linkelés nem ABI-ellenőrzés.

8.5.4.2 A két mezőt tévesen két W-regiszterbe tesszük

Két uint32_t mező 8 bájtos struktúrája nem az A64 W0 és W1 regiszterben tér vissza. Mindkét mező az A64 X0 regiszter része. Ez tipikus hiba, amikor a fejlesztő mezőszámból következtet regiszterszámra.

8.5.4.3 A kimenő mutatót összekeverjük a rejtett mutatóval

A példában lab_pair_out explicit harmadik paramétere A64 X2, míg lab_triple rejtett eredménycíme A64 X8. Ugyanaz a memóriaírási művelet eltérő interfészből származhat.

8.5.4.4 Adatgyűjtés objdump és GDB segítségével

Cél: a qemu-userhez épített AArch64 ELF vizsgálata. A következő parancsok nem futtatják a célprogramot:

aarch64-linux-gnu-objdump -d --disassemble=lab_pair ./lab-return
aarch64-linux-gnu-objdump -d --disassemble=lab_triple ./lab-return

A64 lab_pair bemeneteinek és eredményeinek követéséhez az első terminálban:

# Cél: qemu-user; várakozás a debugger kapcsolódására.
qemu-aarch64 -g 1234 ./lab-return

Második terminálban:

# Cél: az előző qemu-user folyamat A64 állapotának vizsgálata.
gdb-multiarch -q -batch ./lab-return \
    -ex 'target remote :1234' \
    -ex 'break lab_pair' \
    -ex 'continue' \
    -ex 'info registers x0 x1' \
    -ex 'si 3' \
    -ex 'info registers x0 x1 x2' \
    -ex 'detach'

A belépéskor A64 X0 = 20, X1 = 7 várható. Három utasítás után, még az A64 RET előtt: X0 = 27, X1 = 13, X2 = 27. A leválasztás után a program várhatóan befejeződik, és 0 állapottal lép ki.

A hibakeresési ciklus: tünet → feltételezett szerződés- vagy adatfüggőségi hiba → regiszteradatok → egyetlen javítás → teljes teszt újrafuttatása. Az emuláció itt szemantikai ellenőrzést szolgál; hardveres ciklusszámot nem állapítunk meg.

8.5.5 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Hol tér vissza a két uint32_t mezős, a két uint64_t mezős és a három uint64_t mezős struktúra? Az első esetben milyen hexadecimális érték lesz az A64 eredményregiszterben, ha a mezők értéke rendre 1 és 2?

  2. (implementációs, középszint — cél: rutinépítés) Módosítsd lab_triple harmadik eredményét bitenkénti kizáró vagy helyett az a * b előjel nélküli szorzat alsó 64 bitjére. Ne változtasd meg a struktúra méretét vagy a visszaadási mechanizmust. Igazítsd hozzá a C-tesztet is.

  3. (hibakeresési, középszint — cél: rutinépítés) A lab_pair számítását valaki az alábbi A64-részletre cserélte. Milyen eredményt kapsz az első tesztben? Ellenőrizd a hipotézist debuggerrel, majd javítsd az adatfüggőséget.

    // Szándékosan hibás A64 függvénytörzs.
    add X0, X0, X1
    sub X1, X0, X1
    ret

8.5.6 Megoldások

1. megoldás — Segítség. Nézd meg a fejezet első táblázatát (1–8 bájt / 9–16 bájt / 16 bájt felett), majd gondold át little-endian elrendezésben, hogy egy 8 bájtos regiszteren belül az ALACSONYABB című mező kerül-e az alsó vagy a felső félre.

1. megoldás — Teljes megoldás. A 8 bájtos eredmény A64 X0-ban, a 16 bájtos eredmény A64 X0/X1-ben, a 24 bájtos eredmény pedig az A64 X8-ban átadott címen tér vissza. Little-endian profilban az első struktúra eredménye X0 = 0x0000000200000001: az első mező foglalja el az alsó 32 bitet.

2. megoldás — Segítség. Az A64 MUL-nak ugyanaz a célregiszter/forrás-mintája van, mint az ADD/SUB/EOR-nak — csak a mnemonicot és a szemantikát kell cserélni, a struktúra-elrendezést nem.

2. megoldás — Teljes megoldás. A meglévő A64 EOR helyére ez kerül:

// A64: a 64 bites szorzat alsó 64 bitje.
mul X4, X0, X1

A C-forrásban a xor_value mezőnevet következetesen product névre cseréljük, az ellenőrzés pedig t.product != a * b legyen. A kiírás feliratát is módosítjuk. Az első eset harmadik eredménye 140. A struktúra továbbra is 24 bájtos, ezért az A64 X8-alapú szerződés változatlan. A korábbi buildlépéseket újra végre kell hajtani.

3. megoldás — Segítség. A hibás verzió az összeget ÚJRA X0-ba írja, mielőtt a különbséghez szüksége lenne az EREDETI X0 bemenetre — melyik érték vész el emiatt a második sorban?

3. megoldás — Teljes megoldás. Az első A64 utasítás után X0 = 27; a második ezért már 27 - 7 értékét számítja. Az eredmény X0 = 27, X1 = 20, nem pedig 27 és 13. A C-teszt az első esetnél hibát jelez, és 1 állapottal lép ki. A debuggerben két utasítást léptessünk, hogy még a visszatérés előtt lássuk a hibát. A kidolgozott példa A64 X2-t használó változata megőrzi a szükséges bemenetet, ezért helyes.

8.5.7 Rövid összefoglaló

A visszatérési szerződést a függvény típusa határozza meg, nem a függvénytörzs választja futás közben. A vizsgált egész mezős struktúrák legfeljebb 16 bájtig A64 X0 és szükség esetén X1 segítségével térnek vissza; nagyobb eredménynél a hívó A64 X8-ban adja át a tároló címét. A kitöltés beleszámít a méretbe, a mezők pedig nem feltétlenül külön regisztereket jelentenek. Az explicit kimenő mutató közönséges paraméter. A helyes aritmetika mellett a típusdeklaráció, a mezőeltolás és az eredmény helye is ellenőrzendő.

8.5.8 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 itt nem fordítottuk és nem futtattuk. Az Ubuntu 24.04 környezet konkrét csomagverzióit, a statikus cross libc elérhetőségét és a debugger-munkamenetet a laborban kell validálni. A megadott kimenetek és regiszterértékek levezetett elvárások, nem mért eredmények.