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.
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:
ahol az előjelbit, a mantissza tört bitjei kettes számrendszerben, a tárolt exponensmező egész értéke, 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.
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 .
2. Abszolút érték bináris alakja. Az egész rész: . A tört rész kettővel szorozva, a tört részt visszatartva:
A maradék nulla, tehát . Együtt: .
3. Normalizálás. A kettes pontot három hellyel balra
visszük, hogy a vezető bit 1 legyen:
A kitevő tehát . 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ő. , ami binárisan .
5. Mantissza. Az implicit vezető 1-et
elhagyjuk, a maradék 100011-et balra igazítjuk és 23 bitre
nullákkal feltöltjük:
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.
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:
,
.
Ha egy float-ot az S0-ba írunk, az pontosan
V0 alsó 32 bitjén jelenik meg. Két fontos szerződés:
Sn, Dn,
Hn, Bn célregiszterként)
nullázza a regiszter íráson felüli felső bitjeit. Az
S0 írása tehát V0 felső 96 bitjét nullázza;
D0 írása a felső 64 bitet. Ez determinisztikus, nem
„megmaradó szemét”.Xn/Wn regiszterek külön
regisztertömböt alkotnak. Nincs olyan aritmetikai utasítás,
amely W0-t és S0-t közvetlenül összeadná; az
adatot fmov utasítással kell átvinni a két tér között.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.
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/a044Az 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,"",%progbitsA .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-bitsA 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;
}Az exponensmező szélső értékei nem számokat, hanem kijelölt állapotokat jelölnek (single precision; double esetén a kitevőhatárok , a nevezetes kitevő pedig ):
| Exponensmező | Mantissza | Jelentés |
|---|---|---|
0 |
0 |
(előjeles nulla) |
0 |
denormális (subnormal) | |
1..254 |
bármely | normált szám |
255 |
0 |
|
255 |
NaN |
A denormális számoknál az implicit vezető bit
0-ra vált, és az érték
.
Ez a fokozatos alulcsordulást (gradual underflow) biztosítja: a
legkisebb denormális single érték
.
A NaN (Not a Number) akkor keletkezik, ha például
-et
vagy
-t
számolunk; a mantissza legfelső bitje különbözteti meg a quiet
és signaling NaN-t. Az
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
(),
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).
A 0.1f nem ábrázolható pontosan. A bináris tört végtelen
szakaszos:
,
ahol a „1001” végtelenségig ismétlődik. A bias
,
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
nagyságú, ami single precision esetén a gépi epsilon
()
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
.
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.
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ó).
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!
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ő
.
Mantissza: 0100000... → implicit 1-gyel
.
Érték:
.
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:
fabsf(a - b) <= eps NaN-ra
mindig hamis, ezért két NaN-t ez a függvény sosem tekint egyenlőnek. Ha
ez nem a kívánt viselkedés, a NaN-t előbb isnan-nal külön
kell kezelni.Inf - Inf NaN, ezért két egyező
végtelen sem „egyenlő” a fenti függvény szerint; az isinf
vizsgálata és az előjel összevetése külön ág.eps vagy túl szigorú, vagy túl laza.
Ilyenkor a különbséget a nagyobb abszolút értékhez viszonyítjuk:
fabsf(a - b) <= rel * fmaxf(fabsf(a), fabsf(b)), ahol
rel > 0, és a nullához közeli értékekre ezt egy abszolút
alsó küszöbbel egészítjük ki.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.
1-ese.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.
V0–V31, Sn/Dn
regiszterek és a skaláris írás nullázó viselkedésének specifikációja.
Ellenőrizve: a link él; a fejezetszám és a kiadásbetű az Arm-oldalak
közös korlátja miatt nem ellenőrzött.float/double ABI-kezelése és a lebegőpontos
modell kapcsolói. Tartalmilag ellenőrizve: a legújabb
listázott kiadás GCC 16.2.qemu-aarch64 futtatási modellje és
korlátai. Tartalmilag ellenőrizve, de
verzió-megjegyzéssel: a master ág dokumentációja (közös
verziókorlát).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.