14.2 Hívási konvenció, stack frame és visszatérés

A függvényhívás C-ben értékek átadását és egy eredmény visszaadását jelenti. A nyelv azonban nem írja elő, hogy ezek az értékek melyik processzorregiszterbe vagy memóriacímre kerüljenek. Ezt a konkrét platform hívási konvenciója (calling convention) szabályozza az alkalmazásbináris interfész, azaz az ABI (Application Binary Interface) részeként.

Az ABI teszi lehetővé, hogy külön fordított függvények együttműködjenek. A hívó és a hívott félnek ugyanúgy kell értelmeznie a paramétereket, a visszatérési értéket, a regiszterek megőrzését és a verem állapotát. Egy helytelen függvénydeklaráció ezért nem pusztán típushiba: eltérő gépi szerződést is létrehozhat.

Az assembly olvasásához a C forrásból assembly és gépi kód, a variadikus hívások nyelvi szabályaihoz a Variadikus függvények és típusbiztonság részre támaszkodunk.

14.2.1 A vizsgált ABI és a megfigyelés korlátai

A konkrét példák profilja:

Tulajdonság Feltételezés
Nyelv ISO C17
Platform Linux hosted user-space, ELF
Architektúra és ABI x86-64, System V AMD64 psABI
Adatmodell LP64; CHAR_BIT == 8
Utasításkészlet Alap x86-64, AVX nélkül
Fordítás Külön fordítási egység, LTO nélkül

A labor profilja a központi táblában rögzített lab-gcc (GCC 13.3.0, GNU binutils 2.42); a clang parancsok a lab-clang profilhoz tartoznak. Az alábbi parancsok nem linkelnek, ezért libc-verziótól függő működést nem vizsgálnak. A ténylegesen használt binárisok verzióját és targetjét a laborjegyzőkönyvben rögzíteni kell.

Forrás: az ABI-szabályok forrása a forrásjegyzék [psABI] tétele, elsősorban annak Data Representation (adatreprezentáció) és Function Calling Sequence (hívási sorrend) szakasza a 3. Low Level System Information fejezetben. A következő szabályok ennek összefoglalói, nem dokumentált mérési eredmények. A kódok fordításáról vagy futtatásáról nem állítunk ellenőrzött eredményt; az assemblypélda szemléltető.

Az ABI-szerződés akkor érdekes, amikor tényleges gépi híváshatár marad. Beillesztett függvénynél nincs külön paraméterátadási határ, és a fordító belső függvényváltozatokhoz saját konvenciót is használhat, ha ezzel nem sért külső szerződést.

14.2.2 Egész és lebegőpontos argumentumok

ABI-szabály, a megadott profilban: az egész típusú és pointerargumentumok első hat regiszterhelye sorrendben:

rdi, rsi, rdx, rcx, r8, r9.

A skalár float és double argumentumok legfeljebb nyolc SSE-regisztert használhatnak: xmm0–xmm7. A két regisztersorozat számlálója egymástól független.

Például a következő deklarációban:

/* C17 deklarációrészlet, System V AMD64 LP64 profil. */
double combine(long a, double x, long b, double y);

a az rdi, b az rsi, x az xmm0, y az xmm1 regiszterhez tartozik. A double eredmény helye xmm0.

Az „egész argumentum regiszterben” nem jelenti azt, hogy minden típus 64 bites. Egy int értékének értelmezéséhez a típus és az ABI további szabályai is szükségesek; a regiszter fennmaradó bitjeire nem szabad önkényes feltevést építeni.

A regiszterhelyek elfogyása után a megfelelő argumentumok a verembeli argumentumterületre kerülnek. Ez nem írja elő, hogy a fordító sorozatos push utasításokat használjon: gyakran egyszer foglal helyet, majd megfelelő offsetekre ír.

14.2.3 Struktúrák osztályozása és visszaadása

