Jak zapewne większość wie, koprocesor (FPU = Floating Point Unit, NPX = Numerical Processor eXtension) służy do wykonywania działań matematycznych. Podstawowy procesor też oczywiście wykonuje działania matematyczne (dodawanie, mnożenie, ...) ale tylko na liczbach całkowitych. Z czasem jednak przyszła potrzeba wykonywania obliczeń na liczbach niecałkowitych, czyli ułamkach (liczbach zmiennoprzecinkowych). Dlatego firmy produkujące procesory zaczęły je wyposażać właśnie w układy wspomagające pracę na ułamkach. Do procesorów 8086, 80286, 80386 były dołączane jako osobne układy koprocesory: 8087, 80287, 80387 (80187 nie wprowadził żadnych istotnych nowości. Była to przeróbka 8087, a może nawet był to po prostu ten sam układ). Procesory 486SX miały jeszcze oddzielny koprocesor (80387) a od 486DX (w każdym razie u Intela) koprocesor był już na jednym chipie z procesorem. I tak jest do dziś.
Ale dość wstępu. Pora na szczegóły.
Zanim zaczniemy cokolwiek robić, trzeba wiedzieć, na czym
ten cały koprocesor operuje.
Oprócz liczb całkowitych, FPU operuje na liczbach ułamkowych różnej precyzji:
float
.double
long double
Jak widać, ilości bitów są oczywiście skończone. Więc nie każdą liczbę rzeczywistą da się dokładnie zapisać w postaci binarnej. Na przykład, jedna dziesiąta (0.1) zapisana dwójkowo jest ułamkiem nieskończonym okresowym! Poza tym, to, czego nas uczyli na matematyce, na przykład oczywista równość: a+(b-a)=b nie musi być prawdą w świecie ułamków w procesorze ze względu na brak precyzji!
Poza ułamkami, FPU umie operować na BCD (binary coded decimal). W takich liczbach 1 bajt dzieli się na 2 części po 4 bity, z których każda może mieć wartość 0-9. Cały bajt reprezentuje więc liczby od 0 do 99, w których cyfra jedności jest w młodszych 4 bitach a cyfra dziesiątek - w starszych 4 bitach.
Szczegółami zapisu liczb ułamkowych nie będziemy się tutaj zajmować. Polecam, tak jak poprzednio, strony Intela i strony AMD, gdzie znajduje się też kompletny opis wszystkich instrukcji procesora i koprocesora.
Po zapoznaniu się z typami (a przede wszystkim z rozmiarami) liczb ułamkowych, powstaje pytanie: gdzie koprocesor przechowuje takie ilości danych?
FPU ma specjalnie do tego celu przeznaczonych 8 rejestrów, po 80 bitów każdy. W operacjach wewnętrznych (czyli bez pobierania lub zapisywania danych do pamięci) FPU zawsze używa rozszerzonej precyzji.
Rejestry danych nazwano st(0), st(1), ... , st(7) (NASM: st0 ... st7). Nie działają jednak one tak, jak zwykłe rejestry, lecz jak ... stos! To znaczy, że dowolnie dostępna jest tylko wartość ostatnio położona na stosie czyli wierzchołek stosu, w tym przypadku: st(0). Znaczy to, że do pamięci (FPU operuje tylko na własnych rejestrach lub pamięci - nie może używać rejestrów ogólnego przeznaczenia na przykład EAX itp.) może być zapisana tylko wartość z st(0), a każda wartość pobierana z pamięci idzie do st(0) a stare st(0) przesuwa się do st(1) itd. każdy rejestr przesuwa się o 1 dalej. Jeżeli brakuje na to miejsca, to FPU może wygenerować przerwanie (wyjątek) a rejestry danych będą prawdopodobnie zawierać śmieci.
Operowanie na rejestrach FPU będzie wymagało nieco więcej uwagi niż na zwykłych, ale i do tego można się przyzwyczaić.
Zacznijmy więc od omówienia kilku podstawowych instrukcji. Przez [mem] będę nazywał dane będące
w pamięci (32-, 64- lub 80-bitowe, int
oznacza liczbę całkowitą), st
jest częstym skrótem
od st(0)
. Jeżeli komenda kończy się na P to oznacza, że zdejmuje dane raz ze stosu, PP
oznacza, że zdejmuje 2 razy, czyli st(0) i st(1).
FLD/FILD [mem]
- załaduj liczbę rzeczywistą/całkowitą z pamięci. Dla liczby rzeczywistej
jest to 32, 64 lub 80 bitów. Dla całkowitej - 16, 32 lub 64 bity.FST [mem32/64/80]
- do pamięci idzie liczba ze st(0).FSTP [mem32/64/80]
- zapisz st(0) w pamięci i zdejmij je ze stosu. Znaczy
to tyle, że st(1) o ile istnieje, staje się st(0) itd. każdy rejestr cofa się o 1.FIST [mem16/32]
- ewentualnie obciętą do całkowitej liczbę z st(0) zapisz do pamięci.FISTP [mem16/32/64]
- jak wyżej, tylko ze zdjęciem ze stosu.FXCH st(i)
- zamień st(0) z st(i).FLDZ
- załaduj zero. st(0) = 0.0FLD1
- załaduj 1. st(0) = 1.0FLDPI
- załaduj pi.FLDL2T
- załaduj log2(10)FLDL2E
- załaduj log2(e)FLDLG2
- załaduj log(2)=log10(2)FLDLN2
- załaduj ln(2)FADD
, składnia identyczna jak w odejmowaniu prostymFSUB [mem32/64]
st := st-[mem]FSUB st(0),st(i)
st := st-st(i)FSUB st(i),st(0)
st(i) := st(i)-st(0)FSUBP st(i), st(0)
st(i) := st(i)-st(0) i zdejmijFSUBP
(bez argumentów) = FSUBP st(1),st(0)
FISUB [mem16/32int]
st := st-[mem]FSUBR [mem32/64]
st := [mem]-st(0)FSUBR st(0),st(i)
st := st(i)-st(0)FSUBR st(i),st(0)
st(i) := st(0)-st(i)FSUBRP st(i),st(0)
st(i) := st(0)-st(i) i zdejmijFSUBRP
(bez argumentów) = FSUBRP st(1),st(0)
FISUBR [mem16/32int]
st := [mem]-stFMUL
, składnia identyczna jak w odejmowaniu prostym.FDIV
, składnia identyczna jak w odejmowaniu prostym.FDIVR
, składnia identyczna jak w odejmowaniu odwrotnym.FABS
(bez argumentów) zastępuje st(0) jego wartością bezwzględną.FCHS
: st(0) := -st(0).FSQRT
: st(0) := SQRT[ st(0) ]FPREM
, FPREM1
st(0) := st(0) mod st(1).FRNDINT
: st(0) := (int)st(0).FCOM/FCOMP/FCOMPP
, FUCOM/FUCOMP/FUCOMPP
, FICOM/FICOMP
,
FCOMI/FCOMIP
, FUCOMI/FUCOMIP
, FTST
, FXAM
.
Tutaj trzeba trochę omówić sytuację. FPU oprócz rejestrów danych zawiera także rejestr
kontrolny (16 bitów) i rejestr stanu (16 bitów).
W rejestrze stanu są 4 bity nazwane
C0, C1, C2 i C3. To one wskazują wynik ostatniego porównania, a układ ich jest taki sam,
jak flag procesora, co pozwala na ich szybkie przeniesienie do flag procesora.
Aby odczytać wynik porównania, należy zrobić takie coś:
fcom fstsw ax ; tylko od 386. Inaczej: ; fstsw word ptr [zmienna] / mov ax,[zmienna] sahf ; AH zapisane do flag
i używać normalnych komend JE, JB itp.
FCOM st(n)/[mem]
- porównaj st(0) z st(n)
(lub zmienną w pamięci) bez zdejmowania st(0) ze stosu FPUFCOMP st(n)/[mem]
- porównaj st(0) z st(n)
(lub zmienną w pamięci) i zdejmij st(0)FCOMPP
- porównaj st(0) z st(1) i zdejmij oba ze stosuFICOM [mem]
- porównaj st(0) ze zmienną
całkowitą 16- lub 32-bitową w pamięciFICOMP [mem]
- porównaj st(0) ze zmienną
całkowitą 16- lub 32-bitową w pamięci, zdejmij st(0)FCOMI st(0), st(n)
- porównaj st(0) z st(n)
i ustaw flagi procesora, nie tylko FPUFCOMIP st(0), st(n)
- porównaj st(0) z st(n)
i ustaw flagi procesora, nie tylko FPU, zdejmij st(0)Komendy kończące się na I lub IP zapisują swój wynik bezpośrednio do flag procesora.
Można tych flag od razu używać (JZ
, JA
, ...). Te komendy są
dostępne tylko od 386.
FTST
porównuje st(0) z zerem.
FXAM
bada, co jest w st(0) - prawidłowa liczba, błąd
(NaN = Not a Number), czy 0.
FSIN
- st(0) := sinus [st(0)]FCOS
- st(0) := kosinus [st(0)]FSINCOS
- st(0) := kosinus [st(0)], st(1) := sinus [st(0)]FPTAN
- partial tangent = tangens st(0) := tg [st(0)]FPATAN
- arcus tangens st(0) := arctg [st(0)]FYL2X
st(1) := st(1)*log2[st(0)] i zdejmijFYL2XPI
st(1) := st(1)*log2[ st(0) + 1.0 ] i zdejmij F2XM1
st(0) := 2^[st(0)] - 1FINIT/FNINIT
- inicjalizacja FPU. Litera N po F oznacza,
aby nie brać po uwagę potencjalnych niezałatwionych wyjątków.FLDCW
, FSTCW/FNSTCW
- Load/Store control word
- zapisuje 16 kontrolnych bitów do pamięci,
gdzie można je zmieniać na przykład aby zmienić sposób zaokrąglania liczb.FSTSW/FNSTSW
- zapisz do pamięci (lub rejestru AX) słowo statusu, czyli stan FPUFCLEX/FNCLEX
- wyczyść wyjątkiFLDENV
, FSTENV/FNSTENV
- wczytaj/zapisz środowisko (rejestry stanu,
kontrolny i kilka innych, bez rejestrów danych). Wymaga 14 albo 28 bajtów pamięci, w zależności
od trybu pracy procesora (rzeczywisty - DOS lub chroniony - Windows/Linux).FRSTOR
, FSAVE/FNSAVE
- jak wyżej, tylko że z rejestrami danych. Wymaga 94
lub 108 bajtów w pamięci, zależnie od trybu procesora.FINCSTP
, FDECSTP
- zwiększ/zmniejsz wskaźnik stosu - przesuń st(0)
na st(7), st(1) na st(0) itd. oraz w drugą stronę, odpowiednio.FFREE
- zwolnij podany rejestr danychFNOP
- no operation. Nic nie robi, ale zabiera czas.WAIT/FWAIT
- czekaj, aż FPU skończy pracę. Używane do synchronizacji z CPU.Dość już teorii, pora na przykłady. Programiki te wymyśliłem pisząc ten kurs.
Przykład 1 (NASM):
; z wyświetlaniem: ; nasm -O999 -f elf -o fpu1.o fpu1.asm ; ld -s -o fpu1 fpu1.o bibl/lib/libasmio.a ; bez wyświetlania: ; nasm -O999 -f elf -o fpu1.o fpu1.asm ; ld -s -o fpu1 fpu1.o section .text ; jeśli nie chcesz wyświetlania, usuń tę linijkę niżej: %include "bibl/incl/linuxbsd/nasm/std_bibl.inc" global _start _start: finit ; zawsze o tym pamiętaj ! fild dword [dzielna] ; ładujemy dzielną. st(0) = 1234DD fild dword [dzielnik]; ładujemy dzielnik. st(0) = 10000h, ; st(1) = 1234DD fdivp st1 ; dzielimy. st(1) := st(1)/st(0) i pop. ; st(0) ~= 18.2 ; FASM: fdivp st(1) fstp tword [iloraz] ; zapisujemy st(0) do pamięci i ; zdejmujemy ze stosu ; jeśli nie chcesz wyświetlania, usuń te 3 linijki niżej: mov edi, iloraz piszd80 ; wyświetl wynik nwln ; przejdź do nowego wiersza mov eax, 1 xor ebx, ebx int 80h section .data align 8 ; NASM w tym miejscu dorobi kilka ; NOPów (instrukcji nic nie ; robiących, ale zabierających czas), ; aby adres dzielił się ; przez 8 (patrz dalej). dzielna dd 1234ddh ; 4013 91a6 e800 0000 0000 dzielnik dd 10000h section .bss ; dane w sekcji nazwanej .BSS nie ; będą fizycznie umieszczone ; w programie, a dopiero w pamięci. ; Zaoszczędzi to ; miejsce = długość pliku. ; Można tu umieszczać tylko ; niezainicjalizowane dane. iloraz rest 1 ; rezerwacja 10 bajtów.
; z wyświetlaniem: ; fasm fpu1.asm ; ld -s -o fpu1 fpu1.o bibl/lib/libasmio.a ; bez wyświetlania: ; fasm fpu1.asm ; ld -s -o fpu1 fpu1.o format ELF public _start section ".text" executable ; jeśli nie chcesz wyświetlania, usuń tę linijkę niżej: include "bibl/incl/linuxbsd/fasm/std_bibl.inc" _start: finit ; zawsze o tym pamiętaj ! fild dword [dzielna] ; ładujemy dzielną. st(0) = 1234DD fild dword [dzielnik]; ładujemy dzielnik. st(0) = 10000h, ; st(1) = 1234DD fdivp st1, st0 ; dzielimy. st(1) := st(1)/st(0) i ; pop. st(0) ~= 18.2 ; FASM: fdivp st(1) fstp tword [iloraz] ; zapisujemy st(0) do pamięci i ; zdejmujemy ze stosu ; jeśli nie chcesz wyświetlania, usuń te 3 linijki niżej: mov edi, iloraz piszd80 ; wyświetl wynik nwln ; przejdź do nowego wiersza mov eax, 1 xor ebx, ebx int 80h section ".data" writeable align 8 dzielna dd 1234ddh ; 4013 91a6 e800 0000 0000 dzielnik dd 10000h section ".bss" writeable iloraz dt 0.0
Ten przykład do zmiennej iloraz
wstawia częstotliwość zegara komputerowego (ok. 18,2 Hz) i wyświetla
ją przy użyciu jednej z procedur z mojej biblioteki (można to usunąć).
Należy zwrócić uwagę na zaznaczanie rozmiarów zmiennych (dword/tword).
Dyrektywa align 8
ustawia kolejną zmienną/etykietę tak, że jej adres dzieli się przez 8 (qword = 8 bajtów). Dzięki
temu, operacje na pamięci są szybsze (na przykład pobranie 8 bajtów: zamiast 3 razy pobierać po 4 bajty,
bo akurat tak się
zdarzyło, że miała jakiś nieparzysty adres, pobiera się 2x4 bajty). Rzecz jasna, skoro zmienna
dzielna
(i dzielnik
) ma 4 bajty, to adresy zmiennych dzielnik
i iloraz
też będą podzielne przez 4. Ciąg cyfr po średniku to ułamkowa reprezentacja dzielnej. Skomplikowane,
prawda? Dlatego nie chciałem tego omawiać.
Przykład 2: czy sinus liczby pi rzeczywiście jest równy 0 (w komputerze)?
; format ELF executable ; tylko dla FASMa ; entry _start ; FASM: segment readable executable section .text global _start ; FASM: usunąć _start: finit ; zawsze o tym pamiętaj ! fldpi ; wczytujemy PI fsin ; obliczamy sinus z PI ftst ; porównujemy st(0) z zerem. fstsw ax ; zapisujemy rejestr stanu ; bezpośrednio w AX. sahf ; AH idzie do flag mov ah,9 ; AH=9, flagi niezmienione je jest_zero ; st(0) = 0? ; Jeśli tak, to wypisz, że jest mov ecx, nie_zero mov edx, dlugosc_nie_zero jmp pisz jest_zero: mov ecx, zero mov edx, dlugosc_zero pisz: mov eax, 4 mov ebx, 1 int 80h ; wypisz jedną z wiadomości. mov eax, 1 xor ebx, ebx int 80h ; FASM: segment readable writeable section .data nie_zero db "Sin(PI) != 0",0ah dlugosc_nie_zero equ $ - nie_zero ; FASM: "=" zamiast "equ" zero db "Sin(PI) = 0",0ah dlugosc_zero equ $ - zero ; FASM: "=" zamiast "equ"
Przykład 3: czy pierwiastek z 256 rzeczywiście jest równy 16, czy 200 jest kwadratem liczby całkowitej?
; format ELF executable ; tylko dla FASMa ; entry _start ; FASM: segment readable executable section .text global _start ; FASM: usunąć _start: finit ; zawsze o tym pamiętaj ! fild word [dwa_pie_sze]; st(0) = 256 fsqrt ; st(0) = sqrt(256) fild word [szesnascie] ; st(0) = 16, st(1) = sqrt(256) fcompp ; porównaj st(0) i st(1) i ; zdejmij oba. st: [pusty] fstsw ax sahf mov ah,9 je tak256 mov ecx, nie_256 mov edx, dlugosc_nie_256 jmp short pisz_256 tak256: mov ecx, tak_256 mov edx, dlugosc_tak_256 pisz_256: mov eax, 4 mov ebx, 1 int 80h ; wypisz stosowną wiadomość ; do zapisu stanu stosu, czyli ; rejestrów danych FPU można ; używać takiego schematu zapisu, ; który jest krótszy: ; st: (0), (1), (2), ... , (7) fild word [dwiescie] ; st: 200 fsqrt ; st: sqrt(200) fld st0 ; do st(0) wczytaj st(0). ; st: sqrt(200), sqrt(200) frndint ; zaokrąglij do liczby całkowitej. ; st: (int)sqrt(200), sqrt(200) fcompp ; porównaj i zdejmij oba. fstsw ax sahf mov ah,9 je tak200 mov ecx, nie_200 mov edx, dlugosc_nie_200 jmp short pisz_200 tak200: mov ecx, tak_200 mov edx, dlugosc_tak_200 pisz_200: mov eax, 4 mov ebx, 1 int 80h ; wypisz stosowną wiadomość mov eax, 1 xor ebx, ebx int 80h ; FASM: segment readable writeable section .data dwa_pie_sze dw 256 dwiescie dw 200 szesnascie dw 16 nie_256 db "SQRT(256) != 16", 0ah dlugosc_nie_256 equ $ - nie_256 ; FASM: "=" zamiast "equ" tak_256 db "SQRT(256) = 16", 0ah dlugosc_tak_256 equ $ - tak_256 ; FASM: "=" zamiast "equ" nie_200 db "Liczba 200 nie jest kwadratem liczby calkowitej", 0ah dlugosc_nie_200 equ $ - nie_200 ; FASM: "=" zamiast "equ" tak_200 db "Liczba 200 jest kwadratem liczby calkowitej", 0ah dlugosc_tak_200 equ $ - tak_200 ; FASM: "=" zamiast "equ"
Dwa ostatnie programiki zbiłem u siebie w jeden i przetestowałem. Wyszło, że sinus PI jest różny od zera, reszta była prawidłowa.
Oczywiście, w tych przykładach nie użyłem wszystkich instrukcji koprocesora (nawet spośród tych, które wymieniłem). Mam jednak nadzieję, że te proste programy rozjaśnią nieco sposób posługiwania się koprocesorem.
Ładnie brzmi, prawda? Ale co to takiego?
Otóż, bardzo dawno temu pewien polski matematyk, Jan Łukasiewicz, wymyślił taki sposób zapisywania działań, że nie trzeba w nim używać nawiasów. Była to notacja polska. Sposób ten został potem dopracowany przez Charlesa Hamblina na potrzeby informatyki - w ten sposób powstała Odwrotna Notacja Polska.
W zapisie tym argumenty działania zapisuje przed symbolem tego działania. Dla jasności podam teraz kilka przykładów:
Zapis tradycyjny ONP a+b a b + a+b+c a b + c + ; ab+ stanowi pierwszy argument ; drugiego dodawania c+b+a c b + a + (a+b)*c a b + c * c*(a+b) c a b + * (a+b)*c+d a b + c * d + (a+b)*c+d*a a b + c * d a * + (a+b)*c+d*(a+c) a b + c * d a c + * + (a+b)*c+(a+c)*d a b + c * a c + d * + (2+5)/7+3/5 2 5 + 7 / 3 5 / +
Ale po co to komu i dlaczego mówię o tym akurat w tej części?
Powód jest prosty: jak się dobrze przyjrzeć zapisowi działania w ONP, to można zobaczyć, że mówi
on o kolejności działań, jakie należy wykonać na koprocesorze. Omówimy to na przykładzie:
Zapis tradycyjny (jeden z powyższych przykładów): (a+b)*c+(a+c)*d Zapis w ONP: a b + c * a c + d * + Uproszczony kod programu: fld [a] fld [b] faddp st1, st0 fld [c] fmulp st1, st0 fld [a] fld [c] faddp st1, st0 fld [d] fmulp st1, st0 faddp st1, st0 Teraz st0 jest równe wartości całego wyrażenia.
Jak widać, ONP znacznie upraszcza przetłumaczenie wyrażenia na kod programu. Jednak, kod nie jest optymalny. Można byłoby na przykład zachować wartości zmiennych a i c na stosie i wtedy nie musielibyśmy ciągle pobierać ich z pamięci. Dlatego w krytycznych sekcjach kodu stosowanie zasad ONP nie jest zalecane. Ale w większości przypadków Odwrotna Notacja Polska sprawuje się dobrze i uwalnia programistów od obowiązku zgadywania kiedy i jakie działanie wykonać.
Pamiętajcie tylko, że stos koprocesora może pomieścić tylko 8 zmiennych!
Następnym razem o SIMD.