6.2 C if/else és switch leképezése assemblyre

6.2.1 Motiváció és a vezérlési gráf fogalma

Egy protokollfeldolgozóban más művelet tartozhat az egyes üzenettípusokhoz; egy ellenőrző rutinban pedig több, egymást kizáró feltétel alapján választunk eredményt. C-ben mindkettő néhány sor. Assemblyben nekünk kell biztosítanunk, hogy pontosan a megfelelő ág fusson le, majd a végrehajtás a megfelelő helyen folytatódjon.

A vezérlési gráf (control-flow graph) a program végrehajtási útvonalainak modellje: csomópontjai elágazás nélküli utasítássorozatok, élei a lehetséges továbbhaladások. Az if és a switch fordításakor ezt a gráfot építjük fel összehasonlításokból, elágazásokból és közös folytatási pontokból.

Az előfeltétel a Feltétel nélküli és feltételes elágazás ismerete. Az alfejezet minden assembly példája A64 ISA, AArch64, ARMv8-A, little-endian környezetre vonatkozik; a direktívák GNU assembler-direktívák. Cortex-M-kód itt nem szerepel. Az ugrótáblához szükséges egyetlen memóriaolvasást helyben értelmezzük; a címzési módok rendszeres tárgyalása a Load/store címzési módok témája.

6.2.2 Többágú if: sorrend és közös folytatás

Tekintsük ezt a C-referenciafüggvényt:

#include <stdint.h>

uint32_t classify(int32_t x)
{
    if (x < 0)
        return 11;
    else if (x >= 10 && x <= 19)
        return 22;
    else if (x == 30 || x == 50)
        return 33;
    else
        return 44;
}

Az x előjeles, 32 bites érték. A feltételek sorrendje a program jelentésének része: az első igaz ág kiválasztása után a további feltételeket már nem értékeljük ki.

A kézi leképezés lépései:

  1. Megvizsgáljuk az első feltételt; igaz esetben az első eredményblokkra ugrunk.
  2. Hamis esetben a következő feltétel vizsgálata következik.
  3. Az utolsó feltétel hamis kimenete a végső else ághoz vezet.
  4. Minden eredményblokk a közös folytatásra ugrik, nem esik bele a következő eredményblokkba.

A rövidzáras kiértékelés (short-circuit evaluation) további vezérlési éleket jelent:

C-kifejezés Első rész eredménye Következmény
A && B hamis Az egész hamis; B kimarad
A && B igaz B kiértékelése szükséges
A || B igaz Az egész igaz; B kimarad
A || B hamis B kiértékelése szükséges

Ez nem egyszerűen optimalizálási lehetőség, hanem C-szemantika. Ha a második operandus mellékhatást tartalmaz, vagy csak az első feltétel teljesülésekor biztonságos, a feltétel nélküli kiértékelés megváltoztathatja a program működését. A C logikai operátorait ezért általában nem helyettesíthetjük a két operandus feltétel nélküli kiszámításával és bitenkénti összekapcsolásával.

6.2.3 Kidolgozott példa: if, && és ||

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

A következő teljes program a referenciafüggvény döntését valósítja meg, de nem függvényhívással: a kiválasztott eredmény Linux-kilépési állapot lesz. Mentsük lab-if.s néven.

// A64 ISA; GNU as; little-endian Linux.
// LAB_X: a laborban -1 vagy 0..65535.
.ifndef LAB_X
.equ LAB_X, 15
.endif

.section .text
.global _start
_start:
    mov W0, #LAB_X

    cmp W0, #0
    b.lt .Lnegative

    // x >= 10 && x <= 19
    cmp W0, #10
    b.lt .Lspecial
    cmp W0, #19
    b.le .Linterval

.Lspecial:
    // x == 30 || x == 50
    cmp W0, #30
    b.eq .Lselected
    cmp W0, #50
    b.eq .Lselected

    mov W0, #44
    b .Lexit

.Lnegative:
    mov W0, #11
    b .Lexit

.Linterval:
    mov W0, #22
    b .Lexit

.Lselected:
    mov W0, #33

.Lexit:
    mov W8, #93
    svc #0

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

Az A64 CMP W0, #10 után az A64 B.LT előjeles összehasonlítás alapján választ: ha x < 10, az && első operandusa hamis, ezért a 19-cel való összehasonlítás kimarad. Az || esetében a 30-cal való egyezés rögtön az eredményblokkra vezet.

