16.6 Startup kód: .data másolása, .bss törlése

16.6.1 Motiváció: miért nem elég a linker script?

Az előző alfejezetben (Linker script alapjai: szekciók memóriába helyezése) láttuk, hogy a .data szekció VMA-ja (futásidejű címe) RAM-ban van, míg az LMA-ja (betöltési címe) a flashben. Ez a szétválás nem esztétikai: a flash memória a Cortex-M-en tipikusan csak olvasható a futó program számára, így a C nyelvű globális változók kezdőértéke egy másik memóriarégióba kerül, mint ahol futásidőben lakniuk kell.

A C programozó ezt soha nem látja: azt írja, hogy

int counter = 42;     /* inicializált globális */
int buffer[64];       /* nulla-inicializált globális */

és elvárja, hogy a main() első utasításakor a counter értéke 42, a buffer minden eleme 0 legyen. Ez az elvárás nem az ISA-ból, hanem a C runtime szerződéséből következik. Az ISA csak annyit mond, hogy bizonyos bájtok bizonyos címen adott értéket tartalmaznak — azt nem, hogy ezt ki és mikor állítja be. Ezt a feladatot a startup kód (startup code, néha reset handler) végzi el, mielőtt bármilyen C függvény lefutna. Ebben az alfejezetben megírjuk a .data másoló és a .bss nullázó ciklust, és megmutatjuk, miért nélkülözhetetlen a linker script és a startup kód összehangolása.

16.6.2 Definíció és működés

Startup kód (startup code): az a legalacsonyabb szintű, kézzel írt assembly rutin, amely a main() meghívása előtt lefut, és a C runtime előfeltételeit megteremti. A Cortex-M-en ezt tipikusan a reset vektorba írt címre helyezzük (a vektortábla részleteit lásd Reset és boot-folyamat: vektortábla), így a processzor hardveres újrainduláskor automatikusan ide kerül vezérlés.

A startup kód legalább három dolgot végez:

  1. Statikus inicializált adat másolása a flash-beli kezdőérték-tömbből a RAM-beli .data szekcióba.
  2. .bss szekció nullázása — azok a globális és statikus változók, amelyeket C-ben nem inicializáltunk, a C standard szerint 0 értékkel kezdenek. Ehhez nem tárolunk 0-bájtokat a flashben; csak a területet foglaljuk le, és futásidőben nullázzuk.
  3. A main() meghívása — innen veszi át a vezérlést a C világ.

Két külön rutin, két különböző cél. Az első adatot mozgat; a második adat nélkül dolgozik (csak nulláz). Mindkettő ciklushatárait a linker scriptből exportált szimbólumok adják.

16.6.3 Miért nem elég a flash-beli inicializálás?

Képzeljünk el egy .data szekciót, amely 1 KiB-ot foglal el a RAM-ban. Futásidőben a program írhat bele (hiszen ez RAM). Ha a .data LMA-ját és VMA-ját ugyanarra a flash-beli címre tennénk, akkor a counter = 43; utasítás egy flash-címre próbálna írni — a Cortex-M-en ez vagy hard fault, vagy (rosszabb esetben) csendben elveszik, és a globális változó minden írás után visszaáll a kezdőértékre. Ezért van az AT> FLASH és a > RAM kombináció.

A startup kód tehát a linker által kigondolt LMA→VMA leképezést valósítja meg futásidőben: bájtonként (szavanként) átmásolja a tartalmat a flash-beli helyéről a RAM-beli helyére.

16.6.4 A linker script exportált szimbólumai

