2.5 C fordítási láncolat és hova kerül az assembly

2.5.1 Miért kell látnunk a fordítási láncot?

Egy C-program helyesen lefordul, mégsem linkelhető; egy másikból elkészül a futtatható fájl, de az x86_64 fejlesztői gép nem tudja közvetlenül elindítani. Ugyanannak a függvénynek pedig más assembly megfelelőjét kapjuk optimalizálással, mint nélküle. Ezek különböző rétegek problémái, ezért különböző eszközökkel vizsgálandók.

A fordítási eszközlánc (compilation toolchain) a forrásprogramot több lépésben alakítja végrehajtható gépi programmá. Az assembly ennek nem a kiindulópontja és nem is a végállapota: a fordító egyik lehetséges szöveges kimenete, amelyből az assembler gépi kódot készít.

Az ISA és mikroarchitektúra különbségére, valamint az ARM-profilok áttekintésére építünk. A cél most a fájlok és eszközök szerepének felismerése, nem az assembly utasításkészletének megtanulása.

Profil: minden alábbi program AArch64 Linuxra készül, A64 ISA, ARMv8-A alapprofil és little-endian bájtsorrend mellett. A host Ubuntu 24.04 x86_64, a futtatási cél qemu-aarch64, vagyis QEMU user-mode. A GNU assemblerdirektívák és az említett regiszterek ebben az AArch64-környezetben értendők; Cortex-M-kód itt nem szerepel.

2.5.2 A forrástól a futó folyamatig

A lánc fő állomásai:

Lépés Bemenet Kimenet Fő feladat
Előfeldolgozó, preprocesszor (preprocessor) .c, bevont fejlécek előfeldolgozott C, jellemzően .i Makróhelyettesítés, fájlbevonás, feltételes fordítás
Fordító (compiler) előfeldolgozott C assembly szöveg, .s Elemzés, optimalizálás, célarchitektúrára történő kódgenerálás
Assembler assembly szöveg objektumfájl, .o Utasítások kódolása, szimbólumok és relokációk létrehozása
Linker objektumfájlok, könyvtárak futtatható fájl vagy megosztott könyvtár Szimbólumfeloldás, szekciók elrendezése, hivatkozások összekapcsolása
Betöltő (loader) futtatható fájl, szükséges megosztott objektumok futó folyamat memóriaképe Programrészek leképezése, indulási környezet kialakítása

A preprocesszor például a #define LAB_BIAS 22 után a LAB_BIAS makróelőfordulásokat helyettesíti. Nem végez regiszterkiosztást, és nem készít gépi utasításokat. Egy .c fájl az előfeldolgozás után, a bevont tartalmakkal együtt egy fordítási egységet (translation unit) alkot.

A fordító a C szemantikájából indul ki. Belső köztes reprezentációkat használhat, egyszerűsítheti a kifejezéseket, és kiválasztja a célgépi műveleteket. Nincs általános „egy C-sor = egy assembly utasítás” megfeleltetés.

Az assembler már nem C-kifejezéseket értelmez. A64 környezetben például egy összeadó utasítás szöveges alakjából annak gépi kódolását készíti el. A még nem ismert címekhez viszont később feldolgozandó információt hagyhat.

A linker összekapcsolja a külön fordított részeket. Ha az egyik objektum a lab_add22 függvényre hivatkozik, a másik pedig definiálja azt, a linker összerendeli őket.

A betöltés már futásidejű feladat. Natív Linuxon a kernel az ELF programfejlécei alapján alakítja ki a folyamat képét; dinamikusan linkelt programnál egy felhasználói térben futó dinamikus betöltő is közreműködik. A QEMU user-mode a célprogram számára szükséges futtatási környezetet biztosítja, és emulálja az A64 utasítások végrehajtását. Nem teljes AArch64 gépet és vendégkernelt indít.

2.5.3 Mit jelentenek a GCC kapcsolói?

A gcc parancs a szokásos használatban vezérlőprogram (compiler driver): megszervezi a szükséges fordítási, assemblálási és linkelési lépéseket.

