12.1 A lebegőpontos regiszterkészlet és az IEEE 754 ábrázolás

12.1.1 Motiváció és hatókör

A Linux syscall-konvenció tárgyalásakor a rendszerhívás argumentumai egész regiszterekben utaztak, és a visszatérési érték is egész szám volt. Amint azonban a C program float vagy double változóval számol, olyan gépi szintű reprezentációhoz érkezünk, amely nem azonos a kettes komplemens egészekkel, és nem is az Xn regisztereken keresztül közlekedik. Ha egy mérnöki feladatban a hallgató egy szenzor kalibrációs értékét 0.1f-ként tárolja, majd azt tapasztalja, hogy tíz egymás utáni összeadás után az eredmény nem pontosan 1.0, akkor a hibát nem a kód logikájában, hanem a lebegőpontos ábrázolás korlátjában kell keresni. Ez az alfejezet pontosan ezt a réteget nyitja fel: az IEEE 754 bitmezőket, az AArch64 lebegőpontos regiszterkészletét és a kettő kapcsolatát.

A hatókör a következő: bitmező-szintű levezetés egy konkrét számon, a V0–V31 regiszterek Sn/Dn skaláris nézete, valamint a kerekítés gyakorlati következménye. Az A64 lebegőpontos utasítások (összeadás, szorzás, konverzió) a Skaláris lebegőpontos utasítások alfejezetben következnek; a vektoros adatelrendezés pedig a NEON regiszterkészlet anyagához tartozik. Itt kizárólag az adatreprezentációt és a regiszterhozzáférést építjük fel.

12.1.2 Definíció: az IEEE 754 bináris ábra

Az IEEE 754-2019 szabvány bináris lebegőpontos formátuma egy számot három bitmezőre bont: előjel (sign), exponens (exponent) és mantissza (mantissa, más néven significand tört része). Az érték normált esetben:

x=(−1)s×1.m2×2e−b x = (-1)^{s} \times 1.m_{2} \times 2^{e-b}

ahol ss az előjelbit, m2m_2 a mantissza tört bitjei kettes számrendszerben, ee a tárolt exponensmező egész értéke, bb pedig az eltolás (bias). A tárolt exponensbe azért kerül eltolás, hogy ne kelljen külön előjelbitet fenntartani az exponensnek: a negatív és pozitív kitevőket egy eltolt, előjel nélküli egészként tároljuk.

Az egyszeres pontosság (single precision, float) és a kétszeres pontosság (double precision, double) bitmezői:

Formátum Előjel Exponens Mantissza Összesen Bias
single 1 bit 8 bit 23 bit 32 bit 127
double 1 bit 11 bit 52 bit 64 bit 1023

Két konvenciót érdemes rögzíteni. Először: a mantissza a normalizált alakban mindig 1.xxx formájú, ezért a vezető 1 bitet nem tároljuk — ez az úgynevezett implicit vagy rejtett bit. Másodszor: az eltolás miatt a tárolt exponensmező e = kitevő + bias.

12.1.3 Levezetés: a −12.375-12.375 kódolása single precision szerint

Válasszunk egy olyan számot, amely pontosan ábrázolható, így a bitmezők tisztán látszanak. A gondolatmenet lépései, feltételekkel:

1. Előjel. A szám negatív, ezért s=1s = 1.

2. Abszolút érték bináris alakja. Az egész rész: 12=8+4=1100212 = 8 + 4 = 1100_2. A tört rész 0.3750.375 kettővel szorozva, a tört részt visszatartva:

0.375×2=0.75⇒0,0.75×2=1.5⇒1,0.5×2=1.0⇒1 0.375 \times 2 = 0.75 \Rightarrow 0, \quad 0.75 \times 2 = 1.5 \Rightarrow 1, \quad 0.5 \times 2 = 1.0 \Rightarrow 1

A maradék nulla, tehát 0.375=0.01120.375 = 0.011_2. Együtt: 12.375=1100.011212.375 = 1100.011_2.

3. Normalizálás. A kettes pontot három hellyel balra visszük, hogy a vezető bit 1 legyen:

1100.0112=1.1000112×23 1100.011_2 = 1.100011_2 \times 2^{3}

A kitevő tehát 33. Ez az a pont, ahol a legtöbb hiba keletkezik: a kitevőt nem a bináris pont pozíciójából, hanem a normalizált alakból kell leolvasni.

4. Eltolt exponensmező. e=3+127=130e = 3 + 127 = 130, ami binárisan 1000001021000\,0010_2.

