14.6 ABI- és optimalizálásdiagnosztikai labor

Egy assemblyrészlet önmagában nem magyarázat. Ugyanaz a C függvény eltérő gépi kódot kaphat más optimalizálással, fordítóval vagy célarchitektúrával, miközben a nyelvi szerződése változatlan. A labor célja ezért nem egy „helyes assemblyalak” felismerése, hanem ellenőrizhető következtetések készítése: mit ír elő a nyelv, mit az ABI, és mit választott az adott fordító?

A hívási konvencióról szóló rész argumentum- és visszatérési szabályaira, valamint a reprodukálható labor környezetrögzítésére építünk. A volatile és barrier eszközök itt nem megoldások az optimalizálás megfigyelésére: indokolatlan használatukkal éppen a vizsgált program szerződését változtatnánk meg.

A példákhoz nem tartozik tényleges fordítási vagy mérési eredmény. Az assemblyalakok illusztrációk; a jegyzőkönyvbe csak a saját eszközökkel előállított lelet kerüljön. Ellenőrzött forrásjegyzet hiányában a psABI- és fordítódokumentáció pontos kiadásának, szakaszának hozzárendelése is laborfeladat.

14.6.1 A kísérlet szerződése

Az ABI-lelet egy konkrét bináris interfész megfigyelése: például mely regiszterben érkezik egy argumentum. Az optimalizálási lelet egy konkrét fordítás eredménye: például eltűnt-e egy hívás vagy ismételt memóriabetöltés. Egyik sem válik pusztán a megfigyeléstől ISO C-garanciává.

A választott referenciaprofil:

Réteg Laborprofil
Nyelv ISO C17, explicit -std=c17
Elsődleges target Linux, x86-64 System V, LP64
Adatreprezentáció CHAR_BIT == 8, sizeof(long) == 8
Fordítók GCC 14.2.0 és Clang 19.1.7
Eszközök GNU binutils 2.43.1, GDB 15.2
Hosted környezet glibc 2.40
Összehasonlító target Linux AArch64, LP64, AAPCS64 alapkonvenció

Ez kiválasztott, nem ellenőrzötten telepített környezet. Más kiadások is használhatók, de az eltérést rögzíteni kell. Mentsük el a verziókat, a target triple-t, a teljes parancsokat és a forrás Git-azonosítóját. A linker tényleges verziója is számít: a compiler driver nem feltétlenül a feltételezett linkert indítja.

ISO C17-feltételek: a példában minden bemeneti egész érték legyen [−1000,1000][-1000,1000] között. Így az összeadások és szorzások eredménye reprezentálható. A pointerparaméterek inicializált, élő long objektumokra mutassanak; a módosított objektum legyen írható. Az ABI-vizsgálat nem tesz jogszerűvé hibás pointerhozzáférést.

14.6.2 A vizsgált program

A három fájl szándékosan különíti el a fordítási egységen belül látható és kívül definiált függvényeket.

A lab.h tartalma:

#ifndef LAB_H
#define LAB_H

struct pair {
    long first, second;
};

struct triple {
    long first, second, third;
};

long args(long a, long b, long c, long d,
          long e, long f, long g);

struct pair pair_make(long x);
struct triple triple_make(long x);

long reload(long *p, long *q);
long reload_disjoint(long *restrict p, long *restrict q);

long add_one(long x);
long local_use(long x);
long external_use(long x);
long across_call(long x);

#endif

A lab.c tartalma:

#include "lab.h"

long args(long a, long b, long c, long d,
          long e, long f, long g)
{
    return a + b + c + d + e + f + g;
}

struct pair pair_make(long x)
{
    return (struct pair){x, x + 1};
}

struct triple triple_make(long x)
{
    return (struct triple){x, x + 1, x + 2};
}

long reload(long *p, long *q)
{
    long before = *p;
    *q = 7;
    return before + *p;
}

long reload_disjoint(long *restrict p, long *restrict q)
{
    long before = *p;
    *q = 7;
    return before + *p;
}

long add_one(long x)
{
    return x + 1;
}

long local_use(long x)
{
    return 2 * add_one(x);
}

long across_call(long x)
{
    long saved = 3 * x;
    long result = external_use(x);
    return saved + result;
}

A driver.c tartalma:

#include <limits.h>
#include "lab.h"

_Static_assert(CHAR_BIT == 8, "This lab requires 8-bit C bytes");
_Static_assert(sizeof(long) == 8, "This lab requires 64-bit long");