Összetett értékeknél nem elegendő a paraméter sorszáma. Az ABI az értéket eightbyte, vagyis ebben a profilban nyolc C bájtos egységek alapján osztályozza.

A teljes algoritmusban többek között INTEGER, SSE, SSEUP, X87, X87UP, COMPLEX_X87, NO_CLASS és MEMORY osztály szerepel. A következő egyszerűsítés természetesen igazított, egész típusú, float és double tagokból álló szokásos struktúrákra használható:

  1. A struktúrát nyolcbájtos részekre bontjuk.
  2. A tagok az általuk elfoglalt részek osztályozásához járulnak hozzá.
  3. Egész típusú vagy pointertag INTEGER, float vagy double tag SSE hozzájárulást ad.
  4. Ugyanazon részben az INTEGER és SSE összevonásából INTEGER lesz.
  5. Az így körülhatárolt struktúrák közül a 16 C bájtnál nagyobbak MEMORY átadásúak.

Ez nem általános algoritmus tetszőleges vektortípusra, long double-ra vagy fordítóbővítéses elrendezésre. A nem megfelelően igazított tag szintén memóriabeli átadáshoz vezethet.

14.2.3.1 Kidolgozott C példa

Az alábbi abi_demo.c önállóan tárgyfájllá fordítható C17 fordítási egység. A helper definíciója csak egy későbbi linkeléshez szükséges.

#include <limits.h>
#include <stddef.h>

struct pair {
    double weight;
    unsigned long count;
};

struct triple {
    unsigned long a;
    unsigned long b;
    unsigned long c;
};

_Static_assert(CHAR_BIT == 8, "8-bit C byte required");
_Static_assert(sizeof(unsigned long) == 8, "LP64 required");
_Static_assert(sizeof(double) == 8, "8-byte double required");
_Static_assert(offsetof(struct pair, count) == 8, "layout mismatch");
_Static_assert(sizeof(struct pair) == 16, "layout mismatch");
_Static_assert(_Alignof(struct pair) == 8, "alignment mismatch");
_Static_assert(sizeof(struct triple) == 24, "layout mismatch");

struct pair adjust(struct pair p,
                   unsigned long delta,
                   double factor)
{
    p.weight *= factor;
    p.count += delta;
    return p;
}

struct triple make_triple(unsigned long x)
{
    return (struct triple){x, x + 1UL, x + 2UL};
}

extern unsigned long helper(unsigned long x);

unsigned long twice_step(unsigned long x)
{
    unsigned long first = helper(x);
    return helper(first) + first;
}

Az adjust példában feltétel, hogy a lebegőpontos szorzás eredménye ábrázolható legyen. Az előjel nélküli összeadás körbefordulása ezzel szemben definiált.

A megadott profilban:

Objektum vagy tag Offset C bájtban Méret C bájtban
pair.weight 0 8
pair.count 8 8
teljes pair — 16
triple.a, triple.b, triple.c 0, 8, 16 egyenként 8
teljes triple — 24

A pair első eightbyte-ja SSE, második része INTEGER. Ebből az adjust hívási térképe:

Érték Hely
p.weight xmm0
p.count rdi
delta rsi
factor xmm1
eredmény weight tagja xmm0
eredmény count tagja rax

A visszaadás tehát nem feltétlenül egyetlen regisztert jelent. Két INTEGER rész tipikusan rax és rdx, két megfelelő SSE rész xmm0 és xmm1 használatával tér vissza; a példában a két külön regisztercsalád kombinálódik.

Fontos kivétel: ha egy struktúra átadásához nincs elegendő szabad regiszter valamelyik szükséges osztályból, a teljes argumentum a verembe kerül. Nem osztjuk szét tetszőlegesen úgy, hogy egyik fele regiszterben, másik fele a veremben legyen.

A megfigyeléshez:

gcc -std=c17 -O2 -Wall -Wextra -Wpedantic \
    -march=x86-64 -mtune=generic -mno-avx -fno-lto \
    -fomit-frame-pointer -S -masm=intel abi_demo.c -o abi_demo.gcc.s

