4.2 Hello world tisztán assemblyben: szekciók, _start, syscall hívás

4.2.1 Miért nem kell ehhez C futtatókörnyezet?

Egy C-ben megírt üdvözlőprogram rövid, de a végrehajtható állomány rendszerint jóval többet tartalmaz a saját függvényünknél: indulókódot, könyvtári kapcsolódásokat és a szabványos I/O kezelését. Most ezt a rétegzettséget bontjuk szét egy konkrét feladattal: írjunk ki egy üzenetet, majd fejezzük be a folyamatot libc és C-indulókód nélkül.

Az önállóság itt nem operációs rendszer nélküli működést jelent. A program Linux-szolgáltatásokat használ; a fájlleírók és a folyamat kezelése továbbra is az operációs rendszer feladata.

Profil: az alfejezet minden utasítása és regisztere az A64 ISA, AArch64, I. rész környezetéhez tartozik. Az assemblydirektívák GNU assembler-direktívák ebben az AArch64 ELF-profilban, nem processzorutasítások. A cél ARMv8-A, little-endian Linux; az elsődleges futtatási mód qemu-aarch64, vagyis QEMU user-mode emuláció.

Az előfeltétel az A64 regiszterkészletének áttekintése. Veremkezelésre vagy függvényhívási keretre most nem lesz szükség.

4.2.2 A forrástól a belépési pontig

A szekció (section) az objektumfájl tartalmának névvel és tulajdonságokkal ellátott egysége. Ebben a példában két fontos szekciót használunk:

GNU assembler-direktíva, AArch64 ELF-profil Szerep
.text A végrehajtandó utasítások szekciója.
.data Inicializált, írható adatok szekciója.

Az üzenet nem változik, ezért később természetesebb helye lehetne egy csak olvasható adatszekcióban. Most tudatosan .data-ba tesszük, hogy a kód és az inicializált adat elkülönítését lássuk.

A szekció nem azonos a futás közbeni memóriaszegmenssel. A linker a szekciókból alakítja ki a betölthető tartományokat; ezt az ELF formátum alapjai és a program életciklusa részletezi.

A belépési pont (entry point) az a cím, ahol a program végrehajtása megkezdődik. A _start elnevezés szokásos szimbólumnév, nem hardveresen fenntartott szó. A linkernek külön megmondjuk, hogy ezt válassza belépési pontnak.

Fontos különbség a C-hez képest:

A .global _start láthatóvá teszi a szimbólumot a linker számára. Önmagában nem helyettesíti a belépési pont kijelölését.

4.2.3 A rendszerhívás mint regiszterekben leírt kérés

A rendszerhívás (system call, syscall) az alkalmazás és az operációs rendszer közötti szabályozott szolgáltatáskérés. A Linux AArch64 rendszerhívási bináris interfésze (application binary interface, ABI) meghatározza, mely regiszterekbe kerülnek az adatok.

Ez Linux-konvenció, nem az A64 ISA általános tulajdonsága.

Szerep Linux AArch64 rendszerhívási ABI
Rendszerhívásszám A64 X8
Első–hatodik argumentum A64 X0–X5, sorrendben
Szolgáltatáskérés A64 SVC #0
Visszatérési érték A64 X0, ha a hívás visszatér

A64-en az SVC immediate operandusa 16 bites, tehát 0–65535 közötti értéket kódolhat. A Linuxhoz itt SVC #0 kell; a rendszerhívásszám nem ebbe az operandusba, hanem az A64 X8 regiszterbe kerül.

Fizikai AArch64 Linuxon az utasítás kivételt vált ki, amelyet az operációs rendszer kezel. QEMU user-mode környezetben a QEMU emulálja a vendégprogram rendszerhívását a host szolgáltatásaival. Itt nem külön AArch64 Linux-kernelt indítunk.

A két szükséges szolgáltatás:

