2.1 Miért assembly: amit a C elrejt

2.1.1 Mérnöki kérdés: mit hajt végre valójában a processzor?

Egy C-függvény helyes eredményt ad, mégis túl lassú. Egy külső könyvtár meghívása után látszólag sértetlen változók változnak meg. Egy mikrokontroller perifériája nem reagál a programra. Egy hibás memóriahozzáférés pedig nem ott okoz összeomlást, ahol a forráskód alapján várnád.

Ezekben a helyzetekben ugyanaz a kérdés merül fel: milyen gépi műveletek valósítják meg a C-programot, és milyen szerződésekre támaszkodnak?

Az assembly ismerete nem azt jelenti, hogy ezentúl minden függvényt kézzel kell megírnod. Gyakrabban fogsz fordító által előállított kódot olvasni, mint saját assemblyt készíteni. A cél annak felismerése, hogy egy teljesítményprobléma, hibás függvényhívás vagy biztonsági hiba melyik rétegben keletkezik.

Ehhez az alfejezethez nincs szükség korábbi assemblyismeretre. A laborban egy rövid C-programból indulunk, és csak annyi gépi részletet vezetünk be, amennyi a megfigyelés értelmezéséhez szükséges.

2.1.2 Az assembly helye az absztrakciós rétegek között

Az assembly nyelv az adott utasításkészlet gépi utasításainak szimbolikus leírására szolgáló nyelv. Az utasításkészlet-architektúra (instruction set architecture, ISA) meghatározza többek között az elérhető utasításokat, a programból látható regisztereket és az utasítások előírt hatását. A regiszter itt a processzor utasításaiban közvetlenül megnevezhető adattároló hely.

Az assemblyforrás nem kizárólag utasításokat tartalmazhat: vannak benne szimbolikus nevek és a fordítóeszköznek szóló direktívák is. Ezért nem minden assemblyforrás-sor jelent egy végrehajtandó gépi utasítást.

A C-program útja leegyszerűsítve:

Réteg Feladata Mit vizsgálunk benne?
C-forrás A számítás leírása a C szabályaival Típusok, algoritmus, megengedett műveletek
C-fordító Gépi megvalósítás kiválasztása, optimalizálás Milyen műveleteket hagyott el vagy alakított át?
Assemblyforrás A célgépi műveletek szimbolikus alakja Milyen utasítások és regiszterek szerepelnek?
Assembler Tárgyfájl előállítása Utasításkódok, szimbólumok, később feloldandó hivatkozások
Linker Tárgyfájlok és könyvtárak összekapcsolása Hol találhatók a hívott függvények és adatok?
Betöltés és végrehajtás A program futtatása a célkörnyezetben Milyen állapotváltozások történnek?

A gyakorlatban a köztes assemblyszöveget nem feltétlenül mentjük külön fájlba. A rétegek ettől még hasznos gondolkodási modellt alkotnak. A részletes munkafolyamatot a C fordítási láncolat és hova kerül az assembly bontja ki.

Az ISA azt írja elő, mit jelentenek az utasítások. A mikroarchitektúra (microarchitecture) az adott processzor belső megvalósítása: hogyan szervezi meg például az utasítások és memóriaelérések végrehajtását. Azonos ISA-t támogató processzorokon ugyanaz a program eltérő sebességgel futhat.

2.1.3 Négy helyzet, ahol a C-forrás önmagában kevés

Teljesítmény. Egy rövid C-kifejezésből több utasítás keletkezhet, egy hosszabb ciklust pedig a fordító jelentősen átalakíthat. A gépi kód megmutatja, történt-e vektorizálás, maradt-e ismételt memóriaolvasás, vagy keletkezett-e költségesnek bizonyuló elágazás. Ebből azonban még nem következik futási idő: a kevesebb utasítás nem automatikusan gyorsabb. A kódelemzés hipotézist ad, a mérés ellenőrzi.