clang -std=c17 -O2 -Wall -Wextra -Wpedantic \
    -march=x86-64 -mtune=generic -mno-avx -fno-lto \
    -fomit-frame-pointer -S -masm=intel abi_demo.c -o abi_demo.clang.s

gcc -std=c17 -O2 -Wall -Wextra -Wpedantic \
    -march=x86-64 -mno-avx -fno-lto \
    -c abi_demo.c -o abi_demo.o

14.2.3.2 Nagy visszatérési érték: sret

A 24 C bájtos triple visszaadása MEMORY eset. A hívó biztosítja az eredmény tárolóját, és ennek címét rejtett első argumentumként, rdi-ben adja át. Ezt gyakran sret, azaz structure return megoldásnak nevezik.

A látható x paraméter ezért rsi-be kerül. A függvény a kijelölt tárolóba ír, majd annak címét rax-ban is visszaadja.

Szerep Szerződés
Tulajdonos A hívó kezeli az eredménytároló élettartamát
Kölcsönzött hozzáférés A hívott függvény a rejtett pointerrel ír bele
Élettartamfeltétel A tároló a hívás teljes ideje alatt érvényes, megfelelő méretű és igazítású
Érvénytelenítés Az eredményhez kapcsolódó tároló élettartamának későbbi vége
Hibakimenet Az ABI nem ad hibakódot hibás tárolóra; a szerződéssértés nem biztonságosan kezelhető

Ez nem változtatja pointeres API-vá a C deklarációt: nyelvi szinten továbbra is struktúraértéket adunk vissza.

14.2.4 Ki őrzi meg a regisztereket?

A caller-saved regiszter értékét a hívott fél felülírhatja. Ha a hívónak az érték a hívás után is kell, neki kell gondoskodnia a megőrzéséről.

A callee-saved regisztert a hívott fél használhatja, de visszatéréskor helyre kell állítania a belépéskori értéket.

Regisztercsoport Felelősség
rax, rcx, rdx, rsi, rdi, r8–r11 Caller-saved
rbx, rbp, r12–r15 Callee-saved
xmm0–xmm15 Caller-saved ebben a profilban
rsp A visszatéréshez előírt veremállapotot helyre kell állítani

A táblázat az adatregiszterek fő szabályait foglalja össze, nem a teljes processzorállapot ABI-előírásait.

A twice_step függvényben a first értéknek túl kell élnie a második helper hívást. Egy lehetséges, Intel-szintaxisú megvalósítás:

# Szemléltető részlet, nem mért fordítókimenet.
twice_step:
    push rbx
    call helper
    mov rbx, rax
    mov rdi, rax
    call helper
    add rax, rbx
    pop rbx
    ret

Az rbx megőrzi a first értékét a második híváson át, mert a helper köteles megőrizni az rbx-et. Ugyanakkor a twice_step is köteles megőrizni saját hívója eredeti rbx értékét: ezért kell a push és pop.

A „callee-saved” tehát nem azt jelenti, hogy a függvény szabadon birtokolja a regisztert, hanem azt, hogy használatáért helyreállítási kötelezettséggel tartozik.

14.2.5 Visszatérési cím és veremkeret

A veremkeret (stack frame) a függvény végrehajtásához kapcsolódó verembeli területek összefoglaló neve. Tartalmazhat mentett regisztereket, lokális objektumokat, ideiglenes értékeket és további hívások argumentumterületét. Nem kötelező minden függvényhez klasszikus, rbp-alapú keretet kialakítani.

14.2.5.1 A 16 bájtos igazítás levezetése

A példák normál, különleges túlzott igazítást nem igénylő hívásaiban a call előtt:

P≡0(mod16), P \equiv 0 \pmod {16},

ahol PP az rsp értéke. A közeli x86-64 call nyolcbájtos visszatérési címet tesz a veremre, így a függvény belépésekor:

E=P−8,E≡8(mod16). E=P-8,\qquad E\equiv 8\pmod {16}.

Ezért belépéskor nem maga az rsp, hanem az rsp + 8 igazított 16 bájtra.

Bizonyításvázlat: a hívó igazított állapotából a visszatérési cím tárolása nyolc bájtot fogyaszt. A példabeli push rbx újabb nyolcat von le:

E−8=P−16≡0(mod16). E-8=P-16\equiv 0\pmod {16}.

A két további call ezért megfelelő igazítás mellett hajtható végre.

Egyszerű, hét long argumentumos hívásnál, belépéskor:

Hely Tartalom
[rsp, rsp+8) Visszatérési cím
[rsp+8, rsp+16) Hetedik argumentum
Első hat egész argumentumregiszter Első hat argumentum

A visszatérési cím és a visszatérési érték külön fogalom. Az előbbi a vezérlés folytatási helye; az utóbbi például rax, xmm0 vagy a rejtett eredménytároló tartalma.

14.2.5.2 Prológus, epilógus és frame pointer

A prológus a szükséges belépési műveleteket, az epilógus a kilépés előkészítését jelenti. Klasszikus megoldás az rbp mentése, az aktuális verempozíció hozzárendelése, majd lokális hely foglalása.

A frame-pointer omission az állandó keretmutató elhagyása. Ettől még lehet veremkeret, és a fordító által készített unwind-információ alapján a hívási lánc is visszafejthető lehet. Fordítva: a frame pointer jelenléte önmagában nem garantál teljes hibakereshetőséget.

A visszatéréskor felszabaduló veremhely tartalma fizikailag megmaradhat, de ettől a benne volt lokális C objektum élettartama nem hosszabbodik meg. Lokális objektumra mutató pointer visszaadása ettől továbbra is hibás.

14.2.6 Red zone és variadikus hívások

A profilban az aktuális rsp alatti 128 bájtos terület a red zone. Az ABI ezt védi a megfelelő user-space jelzés- és megszakításkezelés felől történő felülírástól. Levélfüggvény — amely nem hív másik függvényt — ezért bizonyos ideiglenes adatokat az rsp módosítása nélkül tárolhat itt.

A red zone viszont nem őrzi meg az adatokat egy normál függvényhíváson át. A call és a hívott függvény veremhasználata beleírhat ebbe a területbe. Nem szabad továbbá a Linux user-space szabályát kernelkódra vagy más ABI-ra átvinni.

Variadikus hívásnál további rejtett információ jelenik meg: az al a vektorregiszterekben átadott argumentumokhoz használt regiszterek számának felső korlátját jelzi, 0 és 8 között. Ebbe a névvel rendelkező és a variadikus argumentumok egyaránt beleszámítanak.

A variadikus argumentumok tehát nem szükségképpen mind a veremben vannak. A megvalósítás regisztermentési területet és verembeli argumentumterületet is használhat a va_arg kiszolgálásához. A va_list ezért nem helyettesíthető hordozhatóan „a következő veremcímre mutató pointerrel”. A nyelvi default argument promotion szabályok ettől függetlenül érvényesek.

14.2.7 AArch64 és Windows x64: az összehasonlítás határa

Az AArch64 vizsgálatához az adott platformhoz tartozó hivatalos AAPCS64-szabályokat, Windows x64 esetén a Microsoft hivatalos hívásikonvenció-leírását kell alapul venni. Az architektúra neve önmagában nem teljes ABI-profil.

Mivel ezekből nincs megadott, ellenőrzött specifikációs forrásjegyzet, itt nem állítunk fel számszerű regiszter- vagy veremkiosztási összehasonlítást. A helyes összevetés külön ellenőrzi az argumentumosztályozást, a megőrzendő regisztereket, az igazítást, a rejtett eredménypointert és a variadikus hívásokat. A System V táblázatát nem szabad puszta regiszterátnevezéssel más platformra alkalmazni.

