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.
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.
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.
Ö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ó:
INTEGER,
float vagy double tag SSE
hozzájárulást ad.INTEGER és SSE
összevonásából INTEGER lesz.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.
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.oA 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.
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
retAz 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.
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.
A példák normál, különleges túlzott igazítást nem igénylő hívásaiban
a call előtt:
ahol
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:
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:
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.
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.
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.
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.
rcx nálam megmaradt.” A
caller-saved érték megmaradása egy konkrét függvényben nem
szerződés.rsp 16-ra igazított.” A
vizsgált normál hívásban az rsp + 8 igazított.rbp, tehát nincs stack frame.”
A keretmutató és a veremhasználat külön kérdés.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?
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.
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
retKeress két egymástól független hibát, és adj javítást.
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.
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.
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.
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.