14.1 C forrásból assembly és gépi kód

Amikor egy natív szolgáltatás profilozása egy rövid C függvényre mutat, a forrás önmagában nem árulja el, milyen utasítások hajtják végre a munkát. Egy változó eltűnhet, egy szorzás címképző utasítássá alakulhat, egy forrássorhoz pedig több, egymástól távoli gépi utasítás tartozhat. Az assembly olvasásának célja ezért nem a C „visszafordítása”, hanem a fordító által választott megvalósítás megértése.

A fordítási fázisoknál megkülönböztettük a fordítást, az assemblálást és a linkelést. Most ezeket megfigyelési pontként használjuk. A paraméterátadás nyelvi szabályait és a méret, igazítás, offset fogalmait ismertnek tekintjük; a hívási konvenció részletes tárgyalása a következő egység feladata.

14.1.1 A nyelvi szerződéstől az utasításkódolásig

Az assembly egy utasításkészlet szöveges jelölése, kiegészítve az assemblernek szóló direktívákkal, címkékkel és szimbólumhivatkozásokkal. A gépi kód az utasítások processzor által értelmezett bináris kódolása. A disassembly ennek visszaalakítása olvasható utasításokká; nem állítja vissza az eredeti C programot.

Négy különböző állítási szintet kell elválasztani:

Szint Mit határoz meg? Példa
ISO C17 A program nyelvi jelentését Az unsigned aritmetika moduláris
Hívási ABI A külön fordított kódrészek együttműködését Mely regiszterben érkezik egy argumentum
Utasításkészlet, ISA A gépi utasítás jelentését és kódolását Mit csinál az x86-64 add
Fordító és kapcsolói Egy konkrét megvalósítás kiválasztását Szorzás helyett használ-e lea utasítást

Az alkalmazási bináris interfész (Application Binary Interface, ABI) nem nyelvi garancia. Az, hogy egy paraméter egy adott regiszterben érkezik, nem következik a C függvénydeklarációjából önmagában.

14.1.1.1 Konkrét megfigyelési profil

Az alábbi példák választott célprofilja:

Tulajdonság Érték
Nyelv ISO C17, -std=c17
Platform Linux, x86-64, System V AMD64 ABI, LP64
Alaptípusok CHAR_BIT == 8, 32 bites unsigned, 64 bites size_t
Fordítók GCC 14.2.0 és Clang 19.1.7
Bináriseszközök GNU Binutils 2.43.1
Rendszerfejlécek glibc 2.40 profil
Kódgenerálás -march=x86-64 -mtune=generic, LTO nélkül
Diagnosztika DWARF 5 debugadatok, -O0 és -O2

Itt egy C bájt, a sizeof egysége, nyolc bites oktett. Más célplatformra ezt nem visszük át automatikusan.

A közölt assemblyrészletek kézzel összeállított, a profilhoz illeszkedő szemléltetések, nem dokumentált fordítási eredmények. A kapcsolók rögzítik a vizsgálat körülményeit, de a fordító tényleges kimenetét a saját laborban kell előállítani. Más fordítókiadás eltérő, mégis helyes utasítássorozatot választhat.

14.1.2 Assembly előállítása és objektumfájl vizsgálata

Az alábbi inspect.c önálló fordítási egység, minimum C17 követelménnyel. Objektumfájllá fordítható; nem teljes végrehajtható program. A report csak deklarált külső függvény, definíciója egy későbbi linkeléshez kellene.

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

_Static_assert(CHAR_BIT == 8, "8-bit C bytes required");
_Static_assert(sizeof(unsigned) == 4 &&
               UINT_MAX == 0xffffffffU,
               "32-bit unsigned required");
_Static_assert(sizeof(size_t) == 8, "64-bit size_t required");

unsigned scale(unsigned x)
{
    unsigned twice = 2U * x;
    unsigned result = twice + 3U;
    return result;
}

unsigned bump(unsigned *items, size_t i, unsigned delta)
{
    unsigned old = items[i];
    unsigned next = old + delta;
    items[i] = next;
    return next;
}

extern void report(unsigned code);

unsigned report_if_nonzero(unsigned code)
{
    if (code == 0U)
        return 0U;

    report(code);
    return 1U;
}

A fordítási parancsok Bash alatt, a táblázat szerinti eszközökkel értendők:

flags=(
  -std=c17 -Wall -Wextra -Wpedantic
  -march=x86-64 -mtune=generic
  -fno-lto -fcf-protection=none
  -g -gdwarf-5
)

for cc in gcc clang; do
  for opt in 0 2; do
    "$cc" "${flags[@]}" "-O${opt}" -S -masm=intel \
      inspect.c -o "${cc}-O${opt}.s"
    "$cc" "${flags[@]}" "-O${opt}" -c \
      inspect.c -o "${cc}-O${opt}.o"
  done
