Jak pisać programy w języku asembler pod Linuksem?

Część 5 - Koprocesor, czyli jak liczyć na ułamkach. Odwrotna Notacja Polska

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.


Typy danych


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:

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.



Rejestry 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ć.



Instrukcje koprocesora

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).

  1. Instrukcje przemieszczenia danych:

  2. Instrukcje ładowania stałych

  3. Działania matematyczne:

  4. Komendy porównania:
    - 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.

    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.

  5. Instrukcje trygonometryczne:

  6. Logarytmiczne, wykładnicze:


  7. Instrukcje kontrolne:


Przykłady

Dość już teorii, pora na przykłady. Programiki te wymyśliłem pisząc ten kurs.

Przykład 1 (NASM):


(przeskocz program wyświetlający częstotliwość zegara)
; 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.

Teraz wersja dla FASMa:


(przeskocz ten program w wersji FASMa)
; 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)?


(przeskocz program z sinusem)
; 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?


(przeskocz ten program)
; 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.


Odwrotna Notacja Polska (Reverse Polish Notation, RPN)

Ł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:


(przeskocz przykłady na ONP)
   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:


(przeskocz ilustrację relacji między ONP a koprocesorem)
	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.



Poprzednia część kursu (Alt+3)
Kolejna część kursu (Alt+4)
Spis treści off-line (Alt+1)
Spis treści on-line (Alt+2)
Ułatwienia dla niepełnosprawnych (Alt+0)



Ćwiczenia

  1. Napisz program, który sprawdzi (wyświetli stosowną informację), czy liczba PI dzielona przez samą siebie daje dokładne 1.0

  2. Napisz program obliczający (nie wyświetlający) wartość 10*PI. Potem sprawdź, czy sinus tej liczby jest zerem.

  3. Napisz program mówiący, która z tych liczb jest większa: PI czy log2(10).

  4. Napisz program sprawdzający, czy 10*PI - PI - PI - PI - PI - PI = 5*PI.

  5. Zamień na ONP:
    a/c/d + b/c/d
    a/(c*d) + b/(c*d)
    (a+b)/c/d
    (a+b)/(c*d)


  6. Zamień z ONP na zapis tradycyjny (daszek ^ oznacza potęgowanie):
    ab*cd*e/-
    a5/c7/ed-9/*+
    a3+b/de+6^-
    dc-7b*2^/