14.2.8 Gyakori hibák

14.2.9 Feladatok

14.2.9.1 Magyarázó feladat

Elemezd ezt a deklarációt a megadott profilban:

/* C17 deklarációrészlet. */
long pick(long a, double x, long b, double y,
          long c, long d, long e, long f, long g);

Add meg az argumentumok és az eredmény helyét. Hol található g közvetlenül belépéskor?

14.2.9.2 Kódolási feladat

Bővítsd az abi_demo.c fájlt egy send_pair függvénnyel. Ez hat unsigned long, egy struct pair és egy double argumentummal hívjon egy külső consume függvényt. A hat egész érték legyen 1–6, az utolsó double 7.0.

Vezesd le, hogy a pair miért kerül teljes egészében a verembe, miközben az utolsó argumentum regiszterben maradhat.

14.2.9.3 Diagnosztikai feladat

Egy kézzel írt változat ezt csinálja közvetlenül belépés után:

# Szándékosan hibás ABI-részlet.
call helper
mov rcx, rax
mov rdi, rax
call helper
add rax, rcx
ret

Keress két egymástól független hibát, és adj javítást.

14.2.10 Megoldások

14.2.10.1 A magyarázó feladat megoldása

a, b, c, d, e, f rendre rdi, rsi, rdx, rcx, r8, r9. Az x és y az xmm0, illetve xmm1 regiszterbe kerül. A g belépéskor [rsp+8] címen található; az eredmény rax.

Ellenőrzési feltétel: a két argumentumosztály számlálását külön kell elvégezni, és a veremoffsetben figyelembe kell venni a visszatérési címet.

14.2.10.2 A kódolási feladat megoldása

Az előző fordítási egységhez adható részlet:

extern void consume(unsigned long a, unsigned long b,
                    unsigned long c, unsigned long d,
                    unsigned long e, unsigned long f,
                    struct pair p, double tail);

void send_pair(struct pair p)
{
    consume(1UL, 2UL, 3UL, 4UL, 5UL, 6UL, p, 7.0);
}

A hat egész argumentum elfoglalja az összes egész argumentumregisztert. A pair INTEGER részének már nincs hely, ezért a teljes struktúra verembeli átadású. Az ehhez megkezdett SSE-regiszterkiosztás sem marad érvényben; a tail az xmm0-t használhatja.

Ellenőrzési feltétel: a generált kódban a hívási határon kell vizsgálni a kiosztást. A fordító más utasítássorrendet vagy farokhívást is választhat; az ABI-állapotnak kell egyeznie.

14.2.10.3 A diagnosztikai feladat megoldása

Az első hiba az igazítás: a belépési rsp mellett az azonnali call nem teljesíti a normál 16 bájtos hívás előtti feltételt.

A második hiba az rcx használata tartós tárolóként. A második helper hívás ezt felülírhatja.

A korábbi twice_step assemblyváltozat mindkettőt javítja: a push rbx helyreállítja a hívásokhoz szükséges igazítást és megmenti a hívó rbx értékét; az rbx pedig megőrzi a köztes eredményt. A pop rbx visszaállítja a regisztert és a visszatéréshez szükséges verempozíciót.

Ellenőrzési feltétel: minden call előtt helyes igazítás, minden visszatérési úton helyreállított callee-saved állapot szükséges.

14.2.11 Összefoglaló

A C deklarációból a paraméterek típusos osztályozásán keresztül jutunk el a gépi átadáshoz. A System V AMD64 profil külön egész és SSE-regisztersorozatot, összetettérték-osztályozást és előírt veremigazítást használ. A caller-saved értékek megőrzése a hívó, a callee-saved regiszterek helyreállítása a hívott felelőssége. A frame pointer, a red zone és a rejtett eredménypointer ABI-eszköz: egyik sem módosítja a C értékátadási és élettartam-szabályait.