Interoperabilitás. Különböző nyelveken vagy külön fordított modulokban megírt függvényeknek meg kell egyezniük az argumentumok és eredmények átadásában. Ezt részben a bináris alkalmazásinterfész (application binary interface, ABI) szabályozza. Egy hibás assemblyfüggvény helyes számítást végezhet, mégis tönkreteheti a hívó állapotát, ha felülír egy megőrzendő regisztert. AArch64-en ezt az AAPCS64 regiszterszerződés alapján fogjuk ellenőrizni.

Hardverelérés. A C nyelv önmagában nem határoz meg mikrokontroller-perifériákat, megszakításvezérlőt vagy processzorspecifikus vezérlőutasításokat. Sok hardverkezelés megírható C-ben, de a helyes működéshez ismerni kell az alatta lévő architektúrát és az eszköz dokumentációját. A volatile sem általános megoldás minden sorrendezési vagy párhuzamossági problémára.

Biztonsági hibák elemzése. Egy érvénytelen memóriaírás forráskódbeli helye és a későbbi összeomlás helye eltérhet. A regiszterek, a memória és a gépi utasítások vizsgálata segít feltárni, mi sérült meg. A könyv ezt védelmi célból használja: hibakeresésre, ellenőrzésre és a védelmi mechanizmusok megértésére, nem támadási receptek készítésére.

Ezekhez nem mindig szükséges kézzel assemblyt írni. Az ISA-szintű olvasási és következtetési képesség viszont sokszor nélkülözhetetlen.

2.1.4 A C szemantikája nem azonos a gép számtanával

A C nem definiált viselkedése (undefined behavior, UB) olyan eset, amelyre a nyelvi szabvány nem ír elő követelményt. Ez nem azt jelenti, hogy a program kötelezően hibajelzést ad, vagy hogy a processzor „véletlenszerűen” működik.

Fontos példa az egész aritmetika:

Nézzük az x + 1 > x kifejezést, ahol x típusa int. Minden olyan bemenetre igaz, amelynél az összeadás definiált. Az INT_MAX bemenetnél viszont már maga az összeadás sérti ezt a feltételt. A fordító ezért a teljes kifejezést állandó igaz értékre egyszerűsítheti.

Nem helyes így érvelni: „A processzor összeadója körbefordul, tehát a C-kifejezés eredménye is ebből következik.” Lehetséges, hogy a fordító egyáltalán nem állít elő összeadó utasítást.

Az assembly az adott bináris megvalósítást teszi láthatóvá. Nem ad visszamenőleg definiált C-jelentést egy UB-t tartalmazó programnak. Más optimalizálás, fordító vagy környezet más kódot eredményezhet.

2.1.5 Kidolgozott példa: egy összeadás megmarad, egy másik eltűnhet

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

A fejezet végigvitt projektje. Az itt elkészülő lab-hidden program a Bevezetés: hardver, ISA és a gép nyelve fejezet közös, végigvitt projektje. A Számítógép-architektúra fogalmai, Az ARM család áttekintése és a C fordítási láncolat alfejezet végén egy-egy rövid „Végigvitt projekt” lépés erre a programra alkalmazza az ott tanultakat: utasításkódolás, célplatform-azonosítás, a fordítási lánc lépései. Az alfejezetek saját, kisebb buildjei célzott kiegészítések; első olvasáskor kihagyhatók, a projekt lépései nem. A lab-hidden fájljai ebben a könyvtárban maradnak, a későbbi lépések átmásolják őket.

Célprofil: A64 ISA, AArch64 Linux, ARMv8-A alapprofil, little-endian; futtatás qemu-aarch64 user-mode emulációban.

A gazdagép az Ubuntu 24.04 LTS x86_64 alapkörnyezet. A parancsokhoz a gcc-aarch64-linux-gnu, binutils-aarch64-linux-gnu és qemu-user csomagok szükségesek. Telepítésüket és ellenőrzésüket a Labor-alapkörnyezet: eszközlánc telepítése és verifikáció részletezi.

Mentsd a következő teljes programot lab-hidden.c néven:

#include <inttypes.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>

uint32_t lab_next_u(uint32_t x)
{
    return x + UINT32_C(1);
}

int lab_rises_s(int x)
{
    return x + 1 > x;
}

