4.3 Az ELF formátum alapjai és a program életciklusa

4.3.1 Miért kell az assembly program mellé az ELF-et is megérteni?

Egy fájl tartalmazhat helyes A64 gépi kódot, mégsem biztos, hogy elindítható. Az operációs rendszernek azt is tudnia kell, mely fájlrészek kerüljenek memóriába, milyen hozzáférési jogokkal, és honnan kezdődjön a végrehajtás. Ezeket az információkat nem maguk az utasítások hordozzák.

Az előző alfejezet Hello world programja már végrehajtható ELF-fájlként futott. Most ezt a fájlt vizsgáljuk meg kívülről: összekötjük az assembler szekcióit a linker kimenetével és a programbetöltő működésével.

A célkörnyezet végig AArch64 Linux, A64 ISA, little-endian ELF64, elsődlegesen qemu-aarch64 felhasználói módú emulációval. A bemutatott kimenetek és ellenőrzési eredmények vártak, nem ténylegesen mért vagy futtatással igazolt adatok.

4.3.2 Az ELF három különböző nézete

Az ELF (Executable and Linkable Format) objektumfájlok, végrehajtható programok és megosztott objektumok leírására szolgáló bináris fájlformátum. Nem kizárólag ARM-rendszereken használják.

Három szerkezetet kell megkülönböztetnünk:

  1. ELF-fejléc (ELF header): az egész fájlt azonosítja, és megadja a további fejléc-táblák helyét.
  2. Programfejléc-tábla (program header table): a futtatáshoz szükséges szegmenseket és egyéb betöltési információkat írja le.
  3. Szekciófejléc-tábla (section header table): a fájl szekcióit írja le, elsősorban a linker és az elemzőeszközök számára.

A két utóbbi tábla nem ugyanannak az információnak kétféle elnevezése.

4.3.2.1 Az ELF-fejléc fontos mezői

Az alábbi táblázat az AArch64 Linux ELF64-környezetre vonatkozik.

Mező Jelentés A laborban várható érték
e_ident Azonosító bájtok, osztály, bájtsorrend, formátumverzió ELF, 64 bites, little-endian
e_type Az objektum típusa ET_EXEC a végleges programban
e_machine Célarchitektúra EM_AARCH64, numerikusan 183
e_entry A program belépési virtuális címe A linker által kiválasztott _start címe
e_phoff A programfejléc-tábla fájlbeli eltolása A linker állítja elő
e_phentsize, e_phnum Programfejléc-bejegyzés mérete és darabszáma A konkrét fájlból olvasható ki
e_shoff A szekciófejléc-tábla fájlbeli eltolása A konkrét fájlból olvasható ki
e_shentsize, e_shnum Szekciófejléc-bejegyzés mérete és darabszáma A konkrét fájlból olvasható ki

Az e_type legfontosabb értékei:

Az e_machine az AArch64 architektúrát azonosítja, nem egy konkrét processzormodellt vagy az összes használt ISA-kiterjesztést.

Fontos különbség: az e_phoff fájleltolás, az e_entry viszont virtuális cím. Egyiket sem szabad automatikusan a másikként értelmezni.

4.3.3 Szekció és szegmens: szerkesztési egység és betöltési egység

A GNU assembler AArch64 ELF-célján a következő szekciók gyakoriak:

Szekció Jellemző szekciótípus Szerep
.text SHT_PROGBITS Gépi utasítások
.rodata SHT_PROGBITS Csak olvasható konstansok
.data SHT_PROGBITS Inicializált, írható adatok
.bss SHT_NOBITS Futáskor nullával inicializált tárterület
.symtab SHT_SYMTAB Szimbólumtábla, például címkék adatai

A szekciófejléc többek között a típust (sh_type), tulajdonságokat (sh_flags), méretet (sh_size), igazítást (sh_addralign) és fájlbeli helyet (sh_offset) írja le. A memóriába kerülő szekcióknál a sh_addr is jelentős.

A szegmens (segment) ezzel szemben a betöltési nézet egysége. Egy PT_LOAD programfejléc-bejegyzés betöltendő memóriatartományt ír le, amely több szekciót is tartalmazhat.