Hívás A64 X8 A64 X0 A64 X1 A64 X2
Linux write 64, azaz 0x40 Fájlleíró: 1 Az üzenet címe Hossz bájtban
Linux nyers exit 93, azaz 0x5d Kilépési állapot Nem argumentum Nem argumentum

A fájlleíró (file descriptor) 1 szokásosan a szabványos kimenetet jelöli. A shell azonban átirányíthatja vagy bezárhatja; ezért ez nem feltétlenül terminált jelent.

A nyers Linux exit nem a C könyvtár exit() függvénye: nem futtat könyvtári lezárási műveleteket, és nem ürít libc-puffereket. A hívó szálat szünteti meg; ebben az egyszálú programban ezzel a folyamat is véget ér.

4.2.4 Kidolgozott példa: teljes, libc nélküli program

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

Mentsük a következő forrást lab-hello.s néven:

// Profil: A64 ISA, AArch64, ARMv8-A, little-endian Linux.
// Direktívák: GNU assembler, AArch64 ELF.
// Cél: qemu-user, qemu-aarch64.

.section .data
message:
    .ascii "Hello, ARM64!\n"
.equ message_len, . - message

.section .text
.balign 4
.global _start
.type _start, %function

_start:
    mov X0, #1
    ldr X1, =message
    mov X2, #message_len
    mov X8, #64

before_write:
    svc #0

after_write:
    mov X0, #0
    mov X8, #93
    svc #0

.size _start, . - _start
.ltorg

.section .note.GNU-stack,"",%progbits

4.2.4.1 Az üzenet és a hossz kapcsolata

Az AArch64 GNU assembler .ascii direktívája a megadott bájtokat helyezi el, lezáró nulla nélkül. A \n ebben a példában egyetlen, 0x0a értékű bájt.

A hossz levezetése:

Üzenetrész Bájtok száma
Hello 5
Vessző 1
Szóköz 1
ARM64 5
Felkiáltójel 1
Újsor 1
Összesen 14

A .equ message_len, . - message assemblerdirektívában a . az aktuális helyszámláló. Közvetlenül az üzenet után állunk, ezért a különbség az üzenet bájthossza.

Ez összeállításkor kiszámított konstans, nem futás közben végzett kivonás és nem memóriában tárolt hosszmező.

A Linux write nem C-sztringet keres: a cím és a bájtszám alapján dolgozik. Ha az üzenetben nulla bájt volna, azt is kiírná, amennyiben beleesik a megadott hosszba. UTF-8 szövegnél ugyanezért a karakterek száma eltérhet a bájtok számától; a címkekülönbség akkor is a megfelelő bájthosszt adja.

4.2.4.2 A cím betöltése nem az adat beolvasása

Az A64-környezetben használt GNU assembler

ldr X1, =message

alakja pszeudoutasítás (pseudo-instruction). A célja itt a message szimbólum címének betöltése az A64 X1 regiszterbe, nem az üzenet első nyolc bájtjának beolvasása.

A GNU assembler ehhez literálkészletet (literal pool) használ: a kód közelében elhelyezi a cím tárolására szolgáló bejegyzést, amelynek végleges értékét a linker rendezi. Az A64 LDR literálalakja innen tölti be a címet.

Ennek a valódi utasításnak az elérése korlátos: 19 bites előjeles eltolást kódol, néggyel skálázva. Az utasítás címéhez képesti tartomány ezért −1 048 576 és +1 048 572 bájt közötti. A .ltorg assemblerdirektíva itt, a rövid kód végén helyezi el a függő literálkészletet, bőven ezen a tartományon belül.

A literálkészlet adatait nem akarjuk utasításként végrehajtani. Előtte a Linux exit rendszerhívás megszünteti a programot.

4.2.4.3 Miért működnek ezek a konstansbetöltések?

A példában szereplő A64 MOV immediate alakok kis, nemnegatív konstansokat töltenek be. Ezek egyetlen A64 MOVZ utasítással kódolhatók.