int main(void)
{
    uint32_t u = lab_next_u(UINT32_MAX);
    int s = lab_rises_s(INT_MAX - 1);

    printf("unsigned_next=%" PRIu32 "\n", u);
    printf("signed_rises=%d\n", s);

    return (u == UINT32_C(0) && s == 1) ? 0 : 1;
}

A teszt szándékosan nem hívja meg a lab_rises_s függvényt INT_MAX argumentummal. A ténylegesen vizsgált előjeles összeadás eredménye INT_MAX, tehát ábrázolható.

2.1.5.1 Fordítás, összeállítás, linkelés és futtatás

Az alábbi parancsok a gazdagépen futnak; az elkészült program célja végig AArch64, qemu-user.

# C -> A64 assemblyforrás.
aarch64-linux-gnu-gcc \
    -std=c17 -O2 -fno-inline -Wall -Wextra \
    -march=armv8-a -mlittle-endian \
    -S lab-hidden.c -o lab-hidden.s

# A64 assemblyforrás -> AArch64 tárgyfájl.
aarch64-linux-gnu-as \
    -march=armv8-a -EL \
    lab-hidden.s -o lab-hidden.o

# Tárgyfájl -> statikusan linkelt AArch64 Linux-program.
aarch64-linux-gnu-gcc \
    -march=armv8-a -mlittle-endian -static \
    lab-hidden.o -o lab-hidden

# Futtatás: AArch64 qemu-user cél.
qemu-aarch64 ./lab-hidden
printf 'exit=%s\n' "$?"

A -S assemblyforrásnál állítja meg a C-fordítást. A -fno-inline megakadályozza a függvények szokásos beágyazását (inlining), így könnyebb külön megvizsgálni őket; az összes többi optimalizálást nem tiltja le. A statikus linkelés ebben a laborban elkerüli a külön célrendszeri dinamikus betöltő beállítását.

Minden buildlépés várt kilépési állapota 0. Ha valamelyik hibával leáll, ne futtasd tovább a lépéssort egy esetleg korábbról megmaradt binárissal.

Levezetett, nem ténylegesen futtatott eredmény:

Ellenőrzés Várt érték
unsigned_next kimeneti mező 0
signed_rises kimeneti mező 1
exit kimeneti mező 0

Az előjel nélküli eredmény levezetése:

(4294967295+1)mod232=0. (4294967295 + 1) \bmod 2^{32} = 0.

Itt minden mennyiség dimenzió nélküli egész érték; a feltétel a labor 32 bites előjel nélküli számítása. A visszaadott 0 tehát nem hiba, hanem a C által előírt eredmény.

2.1.5.2 Mit keress a gépi kódban?

A visszafejtés (disassembly) a gépi utasításkódokat olvasható assemblyalakban jeleníti meg. A következő parancsok a gazdagépen vizsgálják a qemu-user számára készített AArch64 binárist; nem futtatják azt:

aarch64-linux-gnu-objdump \
    -d --disassemble=lab_next_u lab-hidden

aarch64-linux-gnu-objdump \
    -d --disassemble=lab_rises_s lab-hidden

A lab_next_u esetében várható, leegyszerűsített utasításminta:

// A64 ISA (AArch64, I. rész).
// Illusztratív függvénytörzs, nem különálló teljes program.
lab_next_u:
    add W0, W0, #1
    ret

Az A64 W0 egy 32 bites regiszternézet. Ennél az AAPCS64 szerinti függvényinterfésznél ezen érkezik az argumentum, és ezen távozik az eredmény. Az A64 ADD itt hozzáad 1-et, és a 32 bites eredményt tartja meg. Az utasítás ezen alakjának közvetlen konstansa (immediate) 12 bites előjel nélküli érték lehet, opcionálisan 12 bittel balra tolva; az 1 eltolás nélkül kódolható.

Az A64 RET visszatérést végez, alapértelmezésben az A64 X30 64 bites regiszterben tárolt címre. A hívás részleteit később tanuljuk meg.