A linker script SECTIONS blokkjában szimbólumokat definiálhatunk úgy, hogy egy címkéhez a szekció aktuális helyét kötjük. A Linker script alapjai kanonikus lab-mps2.ld-jének vonatkozó része (a teljes scriptet ott találod, ez az alfejezet változatlanul azt használja):

  .data : ALIGN(4) {               /* VMA: RAM, LMA: FLASH */
    _sdata = .;
    *(.data*)
    . = ALIGN(4);                  /* a vég is szóhatár: a szavas másolás nem lép túl */
    _edata = .;
  } > RAM AT> FLASH
  _sidata = LOADADDR(.data);

  .bss (NOLOAD) : ALIGN(4) {
    _sbss = .;
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

A startup kód és a linker script közös szerződése: mind az öt határ (_sidata, _sdata, _edata, _sbss, _ebss) 4 bájtra igazított, és _sdata <= _edata, _sbss <= _ebss. A linker script ezt ASSERT-tel ellenőrzi, a startup kód pedig erre a szerződésre építve dolgozhat szavanként.

Ezek a szimbólumok nem memóriacímet tartalmazó változók: maguk a szimbólumok léteznek, és a címük az, amit képviselnek. Assemblyben ezért így hivatkozunk rájuk:

ldr r1, =_sdata

A ldr Rd, =symbol pszeudo-utasítás a linker relocation során symbol címét tölti be Rd-be. Nincs szükség [pc, #offset] kézi számolgatásra — a linker elvégzi helyettünk.

Fontos: a startup assembly fájlban ezek a szimbólumok külső (extern) szimbólumok, ezért nem kell .global direktívával deklarálni őket (a linker script globális szimbólumai bármelyik objektumból láthatók). Ha viszont egy assembly vagy C fájl .extern _sdata-ként hivatkozik rájuk, az is működik — a fordítás szempontjából az a fontos, hogy ne definiáljuk újra.

16.6.5 Kidolgozott példa: teljes startup rutin

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

Az alábbi lab-startup.s a II. rész kanonikus startup kódja: a vektortáblát (.vectors), a Reset_Handler-t és a gyenge aliasokkal bekötött Default_Handler-t tartalmazza. A párja a Linker script alapjai lab-mps2.ld-je; a firmware build és a későbbi laborok ezt a két fájlt másolják át.

/* lab-startup.s — a II. rész kanonikus startup kódja
 * ISA: Thumb-2 / Armv7-M; Target: qemu-system-arm -machine mps2-an385
 * Szerződés a lab-mps2.ld-vel: _sidata, _sdata, _edata, _sbss, _ebss
 * mind 4 bájtra igazított (a linker script ASSERT-tel ellenőrzi). */
    .syntax unified
    .cpu cortex-m3
    .thumb

    .section .vectors, "a", %progbits
    .align 2
    .global __vectors
__vectors:
    .word _estack                 /* 0x00: kezdeti SP (MSP)      */
    .word Reset_Handler           /* 0x04: Reset (bit0 = 1)      */
    .word NMI_Handler             /* 0x08: NMI                   */
    .word HardFault_Handler       /* 0x0C: HardFault             */
    .word MemManage_Handler       /* 0x10: MemManage             */
    .word BusFault_Handler        /* 0x14: BusFault              */
    .word UsageFault_Handler      /* 0x18: UsageFault            */
    .word 0, 0, 0, 0              /* 0x1C-0x28: fenntartott      */
    .word SVC_Handler             /* 0x2C: SVCall                */
    .word DebugMon_Handler        /* 0x30: DebugMonitor          */
    .word 0                       /* 0x34: fenntartott           */
    .word PendSV_Handler          /* 0x38: PendSV                */
    .word SysTick_Handler         /* 0x3C: SysTick               */

    .text
    .thumb_func
    .global Reset_Handler
    .type   Reset_Handler, %function
Reset_Handler:
    /* 1. .data másolása FLASH (LMA) -> RAM (VMA), szavanként.
       Elöltesztelő ciklus, előjel nélküli összehasonlítással: üres
       szekciónál egyszer sem fut, és a végcímen pontosan megáll. */
    ldr   r0, =_sidata
    ldr   r1, =_sdata
    ldr   r2, =_edata
1:  cmp   r1, r2
    bhs   2f
    ldr   r3, [r0], #4
    str   r3, [r1], #4
    b     1b
    /* 2. .bss nullázása, szavanként. */
2:  ldr   r1, =_sbss
    ldr   r2, =_ebss
    movs  r3, #0
3:  cmp   r1, r2
    bhs   4f
    str   r3, [r1], #4
    b     3b
    /* 3. main(); ha visszatérne, végtelen ciklus. */
4:  bl    main
5:  b     5b
    .size Reset_Handler, . - Reset_Handler

    .thumb_func
    .global Default_Handler
    .type   Default_Handler, %function
Default_Handler:
    b     .
    .size Default_Handler, . - Default_Handler

    /* Gyenge aliasok: amit a firmware nem definiál, az a Default_Handlerre fut. */
    .macro lab_default_handler name
    .weak       \name
    .thumb_set  \name, Default_Handler
    .endm
    lab_default_handler NMI_Handler
    lab_default_handler HardFault_Handler
    lab_default_handler MemManage_Handler
    lab_default_handler BusFault_Handler
    lab_default_handler UsageFault_Handler
    lab_default_handler SVC_Handler
    lab_default_handler DebugMon_Handler
    lab_default_handler PendSV_Handler
    lab_default_handler SysTick_Handler

Nézzük a szerződéseket és a részleteket:

4 bájtos granularitás és a végcím. A ciklusok ldr/str szópárokkal dolgoznak, 4 bájtos lépésközzel. Ehhez nem kell, hogy minden C-objektum mérete 4 többszöröse legyen: a linker script a .data és a .bss kezdetét és végét is szóhatárra igazítja, a köztes réseket és a végét kitölti. Egy 1, 3 vagy 5 bájtos uint8_t objektum tehát belefér a szerződésbe; a firmware build lab-main.c-je pontosan ilyen objektumokkal ellenőrzi a bootot. Ha a végcím mégsem lenne szóhatár, azt a linker ASSERT-je már linkeléskor elutasítja.

Előjel nélküli, elöltesztelő ciklus. Mindkét ciklus a törzs előtt vizsgál (cmp + bhs), ezért üres szekciónál egyszer sem fut. A bhs (unsigned higher or same) címekre való összehasonlítás: a ciklus addig fut, amíg a mutató kisebb a végcímnél. Ez nem egyenlőségvizsgálat. Egy cmp + bne ciklus csak akkor állna meg, ha a mutató pontosan elérné a végcímet, és egy nem 4 többszörös hossznál átlépné azt. Az ellenpróbát lent mérjük.

A regiszterek megválasztása. Az r0..r3 regisztereket használjuk munka-regiszterként. A startup kód itt maga a Reset_Handler, ahol még nincs értelmes programállapot, ezért a main() hívása előtt szabadon használhatjuk a caller-saved regisztereket. A main() hívásakor viszont már betartjuk az AAPCS-t: nem feltételezzük, hogy r0..r3 érintetlen marad a visszatérés után — pont ezért nem használjuk őket a main() előtti és utáni állapot összekapcsolására.

Ellenpróba: mi történik szerződésszegésnél? A helyettesítő eszközlánccal (Clang + ld.lld, Unicorn-alapú Cortex-M3 emulátor) három változatot építettünk ugyanabból a lab-main.c-ből, amelyben 1, 3 és 5 bájtos .data- és .bss-objektumok vannak:

Linker script Startup ciklus Eredmény
kanonikus (ALIGN(4) a végeken, ASSERT) cmp + bhs boot ok
a .data végéről kivett ALIGN(4), ASSERT bekapcsolva – a linkelés megáll: .data bounds not word aligned
ugyanez, ASSERT nélkül (_edata = 0x2000000d) cmp + bhs boot ok, de a ciklus a 0x2000000d–0x2000000f kitöltő bájtokat is írja; ez csak azért ártalmatlan, mert a .bss itt szóhatáron kezdődik
ugyanez, ASSERT nélkül cmp + bne (a korábbi kiadás mintája) a mutató a 0x2000000c → 0x20000010 lépéssel átlépi a 0x2000000d végcímet, és a ciklus nem áll meg: a RAM nagy részét felülírja, majd az emulátorban a forrásmutató a kódterület végén túl olvasva fault-ot okoz

A tanulság: a helyes viselkedést nem egyetlen elem adja, hanem a szerződés két oldala együtt. Az igazítást a linker script garantálja és ellenőrzi, a ciklus pedig a tartományvégre (bhs), nem az egyenlőségre teszteli a mutatót.

A .bss nullázás sorrendje. A .bss nullázást a .data másolás után végezzük, mert a két szekció a RAM-ban egymást követi (a linker script így rendezi el őket), és bár a másolás nem érinti a .bss-t, a sorrend a szokásos konvenció. Ha .bss előbb lenne, sem lenne hiba — a két rutin egymástól független.

16.6.6 Várt futási állapot

A qemu-system-arm -machine mps2-an385 -kernel lab-firmware.elf -nographic parancs futtatása után, közvetlenül a main() belépése előtt:

Regiszter Érték (példa) Jelentés
r0 _sidata + (_edata − _sdata) a forrás-mutató a .data másolás után, a betöltési kép vége
r1 _ebss = _sbss + .bss méret a .bss végére mutat
r2 _ebss a ciklus felső határa
r3 0 a .bss nullázás után
SP a linker script által definiált _estack a verem teteje

Ellenőrzés: a main() első sora után állítsunk be egy töréspontot GDB-ben (break main), és olvassuk ki a .data és .bss tartalmát. A .data értékének meg kell egyeznie a flash-beli LMA-val; a .bss minden bájtja 0.

Az mps2-an385 memóriatérképét a QEMU dokumentációja adja meg; a konkrét címtartományok (a flash-oldali régió és a RAM régió) QEMU-verziófüggők lehetnek, ezért ezeket a linker scriptben kell rögzíteni, és a build során a map fájllal ellenőrizni.

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

1. A szimbólumok elgépelése. Ha a linker scriptben _sdata_ vagy _sData szerepel, de az assembly _sdata-ra hivatkozik, a linker „undefined symbol” hibát ad. Tünet: linkelési hiba, nem futásidejű. Ellenpróba: nm lab-firmware.elf | grep -E '_s?data' — a szimbólumnak meg kell jelennie.

2. A .data vagy .bss vége nem szóhatár. Ha a linker script a záró . = ALIGN(4); nélkül definiálja az _edata/_ebss szimbólumot, a szavas ciklus a szekció végén túl is ír. A bhs ciklus legfeljebb 3 bájttal lépi túl a végcímet, ami a következő szekció elejét írhatja felül; egy bne ciklus meg sem áll. Tünet: a .bss első bájtjai felülíródnak, vagy a boot fault-tal elszáll. Ellenpróba: a kanonikus scriptben az ASSERT ezt linkeléskor jelzi; kézzel nm -n lab-firmware.elf — az _edata és az _ebss értéke legyen 4-gyel osztható.

3. A .bss nullázás kimarad. A C kód lefut, de egy „nullázatlan” globális véletlenszerű értéket tartalmaz. Az mps2-an385 esetében a SRAM tartalma hardver-indításkor nem garantáltan 0 — ezért a C standard megköveteli, hogy a startup kód nullázza. Tünet: a buffer elemei nem 0-k, vagy a counter kezdőértéke nem az, amit a C kód deklarált. Ellenpróba: állítsuk be a .bss régióját előzetesen nem-nulla értékre (QEMU monitorból vagy egyéb debug interfészből), majd futtassuk — a startup után minden 0-nak kell lennie.

4. A _sidata és _sdata felcserélése. A copy ciklus a flash-ből RAM-ba másol, nem fordítva. Ha tévedésből _sdata a forrás és _sidata a cél, a ciklus a flash-beli betöltési képre próbál írni. Valódi flash-es mikrokontrolleren ez tipikusan BusFault/HardFault vagy csendben hatástalan írás. A QEMU mps2-an385-ön viszont a FLASH nevű régió valójában írható SSRAM (memóriatérkép), ezért ott nincs fault: a ciklus csendben felülírja a betöltési képet a RAM tartalmával, és a .data változók rossz kezdőértékkel indulnak. Tünet QEMU-ban: boot FAIL, kivétel nélkül.

5. A main() bl helyett b. A b main nem menti a visszatérési címet, és nem állítja be az LR-t. A main visszatérésekor a processzor egy véletlenszerű címre ugrik. Tünet: a main visszatérése utáni viselkedés kaotikus. Ellenpróba: objdump -d lab-firmware.elf | grep -A20 '<Reset_Handler>' | grep main — a main hívásnak bl-nek kell lennie.

16.6.8 Feladatok

1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Magyarázd el, miért nem oldható meg a .data inicializálás pusztán a linker scripttel, holott a linker ismeri a .data VMA-ját és LMA-ját is. Mi az a lépés, amit futásidőben kell megtenni, és miért nem „elég okos” ehhez a linker?

2. (implementációs, középszint — cél: rutinépítés) Módosítsd a startup rutint, hogy 1 bájtos granularitású legyen (a .data és .bss méretének 4-gyel való oszthatósága nélkül is működjön). Gondoskodj róla, hogy a ciklusok előbb a maradék 1–3 bájtot másolják/nullázzák, majd az igazított 4 bájtos blokkokat. Ellenőrizd, hogy a rutin továbbra is helyes, ha a méret 0, vagy ha pontosan 4.

3. (hibakeresési/mérési, elmélyült — cél: fejezetek összekapcsolása: Memory-mapped I/O alapelve) A qemu-system-arm -machine mps2-an385 célon futtasd a startup kódot úgy, hogy a main() helyett egy olyan függvény hívódjon, amely kiírja a .data és .bss tartalmát UART-on (a memory-mapped UART részleteit lásd Memory-mapped I/O alapelve). Készíts egy kis szkriptet, amely megváltoztatja a _sidata-t és a _sdata-t felcserélő verziót, és figyeld meg, mi történik a bl main után. Gyűjtsd össze, milyen regiszterértékek és kivétel-típusok jelennek meg.

16.6.9 Megoldások

1. megoldás — Segítség. A linker egy FORDÍTÁSI IDEJŰ eszköz — fut-e még, amikor a programnak ténylegesen másolnia kellene a bájtokat a flashből a RAM-ba?

1. megoldás — Teljes megoldás. A linker statikus időben dolgozik, és csak a címeket ismeri. A .data LMA-ja a flashben van, VMA-ja a RAM-ban. A flash tartalma a programozás után fix; a RAM tartalma futásidőben alakul. A linker nem tudja „átmásolni” a bájtokat, mert a program nem fut, amikor a linker dolgozik. A másolást egy futó processzornak kell elvégeznie — ez a startup kód feladata. Az ISA csak a címzést és az utasításokat adja; a programozó (vagy a C runtime implementációja) dönti el, hogy mit másol hova.

2. megoldás — Segítség. Gondolkodj két fázisban: előbb egy szavas (4 bájtos) ciklus addig fut, amíg még BELEFÉR egy teljes szó a maradék tartományba, utána egy bájtos ciklus takarítja el a maradékot — melyik feltétel dönti el, hogy a szavas ciklus mikor áll le?

2. megoldás — Teljes megoldás. A bájt-granularitású változat:

    /* .data másolás bájt-granularitással */
    ldr r0, =_sidata
    ldr r1, =_sdata
    ldr r2, =_edata
    /* először 4-gyel osztható méretig szó, majd maradék bájt */
    b   data_w_check
data_w_copy:
    ldr r3, [r0], #4
    str r3, [r1], #4
data_w_check:
    add r3, r1, #4
    cmp r3, r2
    bls data_w_copy            /* előjel nélkül: r1 + 4 <= r2 */
data_b_copy:
    cmp r1, r2
    bhs data_done
    ldrb r3, [r0], #1
    strb r3, [r1], #1
    b   data_b_copy
data_done:

A .bss nullázás hasonlóan: előbb str szavakkal amíg a mutató + 4 ≤ _ebss, majd strb a maradékra. Ez a megoldás akkor is helyes, ha a méret 0 (mindkét elsődleges ciklus üresen fut le), vagy pontosan 4 (a bájtos rész azonnal kilép). A lényeg: a feltételek előjel nélküliek (bls, bhs, címekre ez a helyes), és a bájtos ciklus is tartományvégre tesztel, így a mutató nem csúszhat túl a végen. A kanonikus scriptben erre nincs szükség, mert az ALIGN(4) és az ASSERT a szavas változatot is biztonságossá teszi; a bájtos farokkezelés akkor kell, ha a linker scriptet nem mi írjuk.

3. megoldás — Segítség. Ha a forrás- és céltartomány-pointer felcserélődik, a program egy RAM-címről MÁR helyesen olvas, de a tárolás célja megváltozik — mi van a flash-címtartomány írási jogosultságával?

3. megoldás — Teljes megoldás. A _sidata/_sdata felcserélése esetén a ldr r3, [r0], #4 utasítás egy RAM-címről olvas (ez még működik), majd a str r3, [r1], #4 a betöltési kép (FLASH nevű régió) címére ír. A kimenetel a céltároló tulajdonságán múlik, nem az utasításon. Valódi flash-es eszközön a flash-vezérlő az ilyen írást elutasítja vagy figyelmen kívül hagyja; az előbbi BusFault-ként, eszkalálva HardFault-ként jelenik meg. A QEMU mps2-an385-ön a FLASH régió írható SSRAM, ezért az írás csendben sikerül: a betöltési kép felülíródik, a RAM-beli .data pedig nem kapja meg a kezdőértékét, és a kimenet boot FAIL lesz kivétel nélkül. Ennek ellenőrzésére GDB-vel érdemes a str utasítás után megállni és a RAM- és a betöltési kép tartalmát összevetni. A gyűjtött regiszterértékek közül a legfontosabb a PC a hiba után (a fault handler címe) és az LR (EXC_RETURN érték), amely a stack frame típusát jelzi.

16.6.10 Rövid összefoglaló

A startup kód felelős a C runtime előfeltételeiért: a .data szekciót a flash-beli LMA-ból a RAM-beli VMA-ba másolja, a .bss szekciót nullázza, majd a main()-t hívja. A ciklushatárok a linker scriptből exportált _sidata, _sdata, _edata, _sbss, _ebss szimbólumok. A tipikus hibaforrások a szimbólum-elgépelés, a 4 bájtos igazítás megsértése, a .bss kihagyása, és a másolási irány fordítottja. A kanonikus pár (lab-mps2.ld + lab-startup.s) szerződése: szóhatárra igazított határok, amelyeket a linker ASSERT-tel ellenőriz, és elöltesztelő, előjel nélküli (bhs) ciklusok, amelyek üres szekciónál nem futnak, és a végcímen túl nem írnak.

Továbbhasznált definíciók: - startup kód: assembly rutin, amely a main() előtt a C runtime előfeltételeit biztosítja (.data másolás, .bss nullázás). - LMA és VMA: betöltési és futásidejű cím; a .data esetében a LMA flashben, a VMA RAM-ban van. - .bss szekció: nulla-inicializált globális/statikus változók szekciója; tartalmát futásidőben nullázni kell.

16.6.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 kanonikus lab-startup.s-t és az ellenpróba-táblázat mind a négy sorát a helyettesítő eszközlánccal (Clang + ld.lld) és a Unicorn-alapú tools/cm-run.py Cortex-M3 emulátorral futtattuk, nem GNU eszközökkel és nem qemu-system-arm-mal. A fennmaradó tételek: az mps2-an385 pontos flash-/RAM-címtartománya és a -kernel alapértelmezett betöltési címe az aktuális QEMU-verziótól függ; ezt a hallgatónak a saját laborkörnyezetében kell a linker script és a map fájl alapján rögzítenie. A GNU toolchain csomagolt verziója (Ubuntu 24.04) a reprodukálható baseline, de a konkrét ld- és as-kiadás viselkedése apró részletekben eltérhet — érdemes a nm, readelf -S, objdump -d kimenetét a build során rendszeresen ellenőrizni.