PT_LOAD mező Jelentés
p_offset A szegmens adatainak kezdete a fájlban
p_vaddr A szegmens virtuális címe
p_filesz A fájlból származó rész mérete, bájtban
p_memsz A memóriabeli tartomány teljes mérete, bájtban
p_flags Olvasási, írási, végrehajtási jogosultság
p_align Igazítási követelmény

A p_paddr mező is létezik, de a szokásos Linux-felhasználói program nem ezzel választ fizikai memóriacímet.

A betöltő lényegében a következő leképezést hozza létre:

PT_LOAD esetén ezért p_filesz ≤ p_memsz. Ha p_align értéke egynél nagyobb, kettőhatvány, és a p_vaddr, illetve p_offset azonos maradékot ad modulo p_align. A tényleges leképezésnél a rendszer lapigazítási követelményei is számítanak.

4.3.3.1 Miért nem foglal teljes méretének megfelelő helyet a .bss a fájlban?

Levezetés: legyen egy írható szegmens fájlbeli tartalma 4 bájt inicializált adat, memóriabeli tartalma pedig ehhez képest további 8 bájt nullázott tárterület. Igazítás nélkül ekkor:

Ez szemléltető számítás, nem a következő bináris pontos szegmensmérete: az igazítás és a linker elrendezése módosíthatja a különbséget.

A .bss tehát nem „hiányzó memória”. A helyét leírjuk, de a nulla kezdőértéket a betöltés biztosítja. Bare-metal környezetben ezt később saját startup kóddal kell majd megoldanunk.

4.3.4 Statikus és dinamikus program az ELF szemszögéből

A statikus linkelésű (statically linked) laborprogramhoz nem kell felhasználói térben futó dinamikus linker: minden szükséges saját kód benne van. Ettől még Linux-rendszerhívásokat használ; a „statikus” nem jelent operációs rendszer nélküli működést.

A szokásos dinamikusan linkelt (dynamically linked) Linux-program további megosztott objektumokra, például a libc-re támaszkodik.

ELF-jel Jelentése
PT_INTERP Megadja az indításhoz kért ELF-értelmező, tipikusan a dinamikus linker útvonalát
PT_DYNAMIC Dinamikus linkelési információkat tartalmazó tartományt jelöl
DT_NEEDED bejegyzés Egy szükséges megosztott objektum nevét adja meg

Az ET_DYN önmagában nem bizonyítja, hogy a fájl hagyományos értelemben dinamikusan linkelt program. Megosztott könyvtár, dinamikus PIE és statikus PIE is lehet ilyen típusú. Fordítva: egy ET_EXEC program is lehet dinamikusan linkelt.

A típust, a programfejléceket és szükség esetén a dinamikus táblát együtt kell vizsgálni. A következő saját belépési pontos programban nem várunk PT_INTERP, PT_DYNAMIC vagy DT_NEEDED bejegyzést.

4.3.5 A program életciklusa: az execve hívástól az első utasításig

Először a natív AArch64 Linux fogalmi menetét tekintsük át.

  1. Egy folyamat meghívja az execve rendszerhívást. Siker esetén a meglévő folyamat programképe lecserélődik; maga az execve nem hoz létre új folyamatot. A shell előzetesen létrehozhatott külön gyermekfolyamatot.
  2. A kernel ellenőrzi a fájl végrehajthatóságát, felismeri az ELF-formátumot, és megvizsgálja többek között az osztályt, architektúrát és programfejléceket.
  3. Létrehozza az új címtér megfelelő tartományait. A PT_LOAD szegmenseket a megadott tartalommal és jogosultságokkal leképezi, a fájlban nem tárolt részek nulla kezdőértéket kapnak.
  4. Előkészíti a kezdeti vermet az argumentumokkal, környezeti változókkal és a segédvektorral (auxiliary vector). Az A64 SP regiszter erre a veremre mutat.
  5. Ha nincs PT_INTERP, a vezérlés a program belépési pontjára kerül. Ha van, először az ELF-értelmező belépési pontja következik.
  6. A dinamikus linker szükség esetén betölti a függőségeket, elvégzi az induláshoz szükséges áthelyezéseket, majd átadja a vezérlést a főprogram belépési pontjának.