A lab_rises_s függvénynél optimalizált fordításban várhatóan az állandó 1 előállítását és visszatérést látod, nem az eredeti összeadást és összehasonlítást. Ez megengedett optimalizálás, nem kötelező utasítássorozat. A konkrét alakot a laborban kell ellenőrizni.

2.1.6 Tipikus hibák és ellenpróbák

A vizsgálat menete legyen mindig: tünet → hipotézis → adatgyűjtés → egy változtatás → új ellenőrzés.

Tünet Lehetséges ok Ellenőrzés vagy javítás
Közvetlen indításkor végrehajtási formátumhiba jelenik meg Az x86_64 gazdagép AArch64 programot kapott Használd a qemu-aarch64 ./lab-hidden parancsot
A program nem a várt eredményt adja a forrás módosítása után Régi assemblyből vagy tárgyfájlból készült bináris fut Ismételd meg mindhárom buildlépést; vizsgáld meg a binárist objdump segítségével
Nem találod az előjeles összeadást A fordító eltüntette Vizsgáld meg a lab_rises_s teljes függvénytörzsét
Hiányzó célrendszeri betöltőről kapsz hibát Nem a bemutatott statikus program készült el Ellenőrizd a linkelési parancsot és a -static kapcsolót

A hiányzó utasítás is adat. Ne módosítsd rögtön a programot azért, hogy a fordító a várt mintát adja: előbb ellenőrizd, jogos volt-e az elvárásod.

Ez a labor funkcionális ellenőrzés, nem sebességmérés. A QEMU user-mode futási idejéből nem következtetünk fizikai ARM-processzor utasításkésleltetésére vagy ciklusszámára.

2.1.7 Hogyan vezet az út AArch64-től Cortex-M-ig?

A könyv két külön végrehajtási környezetet használ.

Szempont I. rész: AArch64 II. rész: Cortex-M
ISA és profil A64, ARMv8-A alapprofil Thumb-2, Armv7-M, Cortex-M3
Környezet Linux-alkalmazás Operációs rendszer nélküli firmware
Elsődleges emuláció qemu-aarch64 qemu-system-arm -machine mps2-an385
Központi kérdés Hogyan működik együtt a C, az ABI és a gépi kód? Hogyan indul el és kezel hardvert a firmware?

Az első részben az operációs rendszer biztosítja a futási környezet jelentős részét. A másodikban az indulást, a memória inicializálását és a megszakítások kezelését is nekünk kell megszerveznünk.

Átvihető tudás a bitszintű gondolkodás, a gépi kód olvasása és a szerződések ellenőrzése. Nem vihetők át változtatás nélkül az utasítások, regiszternevek és indítási feltételezések.

A könyv nem teljes operációsrendszer-kernel megírását, nem valós idejű operációs rendszer (real-time operating system, RTOS) ütemezőjének megvalósítását és nem gyártóspecifikus fejlesztőkörnyezet használatát tanítja. A hangsúly a reprodukálható, architektúraközeli munkafolyamaton van.

2.1.8 A tananyag tanulási szerződése

Minden alfejezet konkrét mérnöki feladatból indul, majd meghatározza a szükséges fogalmakat és feltételeket. A magyarázatot fordítható vagy összeállítható példa, ellenőrzési feltétel, hibakeresési ellenpróba és fokozatos feladatsor követi. A megoldások indoklást is adnak; a rövid összefoglaló a később továbbhasznált fogalmakat emeli ki.

Háromféle állítást különítünk el:

A most közölt kimenet levezetett elvárás. Tényleges laboreredménnyé akkor válik, amikor te futtatod és rögzíted.