5. Mantissza. Az implicit vezető 1-et elhagyjuk, a maradék 100011-et balra igazítjuk és 23 bitre nullákkal feltöltjük:

m=100011000000000000000002 m = 1000110\,0000\,0000\,0000\,0000_2

6. Összeállítás. A 32 bit: 1 | 10000010 | 10001100000000000000000.

Nibblesként csoportosítva:

Bitek 1100 0001 0100 0110 0000 0000 0000 0000
Hexa C 1 4 6 0 0 0 0

Tehát a -12.375f bitmintája 0xC1460000. Ellenőrzésképpen a pozitív 12.375f ennek előjelbit nélküli változata, 0x41460000.

12.1.4 Az AArch64 lebegőpontos regiszterkészlet

Az A64 ISA harminckét 128 bites regisztert biztosít a lebegőpontos és SIMD számításokhoz: V0–V31. Ezek ugyanaz a fizikai regisztertömb, amelyhez többféle szélességű nézet (view) tartozik:

Nézet Szélesség Tipikus használat
Vn 128 bit teljes vektor, NEON
Qn 128 bit NEON 128 bites nézet
Dn 64 bit double skalár, NEON 64 bites
Sn 32 bit float skalár
Hn 16 bit half precision
Bn 8 bit bájt

A skaláris nézetek átfedésben vannak: Sn=Vn[31:0]S_n = V_n[31{:}0], Dn=Vn[63:0]D_n = V_n[63{:}0]. Ha egy float-ot az S0-ba írunk, az pontosan V0 alsó 32 bitjén jelenik meg. Két fontos szerződés:

A lebegőpontos viselkedést két rendszerregiszter szabályozza. Az FPCR (Floating-point Control Register) tartalmazza a kerekítési módot; az alapértelmezés a round to nearest, ties to even (a legközelebbi értékre, holtversenynél a páros mantisszára). Az FPSR (Floating-point Status Register) pedig a kumulatív kivételjelzőket (invalid, division by zero, overflow, underflow, inexact) gyűjti. A V0–V31 regiszterkészlet általános áttekintése a regiszterkészlet alfejezetben szerepelt.

A Linux alatt futó alkalmazásszintű kód ezeket a regisztereket közvetlenül használhatja, mert a kernel az FPEN/CPACR beállítással engedélyezi a lebegőpontos végrehajtást a felhasználói folyamat számára. QEMU user-mode emulációban (qemu-aarch64) ez a viselkedés alapértelmezett.

12.1.5 Kidolgozott példa: bitminta előállítása és visszaolvasása

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

Az alábbi assembly program a 0xC1460000 bitmintát tölti W0-ba, átviszi S0-ba, majd visszaolvassa és kiírja hexadecimálisan. Célja a regiszter-átjárás és a reprezentáció azonosságának igazolása.

// lab-fp-bits.s -- target: qemu-aarch64 (user-mode), A64 ISA
        .section .rodata
hexfmt: .asciz "%08x\n"

        .text
        .global main