Kapcsoló Hol áll meg? Tipikus kimenet
-E Előfeldolgozás után .i, vagy -o nélkül a szabványos kimenet
-S Assembly előállítása után .s
-c Objektumfájl előállítása után .o
Egyik sincs megadva A linkelést is elvégzi Futtatható fájl
-o név Nem választ feldolgozási lépést A kimenet nevét adja meg

A -o nem azt jelenti, hogy „objektumfájlt kérek”. Ha -S mellett a kimenetnek .o végződésű nevet adunk, attól még assembly szöveg keletkezik.

A bemeneti kiterjesztés is számít:

A szokásos egyparancsos fordítás nem feltétlenül hagyja meg a köztes fájlokat. A logikai lépések ettől még elkülöníthetők; az eszközök akár csővezetéken keresztül is továbbíthatnak adatot egymásnak.

2.5.4 Kidolgozott példa: két C-fájlból AArch64-program

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

Szerepe a fejezetben: ez a példa célzott kiegészítés: egyetlen jelenséget mutat meg külön builddel, ezért első olvasáskor kihagyható. A fejezet végigvitt projektjének ide tartozó lépése — amely nem hagyható ki — a szakasz végén, a „Végigvitt projekt” részben található.

A példához a könyv cross-eszközlánca szükséges: gcc-aarch64-linux-gnu, binutils-aarch64-linux-gnu, qemu-user, továbbá az AArch64 glibc fejlesztői fájljai. Utóbbiakat az Ubuntu-környezetben jellemzően a libc6-dev-arm64-cross csomag biztosítja. A részletes telepítés a labor-alapkörnyezetben következik.

Két külön fordítási egységet készítünk. A lab-main.c használ egy függvényt, amelyet a lab-math.c definiál.

A lab-main.c tartalma:

#include <stdio.h>

int lab_add22(int x);

int main(void)
{
    int result = lab_add22(20);
    printf("eredmeny=%d\n", result);
    return result != 42;
}

A lab-math.c tartalma:

#define LAB_BIAS 22

int lab_add22(int x)
{
    return x + LAB_BIAS;
}

A lab_add22 deklarációja alapján a fordító ellenőrizheti a hívás típusait. A függvénytörzs azonban nincs jelen a lab-main.c fordítási egységében. Ez nem fordítási hiba: a definíciót majd a linkernek kell megtalálnia.

2.5.4.1 1. Előfeldolgozás

# Build-cél: AArch64 Linux, későbbi futtatás qemu-user alatt.
aarch64-linux-gnu-gcc -E lab-main.c -o lab-main.i
aarch64-linux-gnu-gcc -E lab-math.c -o lab-math.i

# Hostoldali szövegvizsgálat.
sed -n '1,30p' lab-math.i

A lab-math.i függvénytörzsében várhatóan már return x + 22; olvasható. A kimenet forráshelyeket jelölő sorokat is tartalmazhat. A lab-main.i jóval hosszabb lehet, mert a stdio.h és az általa bevont fejlécek tartalma is megjelenik benne.

2.5.4.2 2. Assembly előállítása

# Build-cél: A64 ISA, ARMv8-A, little-endian; futtatási cél: qemu-user.
aarch64-linux-gnu-gcc -S -O2 -march=armv8-a -mlittle-endian \
    -fno-pie lab-main.i -o lab-main.s
aarch64-linux-gnu-gcc -S -O2 -march=armv8-a -mlittle-endian \
    -fno-pie lab-math.i -o lab-math.s

Az -O2 optimalizálást kér. A -march=armv8-a az engedélyezett célarchitektúrát, a -mlittle-endian a bájtsorrendet rögzíti. A -fno-pie, majd a linkelésnél használt -no-pie ebben a laborban kikapcsolja a pozíciófüggetlen futtatható fájl készítését, hogy az eredmény típusa ne a disztribúció alapbeállításától függjön. Ez szemléltetési döntés, nem általános biztonsági ajánlás.

A lab-math.s egyik lehetséges, leegyszerűsített A64-részlete:

lab_add22:
    add W0, W0, #22
    ret

Ez nem a teljes generált fájl: a GNU assembler számára szükséges direktívákat és a metaadatokat itt elhagytuk. Az assembláláshoz az eredeti, GCC által készített .s fájlt használjuk.