done

objdump -dr -Mintel gcc-O2.o
objdump -dS -Mintel gcc-O2.o
readelf --debug-dump=decodedline gcc-O2.o

Fordítóeszköz: a GCC és Clang -S kapcsolója assemblyszövegnél állítja meg a feldolgozást. A -c objektumfájlt készít, linkelés nélkül. A -masm=intel a szöveges assembly megjelenítési módját választja ki, nem másik utasításkészletet.

Bináriseszköz: az objdump -d disassemblyt készít; a -r a relokációkat is megmutatja. Ez a külső report hívásánál fontos: az objektumfájl még nem tartalmazza a végleges célcímet. Az objdump -S ezzel szemben forrásszöveget próbál keverni a disassembly mellé. Ugyanaz a kapcsolóbetű itt mást jelent, mint a fordítónál.

A fordító assemblykimenetében látható .loc, .cfi_* vagy .section sorok direktívák. Nem mindegyik sor jelent végrehajtandó gépi utasítást.

14.1.3 Regiszterek, operandusok és szintaxis

A regiszter a processzor architekturálisan megnevezett tárolója. x86-64-en a rax 64 bites regiszter, az eax ugyanennek az alsó 32 bites része. ISA-szabály: az eax írása a rax felső 32 bitjét nullázza.

Az operandus egy utasítás bemenete vagy célja. Lehet regiszter, közvetlen konstans vagy memóriaoperandus. A memóriaoperandus nem magát a pointert jelenti, hanem az általa kijelölt tárolóhoz történő hozzáférést.

Az x86 assembly két gyakori szöveges alakja:

Művelet Intel AT&T
Regisztermásolás mov eax, edi movl %edi, %eax
Konstans hozzáadása add eax, 3 addl $3, %eax
32 bites betöltés mov eax, DWORD PTR [rdi] movl (%rdi), %eax
Indexelt betöltés mov eax, DWORD PTR [rdi+rsi*4] movl (%rdi,%rsi,4), %eax

Intel szintaxisban általában a cél áll elöl, AT&T alakban a forrás. AT&T-ben a % regisztert, a $ közvetlen értéket jelöl; az utasításnév l utótagja itt 32 bites operandusméretre utal, nem a C long típusára.

A további szemléltetések Intel szintaxisú assemblyrészletek, nem önálló assemblyprogramok.

14.1.3.1 Az utasításnak bájtjai is vannak

Az utasításkódolás (instruction encoding) az a szabályrendszer, amely az utasítást és operandusait bináris mezőkre képezi. Például:

Utasítás Egy érvényes x86-64 kódolás, hexadecimális oktettekkel
add eax, edx 01 d0
ret c3

A 01 műveleti kód és a d0 operandusválasztó mező együtt adja meg az összeadást. A processzor nem az add karaktereit hajtja végre.

Az x86-64 utasításhossz változó: nem minden utasítás ugyanannyi oktett. Emiatt a disassemblernek helyes utasításhatárról kell indulnia. A rövidebb kód ugyanakkor nem szükségképpen gyorsabb: a függőségek, memóriahozzáférések és a konkrét mikroarchitektúra is számítanak.

14.1.4 Skaláris művelet, indexelt címzés és elágazás

14.1.4.1 Számolás memóriaelérés nélkül

A scale egy lehetséges optimalizált megvalósítása:

scale:
    lea eax, [rdi+rdi+3]    # az eredmény alsó 32 bitje: 2*x + 3
    ret

ABI-feltétel: ebben a profilban az első unsigned argumentum az edi regiszterben, a visszatérési érték az eax regiszterben jelenik meg.

A lea, vagyis load effective address, itt megtévesztő nevű: kiszámítja a címzési kifejezést, de nem olvas memóriát. A szögletes zárójel önmagában tehát nem bizonyít memóriahozzáférést; az utasítás jelentését is ismerni kell.

ISO C17: az előjel nélküli műveletek az adott típus értéktartományához igazodó moduláris aritmetikát követik. A választott profilban:

twice=(2x)mod232, \text{twice}=(2x)\bmod 2^{32},

result=(twice+3)mod232=(2x+3)mod232. \text{result}=(\text{twice}+3)\bmod 2^{32} =(2x+3)\bmod 2^{32}.

Bizonyításvázlat. Ha két egész szám különbsége 2322^{32} többszöröse, ugyanannak a konstansnak a hozzáadása után is az marad. Ezért a közbülső redukció elhagyható, ha a végén az alsó 32 bitet tartjuk meg. Ez az érvelés az előjel nélküli műveletre épül; signed túlcsordulásra nem alkalmazható.