long external_use(long x)
{
    return 2 * add_one(x);
}

int main(void)
{
    long x = 3;
    long y = 9;

    struct pair p = pair_make(10);
    struct triple t = triple_make(10);

    long shared = reload(&x, &x);
    x = 3;
    long separate = reload_disjoint(&x, &y);

    int ok =
        args(1, 2, 3, 4, 5, 6, 7) == 28 &&
        p.first == 10 && p.second == 11 &&
        t.first == 10 && t.second == 11 && t.third == 12 &&
        shared == 10 && separate == 6 &&
        local_use(4) == 10 &&
        external_use(4) == 10 &&
        across_call(4) == 22;

    return ok ? 0 : 1;
}

A pointerek szerződése külön is áttekinthető:

Elem Tulajdonos és élettartam Kölcsönzött hozzáférés Érvénytelenítés, hibakimenet
x, y tárolója A main automatikus objektumai A hívások időtartamára Az objektum élettartamának vége után a dereferencia UB
reload paraméterei Nem vesznek át tulajdont Azonos objektumot is jelölhetnek Hibás pointerre nincs ellenőrzött hibakód
reload_disjoint paraméterei Nem vesznek át tulajdont Ebben a függvényben külön objektumokra kell mutatniuk Az aliasing-előfeltétel megsértése UB
Struktúraeredmények A hívó által kezelt értékek Nem adnak vissza kölcsönzött pointert Nincs dinamikus allokáció vagy felszabadítás

A program elvárt sikerjelzése a nulla kilépési állapot; ez nem futtatással igazolt eredmény.

14.6.2.1 Fordítási mátrix

Az alábbi Bash-parancsok a külön laborban fordítási artefaktumokat készítenek, nem futtatják a programot. Feltételük a fenti x86-64 Linux eszközlánc.

for cc in gcc clang; do
    for opt in 0 2; do
        name="${cc}-O${opt}"
        flags=(-std=c17 "-O${opt}" -g3
               -Wall -Wextra -Wpedantic
               -march=x86-64 -mtune=generic -fno-lto)

        "$cc" "${flags[@]}" -c lab.c -o "${name}-lab.o"
        "$cc" "${flags[@]}" -c driver.c -o "${name}-driver.o"
        "$cc" -fno-lto "${name}-lab.o" "${name}-driver.o" \
            -o "$name"

        "$cc" "${flags[@]}" -S -masm=intel lab.c \
            -o "${name}-lab.s"
    done
done

A linkidejű optimalizálás, azaz LTO kikapcsolása kísérleti kontroll: a lab.c fordításakor az external_use törzse nem látható. A -g3 debuginformációt kér, de nem tiltja az optimalizálást és nem garantálja minden változó megjeleníthetőségét.

14.6.3 Argumentumok és struktúraeredmények

ABI-ellenőrzési lista: a következő várakozásokat a választott x86-64 psABI és AAPCS64 dokumentum megfelelő kiadásával kell alátámasztani. Nem variadikus, közönséges egész argumentumú függvényeket vizsgálunk; vektoros és speciális platformkonvenciókat nem.

Eset x86-64 System V AArch64, AAPCS64
args első hat argumentuma rdi, rsi, rdx, rcx, r8, r9 x0–x5
Hetedik argumentum Veremben x6
long visszatérés rax x0
pair_make eredménye rax, rdx x0, x1
triple_make eredménytárolójának címe Rejtett argumentumként rdi x8
triple_make explicit x argumentuma rsi x0

A két struktúra layoutja a megadott LP64 ABI-profilokban, C bájtban:

Típus Tagoffsetek Méret Igazítás
struct pair 0, 8 16 8
struct triple 0, 8, 16 24 8

Ezek konkrét típusok konkrét ABI szerinti eredményei. A „kis struktúra mindig regiszterben tér vissza” nem általános szabály: a méret mellett az ABI osztályozási szabályai és a tagtípusok is számítanak.

x86-64-en jelölje SS a függvénybelépéskori rsp értékét. A call által elhelyezett visszatérési cím nyolc C bájtot foglal, ezért ennél az aláírásnál a hetedik argumentum címe:

Ag=S+8. A_g = S + 8.

Ez belépéskori összefüggés. Ha a prologue később összesen kk bájttal csökkenti rsp értékét, akkor ugyanaz a cím az aktuális veremmutatóhoz képest:

Ag=rsp+k+8. A_g = rsp + k + 8.

