7.3 Struktúrák, igazítás és mezőelérés

7.3.1 Miért számít a struktúra tényleges elrendezése?

Egy C-ben létrehozott rekordot assemblyben szeretnénk módosítani. Ismerjük a mezők nevét és típusát, de az A64 memóriautasításai nem ismerik sem a struktúrát, sem a mezőneveket: báziscímet és bájteltolást kapnak. A mérnöki feladat ezért az, hogy a C-fordító által választott adatelrendezést pontos címzési szerződéssé alakítsuk.

A mezők méretének egyszerű összeadása nem elegendő. A fordító kitöltő bájtokat helyezhet közéjük, és a struktúra végét is kiegészítheti. Egy hibás offset ettől még gyakran érvényes memóriacímre mutat: a program nem feltétlenül omlik össze, csak rossz mezőt olvas.

Az alfejezet profilja A64 ISA, AArch64 Linux, little-endian, LP64 adatmodell. Normál C-struktúrákat vizsgálunk, bitmezők, csomagolási attribútumok és külön előírt igazítás nélkül. Az előfeltétel a Tömbök és indexelt elérés assemblyben című alfejezet: a struktúramező címét szintén bájtokban számoljuk, de az eltolást itt a típus elrendezése határozza meg.

7.3.2 Természetes igazítás és kitöltés

Az igazítás (alignment) azt írja elő, hogy egy objektum kezdőcíme milyen érték többszöröse legyen. Ha egy típus igazítása 8 bájt, akkor szabályosan elhelyezett objektumának címe 8-cal osztható.

A természetes igazítás (natural alignment) az adott típushoz az ABI által rendelt szokásos igazítás. A jelenlegi AArch64 LP64 profilban:

C-típus Méret [bájt] Természetes igazítás [bájt]
char 1 1
int 4 4
long 8 8
objektummutató 8 8

Ezek nem általános C-nyelvi méretgaranciák. Például a long más adatmodellben lehet 4 bájtos. A laborban fordítási idejű ellenőrzésekkel rögzítjük a feltevéseket.

A C megőrzi a nem bitmező tagok deklarációs sorrendjét, és az első tag elé nem tesz kitöltést. A további tagok között, valamint a struktúra végén azonban lehet kitöltés (padding).

A szokásos, itt használt ABI-elrendezésben:

  1. Minden mező a saját igazításának megfelelő offsetre kerül.
  2. A struktúra igazítása a tagok igazításának maximuma.
  3. A struktúra mérete ennek az igazításnak egész számú többszöröse.

A harmadik szabály a struktúrákból álló tömbök miatt lényeges. Ha az első elem megfelelően igazított, akkor a következő, sizeof bájttal később kezdődő elemnek is annak kell lennie.

7.3.3 Levezetés: mezőoffsetek és teljes méret

Vizsgáljuk ezt a C-típust:

/* C-adatelrendezés: AArch64 Linux, LP64, little-endian. */
struct lab_record {
    char tag;
    int count;
    long total;
    char state;
};

Legyen pp az előző mező utáni első szabad bájt offsetje, aa a következő mező igazítása, ss pedig a mérete. Mindhárom mennyiség bájtban értendő. A következő mező kezdete:

o=align_up(p,a)=a⌈pa⌉ o=\operatorname{align\_up}(p,a) =a\left\lceil\frac{p}{a}\right\rceil

A mező előtti kitöltés o−po-p, az új szabad pozíció pedig o+so+s. Ez a levezetés a fenti, normál ABI-elrendezést feltételezi.

Mező Korábbi pp [bájt] Igazítás [bájt] Offset [bájt] Előtte padding [bájt] Mező utáni pozíció [bájt]
tag 0 1 0 0 1
count 1 4 4 3 8
total 8 8 8 0 16
state 16 1 16 0 17

A struktúra igazítása 8 bájt. A 17 bájtos felhasznált tartományt ezért 24 bájtra kell felfelé kerekíteni:

sizeof(𝚜𝚝𝚛𝚞𝚌𝚝 𝚕𝚊𝚋_𝚛𝚎𝚌𝚘𝚛𝚍)=align_up(17,8)=24 bájt. \operatorname{sizeof}(\texttt{struct lab\_record}) =\operatorname{align\_up}(17,8)=24\text{ bájt}.

Az utolsó 7 bájt végi kitöltés (tail padding).

Bájt-offsetek Tartalom
0 tag
1–3 belső kitöltés
4–7 count
8–15 total
16 state
17–23 végi kitöltés