A leképezés nem feltétlenül jelent azonnali fizikai beolvasást: a lapok egy része igény szerinti lapozással (demand paging) válhat ténylegesen elérhetővé.

PIE esetén az ELF-ben tárolt címekhez betöltési eltolás (load bias) társulhat. A futásidejű belépési cím ilyenkor az eltolás és az e_entry összege.

4.3.5.1 Hol van a _start határa?

A _start szimbólumnév, nem az ELF-betöltő által kötelezően keresett különleges karakterlánc. A linker a választott szimbólum címét beírja az e_entry mezőbe. Futtatáskor már ez a cím számít.

A belépés nem szokásos AAPCS64 függvényhívás: nem egy A64 BL utasítás hívta meg a _start kódját. Az A64 X30 ezért nem tekinthető használható visszatérési címnek. Saját belépési pontunkból rendszerhívással lépünk ki, nem A64 RET utasítással.

A betöltő nem hívja meg automatikusan a C main függvényét. A szokásos C-programban ezt a feladatot a futtatókörnyezet indulókódja készíti elő.

QEMU-különbség: x86_64 hoston a qemu-aarch64 indításakor a host kernel magát a QEMU-programot indítja el. A vendég AArch64 ELF betöltését és az A64 utasítások végrehajtását a QEMU kezeli; a vendég rendszerhívásokat a host szolgáltatásaira közvetíti. Ez nem egy teljes AArch64 Linux-kernel emulációja.

4.3.6 Kidolgozott példa: a hello world ELF-szerkezetének vizsgálata

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

A következő, lab-hello-elf.s nevű fájl az előző program kismértékű bővítése. Az AArch64 ELF-szekciók közül a .rodata üzenetet, a .data kilépési állapotot, a .bss ellenőrizhető nullázott tárhelyet tartalmaz.

A teljes blokk kontextusa: A64 ISA, AArch64 Linux; a direktívák GNU assembler-direktívák AArch64 ELF-célhoz.

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

_start:
    ldr     x9, =lab_zero
    ldr     x10, [x9]
    cbnz    x10, lab_fail

    mov     x0, #1
    ldr     x1, =lab_message
    mov     x2, #lab_message_len
    mov     x8, #64
    svc     #0

    cmp     x0, #lab_message_len
    b.ne    lab_fail

    ldr     x9, =lab_exit_status
    ldr     w0, [x9]
    mov     x8, #93
    svc     #0

lab_fail:
    mov     x0, #1
    mov     x8, #93
    svc     #0

.size _start, . - _start
.ltorg

.section .rodata
lab_message:
    .ascii "Hello, ELF!\n"
.equ lab_message_len, . - lab_message

.section .data
.balign 4
lab_exit_status:
    .word 0

.section .bss
.balign 8
lab_zero:
    .skip 8

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

A kód először ellenőrzi a .bss nyolc bájtjának nullázását. A kiírás után az A64 CMP és B.NE azt ellenőrzi, hogy a visszaadott bájtszám megegyezik-e az üzenet hosszával. Rövid írást hibának tekintünk; újrapróbálkozó I/O-ciklust itt nem építünk.

A használt konstansok kódolhatók egyetlen A64 MOV alias utasítással, 16 bites, eltolás nélküli MOVZ formában. Az A64 CMP immediate-változatának 12 bites, opcionálisan 12 bittel balra tolt konstansába a 12 bájtos hossz belefér; az A64 SVC immediate-mezője 16 bites. Az A64 LDR =szimbólum GNU pszeudoutasítás itt közeli literálkészletből tölt címet. A literálbetöltés, valamint az itt használt feltételes ágak előjeles, 19 bites, néggyel skálázott relatív eltolása elegendő ehhez a rövid kódhoz. A részletes konstanskezelést a MOV-variánsok tárgyalja.

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

Cél: qemu-user, AArch64 Linux. A parancsok az Ubuntu x86_64 hoston futnak.

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

aarch64-linux-gnu-ld -static -z separate-code -e _start \
    -o lab-hello-elf lab-hello-elf.o

qemu-aarch64 ./lab-hello-elf > lab-hello-elf.out
lab_status=$?