A közvetlen operandusok itt mind kódolhatók (lásd az A64 immediate-korlátok összefoglalóját). Egy példára jellemző részlet: a -1 értéket az assembler MOVN kódolással állítja elő. A Linux-rendszerhívás számát az A64 X8 tartalmazza; a W8 írása annak felső 32 bitjét nullázza.

A konkrét összeállítás–linkelés–futtatás:

# Target: qemu-user, AArch64 Linux, little-endian.
aarch64-linux-gnu-as -EL -march=armv8-a --defsym LAB_X=15 \
    -o lab-if.o lab-if.s
aarch64-linux-gnu-ld -EL -e _start -o lab-if lab-if.o
qemu-aarch64 ./lab-if
lab_status=$?
printf 'status=%d\n' "$lab_status"

Levezetett, várt kimenet: status=22. A példákat itt nem futtatjuk; a tényleges ellenőrzés a labor feladata. A nem nulla kilépési állapot ebben a gyakorlatban szándékos eredmény, nem automatikusan build- vagy futtatási hiba.

A --defsym LAB_X=... értékének módosítása és az újraépítés után:

LAB_X, előjeles Várt állapot Kiválasztott ág
−1 11 Negatív
9 44 Végső else
10 22 Intervallum alsó határa
19 22 Intervallum felső határa
20 44 Végső else
30 33 Az || első operandusa igaz
50 33 Az || második operandusa igaz

6.2.3.1 Rövidzár, amely védelmet ad: p != NULL && *p != 0

A fenti feltételek tiszta összehasonlítások: ha a gép mindkét operandust kiértékelné, csak lassabb lenne, az eredmény ugyanaz maradna. A C && rövidzára azonban nem csak optimalizálás, hanem szemantikai garancia: a jobb oldali operandus csak akkor értékelődik ki, ha a bal oldali igaz. Ez akkor válik láthatóvá, amikor a jobb oldal érvényessége a bal oldaltól függ:

/* C-referencia: igaz, ha p érvényes mutató, és nem üres karakterláncra mutat. */
int lab_nonempty(const char *p)
{
    if (p != NULL && *p != 0)
        return 1;
    return 0;
}

Itt a *p kiértékelése NULL mutatón definiálatlan viselkedés, a gépen jellemzően szegmentálási hiba. Az assemblyben ezért az ágak sorrendje maga a védelem: a betöltés csak a mutatóteszt után, annak igaz ágán következhet. Mentsük lab-guard.s néven:

// A64 ISA; GNU as; little-endian Linux.
// LAB_CASE: 0 = NULL mutató, 1 = üres karakterlánc, 2 = "A".
.ifndef LAB_CASE
.equ LAB_CASE, 2
.endif

.section .rodata
lab_empty: .byte 0
lab_text:  .asciz "A"

.section .text
.global _start
_start:
.if LAB_CASE == 0
    mov  X0, #0                     // p = NULL
.elseif LAB_CASE == 1
    adrp X0, lab_empty              // p -> ""
    add  X0, X0, :lo12:lab_empty
.else
    adrp X0, lab_text               // p -> "A"
    add  X0, X0, :lo12:lab_text
.endif

    // p != NULL && *p != 0
    cbz  X0, .Lfalse                // bal oldal hamis: *p NEM értékelődik ki
    ldrb W1, [X0]                   // csak érvényes mutatón olvasunk
    cbz  W1, .Lfalse                // jobb oldal hamis
    mov  W0, #1
    b    .Lexit

.Lfalse:
    mov  W0, #0

.Lexit:
    mov  W8, #93
    svc  #0

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

Az && bal operandusát itt nem CMP, hanem CBZ vizsgálja: a nullateszthez nem kell az NZCV. A lényeg a sorrend: az LDRB csak azon az úton érhető el, amelyen az X0 már bizonyítottan nem nulla.

# Target: qemu-user, AArch64 Linux, little-endian.
for lab_case in 0 1 2; do
    aarch64-linux-gnu-as -EL -march=armv8-a --defsym LAB_CASE=$lab_case \
        -o lab-guard.o lab-guard.s &&
    aarch64-linux-gnu-ld -EL -e _start -o lab-guard lab-guard.o &&
    { qemu-aarch64 ./lab-guard; printf 'LAB_CASE=%d status=%d\n' "$lab_case" "$?"; }
done

Levezetett, várt kimenet:

LAB_CASE Mutató Várt állapot Indoklás
0 NULL 0 a bal oldal hamis, a betöltés kimarad
1 "" 0 a bal oldal igaz, a betöltött bájt 0
2 "A" 1 mindkét oldal igaz