Az A64 MOVZ egy 16 bites mezőt helyez el a célregiszterben; 64 bites cél esetén a megengedett eltolás 0, 16, 32 vagy 48 bit. Itt minden értékhez a 0 bites eltolás elég.

Ebből nem következik, hogy a mov Xn, #konstans bármely 64 bites számot elfogad egyetlen utasításként. A részleteket a MOV-variánsok és immediate-kódolás tárgyalja.

A .balign 4 négybájtos határra igazítja a kód kezdetét. A .type és .size szimbólummetaadatot ad az eszközöknek. A .note.GNU-stack szekcióbejegyzés pedig azt jelzi a GNU eszközláncnak, hogy ez az objektum nem igényel végrehajtható vermet; maga nem végrehajtott kód.

4.2.5 Összeállítás, linkelés és futtatás

A következő parancsok célja qemu-user, Ubuntu 24.04 x86_64 hoston, AArch64 kereszt-eszközlánccal. A szükséges telepítést a labor-alapkörnyezet ismerteti.

# Cél: qemu-user; AArch64 ARMv8-A, little-endian Linux.

aarch64-linux-gnu-as -g -march=armv8-a -EL \
    -o lab-hello.o lab-hello.s

aarch64-linux-gnu-ld -static -e _start \
    -o lab-hello lab-hello.o

qemu-aarch64 ./lab-hello
lab_status=$?
printf 'exit=%d\n' "$lab_status"

Az első lépés objektumfájlt állít elő. A második feloldja a szimbólumhivatkozásokat, kijelöli a belépési pontot és létrehozza a végrehajtható ELF-fájlt. Nem adunk hozzá C-indulókódot vagy könyvtárat.

A -static itt nem „statikusan beépített libc”-t jelent: egyáltalán nem linkelünk libc-t. A létrejövő állomány futtatásához ezért nem kell AArch64 dinamikus betöltő vagy megosztott könyvtárkészlet.

Levezetett, várt eredmény normál, írható szabványos kimenet mellett:

Megfigyelés Várt érték
Program kimenete Hello, ARM64!, majd újsor
A64 X0 közvetlenül a write után 14
Shell által kiírt állapot exit=0

A kilépési állapotot közvetlenül a QEMU futtatása után mentjük el. Ha közben más parancsot hajtanánk végre, a shell $? értéke már annak az állapotát jelentené.

A programot itt nem futtattuk; ezek ellenőrizendő, várt eredmények.

4.2.5.1 Bájtokra pontos kimenetellenőrzés

A terminálon látható szöveg önmagában nem elég: egy fölösleges nulla bájt például láthatatlan maradhat.

# Cél: qemu-user; kimenetrögzítés és ellenőrzés a hoston.

qemu-aarch64 ./lab-hello > lab-output.bin
lab_status=$?
printf 'exit=%d\n' "$lab_status"
wc -c < lab-output.bin
od -An -tx1 lab-output.bin

Várt állapot: a kilépési kód 0, a fájl hossza 14 bájt. A bájtsor hexadecimális jelöléssel:

0x48 0x65 0x6c 0x6c 0x6f 0x2c 0x20 0x41 0x52 0x4d 0x36 0x34 0x21 0x0a

A bájtok sorozatánál nincs több-bájtos egészértékből eredő bájtsorrend-kérdés.

4.2.6 A visszatérési érték tényleges megfigyelése

A Linux write visszatérésekor az A64 X0 új szerepet kap: már nem fájlleíró, hanem eredmény.

A libc write() burkolófüggvényével ellentétben itt nincs automatikus errno-beállítás és egységes -1 visszatérés.

A mi programunk az after_write címkénél rögtön felülírja az A64 X0 regisztert a kilépési állapottal. A 0 kilépési kód ezért nem bizonyítja, hogy az írás sikerült. Ez a kezdőprogram tudatos korlátozása: nincs hibakezelése és nincs részleges írást folytató ciklusa.

A visszatérési érték megfigyeléséhez indítsuk az első terminálban:

# Cél: qemu-user, GDB-kapcsolatra váró program.

qemu-aarch64 -g 1234 ./lab-hello
lab_status=$?
printf 'exit=%d\n' "$lab_status"

A második terminálban:

# Cél: a qemu-user AArch64 vendégprogramjának vizsgálata.
# A vizsgált regiszterek az A64 ISA regiszterei.

gdb-multiarch -q ./lab-hello \
    -ex 'target remote :1234' \
    -ex 'break before_write' \
    -ex 'continue' \
    -ex 'info registers x0 x1 x2 x8' \
    -ex 'x/14bx $x1' \
    -ex 'si' \
    -ex 'p/d (long)$x0' \
    -ex 'continue' \
    -ex 'quit'

A töréspont a szolgáltatáskérés előtt állítja meg a programot. A si egy utasítást hajt végre, így utána az after_write címkénél vagyunk, még az eredmény felülírása előtt.

Ellenőrzési pont Várt állapot
A64 SVC #0 előtt X0=1, X2=14, X8=64
A64 X1 által mutatott memória A felsorolt 14 üzenetbájt
A64 SVC #0 után X0=14, normál sikeres írásnál

A konkrét cím helyett a szimbólumot és a regiszterben lévő címet használjuk; nem építünk be feltételezett memóriacímet.

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

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

Tünet Lehetséges ok Ellenőrzés
Exec format error x86_64 hoston Közvetlenül indítottuk az AArch64 fájlt Indítás qemu-aarch64 segítségével
Nincs üzenet, mégis 0 a kilépési kód A write hibázott, az eredményt eldobtuk A64 X0 vizsgálata közvetlenül a hívás után
Hibás vagy túl hosszú kimenet Helytelen bájtszám A64 X2, majd a kimenet bájtjainak ellenőrzése
A hívás memóriahibát jelez Az A64 X1 nem a megfelelő adat címe Memóriavizsgálat az A64 X1 címen
A szöveg után láthatatlan többletbájt van .ascii helyett .asciz, változatlan hosszszámítással wc és od
Nem a kívánt szolgáltatás fut Rossz rendszerhívásszám A64 X8 ellenőrzése, nem az SVC operandusának módosítása

Az utasítások és a szimbólumok kapcsolatát disassemblyvel is vizsgálhatjuk:

# Célállomány: qemu-user számára készült AArch64 ELF.
# Ez hostoldali vizsgálat, nem futtatás.

aarch64-linux-gnu-objdump -d lab-hello
aarch64-linux-gnu-readelf -h -S -l lab-hello

Az A64 MOV megjelenítése aliasfüggő lehet; a literálkészlet szintén megjelenhet a .text tartalmában. Nem minden ott tárolt bájt végrehajtandó utasítás. A részletes értelmezést a Disassembly objdump/readelf-fel tárgyalja.

4.2.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért működik a kiírás lezáró nulla nélkül? Mi változna, ha kizárólag az AArch64 GNU assembler .ascii direktíváját cserélnénk .asciz direktívára? Miért nem javítható egy rossz rendszerhívásszám az A64 SVC operandusának átírásával?

  2. (implementációs, középszint — cél: rutinépítés) Készíts lab-szia.s nevű változatot, amely a Szia, ARM64! szöveget írja ki újsorral, majd 7 kilépési állapottal fejeződik be. A hosszt továbbra is címkekülönbség számítsa. Ellenőrizd a kimenetet és a kilépési állapotot.

  3. (hibakeresési, középszint — cél: megértés-ellenőrzés) A GDB-munkamenetben, közvetlenül az A64 SVC #0 előtt állítsd az A64 X0 regisztert −1-re. A program forrását ne változtasd meg. Jegyezd fel a rendszerhívás előjeles visszatérési értékét és a folyamat kilépési állapotát. Magyarázd meg az eltérést.

4.2.9 Megoldások