A hasznos mezők összmérete csak 1+4+8+1=141+4+8+1=14 bájt. A fennmaradó 10 bájt kitöltés.

7.3.3.1 A mezősorrend hatása

Ha a nagyobb igazítású mezőket előrevesszük:

/* C-adatelrendezés: AArch64 Linux, LP64, little-endian. */
struct lab_record_compact {
    long total;
    int count;
    char tag;
    char state;
};

akkor a mezőoffsetek rendre 0, 8, 12 és 13. A mezők 14 bájtot foglalnak el, majd 2 bájt végi kitöltés következik: a teljes méret 16 bájt.

Ez rekordonként 8 bájt megtakarítás. A nagyobb igazítású mezők előrevétele jó kiindulópont, de egy már publikált interfészben a sorrend megváltoztatása ABI-változás. A régi assembly offsetjei, illetve a struktúra nyers memóriaképére támaszkodó fájlformátumok többé nem egyeznek az új elrendezéssel.

A padding nem alkalmazásadat. Ne használjuk rejtett mezőként, és ne feltételezzük, hogy minden kitöltő bájt értéke nulla.

7.3.4 sizeof, offsetof és az A64 mezőelérés

A C sizeof operátora a teljes objektumméretet adja vissza, a kitöltéssel együtt. Az <stddef.h> fejléc offsetof makrója egy tag bájteltolását adja meg a struktúra kezdetétől.

A példában:

A count mező címe tehát:

mezőcím=objektum báziscíme+4 bájt. \text{mezőcím}=\text{objektum báziscíme}+4\text{ bájt}.

A64 ISA, AArch64: ha az objektum címe az X0 regiszterben van, a mezőelérés:

// A64 ISA, AArch64; X0 egy érvényes struct lab_record címe.
ldr w1, [x0, #4]       // A count 4 bájtos bitmintája.
ldr x2, [x0, #8]       // A total 8 bájtos bitmintája.
ldrb w3, [x0, #16]     // A state 1 bájtja, nullakiterjesztéssel.

Az utasítás szélességének egyeznie kell a mező szélességével. Az A64 LDR W1 4, az LDR X2 8, az LDRB W3 pedig 1 bájtot olvas. Az A64 LDR W1 az X1 felső 32 bitjét nullázza: ez nem előjeles int → long konverzió. A negatív érték 32 bites bitmintája ettől még helyesen kerül a W1 regiszterbe.

Az itt használt A64 unsigned-offset címzések korlátai (a teljes listát az A64 immediate-korlátok összefoglalója adja):

A64 utasításcsalád Immediate kódolása Leírható bájteltolás
LDR/STR Wn 12 bites előjel nélküli érték, 4-gyel skálázva 0–16380, 4-es lépéssel
LDR/STR Xn 12 bites előjel nélküli érték, 8-cal skálázva 0–32760, 8-as lépéssel
LDRB/STRB Wn 12 bites előjel nélküli érték 0–4095

Az assembly forrásban bájteltolást írunk: a count esetében #4 szerepel, nem a kódolásban tárolt 1.

Az offset kódolhatósága és a tényleges cím igazítása külön kérdés. Egy 8-cal osztható offset nem javítja meg a hibásan igazított báziscímet.

7.3.5 Kidolgozott példa: C-objektum módosítása assemblyből

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

A C-program létrehozza az objektumot, ellenőrzi az elrendezést, majd meghív egy rövid assemblyfüggvényt.

A hívási konvencióból most csak a szükséges szerződést használjuk: A64/AAPCS64 szerint az első mutatóargumentum az X0 regiszterben érkezik; az X1–X3 ideiglenesen felhasználható. A függvény nem hív más függvényt és nem módosítja a vermet. Az A64 RET az X30 visszatérési címét használja. A teljes szabályrendszert Az AAPCS64 regiszterszerződés tárgyalja.

A lab-layout.c tartalma:

/* C-célprofil: AArch64 Linux, LP64, little-endian. */
#include <limits.h>
#include <stddef.h>
#include <stdio.h>

struct lab_record {
    char tag;
    int count;
    long total;
    char state;
};

struct lab_record_compact {
    long total;
    int count;
    char tag;
    char state;
};

_Static_assert(CHAR_BIT == 8, "8 bites bajt szukseges");
_Static_assert(sizeof(int) == 4 && _Alignof(int) == 4,
               "eltero int profil");
_Static_assert(sizeof(long) == 8 && _Alignof(long) == 8,
               "LP64 long profil szukseges");
_Static_assert(offsetof(struct lab_record, tag) == 0, "tag offset");
_Static_assert(offsetof(struct lab_record, count) == 4, "count offset");
_Static_assert(offsetof(struct lab_record, total) == 8, "total offset");
_Static_assert(offsetof(struct lab_record, state) == 16, "state offset");
_Static_assert(sizeof(struct lab_record) == 24, "meret");
_Static_assert(_Alignof(struct lab_record) == 8, "igazitas");
_Static_assert(sizeof(struct lab_record_compact) == 16, "tomor meret");

extern void lab_update(struct lab_record *p);

int main(void)
{
    struct lab_record record = {
        .tag = 0x41,
        .count = -3,
        .total = 1000,
        .state = 1
    };

    printf("size=%zu align=%zu compact=%zu\n",
           sizeof record, _Alignof(struct lab_record),
           sizeof(struct lab_record_compact));
    printf("offsets=%zu,%zu,%zu,%zu\n",
           offsetof(struct lab_record, tag),
           offsetof(struct lab_record, count),
           offsetof(struct lab_record, total),
           offsetof(struct lab_record, state));

    lab_update(&record);

    printf("tag=0x%02x count=%d total=%ld state=%d\n",
           (unsigned int)(unsigned char)record.tag,
           record.count, record.total, (int)record.state);

    return !(record.tag == 0x41 && record.count == 2 &&
             record.total == 1024 && record.state == 2);
}

A lab-layout.s tartalma:

// A64 ISA, ARMv8-A; GNU as direktívák AArch64 célhoz.
.text
.p2align 2
.global lab_update
.type lab_update, %function

lab_update:
    ldr w1, [x0, #4]
    add w1, w1, #5
    str w1, [x0, #4]

    ldr x2, [x0, #8]
    add x2, x2, #24
    str x2, [x0, #8]

    ldrb w3, [x0, #16]
    add w3, w3, #1
    strb w3, [x0, #16]

lab_after_update:
    ret

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

Az ADD konstansai tolás nélkül kódolhatók (lásd az A64 immediate-korlátok összefoglalóját). A GNU as .p2align 2 direktívája a kódot igazítja 22=42^2=4 bájtra; nem a C-struktúra elrendezését módosítja.

7.3.5.1 A lab_update teljes szerződése

A lab_update akkor helyettesíthető a következő C-függvénnyel, ha a lenti előfeltételek teljesülnek:

/* C-referencia: a lab_update C-egyenértékű viselkedése a szerződés tartományán. */
void lab_update_ref(struct lab_record *p)
{
    p->count += 5;
    p->total += 24;
    p->state += 1;
}
Feltétel
Előfeltétel: mutató p nem NULL, élő, írható, struct lab_record típusú objektumra mutat (így a típus 8 bájtos igazítását is teljesíti); a hívás alatt más szál nem éri el az objektumot
Előfeltétel: count p->count <= INT_MAX - 5
Előfeltétel: total p->total <= LONG_MAX - 24
state nincs korlát: AArch64 Linuxon a sima char előjel nélküli, a += 1 modulo 256 definiált, és ugyanazt a bájtot adja, mint az ADD + STRB
Utófeltétel count, total, state a régi érték + 5, + 24, + 1; a tag mező és a kitöltő bájtok nem változnak
Regiszterek és verem csak X1–X3-at írja (caller-saved); a vermet és az NZCV-t nem módosítja (ADD, nem ADDS)

Miért kell a két számtani előfeltétel? Az assembly ADD W1, W1, #5 minden bemeneten definiált: a 32 bites bitmintán modulo 2322^{32} számol, így például INT_MAX − 4-ből pontosan INT_MIN lesz. A C p->count += 5 ugyanezen a bemeneten előjeles túlcsordulás, vagyis definiálatlan viselkedés. A véges bitmintát módosító assembly és a signed C-összeadás ezért csak a C által definiált tartományon azonosítható; azon kívül az assemblynek van eredménye, a C-nek nincs. Aki a lab_update-et C-egyenértékűnek nevezi, annak az előfeltételt is ki kell mondania, és a hívónak kell betartania: az assembly nem ellenőrzi a C értéktartományait.

A tesztbemenet a tartomány belsejében van: a count −3-ról 2-re, a total 1000-ről 1024-re változik, előjeles túlcsordulás nélkül.

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

Target: AArch64 Linux ELF, QEMU user-mode. A statikus linkeléshez az AArch64 cross libc fejlesztői fájljai is szükségesek.

# Host: Ubuntu 24.04 x86_64.
# Target: AArch64 Linux, ARMv8-A, alapértelmezett little-endian.
aarch64-linux-gnu-gcc -std=c11 -O0 -g -Wall -Wextra \
    -march=armv8-a -fno-pie \
    -c lab-layout.c -o lab-layout-c.o

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

aarch64-linux-gnu-gcc -static -no-pie \
    lab-layout-c.o lab-layout-asm.o -o lab-layout

# Target: qemu-user; várt kilépési állapot: 0.
qemu-aarch64 ./lab-layout
printf 'exit=%d\n' "$?"

Várt, levezetett kimenet; itt nem történt futtatás:

A fordítási idejű ellenőrzések az ABI-feltevéseket, a visszatérési érték a mezőmódosításokat vizsgálja. A kettő külön védelmi réteg: a hibás elrendezést jobb már fordításkor észrevenni.

7.3.6 Igazítatlan elérés: mikor működik?

Igazítatlan memóriaelérésről (unaligned access) beszélünk, ha a hozzáférés címe nem többszöröse a hozzáférési szélességhez tartozó természetes igazításnak.

Az A64 alapprofilban nem igaz sem az, hogy minden ilyen elérés tiltott, sem az, hogy minden megengedett.

Helyzet A64/ARMv8-A alapprofil szerinti lényeg
Szokásos LDR/STR, Normal memória, kikapcsolt általános igazításellenőrzés Az igazítatlan elérés megengedett lehet.
Bekapcsolt általános igazításellenőrzés A megfelelő igazítatlan hozzáférés igazítási hibát válthat ki.
Device memóriatípus Az igazítatlan adatelérés nem megengedett.
Exkluzív és atomi hozzáférések Külön, szigorúbb igazítási követelmények érvényesek; a sima LDR/STR szabálya nem vihető át automatikusan.
Hozzáférés nem leképezett lapba is átnyúlik Az igazítás megengedettségétől függetlenül memóriahiba keletkezhet.

Az általános ellenőrzést többek között az AArch64 privilegizált SCTLR_EL1.A vezérlőbit befolyásolja. Ezt Linux-felhasználói programból nem állítjuk. QEMU user-mode alatt sem valódi vendégkernel-konfigurációt módosítunk.

Három fontos különbség:

A GCC csomagolt, packed elrendezése megváltoztathatja az offseteket és az igazítást. Ez külön szerződés, nem a normál struktúraelrendezés „helytakarékos kapcsolója”, amely következmények nélkül használható.

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

Tünet: a mező értéke hibás, de nincs összeomlás. Első hipotézisünk legyen a rossz offset vagy hozzáférési szélesség, ne rögtön a bájtsorrend.

Target: AArch64 objektumkód vizsgálata; nem futtatás.

aarch64-linux-gnu-objdump -dr lab-layout-asm.o

Ellenőrizzük, hogy az A64 kódban valóban a kívánt Wn/Xn szélesség és bájteltolás szerepel-e.

A futás közbeni vizsgálathoz az első terminálban:

# Target: qemu-user, GDB-kapcsolatra vár.
# Folytatás után várt kilépési állapot: 0.
qemu-aarch64 -g 1234 ./lab-layout

A második terminálban:

# Target: a qemu-user alatt futó AArch64 program.
gdb-multiarch -q ./lab-layout \
    -ex 'set pagination off' \
    -ex 'target remote :1234' \
    -ex 'break lab_after_update' \
    -ex 'continue' \
    -ex 'info registers x0 x1 x2 x3' \
    -ex 'x/24bx $x0' \
    -ex 'x/wd $x0+4' \
    -ex 'x/gd $x0+8' \
    -ex 'x/ub $x0+16' \
    -ex 'continue'

A töréspontnál az A64 X0 még az objektum báziscíme. A várt értékek: X1=2, X2=1024, X3=2; a mezők vizsgálata rendre 2, 1024 és 2 értéket mutat. A padding bájtjaira nincs előírt várt érték.

Tünet: mezősorrend-változtatás után romlik el a program. Hasonlítsuk össze az új offsetof értékeket az assembly konstansaival. Egyetlen offsetet javítsunk, majd ismételjük meg az ellenőrzést.

Tünet: ugyanazon mezőértékek mellett eltér a teljes memóriakép. A padding eltérhet. A struktúrák logikai egyenlőségét ezért általában nem helyettesíti a teljes objektumon végzett memcmp.

7.3.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Vezesd le a lab_record_compact összes mezőoffsetjét és végi kitöltését! Mennyi tárhelyet foglal 1000 eredeti, illetve 1000 átrendezett rekord? Miért nem elég 14 bájtos lépésköz?

  2. (implementációs, középszint — cél: rutinépítés) Az eredeti lab_record mezősorrendjét változtasd a kompakt sorrendre. Frissítsd a fordítási idejű ellenőrzéseket és az assembly mezőeléréseit úgy, hogy az értékmódosítások változatlanok maradjanak. Ugyanazokkal a laborparancsokkal építs és futtass.

  3. (hibakeresési, középszint — cél: rutinépítés) Állítsd vissza az eredeti elrendezést. Kizárólag az első A64 betöltést módosítsd erre: ldr w1, [x0, #8]. A hozzá tartozó tárolás maradjon #4 offsetű. Jósolj kimenetet, majd objdump és GDB segítségével különítsd el az offsethibát a bájtsorrendi problémától.

7.3.9 Megoldások

1. megoldás — Segítség. Rendezd sorba a lab_record_compact mezőit igazítás szerint csökkenő sorrendbe, és alkalmazd a fejezet align_up képletét egymás után mindegyikre.

1. megoldás — Teljes megoldás. A total offsetje 0, a count mezőé 8, a tag mezőé 12, a state mezőé 13. A végpozíció 14, amelyet 8 bájtos igazításhoz 16-ra kerekítünk. A két tömb mérete 24000, illetve 16000 bájt; a különbség 8000 bájt. A 14 bájtos lépésköz mellett a második elem long mezője már nem lenne 8 bájtra igazítva.

2. megoldás — Segítség. Az offsetek megváltoznak, de a MŰVELETEK (melyik mezőhöz mennyit adunk hozzá) nem — csak az assembly #offset konstansait és a _Static_assert értékeit kell a kompakt elrendezéshez igazítanod.

2. megoldás — Teljes megoldás. Az új ellenőrzött offsetek: tag=12, count=8, total=0, state=13; a méret 16, az igazítás továbbra is 8. Az A64 count-betöltés és -tárolás offsetje #8, a total műveleteié #0, a state műveleteié #13. A szélességeket nem változtatjuk meg. A kijelölt mezőneves C-inicializálás továbbra is helyes. A mezőértékeket kiíró sor változatlan, az offseteket és méreteket kiíró sor módosul; a várt kilépési állapot 0.

3. megoldás — Segítség. A #8 offset a total mező ELSŐ 4 bájtjára mutat, nem a count-ra — little-endian sorrendben milyen 32 bites értéket adna ez vissza 1000-ből?

3. megoldás — Teljes megoldás. Little-endian profilban a total mező első négy bájtjának előjel nélküli 32 bites értéke 1000. A hibás A64 betöltés ezt adja a W1 regiszternek, majd az összeadás után 1005 kerül a count mezőbe. A total végértéke továbbra is 1024. A várt kilépési állapot 1. Az objdump megmutatja az eltérő betöltési és tárolási offsetet; GDB-ben a count és total címén olvasott értékek alátámasztják a hipotézist. Egyetlen javítás szükséges: az első betöltés offsetjét visszaállítani #4 értékre.

7.3.10 Rövid összefoglaló

A struktúra mezőcíme a báziscím és a mező bájteltolásának összege. Az eltolásokat nem pusztán a mezőméretek összeadásával kapjuk: minden mezőt a saját igazításához, a teljes méretet pedig a struktúra igazításához kell kerekíteni. A sizeof tartalmazza a kitöltést, az offsetof közvetlenül ellenőrizhetővé teszi az assembly konstansait. A mezősorrend csökkentheti a méretet, de megváltoztatja az interfész elrendezési szerződését. Az A64 hozzáférési szélességet a C-mező méretéhez választjuk. Az igazítatlan elérés ISA-szintű megengedettsége nem helyettesíti a C szabályait, és nem garantál kedvező teljesítményt.

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

Ellenőrzési hiányok, amelyek a fenti ellenőrzés után is ténylegesen fennmaradnak: a programot itt nem fordítottuk és nem futtattuk. A telepített Ubuntu-csomagverziók, a statikus cross libc rendelkezésre állása és a GDB–QEMU munkafolyamat tényleges eredménye a hallgatói laborban ellenőrizendő. Az igazítatlan hozzáférés konkrét költségéről nem áll rendelkezésre mérési adat.