Ellenpróba: felcserélt kiértékelési sorrend. Cseréljük fel a két első utasítást, mintha a gép „előbb betöltené az adatot, aztán döntene”:

    ldrb W1, [X0]                   // HIBÁS: a dereferálás megelőzi a NULL-tesztet
    cbz  X0, .Lfalse
    cbz  W1, .Lfalse

A LAB_CASE=1 és LAB_CASE=2 eset ugyanazt az eredményt adja, mint a helyes változat — a tiszta, érvényes bemenetekre írt tesztek tehát nem veszik észre a hibát. A LAB_CASE=0 esetben viszont az LDRB a 0-s címet olvassa: a QEMU szegmentálási hibával áll le, és a shell jellemzően 139 (128 + SIGSEGV) állapotot jelez. Ezért kell a C rövidzáras sorrendjét az assemblyben is megőrizni, és ezért kell a tesztesetek közé érvénytelen bemenetet is felvenni.

6.2.4 A switch két tipikus leképezése

A C switch nem ír elő konkrét gépi megvalósítást. Két gyakori lehetőség:

Kevés vagy erősen szórt esethez az elágazáslánc egyszerű és helytakarékos lehet. Sűrű esetekhez az ugrótábla elkerüli a hosszú összehasonlítássorozatot.

Ez azonban nem merev szabály. A fordító figyelembe veheti az optimalizálási szintet, a kódméretet és az ágak gyakoriságát; használhat döntési fát vagy vegyes megoldást is. Ha az ágak csupán konstansokat adnak vissza, akár adatértékeket tartalmazó táblát is készíthet kódcímeket tartalmazó ugrótábla helyett.

6.2.5 Sűrű esetek: indexelt ugrótábla

A következő referenciafolyamot most szándékosan ugrótáblával valósítjuk meg:

#include <stdint.h>

uint32_t dispatch(int32_t key)
{
    switch (key) {
    case 10: return 60;
    case 11: return 61;
    case 12: return 62;
    case 13: return 63;
    default: return 99;
    }
}

Az esetértékek a [10, 13] zárt intervallumot töltik ki. A táblához 0..3 index kell, ezért először levonjuk a 10 alsó határt.

Mentsük a teljes A64-programot lab-switch.s néven:

// A64 ISA; GNU as; little-endian Linux.
// LAB_KEY: a laborban -1 vagy 0..65535.
.ifndef LAB_KEY
.equ LAB_KEY, 12
.endif