A struktúraeredményhez átadott rejtett pointer tárolót jelöl, nem ownership-átadást. A hívó által biztosított eredménytároló nem szükségképpen azonos egy debuggerben látható forrásváltozó végső helyével.

14.6.4 A bináris eszközök közös használata

A különböző eszközök eltérő kérdésekre válaszolnak:

readelf -h -W gcc-O2-lab.o
readelf -s -W gcc-O2-lab.o
readelf -r -W gcc-O2-lab.o

nm -S --size-sort gcc-O2-lab.o

objdump -drwC -Mintel gcc-O2-lab.o
objdump -d -Mintel gcc-O2

A readelf megmutatja az ELF célarchitektúráját, szimbólumait és relokációit. A nm segítségével feljegyezhető a függvényszimbólumok mérete. Az objdump a gépi utasításokat mutatja; relokálható objektumban a hívás céljának értelmezéséhez a relokációt is olvasni kell.

Az .s fájlban még assemblerdirektívák is szerepelnek, ezért a sorok száma nem utasításszám. A szimbólumméret pedig nem végrehajtási idő.

A debuggerben először pontos belépési pontot vizsgáljunk. A break *args a függvény gépi belépési címére kér töréspontot; a soralapú megállás már a prologue utánra eshet. A külön interaktív munkamenetben használható parancsok:

A debugger memóriavizsgálata nem bizonyítja, hogy ugyanazt a címet a C program szabályosan dereferálhatná.

14.6.4.1 Stack slot, regisztermentés és spill

A register spill egy regiszterben tartott vagy oda szánt érték memóriába helyezése a regiszterallokáció során. Nem minden veremírás spill:

Az across_call esetében a saved értékének a hívás után is rendelkezésre kell állnia. A fordító megőrizheti regiszterben, veremhelyre mentheti, vagy alkalmas alakban újraszámolhatja. A tényleges spill hiánya is érvényes lelet.

Mérjük meg a szimbólumméretet, a látható veremmutató-csökkentéseket, valamint külön számoljuk a regisztermentéseket és lokális memóriaműveleteket. Ez az egyszerű kódvizsgálat nem méri a teljes dinamikus veremhasználatot: x86-64-en például red-zone használata miatt lokális tároló explicit rsp-csökkentés nélkül is megjelenhet.

14.6.5 Inlining és aliasing: korrekt ellenpéldák

14.6.5.1 A hívás eltűnése nem nyelvi ígéret

A local_use számára az add_one törzse látható. A fordító a hívást helyettesítheti a törzzsel, majd egyszerűsítheti:

2⋅add_one(x)=2(x+1)=2x+2. 2 \cdot add\_one(x) = 2(x+1) = 2x+2.

Bizonyításvázlat: a megadott bemeneti tartományban minden közbülső signed eredmény reprezentálható, és nincs mellékhatás; az átalakítás megőrzi a visszatérési értéket.

Az inline kulcsszó hiánya tehát nem tiltja az inliningot. Megfordítva, jelenléte sem garantál gépi beillesztést. Az external_use másik fordítási egységből hívja az add_one függvényt, ezért a jelenlegi, LTO nélküli profil jó kontroll.

A hívás hiánya önmagában még nem bizonyít inliningot: más kódban dead-code elimination vagy konstanshelyettesítés is eltüntethet hívást.

14.6.5.2 Azonos típusú pointerek aliasolhatnak

A reload(&x, &x) szabályos. Ha kezdetben x == 3, akkor:

  1. before értéke 3;
  2. a *q = 7 ugyanazt az objektumot módosítja;
  3. a második *p értéke 7;
  4. az eredmény 3+7=103+7=10.

A következő lehetséges, nem mért x86-64 Intel-szintaxisú részlet ezt kezeli:

# ILLUSZTRACIO: nem konkret compiler-output.
reload:
    mov rax, QWORD PTR [rdi]
    mov QWORD PTR [rsi], 7
    add rax, QWORD PTR [rdi]
    ret

A memóriaolvasásokat ne csak mov utasítások alapján számoljuk: itt az add is olvas memóriát.

A reload_disjoint szerződése kizárja a két hozzáférési út érintett objektumainak átfedését. Emiatt a *q írás nem változtathatja meg a *p értékét, és a második betöltés elhagyható. Külön x == 3 és y == 9 objektumokkal az eredmény 6, az utóállapot x == 3, y == 7.

Azonos pointerekkel meghívni ezt a restrict változatot nem korrekt ellenpélda, hanem szerződéssértés és UB. A fordító optimalizálását csak definiált viselkedésű bemenetekkel szabad cáfolni.

