Jak pisać programy w języku asembler?

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 (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 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: 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 -> flagi

    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 (będzie to program typu .exe, bo dodamy moją bibliotekę do wyświetlania wyników):


(przeskocz program wyświetlający częstotliwość zegara)
; TASM:
; z wyświetlaniem:
;   tasm naszplik.asm
;   tlink naszplik.obj bibl\lib\bibldos.lib
; bez wyświetlania:
;   tasm naszplik.asm
;   tlink naszplik.obj

.model small
.stack 400h			; stos dla programu .exe
.data

dzielna		DQ 1234DDh	; 4013 91a6 e800 0000 0000
dzielnik	DQ 10000h
iloraz		DT ?

.code

; jeśli nie chcesz wyświetlania, usuń tę linijkę niżej:
 include incl\std_bibl.inc

start:
	mov	ax, @data
	mov	ds, ax
	mov	es, ax		; konieczne w programie typu .exe !
				; Życie przestaje być
				; wygodne. DOS już nam nie ustawi
				; DS=ES=CS. A nasze dane są w
				; segmencie kodu, stad ustawiamy DS=CS
				; W programie typu .com to na pewno
				; nie zaszkodzi.

	finit			; zawsze o tym pamiętaj !

	fild	qword ptr [dzielna]	; ładujemy dzielną. st(0) = 1234DD
	fild	qword ptr [dzielnik]	; ładujemy dzielnik. st(0) = 10000h,
					; st(1) = 1234DD
	fdivp				; dzielimy. st(1) := st(1)/st(0) i
					; zdejmij. st(0) ~= 18.2
	fstp	tbyte ptr [iloraz]	; zapisujemy st(0) do pamięci i
					; zdejmujemy ze stosu

; jeśli nie chcesz wyświetlania, usuń te 3 linijki niżej:
	mov	di, offset iloraz ; DI=adres zmiennej zawierającej wynik
	piszd80			  ; wyświetl wynik
	nwln			  ; przejdź do nowej linii

	mov	ax, 4c00h
	int	21h

end start

Teraz wersja dla NASMa. O tym, jak NASMem zrobić program typu .exe napisane jest w jego dokumentacji. Wymaga to przede wszystkim stworzenia własnego segmentu stosu i nakierowanie na niego rejestrów SS:SP. Trzeba mieć też odpowiedni linker, na przykład VAL. Można jednak użyć jednego z plików dołączonych do mojej biblioteki i tak też zrobimy.


(przeskocz ten program w wersji NASMa)
; NASM:
; z wyświetlaniem:
;   nasm -f obj -o fpu1.obj fpu1.asm
;   val fpu1.obj,fpu1.exe,,bibl\lib\bibldos.lib,
; bez wyświetlania:
;   nasm -f obj -o fpu1.obj fpu1.asm
;   val fpu1.obj,fpu1.exe,,,

; częściowa zgodność z TASMem:
%include "bibl\incl\dosbios\nasm\do_nasma.inc"

; jeśli nie chcesz wyświetlania, usuń tę linijkę niżej:
%include "bibl\incl\dosbios\nasm\std_bibl.inc"

.model small
.stack 400h
.code

..start:
	mov	ax, cs
	mov	ds, ax
	mov	es, ax		; konieczne w programie typu .exe !
				; Życie przestaje być
				; wygodne. DOS już nam nie ustawi DS=ES=CS.
				; A nasze dane są w
				; segmencie kodu, stąd ustawiamy DS=CS.
				; W programie typu .com to na pewno nie
				; zaszkodzi.

	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
				; zdejmij. 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	di, iloraz
	piszd80			; wyświetl wynik
	nwln			; przejdź do nowego wiersza

	mov	ax, 4c00h
	int	21h

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

iloraz		dt 0.0

Wersja dla FASMa:


(przeskocz ten sam program w wersji FASMa)
; FASM:
; z wyświetlaniem (stary format biblioteki - OMF):
;   fasm fpu1.asm fpu1.obj
;   alink fpu1.obj  bibl\lib\bibldos.lib -c- -entry _start -oEXE -m-
; z wyświetlaniem (nowy format biblioteki - COFF):
;   fasm fpu1.asm fpu1.obj
;   ld -s -o fpu1.exe fpu1.obj bibl\lib\bibldos.a
; bez wyświetlania:
;   fasm fpu1.asm fpu1.exe

; jeśli chcesz wyświetlanie (stary format biblioteki - OMF):
format coff
public _start
public start
include "bibl\incl\dosbios\fasm\std_bibl.inc"
use16

; jeśli chcesz wyświetlanie (nowy format biblioteki - COFF):
;format coff
;public _start
;public start
;include "bibl\incl\dosbios\fasm32\std_bibl.inc"

; jeśli nie chcesz wyświetlania:
;format MZ
;entry kod:_start
;stack 400h
;segment kod

start:
_start:
	; wyłączyć trzy poniższe linie w przypadku FASMa z nowym formatem
	; biblioteki (32-bitowy COFF nie pozwala na manipulację segmentami)
	mov	ax, cs
	mov	ds, ax
	mov	es, ax		; konieczne w programie typu .exe !
				; Życie przestaje być
				; wygodne. DOS już nam nie ustawi DS=ES=CS.
				; A nasze dane są w
				; segmencie kodu, stąd ustawiamy DS=CS.
				; W programie typu .com to na pewno nie
				; zaszkodzi.

	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			; dzielimy. st(1) := st(1)/st(0) i
				; zdejmij. st(0) ~= 18.2

	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	ax, 4c00h
	int	21h

dzielna		dd 1234ddh	; 4013 91a6 e800 0000 0000
dzielnik	dd 10000h

iloraz		dt 0.0

Ten przykład do zmiennej iloraz wstawia częstotliwość zegara komputerowego (ok. 18,2 Hz). Należy zwrócić uwagę na zaznaczanie rozmiarów zmiennych (dword/qword/tbyte ptr).

Dyrektywa ALIGN 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 dla zmiennej 8-bajtowej 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)
.model tiny
.code
;.386				; odkomentować, jeżeli .387 sprawia problemy
.387