.section .text
.global _start
_start:
    mov W0, #LAB_KEY

    sub W1, W0, #10
    cmp W1, #3
    b.hi .Ldefault

    adr X2, .Ltable
    ldr X3, [X2, W1, uxtw #3]
    br X3

.Lr60:
    mov W0, #60
    b .Lexit
.Lr61:
    mov W0, #61
    b .Lexit
.Lr62:
    mov W0, #62
    b .Lexit
.Lr63:
    mov W0, #63
    b .Lexit

.Ldefault:
    mov W0, #99

.Lexit:
    mov W8, #93
    svc #0

.section .rodata
.balign 8
.Ltable:
    .xword .Lr60, .Lr61, .Lr62, .Lr63

.section .note.GNU-stack,"",@progbits
# Target: qemu-user, AArch64 Linux, little-endian.
aarch64-linux-gnu-as -EL -march=armv8-a --defsym LAB_KEY=12 \
    -o lab-switch.o lab-switch.s
aarch64-linux-gnu-ld -EL -e _start -o lab-switch lab-switch.o
qemu-aarch64 ./lab-switch
lab_status=$?
printf 'status=%d\n' "$lab_status"

Levezetett, várt kimenet: status=62.

6.2.5.1 Az indexellenőrzés levezetése

Az A64 SUB W1, W0, #10 32 bites, modulo 2322^{32} eredményt képez. Az eredményt ezután előjel nélküli indexként vizsgáljuk.

key, előjeles A64 W1, hexadecimális W1 > 3, előjel nélkül Útvonal
−1 0xfffffff5 Igaz default
9 0xffffffff Igaz default
10 0x00000000 Hamis Első bejegyzés
12 0x00000002 Hamis Harmadik bejegyzés
13 0x00000003 Hamis Negyedik bejegyzés
14 0x00000004 Igaz default

A kivonás miatt az alsó határ alatti értékek nagy előjel nélküli indexszé válnak. Így az A64 B.HI egyetlen vizsgálattal kizárja mindkét oldali tartományhibát.

A megfelelő C-indexképzés:

uint32_t index = (uint32_t)key - 10u;

Ez nem azonos az általános, előjeles key - 10 kifejezés kiértékelésével: utóbbi bizonyos bemeneteknél előjeles túlcsordulást okozhatna.

6.2.5.2 Hogyan lesz az indexből vezérlésátadás?

Az A64-példában:

  1. Az A64 ADR X2, .Ltable kiszámítja a tábla címét.
  2. Az A64 LDR X3, [X2, W1, UXTW #3] az indexet nullakiterjeszti 64 bitre, nyolccal szorozza, hozzáadja a táblacímhez, majd onnan 8 bájtot olvas.
  3. Az A64 BR X3 a beolvasott címre ugrik, visszatérési cím mentése nélkül.

Az effektív cím:

a=b+8i a=b+8i

ahol aa és bb bájtcím, ii dimenzió nélküli index, és az ellenőrzés után 0≤i≤30\le i\le3.

A GNU .xword direktíva bejegyzésenként 8 bájtot hoz létre; a .balign 8 8 bájtos igazítást kér. A tábla tehát 32 bájtos. A linker helyettesíti a szimbolikus célokat tényleges kódcímekkel: nincs kézzel beírt memóriacím.

Kódolási korlátok:

Ez kisméretű, nem PIE laborprogram, abszolút kódcímeket tartalmazó táblával. Pozíciófüggetlen fordítói kódban gyakoriak a relatív eltolásokat tároló táblák is.

6.2.6 Szórt esetek: összehasonlítások lánca

Legyen a három esetérték 10, 1000 és 60000, eredményük rendre 60, 61, 62. A teljes intervallumot lefedő, 8 bájtos bejegyzésű tábla 59 991 bejegyzést, azaz 479 928 bájtot igényelne. Ehhez képest három összehasonlítás gazdaságos.

Másoljuk a lab-switch.s fájlt lab-sparse.s néven, majd az A64 SUB utasítástól az A64 BR utasításig terjedő döntési részt cseréljük erre:

// A64 ISA; a lab-sparse.s döntési részének helyettesítése.
    cmp W0, #10
    b.eq .Lr60
    cmp W0, #1000
    b.eq .Lr61
    mov W1, #60000
    cmp W0, W1
    b.eq .Lr62
    b .Ldefault

A megmaradó táblát és a 63-as eredményblokkot ez a változat nem használja; eltávolíthatók.

A 60000 nem kódolható az A64 CMP immediate-alakjával: nem fér 12 bitbe, és nem ilyen érték 12 bites balra tolása. Egy 16 bites A64 MOV konstansmezőbe viszont belefér, ezért regiszteres összehasonlítást használunk.

# Target: qemu-user, AArch64 Linux, little-endian.
aarch64-linux-gnu-as -EL -march=armv8-a --defsym LAB_KEY=60000 \
    -o lab-sparse.o lab-sparse.s
aarch64-linux-gnu-ld -EL -e _start -o lab-sparse lab-sparse.o
qemu-aarch64 ./lab-sparse
lab_status=$?
printf 'status=%d\n' "$lab_status"

Levezetett, várt kimenet: status=62; LAB_KEY=11 esetén viszont status=99.

6.2.7 default, lefedettség és tipikus hibák

Az ugrótábla tartományellenőrzése csak az indexhatárokat ellenőrzi. Ha például a 12 eset hiányzik, a hozzá tartozó táblabejegyzésnek a default címére kell mutatnia. A tartományon belüli lyuk is lefedendő bemenet.

C-ben default nélkül egyetlen egyező eset hiányában a végrehajtás a switch után folytatódik. Ilyenkor az assembly megfelelő célja a közös folytatás, nem egy önkényesen kiválasztott eset. Szándékos C-átfolyásnál (fall-through) pedig nem szabad automatikusan minden eset végére kilépő elágazást tenni.

Hibakereséshez vizsgáljuk meg a tényleges gépi kódot:

# Target-objektumok: A64, qemu-user labor; hostoldali elemzés.
aarch64-linux-gnu-objdump -dr lab-if.o
aarch64-linux-gnu-objdump -dr lab-switch.o
Tünet Hipotézis Adatgyűjtés és egyetlen javítás
Mindig egy későbbi ág eredménye jelenik meg Hiányzó ugrás miatt átfolyás történik Disassemblyben követjük az eredményblokk végét; pótoljuk a közös kilépésre ugrást
Alsó határ alatti index átjut az ellenőrzésen A64 B.GT került A64 B.HI helyére Statikusan ellenőrizzük a feltételkódot; előjel nélküli feltételre javítjuk
A második táblabejegyzéstől hibás cél adódik Hibás indexskálázás Ellenőrizzük a 8 bájtos bejegyzést és az A64 #3 skálázást; csak a skálát javítjuk
A hiányzó eset mégis eredményt kap Rossz bejegyzés maradt a táblában A megfelelő bejegyzést a default címére cseréljük

Ismerten hibás tartományellenőrzéssel ne futtassuk végig a programot: először statikus elemzéssel javítsuk, és csak utána végezzünk határértékteszteket.

6.2.8 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Kövesd végig az if programot x=9, illetve x=30 esetén! Mely összehasonlítások maradnak ki a rövidzár miatt?
  2. (implementációs, középszint — cél: rutinépítés) A sűrű switch változatból töröld szemantikailag a 12 esetet, de tartsd meg az ugrótáblát! Ellenőrizd a 11, 12, 13 és 14 bemenetet.
  3. (hibakeresési, középszint — cél: megértés-ellenőrzés) Egy módosított változatban az indexellenőrzés A64 B.GT utasítást használ. Mi történne key=9 esetén? A hibás változat futtatása nélkül indokold meg a hibát, majd javítás után teszteld a 9, 10, 13, 14 határértékeket!

6.2.9 Megoldások

1. megoldás — Segítség. Rajzold fel az if-láncot blokkonként, és minden x-re csak addig haladj, amíg az adott feltétel igaz vagy hamis volta ténylegesen eldönti a következő lépést.

1. megoldás — Teljes megoldás. x=9 nem negatív, de kisebb 10-nél. Ezért a 19-cel való összehasonlítás kimarad. A 30 és 50 vizsgálata hamis, az eredmény 44. x=30 esetén az intervallum mindkét összehasonlítása megtörténik, de a második hamis. Ezután a 30-cal való egyezés miatt az 50 vizsgálata marad ki; az eredmény 33.

2. megoldás — Segítség. Az ugrótábla mérete és indexelése nem változik, csak az egyik bejegyzés célcíméje — gondold át, hova kell mutatnia egy “törölt” esetnek.

2. megoldás — Teljes megoldás. A GNU .xword sor harmadik bejegyzése legyen .Ldefault. A tartományellenőrzés továbbra is 0..3 indexet enged. A várt állapotok sorrendben 61, 99, 63, 99: a 12 tartományon belüli lyuk, a 14 tartományon kívüli érték.

3. megoldás — Segítség. Számítsd ki, milyen bitmintát hagy maga után az indexszámítás key=9-re, és nézd meg, mit jelent ugyanaz a bitminta előjelesen, illetve előjel nélkül értelmezve.

3. megoldás — Teljes megoldás. key=9 után az A64 W1 értéke 0xffffffff. Ez előjel nélkül 4 294 967 295, előjelesen −1. Az A64 B.GT ezért nem ugrana a default ágra, és érvénytelen táblán kívüli olvasás következhetne. A megfelelő utasítás az A64 B.HI. Javítás után a várt állapotok: 99, 60, 63, 99.

6.2.10 Rövid összefoglaló

A C többágú döntéseit összehasonlításokkal, feltételes elágazásokkal és közös folytatási pontokkal képezhetjük le A64-re. Az ágak sorrendje és a rövidzár megőrzése a helyesség része: ha a jobb oldali operandus a bal oldal igaz voltától függ (például p != NULL && *p != 0), a sorrend felcserélése érvénytelen bemeneten hibás memóriaelérést okoz, amelyet a csak érvényes bemenetű tesztek nem mutatnak ki. A sűrű switch esetekhez ugrótábla, a szórtakhoz összehasonlításlánc lehet célszerű, de a fordító más szerkezetet is választhat. A normalizált index előjel nélküli ellenőrzése egyszerre kezelheti az alsó és felső határt. A táblán belüli lyukakat külön le kell fedni. Határértékekkel, kilépési állapottal és disassemblyvel ellenőrizzük a megvalósítást; utasításszámból önmagában nem állapítunk meg gyorsulást.

6.2.11 Források és ellenőrzési hiányok

Az alábbiak dokumentációs kiindulópontok, nem ebben a munkafolyamatban megnyitott, olvasott források:

Ellenőrzési hiányok: a példák összeállítása és QEMU-futtatása itt nem történt meg. A várt állapotok levezetettek. Ez a lab-guard.s példára is vonatkozik, a felcserélt sorrendű ellenpróba 139-es állapotát is beleértve. Az Ubuntu 24.04-ben telepített eszközverziókat és a tényleges disassemblyt a laborban kell rögzíteni. Teljesítménymérést nem közöltünk; a QEMU user-mode futás nem szolgáltat garantáltan hardverhű ciklusszámot.