14.6.6 Cross-target vizsgálat és dokumentációs indoklás

A header és a lab.c nem igényel libc-headereket, így megfelelő AArch64 backenddel assembly sysroot nélkül is előállítható:

clang --target=aarch64-linux-gnu -std=c17 -O2 -g \
    -Wall -Wextra -Wpedantic -fno-lto \
    -S lab.c -o lab-aarch64.s

Ez fordítási kísérlet, nem AArch64-futtatás. A teljes program linkeléséhez megfelelő target runtime, libc és linker is szükséges. A futtatás külön natív gépet vagy megfelelő emulációs környezetet igényelne; emulátoros időmérés nem a target processzor natív teljesítménye.

A dokumentációs indoklás három külön ellenőrzésből álljon:

Mindenhez rögzítsük a ténylegesen olvasott dokumentum kiadását és szakaszát. A kutatási kiinduló URL nem bizonyíték.

14.6.7 A jegyzőkönyv és az assembly kommentálása

Függvény Profil Szimbólumméret Releváns memóriaolvasások Veremműveletek Magyarázat
reload GCC, -O0 saját adat saját adat saját adat lelet és indoklás
reload GCC, -O2 saját adat saját adat saját adat lelet és indoklás
across_call Clang, -O2 saját adat saját adat saját adat lelet és indoklás

A statikus utasításszám csak a megvizsgált kódalak jellemzője; elágazó kódnál nem azonos egy futás műveletszámával.

Az assembly mellé háromféle kommentet írjunk:

Gyakori hiba a „mindig” szó: „az optimalizált programnak nincs stack frame”, „a pointerdereferencia mindig egy load”, „a struktúra mindig rejtett pointerrel tér vissza”. Ezek helyett nevezzük meg a típust, targetet és artefaktumot.

14.6.8 Feladatok

  1. Magyarázó: vezessük le a hetedik argumentum helyét x86-64 belépéskor és egy nyolcbájtos push után. Magyarázzuk meg, miért más a helyzete AArch64-en.

  2. Kódolási: egészítsük ki a main ellenőrzését azzal, hogy a külön objektumokon végzett reload_disjoint után x == 3 és y == 7 is teljesüljön. Indokoljuk, miért hibás az azonos pointerekkel végzett „optimalizálási teszt”.

  3. Diagnosztikai: hasonlítsuk össze mindkét fordító -O0 és -O2 artefaktumában a local_use, external_use és across_call függvényeket. Osztályozzuk a veremírásokat, és fogalmazzuk meg, mit nem bizonyít az AArch64 .s fájl előállítása.

14.6.9 Megoldások

14.6.9.1 Argumentumhely

Belépéskor a visszatérési cím az SS címen, a hetedik argumentum S+8S+8 címen van. Egy nyolcbájtos push után rsp=S−8rsp=S-8, tehát az argumentum címe rsp+16rsp+16. AArch64-en a hét egész argumentum még elfér az argumentumregiszterekben. Az ellenőrzés feltétele a pontos függvénybelépési pont és az adott prologue követése.

14.6.9.2 Utóállapot ellenőrzése

A meglévő ok kifejezéshez hozzáadható:

/* C17; reszlet a driver.c ellenorzeseben. */
x == 3 && y == 7

Ez a visszatérési érték mellett a módosítás helyét is ellenőrzi. Az azonos pointeres restrict hívás UB volna; annak tetszőleges eredménye nem optimalizálási hiba bizonyítéka.

14.6.9.3 Diagnosztikai következtetés

Nincs előre garantált utasítássor. A helyes megoldás minden állításához artefaktumot és profilt rendel, külön kezeli a lokális tárolást, callee-saved mentést és spillt, valamint elfogadja ezek hiányát is. Az AArch64 assembly bizonyítja, hogy az adott fordító előállított targetkódot; nem bizonyít sikeres linkelést, futást vagy natív teljesítményt.

14.6.10 Összefoglaló

A megbízható ABI-diagnózis három réteget választ szét: nyelvi szerződést, dokumentált bináris interfészt és konkrét fordítói megvalósítást. A reprodukálható labor kontrollált profilokat, definiált viselkedésű programot és egymást kiegészítő bináris eszközöket használ. A cél nem egy assemblyminta bemagolása, hanem olyan jegyzőkönyv, amelyből minden következtetés feltétele és bizonyítékának korlátja visszakereshető.