2.1.9 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért adhat a lab_next_u(UINT32_MAX) definiáltan 0-t, miközben a lab_rises_s(INT_MAX) eredményét nem határozhatjuk meg ugyanezzel az érveléssel? Miért nem cáfolja az optimalizálás helyességét az eltűnt összeadás?

  2. (implementációs, alapszint — cél: rutinépítés) Bővítsd a lab-hidden.c fájlt egy uint32_t lab_double_u(uint32_t x) függvénnyel, amely összeadással kétszerezi az argumentumot. Ellenőrizd a 0, 7 és UINT32_MAX bemeneteket. A main akkor térjen vissza 0-val, ha minden ellenőrzés sikeres.

  3. (hibakeresési, középszint — cél: rutinépítés) Egy módosított változatban a lab_next_u már 2-t ad hozzá, de a futtatás továbbra is 0-t ír ki az előjel nélküli eredményre. A készítő csak a linkelési parancsot ismételte meg. Adj hipotézist, válassz adatgyűjtő eszközt, és írd le a javítást. Mi lesz az új helyes eredmény?

2.1.10 Megoldások

1. megoldás — Segítség. Gondold végig külön az előjel nélküli és az előjeles túlcsordulás C-beli definiáltságát — az egyik moduláris aritmetika, a másik nem azonosítható egyetlen konkrét eredménnyel.

1. megoldás — Teljes megoldás. A 32 bites előjel nélküli számítás modulo 2322^{32} történik. Az előjeles INT_MAX + 1 viszont C-ben nem definiált. A fordító az összes definiált bemenetre helyesen helyettesítheti az x + 1 > x kifejezést 1-gyel. Az UB-t okozó bemenet nem követel meg ettől eltérő eredményt.

2. megoldás — Segítség. A függvény teste egyetlen sor; a nehezebb rész a UINT32_MAX eset modulo 2322^{32} levezetése — ne tizedestört közelítéssel, hanem a 232−22^{32}-2 alakkal számolj.

2. megoldás — Teljes megoldás. A meglévő fájlba illeszthető függvény:

uint32_t lab_double_u(uint32_t x)
{
    return x + x;
}

A várt eredmények rendre 0, 14 és 4294967294. Az utolsó érték levezetése:

2(232−1)mod232=232−2. 2(2^{32}-1) \bmod 2^{32} = 2^{32}-2.

A labor AArch64-profiljában az összeadás 32 bites előjel nélküli aritmetika. Bővítsd a main ellenőrzését ezekkel az esetekkel, majd ismételd meg a teljes C → assembly → tárgyfájl → program lépéssort. A várt kilépési állapot 0.

3. megoldás — Segítség. A tünet (“a forrás megváltozott, az eredmény nem”) klasszikus jele annak, hogy a build valamelyik köztes lépése elmaradt. Nézd meg objdump-pal, hogy a ténylegesen linkelt .o tartalmazza-e az új forrás utasításait.

3. megoldás — Teljes megoldás. Valószínűleg a régi lab-hidden.o került ismét a programba. Az objdump segítségével vizsgáld meg a lab_next_u függvényt a végleges binárisban: az eredeti kódminta esetén még 1 hozzáadása látszik. Generáld újra az assemblyforrást, állítsd össze, majd linkeld. Az új eredmény (4294967295+2)mod232=1(4294967295+2)\bmod 2^{32}=1. A main elvárását is módosítani kell 1-re; különben helyesen hibát jelez a már elavult teszt.

2.1.11 Rövid összefoglaló

Az assemblytudás elsősorban betekintést ad abba, hogyan valósul meg a C-program az adott gépen. Segít teljesítményproblémák elemzésében, bináris interfészek ellenőrzésében, hardverkezelésben és biztonsági hibák feltárásában. A fordító nem köteles megőrizni a C-forrás műveleteinek látható szerkezetét: a definiált jelentést kell megtartania. A konkrét bináris megfigyelése nem teszi definiálttá a hibás C-programot, és az utasítások száma önmagában nem teljesítménymérés. A könyv ezért külön kezeli az előírást, a megvalósítást és a tényleges megfigyelést. A laborban mindig a teljes buildláncot és az ellenőrzési feltételeket követjük.

2.1.12 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 fennmaradnak: a program nem lett itt lefuttatva; a konkrét GCC által előállított utasítássorozat és a helyben telepített csomagverziók (Ubuntu 24.04 LTS csomagolt gcc, binutils, qemu-user) a hallgatói laborban ellenőrzendők, mert ebben a munkafolyamatban nem lettek lekérdezve.