10.2 Assembly függvény hívása C-ből és C hívása assemblyből

10.2.1 Miért kell külön szerződés a C és az assembly közé?

Egy C-program teljesítménykritikus részét külön assembly fájlban szeretnénk megírni, de a tesztelést, a kiírást és az általános programlogikát C-ben hagynánk. Fordított irányra is szükség lehet: az assembly rutin egy már meglévő C-függvényt használjon.

A két nyelv közötti határ nem automatikus típusellenőrzési pont. A C-fordító a deklarációból következtet a hívás módjára; az assembly szerzője pedig kézzel valósítja meg ugyanazt a szerződést. A linkelő elsősorban szimbólumokat és címhivatkozásokat kapcsol össze, nem C-függvényszignatúrákat ellenőriz.

Definíció — bináris interfész: az alkalmazás bináris interfésze (Application Binary Interface, ABI) meghatározza többek között az argumentumok, visszatérési értékek, regisztermegőrzések és veremhasználat szabályait. A labor AArch64 Linux környezetében a függvényhívási szerződést az AAPCS64 adja.

Az Inline assembly GCC-ben esetében operandusokkal és clobber-listával tájékoztattuk a fordítót. Önálló assembly függvénynél ezt a szerepet a C-prototípus és az ABI tölti be. A keretkezeléshez a Prológus és epilógus: regisztermentés és keretkezelés ismeretére támaszkodunk.

A teljes alfejezet profilja: I. rész, A64 ISA, ARMv8-A, AArch64 Linux, little-endian, LP64 adatmodell. Az assembly blokkok utasításai és regiszterei ebben a profilban értendők; direktíváik GNU assembler, AArch64 ELF környezethez tartoznak. Thumb-2 kód itt nem szerepel.

10.2.2 A forrásszintű és a gépi szerződés

A példában kizárólag uint64_t argumentumokat és visszatérési értékeket használunk. Ezek előjel nélküli, 64 bites egészek, így az összeadás és szorzás eredménye modulo 2642^{64} értendő.

A labor C-interfészének eleme AAPCS64-leképezés, A64
Első uint64_t argumentum X0
Második uint64_t argumentum X1
Harmadik uint64_t argumentum X2
uint64_t visszatérési érték X0
A hívásokon át megtartandó helyi érték Például X19, belépési értékének mentésével
Visszatérési cím X30; újabb BL felülírja
Veremmutató SP; a példában végig 16 bájtra igazított

Ez nem általános szabály minden C-típusra. A lebegőpontos értékeknek és az összetett típusoknak külön besorolási szabályaik vannak; utóbbiakat a Több visszatérési érték és struktúra-visszaadás szerződése tárgyalja. A jelenlegi skaláris egész argumentumosztályban az első nyolc argumentum számára az A64 X0–X7 használható, a további argumentumok veremre kerülnek.

A szimbólumkapcsolat két oldala:

Ezért egy sikeres linkelés még nem bizonyítja az interfész helyességét.

10.2.3 Kidolgozott példa: C → assembly → C

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

Négy forrásfájlt és egy Makefile-t készítünk:

A számítás:

apply(a,b,bonus)=3(a+b)+1+bonus(mod264). \operatorname{apply}(a,b,\text{bonus}) = 3(a+b)+1+\text{bonus}\pmod{2^{64}}.

Minden változó dimenzió nélküli, előjel nélküli 64 bites egész. A szorzást és az egy hozzáadását a C-segédfüggvény végzi.

10.2.3.1 Közös deklarációk és C-oldali teszt

A lab-interop.h tartalma:

#ifndef LAB_INTEROP_H
#define LAB_INTEROP_H

#include <stdint.h>

extern uint64_t lab_add(uint64_t a, uint64_t b);
extern uint64_t lab_apply(uint64_t a, uint64_t b, uint64_t bonus);
extern uint64_t lab_scale(uint64_t value);

#endif

A lab-helper.c tartalma:

#include "lab-interop.h"

uint64_t lab_scale(uint64_t value)
{
    return UINT64_C(3) * value + UINT64_C(1);
}

A lab-main.c tartalma:

#include <inttypes.h>
#include <stdio.h>
#include "lab-interop.h"

int main(void)
{
    const uint64_t sum = lab_add(7, 5);
    const uint64_t result = lab_apply(7, 5, 9);
    const uint64_t wrap = lab_add(UINT64_MAX, 1);

    printf("sum=%" PRIu64 " apply=%" PRIu64 " wrap=%" PRIu64 "\n",
           sum, result, wrap);

    return (sum == UINT64_C(12) &&
            result == UINT64_C(46) &&
            wrap == UINT64_C(0)) ? 0 : 1;
}