org 100h
start:
	finit			; zawsze o tym pamiętaj !

	fldpi			; wczytujemy PI
	fsin			; obliczamy sin(PI)
	ftst			; porównujemy st(0) z zerem.
	fstsw ax		; zapisujemy rejestr stanu bezpośrednio w AX.
				; Dlatego było .387

	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 dx,offset nie_zero	; zmienić DX na EDX, jeżeli sprawia problemy
	jmp short pisz
jest_zero:
	mov dx,offset zero	; DX/EDX jak wyżej
pisz:
	int 21h			; wypisz jedną z wiadomości.

	mov ax,4c00h
	int 21h

nie_zero	db	"Sin(PI) != 0.$"
zero		db	"Sin(PI) = 0$"

end start

Wersja dla NASMa i FASMa:


(przeskocz wersję NASM/FASM programu z sinusem)
org 100h

start:
	finit			; zawsze o tym pamiętaj !

	fldpi			; wczytujemy PI
	fsin			; obliczamy sin(PI)
	ftst			; porównujemy st(0) z zerem.
	fstsw ax		; zapisujemy rejestr stanu bezpośrednio w AX.
				; Dlatego było .387

	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 dx,nie_zero
	jmp short pisz
jest_zero:
	mov dx,zero
pisz:
	int 21h			; wypisz jedną z wiadomości.

	mov ax,4c00h
	int 21h

nie_zero	db	"Sin(PI) != 0$"
zero		db	"Sin(PI) = 0$"

Przykład 3: czy pierwiastek z 256 rzeczywiście jest równy 16, czy 200 jest kwadratem liczby całkowitej (komentarze do .386/.387 jak wyżej)?


(przeskocz ten przykład)
.model tiny
.code
.386
.387

org 100h			; program typu .com

start:
	finit			; zawsze o tym pamiętaj !

	mov ax,cs
	mov ds,ax		; konieczne w programie typu .exe !
				; Życie przestaje być
				; wygodne. DOS już nam nie ustawi DS=ES=CS.
				; A nasze dane są w
				; segmencie kodu, stąd ustawiamy DS=CS.

	fild word ptr [dwa_pie_sze]	; st(0) = 256
	fsqrt				; st(0) = sqrt(256)
	fild word ptr [szesnascie]	; st(0) = 16, st(1) = sqrt(256)
	fcompp				; porównaj st(0) i st(1), zdejmij oba
					; st: [pusty]
	fstsw ax
	sahf

	mov ah,9
	je tak256
	mov dx,offset nie_256
	jmp short pisz_256
tak256:
	mov dx,offset tak_256
pisz_256:
	int 21h				; 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 ptr [dwiescie]	; st: 200
	fsqrt				; st: sqrt(200)
	fld st(0)			; 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 dx,offset nie_200
	jmp short pisz_200
tak200:
	mov dx,offset tak_200
pisz_200:
	int 21h				; wypisz stosowną wiadomość


	mov ax,4c00h
	int 21h

dwa_pie_sze	dw	256
dwiescie	dw	200
szesnascie	dw	16

nie_256	db	"SQRT(256) != 16$"
tak_256	db	"SQRT(256) = 16$"
nie_200	db	"Liczba 200 nie jest kwadratem liczby calkowitej$"
tak_200	db	"Liczba 200 jest kwadratem liczby calkowitej$"
end start

Teraz dla NASMa i FASMa:


(przeskocz ten sam przykład w wersji NASM/FASM)
org 100h			; program typu .com

start:
	finit			; zawsze o tym pamiętaj !

	mov ax,cs
	mov ds,ax		; konieczne w programie typu .exe !
				; Życie przestaje być
				; wygodne. DOS już nam nie ustawi DS=ES=CS.
				; A nasze dane są w
				; segmencie kodu, stąd ustawiamy DS=CS.
				; W programie typu .com to na pewno nie
				; zaszkodzi.

	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 dx, nie_256
	jmp short pisz_256
tak256:
	mov dx,tak_256
pisz_256:
	int 21h				; 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 dx,nie_200
	jmp short pisz_200
tak200:
	mov dx,tak_200
pisz_200:
	int 21h				; wypisz stosowną wiadomość


	mov ax,4c00h
	int 21h

dwa_pie_sze	dw	256
dwiescie	dw	200
szesnascie	dw	16

nie_256	db	"SQRT(256) != 16$"
tak_256	db	"SQRT(256) = 16$"
nie_200	db	"Liczba 200 nie jest kwadratem liczby calkowitej$"
tak_200	db	"Liczba 200 jest kwadratem liczby calkowitej$"

Dwa ostatnie programiki zbiłem 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			; NASM: faddp st1, st0
	fld	[c]
	fmulp			; NASM: fmulp st1, st0
	fld	[a]
	fld	[c]
	faddp			; NASM: faddp st1, st0
	fld	[d]
	fmulp			; NASM: fmulp st1, st0
	faddp			; NASM: 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 liczb!

Następnym razem o SIMD.

Poprzednia część kursu (klawisz dostępu 3)
Kolejna część kursu (klawisz dostępu 4)
Spis treści off-line (klawisz dostępu 1)
Spis treści on-line (klawisz dostępu 2)
Ułatwienia dla niepełnosprawnych (klawisz dostępu 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^/