main:
        .cfi_startproc
        stp x29, x30, [sp, #-16]!
        .cfi_def_cfa_offset 16
        .cfi_offset 29, -16
        .cfi_offset 30, -8
        mov x29, sp
        // 1) bitminta betöltése: movz az alsó 16 bitet, movk a felsőt
        movz    w0, #0xC146, lsl #16    // w0 = 0xC1460000

        // 2) ugyanaz a 32 bit atkerul az FP regiszterterbe
        fmov    s0, w0                  // s0 = -12.375 (single)

        // 3) visszaolvasas: ugyanazt a bitmintat kell latnunk
        fmov    w1, s0

        // 4) kiiras: printf(hexfmt, w1)
        adrp    x0, hexfmt
        add     x0, x0, :lo12:hexfmt
        mov     w2, w1
        mov     w1, w2
        bl      printf

        mov     w0, #0                  // visszateresi ertek
        ldp x29, x30, [sp], #16
        .cfi_restore 29
        .cfi_restore 30
        .cfi_def_cfa_offset 0
        ret
        .cfi_endproc

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

A .cfi_* sorok a keretet írják le a hibakereső számára (lásd a Prológus és epilógus CFI-szabályát); a main a C-futtatókörnyezetből hívott függvény, ezért ugyanúgy kap CFI-t, mint bármely más C-ből hívható assembly függvény.

Fordítás és futtatás:

aarch64-linux-gnu-gcc -static -o lab-fp-bits lab-fp-bits.s
# elvárt: GNU_STACK RW
readelf -lW lab-fp-bits | awk '$1=="GNU_STACK"{f=$7} END{print "GNU_STACK", f; exit !(f=="RW")}'
qemu-aarch64 ./lab-fp-bits

A forrás végi .note.GNU-stack szekció jelzi a linkernek, hogy az objektum nem igényel végrehajtható vermet (Az első assembly program). Ennek hatását nem a forrás szövegében, hanem a kész ELF-ben ellenőrizzük. A readelf sor a GNU_STACK programfejléc jelzőit írja ki, és nem nulla állapottal tér vissza, ha azok nem pontosan RW (például ha RWE, vagy ha a fejléc hiányzik). A könyv C-ből hívható vagy önálló Linux/ELF assembly forrásai mind ezzel a direktívával zárulnak, és a FP/NEON-fejezetek buildjei ugyanezzel a sorral ellenőrzik az eredményt. A linker missing .note.GNU-stack section implies executable stack figyelmeztetése ezért nem kozmetikai: a build ellenőrzendő eredménye, amelyet a hiányzó direktíva pótlásával kell megszüntetni.

Várt kimenet (levezetett, nem mért): c1460000. Az azonosság azt mutatja, hogy az S0 pontosan ugyanazt a 32 bitet tartalmazza, amit a W0-ba betöltöttünk — a lebegőpontos regiszter nem „értelmezi át” az adatot, csak tárolja. Az értelmezést az adja, hogy melyik utasítás olvassa.

Ugyanez C-ből, unióval, ami a bájt-szintű reprezentációt teszi láthatóvá:

// lab-fp-dump.c -- target: qemu-aarch64 (user-mode)
#include <stdio.h>
#include <stdint.h>

int main(void) {
    union { float f; uint32_t u; } v;
    v.f = -12.375f;
    printf("%08x\n", v.u);      /* vart: c1460000 */

    v.f = 0.1f;
    printf("%08x\n", v.u);      /* vart: 3dcccccd */
    return 0;
}

12.1.6 Speciális értékek

Az exponensmező szélső értékei nem számokat, hanem kijelölt állapotokat jelölnek (single precision; double esetén a kitevőhatárok 1..20461..2046, a nevezetes kitevő pedig 2−10222^{-1022}):

Exponensmező Mantissza Jelentés
0 0 ±0\pm 0 (előjeles nulla)
0 ≠0\neq 0 denormális (subnormal)
1..254 bármely normált szám
255 0 ±∞\pm\infty
255 ≠0\neq 0 NaN

A denormális számoknál az implicit vezető bit 0-ra vált, és az érték x=(−1)s×0.m2×2−126x = (-1)^s \times 0.m_2 \times 2^{-126}. Ez a fokozatos alulcsordulást (gradual underflow) biztosítja: a legkisebb denormális single érték 2−1492^{-149}. A NaN (Not a Number) akkor keletkezik, ha például −1\sqrt{-1}-et vagy 0/00/0-t számolunk; a mantissza legfelső bitje különbözteti meg a quiet és signaling NaN-t. Az ∞\infty jön létre túlcsordulásnál vagy nullával osztásnál.

Gyakorlati szempontból a legfontosabb: a 0 és a -0 egyenlőnek számít összehasonlításkor (+0=−0+0 = -0), a NaN viszont nem egyenlő önmagával sem, ami a == operátorral írt feltételeknél meglepetést okozhat. Az egyenlőség ellenére a két nulla nem felcserélhető: a C signbit(-0.0f) igaz, a copysign(1.0f, -0.0f) eredménye -1.0f, az 1.0f / -0.0f pedig -Inf. Az FCMP/FCMEQ utasítások viselkedését a Feltételes végrehajtás mintázataihoz hasonlóan, de külön szabályok szerint kell olvasni (Skaláris lebegőpontos utasítások).

12.1.7 Kerekítés és pontosságvesztés

A 0.1f nem ábrázolható pontosan. A bináris tört végtelen szakaszos: 0.1=1.1001100110011…2×2−40.1 = 1.1001100110011\ldots_2 \times 2^{-4}, ahol a „1001” végtelenségig ismétlődik. A bias e=−4+127=123e = -4 + 127 = 123, azaz 01111011. A 23 mantisszabiten a végtelen sorozatot le kell vágni; a kerekítési szabály (round to nearest, ties to even) a 24. bit alapján felfelé kerekít, így a mantissza 10011001100110011001101. Az eredmény:

0 | 01111011 | 10011001100110011001101 = 0x3DCCCCCD.

Ez a tárolt érték nem 0.1, hanem 0.100000001490116119384765625. A hiba ≈1.49×10−9\approx 1.49 \times 10^{-9} nagyságú, ami single precision esetén a gépi epsilon (2−23≈1.19×10−72^{-23} \approx 1.19 \times 10^{-7}) fele alatt van — ezért tűnik „elég pontosnak”. A hibák a műveletek során halmozódhatnak, de nem szükségszerűen. Tanulságos példa: single precisionben a 0.1f + 0.2f eredménye bitre azonos a 0.3f-fel (mindkettő 0x3E99999A), mert a két tárolási hiba és az összeadás kerekítése éppen ugyanarra a legközelebbi float-ra vezet. Double pontosságban viszont a 0.1 + 0.2 eredménye 0x3FD3333333333334, a 0.3 pedig 0x3FD3333333333333: a kettő egy egységnyi utolsó helyiértékkel (ULP) eltér. A „lebegőpontos számítás mindig pontatlan” leegyszerűsítés tehát éppúgy hibás, mint a „mindig pontos”: hogy két érték egyezik-e, azt a konkrét bitminták döntik el. Ezért == helyett általában toleranciás összehasonlítás kell, de a toleranciát a feladathoz kell választani (lásd a 3. feladat megoldását).

A double pontosság esetén a 52 mantisszabit miatt a hiba sok nagyságrenddel kisebb, de eltűnni nem fog. A 0.1 double alakja 0x3FB999999999999A, hibatartalma ≈5.55×10−18\approx 5.55 \times 10^{-18}. A pontosságvesztés mértéke a művelet jellegétől is függ: az azonos nagyságrendű számok kivonásánál (katasztrofális kancelláció) a relatív hiba hirtelen megnő, mert a vezető értékes bitek kiesnek. Ez az oka annak, hogy numerikus algoritmusokban a kivonásos lépéseket igyekeznek átalakítani stabil formára.

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

Hiba 1: az exponens eltolásáról megfeledkezés. A -12.375 exponensébe 3 kerül, nem 130. Tünet: a bitminta 0xC0C60000 körül jelenik meg, az érték néhány nagyságrenddel téves. Hipotézis: az eltolás kimaradt. Adatgyűjtés: másoljuk ki a 32 bitet, és kézzel fejtsük vissza az exponenst. Egy változtatás: adjunk hozzá 127-et, ellenőrizzük újra.

Hiba 2: fmov és az egész mozgatás összekeverése. A mov w0, #0xC1460000 nem assemblálható le, mert a MOV immediate-kódolása korlátozott (16 bitenként mozgatható azonnali). Tünet: „immediate out of range” assembler-hiba. Megoldás: movz/movk páros, ahogy a példában. Ezt az immediate-korlátot a MOV-variánsok tárgyalja részletesen.

Hiba 3: a Sn és Dn nézet téves használata. Ha egy double értéket Sn-ként olvasunk vissza, csak az alsó 32 bitet kapjuk meg (a mantissza egy részét), az eredmény szemét. Ellenpróba: mindig a C típusához illeszkedő skaláris nézetet használjuk (float → Sn, double → Dn), és a printf formátumát is egyeztessük (%f a float/double esetén: a printf változó számú argumentumában a float a C nyelv alapértelmezett argumentum-előléptetési szabálya szerint double-ként adódik át — ez nyelvi szabály, nem platformkonvenció).

12.1.9 Feladatok

1. (magyarázó, alapszint — cél: megértés-ellenőrzés) Bontsd fel kézzel a 0xC1200000 bitmintát! Add meg az előjelet, a nyers exponensmezőt, a kitevőt, a mantisszát és a decimális értéket!

2. (implementációs, középszint — cél: rutinépítés) Készíts assembly rutint, amely egy S0-ban tárolt float előjel-, exponens- és mantisszamezőjét W0-ba, W1-be, W2-be bontja! Az előjelet LSR-rel a 31. bitről, az exponenst LSR és maszkolás útján, a mantisszát 23 bites maszkkal nyerd ki.

3. (hibakeresési/mérési, középszint — cél: megértés-ellenőrzés) Írj C programot, amely kiírja 0.1f, 0.2f, 0.3f és 0.1f + 0.2f bitmintáját, majd ugyanezt double típussal is (0.1, 0.2, 0.3, 0.1 + 0.2)! Melyik pontosságban egyezik az összeg a 0.3 tárolt értékével, és miért? Írj toleranciás összehasonlítást, és add meg, milyen bemenetekre érvényes!

12.1.10 Megoldások

1. megoldás — Segítség. Csoportosítsd a 32 bitet 1+8+23 szerint, az exponensmezőből vond ki a biast (127), majd a mantisszát implicit vezető 1-gyel olvasd.

1. megoldás — Teljes megoldás. A 0xC1200000 binárisan 1100 0001 0010 0000 .... Előjel: 1 → negatív. Exponensmező: 10000010 = 130, tehát kitevő =130−127=3= 130 - 127 = 3. Mantissza: 0100000... → implicit 1-gyel 1.012=1,251.01_2 = 1{,}25. Érték: −1,25×23=−10-1{,}25 \times 2^{3} = -10. Ellenőrzés: a -10.0f kanonikus bitmintája valóban 0xC1200000.

2. megoldás — Segítség. Előbb vidd át a bitmintát fmov-val egy általános célú regiszterbe — onnantól a feladat ugyanaz a bitmező-kinyerés, mint a fejezet IEEE 754 levezetésében.

2. megoldás — Teljes megoldás. A mezők kinyerése:

// bemenet: s0 (float), kimenet: w0=elojel, w1=exponens, w2=mantissza
        fmov    w3, s0
        lsr     w0, w3, #31             // elojel: 0 vagy 1
        ubfx    w1, w3, #23, #8         // exponensmezo (8 bit a 23. bitrol)
        and     w2, w3, #0x007FFFFF     // mantissza (alsó 23 bit)

A UBFX (Unsigned Bit Field Extract) utasítás pontosan két pozíció és szélesség közötti bitmezőt emel ki — ez a bitfield-utasítások körébe tartozik. A #0x007FFFFF maszk az alsó 23 bitet tartja meg.

3. megoldás — Segítség. Ne feltételezd előre, hogy az összeg eltér: nézd meg a bitmintákat. A 0.1f és a 0.2f tároláskor kerekített érték, de a hibák előjele és nagysága dönti el, hová kerekít az összeadás.

3. megoldás — Teljes megoldás. Single precisionben a 0.1f, 0.2f, 0.3f bitmintái rendre 0x3DCCCCCD, 0x3E4CCCCD, 0x3E99999A, és a 0.1f + 0.2f eredménye (alapértelmezett, round-to-nearest-ties-to-even kerekítéssel) szintén 0x3E99999A: az összeg egyezik a 0.3f-fel. Double pontosságban a 0.1 + 0.2 eredménye 0x3FD3333333333334, a 0.3 értéke 0x3FD3333333333333: itt egy ULP az eltérés, ezért a == hamis. A tanulság: a pontos egyezés vagy eltérés a konkrét bitmintákon múlik, nem általános szabályon.

Toleranciás összehasonlítás véges értékekre, pozitív abszolút toleranciával:

#include <math.h>

/* Előfeltétel: a és b véges (nem NaN, nem ±Inf), eps > 0. */
int feq_abs(float a, float b, float eps) { return fabsf(a - b) <= eps; }

Ez csak akkor helyes, ha az értékek nagyságrendje ismert, és ehhez választjuk az eps-et. Külön esetek:

12.1.11 Rövid összafoglaló

Az IEEE 754 single precision formátum egy előjelbitből, egy 8 bites, 127-tel eltolt exponensből és egy 23 bites, implicit vezető bites mantisszából áll; a double megfelelője 11 és 52 bites. A -12.375 levezetése megmutatta, hogy a bitmezőket a normalizált alakból kell számolni, és az eredmény 0xC1460000. Az AArch64 V0–V31 regiszterei 128 bitesek, skaláris nézeteik (Sn, Dn) ezek alsó szeletei, és a skaláris írás nullázza a felső biteket. A lebegőpontos és az egész regisztertér között fmov közvetít.

12.1.12 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 qemu-aarch64 user-mode lebegőpontos viselkedése hardveresen nem garantált, ciklusszintű következtetésre nem alkalmas. Az IEEE 754 speciális értékeinek pontos FPSR-jelzőkezelése (invalid, inexact) utasítás-specifikus, ezt az utasítások alfejezet tárgyalja. A printf változó argumentumaiban a float → double előléptetés C-nyelvi szabály (alapértelmezett argumentum-előléptetés), nem az ISA része és nem pusztán platformkonvenció. A 0.1f + 0.2f és a 0.1 + 0.2 bitmintáit natív x86_64 GCC-vel és helyettesítő AArch64-eszközlánccal (zig cc, Clang + musl, Unicorn-alapú emulátor) is ellenőriztük; mindkettő a fenti értékeket adta.