1. megoldás — Segítség. Gondold végig, mit kap paraméterként a write: egy címet és egy hosszt, vagy egy sztringet? Ha .asciz-re cserélnéd a direktívát, hogyan változna a . - message különbség?

1. megoldás — Teljes megoldás. A Linux write cím–hossz párost kap, nem nullával lezárt sztringet. Az AArch64 GNU assembler .asciz direktívája hozzáadna egy 0x00 bájtot. Mivel a hosszszámítás az üzenet után áll, az új hossz 15 lenne, és a program a nullát is kiírná.

A szolgáltatást a Linux AArch64 ABI szerint az A64 X8 választja ki. Az A64 SVC immediate mezője nem ennek alternatív tárolóhelye; a laborban maradjon #0.

2. megoldás — Segítség. Másold a teljes lab-hello.s-t, és csak három helyen változtass: az üzenetszöveg, a kilépési kód betöltése, és — mivel az üzenet hossza is más — semmit a hosszszámításon, hiszen az automatikusan követi a címkekülönbséget.

2. megoldás — Teljes megoldás. A teljes forrás másolatában az üzenet legyen .ascii "Szia, ARM64!\n", az after_write címke utáni első utasítás pedig A64 mov X0, #7.

A várt hossz 13 bájt: négy betű, vessző, szóköz, öt üzenetbeli karakter, felkiáltójel és újsor.

# Cél: qemu-user; AArch64 ARMv8-A, little-endian Linux.

aarch64-linux-gnu-as -g -march=armv8-a -EL \
    -o lab-szia.o lab-szia.s

aarch64-linux-gnu-ld -static -e _start \
    -o lab-szia lab-szia.o

qemu-aarch64 ./lab-szia
lab_status=$?
printf 'exit=%d\n' "$lab_status"

Várt kimenet: Szia, ARM64! és újsor, majd a shell ellenőrzésében exit=7. Ez szándékosan választott, nem véletlen hibajelzés.

3. megoldás — Segítség. A -1 mint fájlleíró biztosan érvénytelen; gondold végig, a write szokásos Linux-hibajelzése hogyan jelenik meg A64 X0-ban (előjeles, negatív érték), és hogy a program forrása mikor írja felül ezt az értéket.

3. megoldás — Teljes megoldás. A GDB-indításban közvetlenül a -ex 'si' elé illesztendő argumentum: -ex 'set $x0 = -1'.

A −1 nem érvényes fájlleíró. A Linux AArch64 nyers visszatérés várt értéke −9, vagyis -EBADF; ugyanennek 64 bites bitmintája 0xfffffffffffffff7.

A program ezután az A64 X0 értékét 0-ra írja, és meghívja az exit szolgáltatást. A folyamat kilépési állapota tehát továbbra is 0. Az ellenpróba megmutatja: a rendszerhívás eredménye és a program által választott kilépési állapot két külön adat.

4.2.10 Rövid összefoglaló

A libc nélküli AArch64 program saját belépési pontból indul, de továbbra is Linux-szolgáltatásokra támaszkodik. A kódot és az inicializált adatot külön szekcióba helyeztük, az üzenet hosszát pedig összeállításkor számítottuk ki. A Linux AArch64 rendszerhívási szerződésében az A64 X8 választja ki a szolgáltatást, az A64 X0–X5 hordozza az argumentumokat, az A64 SVC #0 pedig elindítja a kérést. A visszatérő A64 X0 tartalmát felülírás előtt kell ellenőrizni. A sikeres folyamatkilépés önmagában nem igazolja a kiírás sikerét; ehhez a visszatérési érték és a kimeneti bájtok vizsgálata is szükséges.

4.2.11 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 összeállítása, QEMU-futtatása és GDB-léptetése itt nem történt meg. A helyi Ubuntu 24.04 csomagverziókat és a labor tényleges eredményeit a hallgatónak kell rögzítenie. A bemutatott kimenetek és regiszterértékek levezetett, várt állapotok; teljesítménymérés nem szerepel a példában.