cat lab-hello-elf.out
printf 'exit=%s\n' "$lab_status"

printf 'Hello, ELF!\n' | cmp - lab-hello-elf.out
printf 'cmp=%s\n' "$?"

Várt eredmény: egy Hello, ELF! sor, majd exit=0 és cmp=0. A bájtpontos összehasonlítás a lezáró sortörést is ellenőrzi.

Az explicit -e _start választja ki a belépési szimbólumot. A -z separate-code a kód elkülönített betöltési elrendezését kéri; a pontos szegmensszámot nem rögzítjük feltételezésként.

4.3.6.2 A readelf kimenetének értelmezése

Vizsgálati cél: az előző qemu-user program; az eszközök a hoston futnak.

aarch64-linux-gnu-readelf -h lab-hello-elf.o
aarch64-linux-gnu-readelf -h lab-hello-elf
aarch64-linux-gnu-readelf -lW lab-hello-elf
aarch64-linux-gnu-readelf -SW lab-hello-elf
aarch64-linux-gnu-readelf -d lab-hello-elf
aarch64-linux-gnu-nm -n lab-hello-elf
aarch64-linux-gnu-objdump -d lab-hello-elf

A teljes kimenet helyett a következő ellenőrzési pontokra koncentráljunk:

Vizsgálat Várt megfigyelés Következtetés
Objektumfájl, -h REL, AArch64 Még linkelendő AArch64 objektum
Végrehajtható fájl, -h ELF64, little-endian, EXEC, AArch64 A választott profilnak megfelelő program
Entry point address és nm A belépési cím egyezik _start címével A linker a kívánt kezdőpontot választotta
-lW, LOAD sorok Fájleltolások, virtuális címek, méretek, jogosultságok A betöltési nézet látható
Section to Segment mapping Szekciók hozzárendelése szegmensekhez Nem szükségszerű az egy-egy megfelelés
-SW, .bss NOBITS, mérete 8 bájt A tárhely tartalma nincs nyolc adatbájtként tárolva
Írható LOAD MemSiz > FileSiz Nullázandó memóriarész tartozik hozzá
-lW, -d Nincs interpreter és dinamikus szakasz Ehhez a programhoz nem kell dinamikus linker

A Flg oszlopban az R olvasást, a W írást, az E végrehajtást jelöl. A belépési címnek ebben a programban végrehajtható PT_LOAD tartományba kell esnie.

A címeket a saját fájlból olvassuk ki; nem használunk rögzített mintacímet. A linker alapértelmezett szkriptje és a toolchain változata az elrendezést befolyásolhatja.

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

„Van .text, tehát futtatható.”
Hipotézis: az assembler .o kimenetét próbáltuk futtatni. Adatgyűjtés: readelf -h és readelf -l. Ha ET_REL fájlt látunk, a javítás a végső linkelési lépés, nem a fájlnév átírása.

„A .bss nem tárol adatbájtokat, ezért olvasni sem lehet.”
Ellenpróba: a laborprogram A64 LDR utasítása betölti a lab_zero értékét, és nem nulla esetén hibával kilép. Ez a nyolc bájt a memóriakép része, nem a fájl adatbájtjainak része.

„A _start név szükséges a betöltőnek.”
Adatgyűjtés: hasonlítsuk össze az e_entry értékét a szimbólumtábla címével. A betöltési szerződés a cím; a név a linkelésben segít. A futtatáshoz nem szükséges a normál .symtab szimbólumtábla megőrzése.

„Minden regiszter nulla a program elején.”
A nullázott .bss tartalmából nem következik az általános célú A64 regiszterek nullázottsága. A programnak minden felhasznált regisztert inicializálnia kell, vagy dokumentált indulási szerződésre kell támaszkodnia.