A64 környezetben a W0 32 bites regiszternézet. Az AArch64 Linuxon alkalmazott AAPCS64 hívási konvenció szerint ebben a példában az első int argumentum és az int visszatérési érték is ezen keresztül halad. A RET alapértelmezésben az X30 64 bites regiszterben levő visszatérési címet használja. A részletes szerződést az AAPCS64 regiszterszerződése fejti ki.

Az A64 ADD itt használt immediate alakja nem fogad el tetszőleges konstanst: 12 bites előjel nélküli értéket kódol, opcionális 12 bites balra tolással. A 22 belefér a nem eltolt alakba.

2.5.4.3 3. Objektumfájlok készítése

# Build-cél: AArch64 Linux, qemu-user.
aarch64-linux-gnu-gcc -c -march=armv8-a -mlittle-endian \
    lab-main.s -o lab-main.o
aarch64-linux-gnu-gcc -c -march=armv8-a -mlittle-endian \
    lab-math.s -o lab-math.o

A .s bemenet és a -c együtt azt jelenti: az assembler dolgozzon, linkelés még ne történjen. A .o fájl már bináris, ezért szövegszerkesztő helyett objektumvizsgáló eszközt használunk.

2.5.4.4 4. Linkelés és futtatás

# Linkelési cél: statikus AArch64 Linux-program, qemu-user.
aarch64-linux-gnu-gcc -static -no-pie \
    lab-main.o lab-math.o -o lab-app

# Futtatási cél: qemu-user.
qemu-aarch64 ./lab-app
printf 'kilepesi_allapot=%d\n' "$?"

Várt, nem ténylegesen futtatott eredmény:

A $? közvetlenül az előző parancs állapotát tartalmazza. Ha előbb más parancsot futtatunk, már nem a QEMU-futtatás eredményét vizsgáljuk.

A linkelést szándékosan a GCC vezérlőprogramjával végezzük. Ez hozzáadja a C futtatókörnyezet szokásos induló objektumait és könyvtárait. A folyamat nem közvetlenül a main függvényben indul; az indulókód készíti elő annak meghívását.

A lánc rövidítve is megadható:

# Teljes build és futtatás: AArch64 Linux, qemu-user.
aarch64-linux-gnu-gcc -O2 -march=armv8-a -mlittle-endian \
    -fno-pie -static -no-pie \
    lab-main.c lab-math.c -o lab-app-onepass

qemu-aarch64 ./lab-app-onepass
printf 'kilepesi_allapot=%d\n' "$?"

A várt kimenet ugyanaz. Az egyparancsos változat önmagában nem őrzi meg a vizsgált .i, .s és .o köztes fájlokat.

2.5.4.5 Végigvitt projekt: a lab-hidden négy lépésben

A Miért assembly alfejezetben a lab-hidden buildje a C → assembly lépéssel indult; az előfeldolgozás rejtve maradt. Most mind a négy lépést külön fájlba mentjük:

# Build-cél: AArch64 Linux, qemu-user.
cp ~/arm-labor/a64/a001/lab-hidden.c .
LAB_CFLAGS="-std=c17 -O2 -fno-inline -Wall -Wextra -march=armv8-a -mlittle-endian"

aarch64-linux-gnu-gcc $LAB_CFLAGS -E lab-hidden.c -o lab-hidden.i   # 1. előfeldolgozás
aarch64-linux-gnu-gcc $LAB_CFLAGS -S lab-hidden.i -o lab-hidden.s   # 2. fordítás
aarch64-linux-gnu-gcc $LAB_CFLAGS -c lab-hidden.s -o lab-hidden.o   # 3. assemblálás
aarch64-linux-gnu-gcc -march=armv8-a -mlittle-endian -static \
    lab-hidden.o -o lab-hidden                                       # 4. linkelés

grep -n 'lab_next_u(' lab-hidden.i
aarch64-linux-gnu-nm lab-hidden.o
qemu-aarch64 ./lab-hidden
printf 'exit=%s\n' "$?"

Levezetett, várt megfigyelések:

Lépés Mit ellenőrzünk Várt megfigyelés
1. .i a main hívássora az UINT32_MAX makró helyén számkonstans áll (például 4294967295U; a pontos alak a C-könyvtár fejléceitől függ)
2–3. .s, .o nm lab-hidden.o T lab_next_u, T lab_rises_s, T main definiált, U printf feloldatlan
4. futtatás kimenet és állapot unsigned_next=0, signed_rises=1, exit=0 — ugyanaz, mint a Miért assembly alfejezetben

A printf feloldatlan hivatkozása a .o-ban ugyanaz a jelenség, mint a lab_add22 esete a fenti kiegészítő példában: a definíciót a linker a statikus C-könyvtárból emeli be. A projekt ezzel a fejezet végére minden szinten — forrás, előfeldolgozott forrás, assembly, objektum, futtatható fájl — megvizsgált program.

2.5.5 Objektumfájl és futtatható fájl: mindkettő lehet ELF

Az ELF (Executable and Linkable Format) többféle bináris szerepet támogat. A „bináris fájl” ezért nem a „futtatható program” szinonimája.

Az objektumfájl már tartalmazhat gépi utasításokat, mégsem teljes program. Lehetnek benne feloldatlan külső hivatkozások és relokációk (relocations): olyan bejegyzések, amelyek megadják, hogyan kell egy címfüggő mezőt később a végleges elrendezéshez igazítani. Relokáció akkor is szükséges lehet, ha a hivatkozott szimbólum ugyanabban az objektumban definiált.

Vizsgáljuk meg a példát:

# Hostoldali vizsgálat; a fájlok célja AArch64 Linux, qemu-user.
aarch64-linux-gnu-readelf -h lab-main.o
aarch64-linux-gnu-readelf -h lab-app
aarch64-linux-gnu-nm -u lab-main.o
aarch64-linux-gnu-objdump -dr lab-main.o

Várt ellenőrzési pontok:

Vizsgálat Várt megfigyelés
lab-main.o ELF-típusa REL, relokálható objektum
lab-app ELF-típusa EXEC, ennél a statikus, nem PIE buildnél
Mindkét fájl géptípusa AArch64
Mindkét fájl adatábrázolása little-endian
nm -u lab-main.o Többek között lab_add22 és printf feloldatlan hivatkozása
objdump -dr Visszafejtett utasítások és a hozzájuk kapcsolódó relokációk

A lab_add22 feloldatlan állapota itt normális: a fordítás sikerült, az összekapcsolás még hátravan. Az ELF részletes felépítése az ELF formátum alapjainál következik.

2.5.6 Statikus és dinamikus linkelés röviden

Statikus linkelésnél a szükséges könyvtári objektumok kódja bekerül a futtatható fájlba. A statikus könyvtár gyakran .a archívum; a linker általában a szükséges tagokat emeli be, nem automatikusan a teljes archívumot.

Dinamikus linkelésnél a program egyes szolgáltatásokat külön megosztott objektumokból, például .so fájlokból kap. A futtatáshoz kompatibilis céloldali dinamikus betöltő és könyvtárak szükségesek.

A meglévő objektumokból dinamikus változat is készíthető:

# Linkelési cél: dinamikus AArch64 Linux-program, qemu-user.
aarch64-linux-gnu-gcc -no-pie \
    lab-main.o lab-math.o -o lab-app-dynamic

# Hostoldali vizsgálat.
aarch64-linux-gnu-readelf -l lab-app-dynamic
aarch64-linux-gnu-readelf -d lab-app-dynamic

# Futtatási cél: qemu-user, AArch64 céloldali könyvtárakkal.
qemu-aarch64 -L /usr/aarch64-linux-gnu ./lab-app-dynamic
printf 'kilepesi_allapot=%d\n' "$?"

A megadott prefix az Ubuntu cross-környezetének szokásos céloldali könyvtárfája; meglétét a laborban ellenőrizni kell. Nem helyettesíthető a host x86_64 könyvtáraival.