14.1.4.2 Betöltés és visszaírás

A bump lehetséges megvalósítása:

bump:
    mov eax, DWORD PTR [rdi+rsi*4]  # load: old = items[i]
    add eax, edx                  # next = old + delta
    mov DWORD PTR [rdi+rsi*4], eax # store: items[i] = next
    ret

Itt az ABI szerint rdi hordozza az items, rsi az i, edx a delta értékét. A memóriaoperandus effektív címe:

A=B+i⋅s, A=B+i\cdot s,

ahol BB a tömb kezdőcíme, ii az index, s=sizeof(𝚞𝚗𝚜𝚒𝚐𝚗𝚎𝚍)=4s=\operatorname{sizeof}(\texttt{unsigned})=4 C bájt.

Például i == 3 esetén az eltolás 3⋅4=123\cdot4=12 C bájt. A hardveres címképzés azonban nem végez C tömbhatár-ellenőrzést.

A függvény teljes hozzáférési szerződése:

Szempont Követelmény
Tulajdonos A hívó kezeli a tömb tárolóját és élettartamát
Kölcsönzött hozzáférés Az items nem tulajdonló pointer, a függvény nem őrzi meg
Tartomány i < n, ahol n az elérhető tömbelemek száma
Tároló Az elem élő, inicializált, írható és megfelelően igazított
Érvénytelenítés Felszabadítás, sikeres átfoglalás vagy élettartamvég nem történhet a hozzáférés közben
Konkurencia Nem lehet szinkronizálatlan, ütköző hozzáférés
Hibakimenet Nincs hibajelzés; a szerződés megsértése UB-hez vezethet

Érvényes hozzáférés mellett az előjel nélküli összeg körbefordulása nem hiba. Ezzel szemben egy hozzáférhetőnek látszó, de már felszabadított cím használatát az assembly nem teszi helyessé.

14.1.4.3 Feltételes ugrás és függvényhívás

A report_if_nonzero lehetséges alakja:

report_if_nonzero:
    test edi, edi       # állapotjelzők beállítása code alapján
    je .Lzero           # ugrás, ha code == 0
    sub rsp, 8          # hívás előtti veremigazítás ebben az alakban
    call report         # objektumfájlban relokálható hivatkozás
    mov eax, 1
    add rsp, 8
    ret
.Lzero:
    xor eax, eax        # nulla visszatérési érték
    ret

A test nem módosítja az edi értékét, de beállítja többek között a nullaállapot-jelzőt. A je ezt vizsgálja. Ez a feltételes elágazás (conditional branch) egyik alapmintája.

A call vezérlést ad át és visszatérési információt hoz létre; a ret visszatér. A veremigazítás ABI-követelmény, részleteit a hívási konvencióról szóló egység bontja ki. A report normál visszatérése esetén a függvény eredménye egy; ha a külső függvény nem tér vissza, ez a visszatérési út sem valósul meg.

Egy C if nem feltétlenül eredményez ugrást: a fordító választhat feltételes adatmozgatást, aritmetikai megoldást vagy teljes egyszerűsítést is.

14.1.5 -O0, -O2 és a megszűnő változók

A scale optimalizálatlan alakjában megjelenhetnek külön tárolóhelyek a paraméterhez és a lokális változókhoz. Az alábbi részletből a veremkeret létrehozását és lebontását elhagytuk:

    mov DWORD PTR [rbp-20], edi
    mov eax, DWORD PTR [rbp-20]
    add eax, eax
    mov DWORD PTR [rbp-4], eax    # twice tárolása
    mov eax, DWORD PTR [rbp-4]
    add eax, 3
    mov DWORD PTR [rbp-8], eax    # result tárolása
    mov eax, DWORD PTR [rbp-8]

Ezek az offsetek szemléltető fordítói választások a megadott x86-64 profilban, nem a C lokális változóinak előírt elrendezései.

-O2 mellett a teljes számítás elférhet az előző lea utasításban. A twice és result nyelvi változókhoz ekkor nem tartozik külön gépi tárolóhely.

Ez nem azt jelenti, hogy a fordító „megsértette” a forrást. A változók önálló tárolása itt nem szükséges a program megfigyelhető viselkedésének előállításához. A részletes optimalizálási indoklást az as-if szabály tárgyalása adja.

Gyakori tévedés a -O0 kimenetet közvetlen nyelvi modellnek tekinteni. Az is fordítói megvalósítás, és nem garantál sem egy utasítást forrássoronként, sem minden változóhoz külön stackhelyet. A volatile hozzáadása pusztán a változó „láthatóvá tételéért” szintén félrevezető: megváltoztatja a program szerződését.

14.1.6 Forrássorok, DWARF és optimalizált hibakeresés