4.3.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért elegendő a betöltőnek a programfejléc-tábla a .text, .data és .bss szekciónevek ismerete nélkül? Miért nem ugyanaz az e_entry és az e_phoff?

  2. (implementációs, középszint — cél: rutinépítés) Két külön kísérletben módosítsd a programot:

    Mindkét esetben építsd újra és futtasd a programot. Vizsgáld a kilépési állapotot, a szekcióméreteket és a PT_LOAD méreteit.

  3. (hibakeresési, középszint — cél: rutinépítés) Készíts hibás belépésipont-választással külön binárist. Cél: qemu-user; linkelés a hoston.

    aarch64-linux-gnu-ld -static -z separate-code -e lab_missing \
        -o lab-hello-elf-wrong lab-hello-elf.o
    
    aarch64-linux-gnu-readelf -h lab-hello-elf-wrong
    aarch64-linux-gnu-nm -n lab-hello-elf-wrong
    aarch64-linux-gnu-objdump -d lab-hello-elf-wrong

    Jegyezd fel a linker diagnosztikáját. Megbízható-e a bináris pusztán azért, mert esetleg ugyanazt a belépési címet kapta, mint a helyes változat? Egyetlen kapcsoló módosításával javítsd.

4.3.9 Megoldások

1. megoldás — Segítség. Nézd meg a PT_LOAD mezőtáblázatot — szerepel-e benne bárhol szekciónév? Az e_entry és az e_phoff közül melyik virtuális cím, melyik fájleltolás?

1. megoldás — Teljes megoldás. A PT_LOAD bejegyzések megadják a betöltendő fájltartományokat, virtuális címeket, memóriaméreteket és jogosultságokat. A nulla kitöltés a méretkülönbségből következik; ehhez nem kell felismerni a .bss nevet. Az e_entry végrehajtási cím, az e_phoff pedig egy fejléc-tábla fájlbeli eltolása.

2. megoldás — Segítség. A .word 0→.word 7 csak a .data TARTALMÁT érinti, a méretét nem — mit vár ettől a kilépési állapot? A .skip 8→.skip 4096 viszont a .bss MÉRETÉT növeli — ez p_filesz-t vagy p_memsz-t növeli inkább?

2. megoldás — Teljes megoldás. Az első változat várt kimenete változatlan, kilépési állapota 7. Az A64 LDR W0 a 32 bites adatot tölti be, és az A64 X0 felső 32 bitjét nullázza. A bájtösszehasonlítás továbbra is sikeres lehet: a helyes szöveg és a nulla kilépési állapot külön tulajdonság.

A második változatban a .bss mérete 4096 bájt, a kilépési állapot ismét 0. Az érintett szegmens memóriamérete nő, de nem kerül 4088 további nulla adatbájt a fájlba. A teljes fájlméret pontos változását nem jósoljuk meg pusztán ebből, mert egyéb elrendezési részletek is számítanak. A program csak az első nyolc bájtot ellenőrzi.

3. megoldás — Segítség. Ha a linker nem találja a kért szimbólumot, biztos, hogy hibával leáll, vagy választhat helyette valamilyen alapértelmezett címet? Mi történne, ha ez a cím véletlenül megegyezne a helyes _start-tal?

3. megoldás — Teljes megoldás. A GNU linker várhatóan jelzi, hogy a kért szimbólum nem található, és helyettesítő belépési címet választ. Ennek pontos szövegét és címválasztását a helyi verzión kell ellenőrizni. A helyettesítő cím véletlenül egybeeshet _start címével, így a futási próba önmagában elfedheti a hibát. A javítás -e _start; utána a diagnosztikát és az e_entry–szimbólum egyezést is újra ellenőrizni kell.

4.3.10 Rövid összefoglaló

Az ELF egyszerre szolgálja a linkelést és a programbetöltést, de ezek eltérő nézeteket használnak. A szekciók a kód és adat szervezését, a programfejlécek a futásidejű leképezést írják le. A .bss nulla kezdőértékű memóriát foglal anélkül, hogy tartalmát teljes egészében tárolnánk a fájlban. A linker választja ki a belépési címet; a betöltő nem a _start nevet keresi. Dinamikus programnál először az értelmező indulhat. A readelf és a szimbólumtábla összevetése ezért a futási próba fontos kiegészítője.

4.3.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.

Nyitott ellenőrzések, amelyek a fenti ellenőrzés után is ténylegesen fennmaradnak: az Ubuntu 24.04 telepített binutils- és QEMU-verziója, a konkrét linker-szkript szegmenselrendezése, a hibás belépési szimbólum diagnosztikája, valamint a laborprogram tényleges kimenete.