A várt kimenet ismét eredmeny=42, majd nulla kilépési állapot. A programfejlécek között várhatóan INTERP, a dinamikus információk között NEEDED bejegyzés található. Az előbbi az ELF-interpretert, az utóbbi szükséges megosztott könyvtárat jelöl.

A statikus program sem „operációs rendszer nélküli”: továbbra is Linux-szolgáltatásokat használ. A statikus és dinamikus linkelés közötti különbség nem azonos a Linux és a bare-metal futtatás különbségével.

2.5.7 Miért változik az assembly ugyanabból a C-kódból?

A generált kódot több, egymástól elkülönítendő döntés alakítja:

A helyes összehasonlítás nem a szövegazonosságot keresi, hanem a megkövetelt viselkedés megőrzését. Ez jól definiált C-programra vonatkozik: például az előjeles egész túlcsordulására nem alapozhatunk hordozható C-szemantikát. A labor 20 + 22 művelete 32 bites előjeles int esetén ábrázolható.

A rövidebb assembly önmagában nem bizonyít gyorsulást. Itt működést és fájlformátumot vizsgálunk, nem időt mérünk; a QEMU user-mode nem szolgáltat garantáltan hardverhű ciklusszámot.

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

A hasznos hibakeresési sorrend: tünet → hipotézis → adatgyűjtés → egy változtatás → új ellenőrzés.

Tünet Hipotézis és adatgyűjtés Egy javítás és ellenőrzése
undefined reference to lab_add22 A linker nem kapta meg a definíciót; vizsgáljuk nm-mel az objektumokat Adjuk hozzá a lab-math.o fájlt; linkeljünk újra
A fájl géptípusa x86-64 Véletlenül a host gcc fordított; ellenőrizzük readelf -h segítségével Fordítsunk újra az aarch64-linux-gnu-gcc eszközzel
QEMU nem találja az ELF-interpretert Dinamikus programhoz hiányzik vagy rossz a céloldali könyvtárfa; nézzük meg a readelf -l kimenetét Javítsuk a -L prefixet, majd futtassunk újra
Forrásmódosítás után változatlan az eredmény Elavult köztes fájlt használtunk Indítsuk újra az érintett láncot a megváltozott bemenettől

Az utolsó eset különösen fontos: a .i nem élő kapcsolat a .c fájlhoz. Ha a makrót a forrásban átírjuk, de továbbra is a régi .i fájlt fordítjuk, a módosítás nem jut el a programig.

2.5.9 Feladatok

  1. Magyarázó feladat (alapszint — cél: megértés-ellenőrzés). Melyik lépés helyettesíti a LAB_BIAS makrót, melyik kódolja az A64 utasításokat, és melyik kapcsolja össze a lab_add22 hívását a definíciójával? Miért nem futtatható önmagában a lab-main.o?

  2. Implementációs feladat (középszint — cél: rutinépítés). Állítsd a LAB_BIAS értékét 7-re, és módosítsd a main ellenőrzését az új eredményhez. Végezd el újra a teljes lépéssort. Mi legyen a kimenet és a kilépési állapot? Mi történne, ha csak az eredeti .o fájlokat linkelnéd újra?

  3. Hibakeresési feladat (középszint — cél: rutinépítés). Az eredeti forrásváltozat objektumaival hagyd el szándékosan a matematikai objektumot:

    # Szándékosan hibás linkelés; cél: AArch64 Linux, qemu-user.
    aarch64-linux-gnu-gcc -static -no-pie \
        lab-main.o -o lab-missing-math

    A várt eredmény nem nulla linkelési állapot; futtatható programra ne számíts. Igazold a hiányzó definíció okát nm segítségével, majd egyetlen linkparancs-módosítással javítsd a hibát.

  4. Összehasonlító feladat (középszint — cél: megértés-ellenőrzés). Készíts optimalizálatlan assemblyt is:

    # Build-cél: AArch64 Linux, qemu-user; futtatás itt nincs.
    aarch64-linux-gnu-gcc -S -O0 -march=armv8-a -mlittle-endian \
        -fno-pie lab-math.i -o lab-math-O0.s
    
    # Hostoldali szövegösszehasonlítás.
    diff -u lab-math-O0.s lab-math.s

    Keress művelet- vagy veremkezelési különbséget. A diff eltérő fájloknál szokásosan 1 állapottal tér vissza; ez nem fordítási hiba. Mit nem állapíthatsz meg ebből a futási időről?