A DWARF debugadat-formátum többek között típusokat, változóleírásokat és gépikód-címekhez kapcsolt forráshelyeket tárolhat. A -g nem kapcsolja ki az optimalizálást; a -O2 -g értelmes kombináció.

A forrás és a gépi kód kapcsolata azonban nem egy-egy megfeleltetés:

Az objdump -dS ezért tájékozódási segédlet, nem szemantikai bizonyíték. A readelf --debug-dump=decodedline a sortábla cím–forráshely kapcsolatait teszi vizsgálhatóvá. A forrásszöveg megjelenítéséhez a megfelelő fájlnak is elérhetőnek kell lennie.

Egy utólag módosított forrás mellé tett régi objektumfájl megtévesztő diagnózist adhat. Incidensvizsgálatnál ezért a binárist, debugadatokat, pontos forrásrevíziót és fordítási profilt együtt kell megőrizni.

14.1.7 Feladatok

14.1.7.1 Magyarázó feladat

Mit csinál a mov eax, DWORD PTR [rdi+rsi*4]? Melyik információ C-szintű, ABI-szintű, illetve utasításkészlet-szintű? Mitől lenne más a lea eax, [rdi+rsi*4]?

14.1.7.2 Kódolási feladat

Bővítsd az inspect.c fájlt egy unsigned affine(unsigned x) függvénnyel, amely 5U * x + 7U értékét adja vissza. Fordítsd mindkét fordítóval -O0 és -O2 mellett. Keress szorzást, összeadást vagy címképző utasítást.

14.1.7.3 Diagnosztikai feladat

Egy kolléga szerint az optimalizált bump biztonságosan kezeli az i == n esetet, mert a disassemblyben nem látható hibára ugrás, és a debugger olvasható memóriát mutat a számított címen. Cáfold az állítást. Magyarázd meg azt is, miért nem bizonyít hibát, ha az old változó optimized out.

14.1.8 Megoldások

14.1.8.1 A magyarázó feladat megoldása

A mov négy oktettet olvas az rdi + 4*rsi címen, és az értéket az eax regiszterbe helyezi. A címképzés és az utasítás jelentése ISA-szabály. Az, hogy rdi a tömbpointer és rsi az index, itt ABI-következmény. A hozzáférés megengedettségét a C objektumhatár-, igazítás- és élettartamszabályai döntik el.

A lea csak kiszámítaná a címkifejezés alsó 32 bitjét, memóriaolvasás nélkül.

Ellenőrzési feltétel: külön tudd választani a cím kiszámíthatóságát a címzett objektumhoz való hozzáférés jogosságától.

14.1.8.2 A kódolási feladat megoldása

Az előző fájlhoz illeszthető C17-részlet:

unsigned affine(unsigned x)
{
    return 5U * x + 7U;
}

A korábbi buildparancsok változatlanul használhatók. Egy lehetséges optimalizált alak:

affine:
    lea eax, [rdi+rdi*4+7]
    ret

A címkifejezés x+4x+7=5x+7x+4x+7=5x+7. A célregiszterben megtartott alsó 32 bit megfelel a profil szerinti előjel nélküli eredménynek.

Ellenőrzési feltétel: nem pontosan ezt az utasítássort kell kapnod. Rögzítsd a tényleges kimenetet, és vezesd le, hogyan állítja elő ugyanazt a moduláris értéket.

14.1.8.3 A diagnosztikai feladat megoldása

Az i == n index a tömb utáni pozíciót jelöli. Az oda mutató one-past pointer bizonyos műveletekhez képezhető, de nem dereferálható. A bump olvasása és írása ezért megsérti a szerződést; az esethez nincs garantált kimenet.

A hardveresen olvasható lap nem bizonyít élő, megfelelő típusú C objektumot. A hiányzó ellenőrzés pedig nem bizonyít biztonságot: a függvény a hívóra bízza a tartomány helyességét.

Az old tárolása szükségtelenné válhatott, illetve a debugadat nem feltétlenül teszi rekonstruálhatóvá.

Ellenőrzési feltétel: a javításnak a memóriaelérés előtt kell biztosítania az i < n feltételt, vagy dokumentált hívói invariánssal kell garantálnia azt.

14.1.9 Összefoglaló

Az assembly egy konkrét fordítás megvalósítását mutatja, nem a C nyelv univerzális működését. A -S fordítói nézetet, az objdump bináris nézetet ad; a DWARF ezekhez kapcsol forrásinformációt. Load, store, branch és call felismerésével már értelmezhető számos kis függvény, de a helyességet továbbra is a nyelvi szerződésből kell levezetni. Az optimalizálás megszüntethet változókat és összevonhat műveleteket, miközben a program előírt viselkedése változatlan marad.