A PRIu64 a uint64_t típushoz illeszkedő kiírást biztosítja. A közös fejléc a C-fájlok közötti eltéréseket fordításkor segít észlelni; az assembly implementációt továbbra is külön kell auditálni.

10.2.3.2 Az assembly implementáció

A lab-bridge.s tartalma:

// A64 ISA, ARMv8-A; GNU as, AArch64 ELF, little-endian.
.arch armv8-a
.text

.p2align 2
.global lab_add
.type lab_add, %function
lab_add:
    .cfi_startproc
    add     x0, x0, x1
    ret
    .cfi_endproc
.size lab_add, .-lab_add

.p2align 2
.global lab_apply
.type lab_apply, %function
lab_apply:
    .cfi_startproc
    stp     x29, x30, [sp, #-32]!
    .cfi_def_cfa_offset 32
    .cfi_offset 29, -32
    .cfi_offset 30, -24
    mov     x29, sp
    str     x19, [sp, #16]
    .cfi_offset 19, -16

    mov     x19, x2
    add     x0, x0, x1
    bl      lab_scale
    add     x0, x0, x19

    ldr     x19, [sp, #16]
    .cfi_restore 19
    ldp     x29, x30, [sp], #32
    .cfi_restore 29
    .cfi_restore 30
    .cfi_def_cfa_offset 0
    ret
    .cfi_endproc
.size lab_apply, .-lab_apply

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

A lab_add levélfüggvény: nem hív másik függvényt, és nem módosít megőrzendő regisztert. Nincs szüksége saját veremkeretre.

A lab_apply nem-levél függvény. A harmadik argumentumot nem hagyhatja egyszerűen az A64 X2 regiszterben: a meghívott C-függvény ezt szabadon felhasználhatja. Ez akkor is igaz, ha a C-függvénynek csak egy argumentuma van.

Az A64 X19 viszont megőrzendő regiszter. A lab_apply ezért:

  1. elmenti saját hívójának A64 X19 értékét;
  2. saját köztes adatát az A64 X19 regiszterbe helyezi;
  3. meghívja a C-függvényt, amely köteles megőrizni ezt az értéket;
  4. visszatérés előtt visszaállítja az eredeti A64 X19 tartalmat.

Az A64 X30 mentése ettől függetlenül szükséges: az A64 BL a C-segédfüggvényből való visszatérés címét írja bele.

A 32 bájtos keret kiosztása:

Eltolás az új A64 SP-hez képest Tartalom Méret
0 Belépési A64 X29 8 bájt
8 Belépési A64 X30 8 bájt
16 Belépési A64 X19 8 bájt
24 Nem használt kitöltés 8 bájt

A kitöltés biztosítja, hogy a keret mérete 16 bájt többszöröse legyen.

A két függvény C-ből hívható, végleges minta, ezért a Prológus és epilógus szabálya szerint CFI-t kap: a levélfüggvény lab_add csak a .cfi_startproc/.cfi_endproc párt, a keretet építő lab_apply a mentések helyét is. A .cfi_offset értékei a fenti kerettáblázatból következnek: a CFA a belépési SP, vagyis az új SP + 32.

Kódolási korlátok: a STP/LDP ±32, a STR/LDR 16 bájtos eltolása és a BL cél mind kódolható (lásd az A64 immediate-korlátok összefoglalóját); a BL végleges eltolása linkeléskor áll elő. Az AArch64 ELF/GNU as .p2align 2 négybájtos igazítást jelent, nem két bájtot.

Az AArch64 ELF/GNU as .note.GNU-stack szekció nem végrehajtható veremigényt jelez a linkelőnek; nem futásidejű utasítás.

10.2.4 Vegyes build: GCC, assembler és linkelő

Az önálló fordítási egységek (translation units) külön objektumfájlokká alakulnak. A C-fájlokat GCC-vel fordítjuk; a kisbetűs .s fájlt közvetlenül az assemblerrel dolgozzuk fel.

A végső linkelést is a GCC meghajtóprogramra bízzuk. Ez elindítja a linkelőt, és hozzáadja a C-futási környezethez szükséges indítóobjektumokat és könyvtárakat. A puszta ld meghívás nem tenné meg mindezt automatikusan. A program belépési környezetét most a C-runtime szolgáltatja: nem írunk saját _start rutint.

A lab-Makefile tartalma; a receptsorok elején valódi tabulátor álljon:

# Target: AArch64 Linux, ARMv8-A, little-endian, qemu-user.
CC := aarch64-linux-gnu-gcc
AS := aarch64-linux-gnu-as

CFLAGS := -std=c11 -O2 -g -Wall -Wextra -Wpedantic -march=armv8-a
ASFLAGS := -g -march=armv8-a
LDFLAGS := -static

.PHONY: lab-all lab-run lab-clean

lab-all: lab-interop

lab-interop: lab-main.o lab-helper.o lab-bridge.o
    $(CC) $(LDFLAGS) -o $@ $^

lab-%.o: lab-%.c lab-interop.h
    $(CC) $(CFLAGS) -c $< -o $@

lab-bridge.o: lab-bridge.s
    $(AS) $(ASFLAGS) -o $@ $<

lab-run: lab-interop
    qemu-aarch64 ./lab-interop

lab-clean:
    rm -f lab-main.o lab-helper.o lab-bridge.o lab-interop

A statikus linkelés miatt futtatáskor nincs szükség külön AArch64 dinamikus betöltő megadására. A buildhez viszont rendelkezésre kell állniuk az AArch64 C-könyvtár statikus állományainak és indítóobjektumainak. Az ezek hiányára utaló linkelési hiba környezeti probléma, nem ABI-hiba.

Target: qemu-user; host: Ubuntu 24.04 x86_64. Összeállítás, linkelés és futtatás:

make -f lab-Makefile lab-all
make -f lab-Makefile lab-run
printf 'make-status=%d\n' "$?"

Várt, nem itt mért eredmény: a build kilépési állapota 0; a program kiírása sum=12 apply=46 wrap=0; a program és a lab-run cél kilépési állapota 0. A make ezenfelül a végrehajtott parancsokat is kiírhatja.

A számítás ellenőrzése:

Megfigyelési pont Várt állapot, A64
lab_apply belépése X0=7, X1=5, X2=9
Közvetlenül a C-hívás előtt X0=12, X19=9
A C-függvény visszatérése után X0=37, X19=9
lab_apply visszatérésekor X0=46; SP, X19, X29 belépési értéke visszaállt

10.2.4.1 ABI-ellenőrzés a közös teszthívóval

A fenti táblázat utolsó sora állítás, amelyet a sum=12 apply=46 kimenet nem igazol: egy X19-et rontó lab_apply is kiírhatná a helyes összeget. Ezt a Prológus és epilógus közös teszthívójával ellenőrizzük, amely magas bites mintákkal a teljes megőrzött regiszterkészletet és az SP-t vizsgálja. A teszthívó fájljait átmásoljuk:

cp ~/arm-labor/a64/a029/lab-abi-call.h ~/arm-labor/a64/a029/lab-abi-call.S .

A lab-abi-main.c tartalma:

#include <inttypes.h>
#include <stdio.h>
#include "lab-abi-call.h"
#include "lab-interop.h"

int main(void)
{
    uint64_t r_add = 0, r_apply = 0;
    uint64_t m_add = lab_abi_call((lab_abi_fn)lab_add, 7, 5, 0, 0, 0, 0, &r_add);
    uint64_t m_apply = lab_abi_call((lab_abi_fn)lab_apply, 7, 5, 9, 0, 0, 0, &r_apply);

    printf("lab_add   maszk=0x%05" PRIx64 " eredmeny=%" PRIu64 "\n", m_add, r_add);
    printf("lab_apply maszk=0x%05" PRIx64 " eredmeny=%" PRIu64 "\n", m_apply, r_apply);

    return (m_add == 0 && r_add == 12 && m_apply == 0 && r_apply == 46) ? 0 : 1;
}

A (lab_abi_fn) típuskényszerítés C-ben megengedett: a void (*)(void) típusra minden függvénymutató átalakítható, és a -Wextra sem figyelmeztet rá. A függvényt nem C-ből hívjuk ezen a típuson át, hanem a teszthívó BLR utasítása.

# Target: AArch64 Linux, qemu-user.
aarch64-linux-gnu-gcc -std=c11 -O2 -Wall -Wextra -march=armv8-a -static \
    lab-abi-main.c lab-helper.c lab-bridge.s lab-abi-call.S -o lab-abi-interop
qemu-aarch64 ./lab-abi-interop
printf 'kilepesi_allapot=%d\n' "$?"

Várt kimenet:

lab_add   maszk=0x00000 eredmeny=12
lab_apply maszk=0x00000 eredmeny=46
kilepesi_allapot=0

A nulla maszk a lab_apply esetén két szerződést igazol egyszerre: a saját X19-mentését és a közben meghívott, GCC által fordított lab_scale megőrzési fegyelmét. Ellenpróbaként töröld a lab_apply ldr x19, [sp, #16] sorát: a maszk 0x00001 lesz. Az eredeti lab-interop kimenete ekkor fordítófüggő: ha a main a sum értékét véletlenül éppen X19-ben tartja a lab_apply hívásán át, a kiírás sum=9 lesz (a bonus értéke szivárog át), különben helyesnek látszik. A teszthívó maszkja viszont a fordítótól függetlenül jelzi a hibát.

10.2.5 Objektumvizsgálat és tipikus hibák

Az áthelyezési bejegyzés (relocation) azt jelöli, hogy egy hivatkozás végleges értékét később kell meghatározni. Az assembly objektum a C-függvény végleges címét még nem ismeri.

Target: qemu-userhez készült AArch64 ELF; hostoldali vizsgálat, futtatás nélkül:

aarch64-linux-gnu-readelf -Ws lab-bridge.o
aarch64-linux-gnu-objdump -dr lab-bridge.o
aarch64-linux-gnu-objdump -d --disassemble=lab_scale lab-interop

A parancsok várt kilépési állapota 0. A szimbólumtáblában lab_add és lab_apply globális függvénydefiníció, lab_scale pedig még nem definiált, UND jelölésű szimbólum. A tárgykód disassemblyjében a hívásnál R_AARCH64_CALL26 áthelyezés várható. A végleges ELF-ben már a feloldott hívást vizsgáljuk.

Tünet Hipotézis és adatgyűjtés Egy változtatás, majd ellenőrzés
undefined reference to lab_add A szimbólumtábla szerint nem globális, vagy neve eltér Javítsuk az exportot vagy a nevet; linkeljünk újra
lab_scale nem található Hiányzik a megfelelő objektum a linkparancsból Vegyük fel a lab-helper.o fájlt; építsünk újra
Csak nagy bemenetnél hibás az eredmény Disassemblyben A64 W0/W1 szerepel a szükséges X0/X1 helyett Javítsuk az operandusszélességet; futtassunk határértéktesztet
A bónusz néha eltűnik Hívó által mentendő regiszterben maradt a C-hívás alatt Tároljuk mentett A64 X19-ben; ismételjük a teszteket
A visszatérés hibás vagy a program elakad Hiányzik az A64 X30 mentése Állítsuk helyre a prológus–epilógus párt; ellenőrizzük a vezérlési utat

Különösen veszélyes a prototípuseltérés. Ha a C-deklaráció két argumentumot ír le, az assembly pedig harmadikat is vár, az A64 X2 tartalmára nincs argumentumként vállalt garancia. Ha a C-oldal uint32_t, az assembly pedig uint64_t értéket feltételez, már az argumentum-előkészítés és az eredmény értelmezése is eltérhet.

A 32 bites érték felső regiszterbitjeire nem szabad általános, 64 bites argumentumszerződésként támaszkodni. Ugyanígy egy előjeles 32 bites érték sem válik automatikusan helyesen előjelkiterjesztett 64 bites argumentummá. A típus, a szélesség és az argumentumszám együtt az interfész része.

10.2.6 Feladatok

  1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Miért nem garantálja a sikeres linkelés, hogy a lab_apply három uint64_t argumentumot kap? Miért kell megőriznie az A64 X19 belépési értékét, ha a C-segédfüggvény is köteles megőrizni azt?

  2. (implementációs, alapszint — cél: rutinépítés) Adj a projekthez uint64_t lab_max(uint64_t a, uint64_t b) assembly függvényt, amely előjel nélküli maximumot ad vissza. Ellenőrizd a 7 és 5, valamint a 0x8000000000000001 és 7 bemenetpárral.

  3. (hibakeresési, középszint — cél: megértés-ellenőrzés) A lab_add A64 utasítását szándékosan módosítsd add w0, w0, w1 alakra. Miért megy át továbbra is az eredeti teszt? Készíts olyan ellenpróbát, amely kimutatja a hibát, majd disassemblyvel igazold a javítást.

10.2.7 Megoldások

1. megoldás — Segítség. A linkelő és a C-fordító két különböző ellenőrzési szintet képvisel — gondold át, melyik szint mit lát a kézzel írt assembly esetén, és hogy az A64 X19 megőrzése kinek a szerződését szolgálja (a hívóét vagy a hívottét).

1. megoldás — Teljes megoldás. A linkelő a szimbólumneveket és az objektumok kapcsolatait oldja fel; a kézzel írt assemblyhez nincs teljes C-típusellenőrzés. Az A64 X19 megőrzése pedig egymásba ágyazott szerződés: a C-segédfüggvény a lab_apply által elhelyezett bónuszt őrzi meg, míg a lab_apply saját hívójának korábbi értékét köteles visszaadni.

2. megoldás — Segítség. Az előjel nélküli összehasonlításhoz a megfelelő feltételkódot válaszd (nem az előjeles változatot) — gondold át, miért számítana a magas helyiértékű bit előjelesen.

2. megoldás — Teljes megoldás. A fejlécbe kerüljön:

extern uint64_t lab_max(uint64_t a, uint64_t b);

A lab-bridge.s fájlban, a záró veremjelölő szekció előtt:

// A64 ISA; GNU as, AArch64 ELF; a .text szekcióban.
.p2align 2
.global lab_max
.type lab_max, %function
lab_max:
    cmp     x0, x1
    csel    x0, x0, x1, hs
    ret
.size lab_max, .-lab_max

Az A64 HS előjel nélküli „nagyobb vagy egyenlő” feltétel. Előjeles feltétellel a magas helyiértékű bitet tartalmazó második teszt hibás lehetne.

A main végső visszatérése elé illeszthető ellenőrzés:

if (lab_max(7, 5) != UINT64_C(7) ||
    lab_max(UINT64_C(0x8000000000000001), 7) !=
        UINT64_C(0x8000000000000001))
    return 1;

A korábbi, qemu-user célú build- és futtatási parancsok változatlanok; a várt kilépési állapot 0.

3. megoldás — Segítség. A kis teszt-bemenetek nem haladják meg a 32 bitet — gondold át, milyen bemenettel lehet olyan 64 bites összeget képezni, amelynek alsó 32 bitje önmagában nem árulkodik a hibáról.

3. megoldás — Teljes megoldás. A kis bemenetek elférnek 32 biten. A túlcsordulási tesztben pedig az alsó 32 bit összeadása is nullát ad, ezért az sem különbözteti meg a hibás implementációt.

A main végső visszatérése elé:

if (lab_add(UINT64_C(0x100000000), UINT64_C(5)) !=
    UINT64_C(0x100000005))
    return 1;

A várt helyes eredmény előjel nélkül 4294967301. A hibás A64 W0-írás lenullázza az A64 X0 felső felét, így 5 lesz az eredmény. A program ekkor 1 állapottal lép ki; a make ezt hibaként jelzi, saját hibakódja nem feltétlenül 1.

A korábbi objdump paranccsal ellenőrizzük az operandusokat, állítsuk vissza az A64 add x0, x0, x1 utasítást, majd ugyanazzal az ellenpróbával várjunk ismét 0 programkilépési állapotot.

10.2.8 Rövid összefoglaló

Az önálló assembly függvény és a C-program együttműködését a deklaráció, a szimbólumkapcsolat és az AAPCS64 hívási szabályai együttesen határozzák meg. A sikeres linkelés önmagában nem ellenőrzi az argumentumok típusát vagy számát. C-hívás előtt az assembly rutinnak meg kell őriznie visszatérési címét és a híváson át élő adatokat, visszatéréskor pedig helyre kell állítania a megőrzendő állapotot. A GCC meghajtóprogram leegyszerűsíti a C-runtime-mal történő linkelést. A szimbólumtábla, az áthelyezések és a határértéktesztek együtt adnak használható ellenőrzési módszert.

10.2.9 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 fennmaradnak: a könyv referencia-eszközláncával (GNU GCC/binutils, qemu-aarch64) a kódot itt nem fordítottuk és nem futtattuk. A lab-interop és a lab-abi-interop kimenetét, valamint a törölt ldr x19 sorral végzett ellenpróbát helyettesítő eszközlánccal mértük: zig cc (Clang, musl, aarch64-linux-musl), Unicorn-alapú AArch64 user-mode emulátorban; ebben a mérésben a Clang-fordította main a sum-ot X19-ben tartotta, ezért írt ki sum=9-et. A laborban rögzíteni kell az Ubuntu 24.04 telepített GCC/binutils/QEMU csomagverzióját, ellenőrizni kell a statikus AArch64 C-runtime elérhetőségét, valamint össze kell vetni a tényleges kimenetet és objektumadatokat a megadott várakozásokkal. Teljesítménymérést ez a példa nem tartalmaz.