2.5.10 Megoldások

1. megoldás — Segítség. Nézd meg a fejezet elején lévő táblázatot: melyik sor bemenete/kimenete illeszkedik a makróra, melyik az utasításkódolásra, melyik a hivatkozás-összekapcsolásra?

1. megoldás — Teljes megoldás. A makrót a preprocesszor helyettesíti; az utasításokat az assembler kódolja; a külső hivatkozást a linker kapcsolja a definícióhoz. A lab-main.o relokálható részprogram: hiányzik belőle többek között a hivatkozott függvény definíciója és a teljes program indulási környezete.

2. megoldás — Segítség. Számold ki fejben 7 + 20-at, és gondold át: a .i-fájl már a makróhelyettesítés UTÁNI állapotot tükrözi — ha csak a régi .o-kat linkelnéd újra, melyik lépés maradna ki?

2. megoldás — Teljes megoldás. Az eredmény 27, ezért az ellenőrzés return result != 27;. A várt kimenet eredmeny=27, a kilépési állapot 0. Mindkét módosított forrást újra elő kell feldolgozni, fordítani és assemblálni. A régi objektumok újralinkelése a régi viselkedést őrizné meg.

3. megoldás — Segítség. Az nm -u a feloldatlan (undefined), a --defined-only a ténylegesen definiált szimbólumokat listázza egy objektumban — melyik objektumban várod a lab_add22-t melyik szerepben?

3. megoldás — Teljes megoldás. A szükséges vizsgálat és javítás:

# Hostoldali vizsgálat, AArch64 objektumokon.
aarch64-linux-gnu-nm -u lab-main.o
aarch64-linux-gnu-nm --defined-only lab-math.o

# Javított linkelés és futtatás: AArch64 Linux, qemu-user.
aarch64-linux-gnu-gcc -static -no-pie \
    lab-main.o lab-math.o -o lab-app
qemu-aarch64 ./lab-app
printf 'kilepesi_allapot=%d\n' "$?"

A hívó objektumban U, a definiáló objektumban várhatóan T jelöli a lab_add22 szimbólumot. Az eredeti változat várt eredménye 42, kilépési állapota 0. A hiba linkelési, nem betöltési probléma.

4. megoldás — Segítség. Gondolj arra, mit kell egy -O0 fordításnak garantálnia (pl. hogy minden változó bármikor megfigyelhető legyen egy debuggerben) — ez miért igényel több explicit memóriaműveletet, mint az -O2?

4. megoldás — Teljes megoldás. Az optimalizálatlan változat például verembe mentheti és visszatöltheti az argumentumot, míg az optimalizált változat közvetlenül regiszteren számolhat. A pontos eltérés fordítófüggő. Utasításszövegből nem következik tényleges időadat: ehhez mérési terv és megfelelő futtatási környezet szükséges.

2.5.11 Rövid összefoglaló

A C-program előfeldolgozáson, fordításon, assembláláson és linkelésen keresztül válik betölthető programmá. Az assembly olvasható köztes reprezentáció, nem maga a futó program. A GCC vezérlőprogram -E, -S és -c kapcsolóival külön megállhatunk a lánc állomásainál; a -o csak nevet választ. Az objektumfájl gépi kódot tartalmazhat úgy is, hogy külső hivatkozásai még feloldatlanok. A linker és a betöltő eltérő feladatot végez. A generált assembly optimalizálási, ISA- és ABI-döntéseket tükröz, ezért szövege nem egyetlen kötelező fordítása a C-forrásnak. A laborban a várt működést a kimenet és a kilépési állapot együtt ellenőrzi.

2.5.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 ténylegesen fennmaradnak: a kód nem volt futtatva; minden eredmény várt vagy szemléltető. A konkrét Ubuntu-csomagverziókat, a statikus glibc elérhetőségét, a céloldali könyvtárprefixet és a ténylegesen generált assemblyt a hallgatói laborban kell ellenőrizni.