1.4 Operacje logiczne i arytmetyczne

Do podstawowych instrukcji procesora należą operacje arytmetyczne i logiczne na liczbach. Operacje te składają się zwykle z dwóch wartości wejściowych i jednej wyjściowej która jest wynikiem określonej operacji. Przed opisem tych podstawowych instrukcji należałoby wspomnieć, jakie typy danych są rozpoznawane przez procesor. Najmniejszą porcją danych w komputerze jest jeden bit. Jednak mimo że bit jest najmniejszą i podstawową jednostką pamięci, to jest niewygodny w użyciu ponieważ niesie ze sobą bardzo mało informacji. W wyniku tego wprowadzono grupowanie bitów w komórki po 8 i nazwano je bajtami. Bajt jest w większości układów elektronicznych najmniejszą porcją informacji, na jakiej można wykonać operację. Tak też jest w przypadku komputerów PC, mianowicie nie istnieją zmienne o rozmiarze jednego bitu. Najmniejszymi zmiennymi w komputerze są bajty.

W wyższych językach programowania można spotkać się ze zmiennymi typu bool, które jak to się definiuje mogą przechowywać binarną wartość "true" lub "false". Jak wspomniałem wcześniej, odwoływanie się do pamięci o takim rozmiarze jest fizycznie niemożliwe. W rzeczywistości więc na taką zmienną w językach wyższego poziomu rezerwuje się komórkę pamięci o rozmiarze całego bajta, która może w ogólnym przypadku przechowywać 256 wartości, a nie tylko dwie.

Podstawowe typy danych

Przyglądając się bliżej bajtom można zauważyć kilka ich podstawowych cech. W zapisie binarnym zajmują, jak już wspomniałem, 8 bitów (00000000b). W zapisie szesnastkowym jedną cyfrę zapisuje się za pomocą 4 bitów, więc jeden bajt szesnastkowo zapisuje się za pomocą dwóch cyfr (00h). Najważniejszą własnością danych, poza ich rozmiarem, jest największa liczba, jaką można zapisać za pomocą takiej zmiennej. W przypadku jednego bajta jest to liczba FFh = 11111111b = 255. Jak widać wartości zapisywane w taki sposób w pamięci są wartościami całkowitymi dodatnimi. Jednak kodując odpowiednio niektóre bity danej można otrzymać liczby ze znakiem lub wartości niecałkowite, ale o tym później.

Zgodnie z ogólną zasadą rozszerzania zasobów komputera kolejne typy danych są dwa razy większe od poprzednich. Więc mamy obecnie następujące podstawowe typy danych:

Nazwa zmiennej Skrót Rozmiar w bitach Maksymalna wartość całkowita
byte (bajt) byte 8 FFh = 255
word (słowo) word 16 FFFFh = 65536
double word (podwójne słowo) dword 32 FFFFFFFFh = 4294967295
quad word (poczwórne słowo) qword 64 18446744073709551615

Oczywiście to, czy procesor może naturalnie operować na niektórych typach danych, zależy od tego, czy jego architektura jest: 16 bitowa - byte, word; 32  bitowa - byte, word, dword; 64 bitowa - byte,word,dword,qword. Na pewno w następnej kolejności można spodziewać się wprowadzenia nowego 128 bitowego rozmiaru zmiennej w przypadku nowych procesorów, ale patrząc na nowe możliwości uzyskane dzięki temu w najbliższych latach nie należy się tego spodziewać.

Przykładowe operacje logiczne i arytmetyczne przedstawię na przykładzie liczb o rozmiarze jednego bajta. Dla łatwiejszego zrozumienia ich działania operacje będę przedstawiał opisując wartości równolegle w trzech systemach liczbowych. Wszystkie operacje procesor wykonuje jedynie na liczbach o takim samym rozmiarze. Nie można więc dodać wartości o rozmiarze bajta do zmiennej o rozmiarze słowa. Tak samo dzieje się z wynikiem operacji. Wykonując dodawanie dwóch wartości o rozmiarze bajta spodziewajmy się wyniku o tym samym rozmiarze.

Operacje logiczne

OR

Operacje logiczne są operacjami działającymi na poszczególnych bitach, dzięki czemu można je całkowicie opisać przedstawiając jak oddziałują ze sobą dwa bity. Takie operacje logiczne przedstawia się najczęściej w postaci tabeli.

A B A OR B
0 0 0
1 0 1
0 1 1
1 1 1

Operacja OR działa w ten sposób, że jeżeli bit A lub B ma wartość jeden to wynik też ma wartość 1 w przeciwnym wypadku wynik wynosi 0. Przykładowy wynik operacji OR na dwóch bajtach:

  bin hex dec
A 00000011b 03h 3
B 00011001b 19h 25
A OR B 00011011b 1Bh 27

Operacja OR nazywana jest sumą logiczną, jednak może to być trochę mylne, ponieważ niewiele ma ona wspólnego z tym, czego uczyliśmy się o dodawaniu w szkole podstawowej. W asemblerze operacja ta służy przede wszystkim do ustawiania określonego bitu na wartość 1. Przykładowo mamy pewien rejestr o nazwie AL i chcemy ustawić jego trzeci bit na wartość 1 niezależnie od tego jaką on ma wartość obecnie. Wykonamy to po przez operację OR na rejestrze AL z liczbą która ma ustawiony trzeci bit czyli 02h. AL OR 02h, w wyniku niezależnie od tego jaką wartość miał rejestr AL będzie on miał trzeci bit ustawiony.:

  bin
AL 01110100b
B 00000010b
AL OR B 01110110b

 


AND

A B A AND B
0 0 0
1 0 0
0 1 0
1 1 1

Operacja AND da w wyniku wartość 0 tylko wtedy gdy bit A lub B miał wartość 0. Przykład:

  bin hex dec
A 00000011b 03h 3
B 00011001b 19h 25
A AND B 00000001b 01h 1

Operacja AND nazywana jest iloczynem logicznym i właściwie można ją traktować podobnie jak zwykłe mnożenie bitowe. W asemblerze bardzo często stosuje się tą operacje do zerowania określonego bitu. Przykładowo chcąc wyzerować bit czwarty rejestru AL należy wykonać na nim operację AND z liczbą F7h. Liczba F7h ma wyzerowany bit czwarty, ale pozostałe bity ma ustawione. To pozwala zachować poprzednią wartość tych bitów.

  bin
AL 01111000b
B 11110111b
AL AND B 01110000b

 


NOT

A NOT A
0 1
1 0

Operacja NOT działa na tylko jednym argumencie. W wyniku daje liczbę o wartościach bitowych przeciwnych do wartości argumentu operacji. Przykład:

  bin hex dec
A 00011001b 19h 3
NOT A 11100110b E6h 230

 


XOR

A B A XOR B
0 0 0
1 0 1
0 1 1
1 1 0

Operacja XOR (exclusive OR) jest to operacja OR z pewną różnicą w stosunku do działania na obu ustawionych bitach. Operacja ta daje w wyniku 1 jeżeli oba bity miały różną wartość, a jeżeli miały taką samą wartość to wynik będzie wynosił 0.

Operację tą też można tłumaczyć jako sumę modulo 2. Nie opisywałem wcześniej, czym jest operacja modulo, tak więc pokrótce to teraz zrobię. Operacja modulo nie jest instrukcją procesora, a jedynie funkcją matematyczną, którą definiujemy jako zwracającą resztę z dzielenia pewnej liczby przez drugą liczbę. X mod Y oznacza w wyniku resztę z dzielenia liczby X przez liczbę Y. Tak więc można zapisać operacje logiczną A XOR B w postaci matematycznej jako (A+B) mod 2 (przypominam, że jest to operacja działająca na poszczególne bity). Ta definicja operacji XOR może być trudna do zrozumienia ze względu na swój matematyczny charakter.

W asemblerze często będziemy stosowali operację XOR do zerowania zawartości rejestrów po przez operację A XOR A, która zawsze w wyniku daje wartość 0. Dodatkowo operację tą będziemy również stosować do odwracania wartości określonego bitu. Przykładowo chcąc odwrócić wartości bitów pierwszego i ostatniego pewnego rejestru wykonamy operację XOR z następującym argumentem:

  bin hex dec
A 00011001b 03h 3
B 10000001b 19h 25
A XOR B 10011000b 98h 152

Tak więc jako podsumowanie operacji logicznych stosowanych w asemblerze podam je w tabeli z krótkim opisem. Należy pamiętać, że operacje logiczne działają na odpowiadających sobie bitach obu  argumentów.

Operacja Opis zastosowania
OR  Ustawia określone bity
AND  Zeruje określone bity
NOT  Odwraca bity w argumencie
XOR  Odwraca wartości określonych bitów

Operacje arytmetyczne

Procesor realizuje wszystkie 4 podstawowe operacje arytmetyczne na liczbach całkowitych. Są to oczywiście dodawanie, odejmowanie, mnożenie i dzielenie. Dodatkowym rozszerzeniem jest operowanie na liczbach całkowitych ze znakiem. W takim przypadku stosuje się jeden z bitów liczby jako znak. Jednak nie będę tutaj opisywał instrukcji procesora, a jedynie to, jak wykonuje się działania na liczbach binarnych. Teoretycznie nie jest to niezbędna wiedza, ponieważ procesor wykona to za nas, ale lepiej wiedzieć jak to się odbywa, aby móc zastosować czasem jakiś sprytny trik przy obliczeniach.

Dla wygody posłużę się danymi o rozmiarze jednego bajta. Opiszę jak dodawać dwie liczby binarne. Nie jest to właściwie żadna odkrywcza metoda, a jedynie dodawanie pod kreską którego się uczymy w szkole podstawowej. Jako przykład przedstawię dodawanie liczb 10+9=19 co binarnie zapisujemy 1010b+1001b=10011b. Teraz sprawdzimy, czy to się zgadza.

Identycznie jak dla liczb w systemie dziesiętnym dokonujemy sumowania pierwszych bitów 0+1=1, 1+0=1, 0+0=0.

1010b
+    1001b
------------------
011b

Następnie mamy dodawanie bitów 1+1 (co w wyniku nie daje liczby 2 !). Należy pamiętać, że działamy w systemie binarnym więc wynik wynosi 10b. Co oznacza że w miejsce pod działanie 1+1 wpisujemy wartość 0, a cyfrę 1 dodajemy jako przeniesienie w kolejnym sumowaniu.

1     
1010b
+    1001b
------------------
10011b

Jeżeli bit przeniesienia nie mieści się w rozmiarze zmiennej (w przykładowych działaniach jest to 8 bitów), to należy go pominąć. Tak też się częściowo dzieje w procesorze. To jest podstawowa wada obliczeń komputerowych, czyli ich skończona dokładność. Jeżeli wybierzemy zmienną o rozmiarze bajta, to maksymalna liczba jaka się zmieści w tej zmiennej to 255. Tak więc wykonując działanie 151+201 nie otrzymamy wyniku 352 ponieważ nie zmieści się on w pamięci przeznaczonej na zmienną. 151+201=352, 10010111b+11001001b=101100000b. Widać wyraźnie, że wynik zajmuje 9 bitów, więc nie może zostać zapisany w jednym bajcie. Procesor realizuje takie działanie zapisując jako wynik jedynie pierwsze 8 bitów zmiennej. Jest to równoznaczne z wykonaniem funkcji 352 mod 256 = 96.

 11111  
10010111b
+    11001001b
------------------
101100000b

I taki otrzymamy wynik. Tak więc jeżeli wynik operacji na zmiennych przekracza ich zakres, to zostaje on obcięty tak aby można było go zapisać w zmiennej takiego samego typu. W przypadku zmiennych zajmujących dwa bajty zakres ten zwiększa się do 65535. Podczas projektowania programu będziemy zmuszeni do określenia, jak dużych typów danych użyć, aby jednocześnie nie marnować miejsca i mieć dostępną zmienną o wystarczającym zakresie pojemności. Przykładowo zmienna o rozmiarze bajta nadaje się bardzo dobrze do zapamiętania numeru miesiąca lub dnia miesiąca. Jednak rok będziemy zmuszeni przechowywać w zmiennej zajmującej co najmniej dwa bajty (użycie do tego jednego bajta najpewniej doprowadzi do problemów podobnych jak "problem roku 2000").

Dla przykładu podam jeszcze działanie dodawania dwóch liczb w systemie szesnastkowym:

1  
97h
+    C9h
------------------
160h

7h+9h=10h jedynka przechodzi dalej jako przeniesienie i wykonujemy całą operację analogicznie jak dla liczb dziesiętnych. Część wychodzącą poza rozmiar zmiennej należy odciąć. Otrzymaliśmy taki sam wynik jak w poprzednim działaniu 96 = 01100000b = 60h.

Liczby całkowite ze znakiem

Do opisu znaku liczby stosuje się jeden z bitów zmiennej. Dokładniej mówiąc jest to najstarszy bit. Istotny jest fakt, iż jeżeli jeden z bitów zarezerwujemy na zapisanie znaku liczby, to zakres liczb jakie możemy zapisać w zmiennej ulegnie zmianie. W przypadku zmiennej typu bajt zmieni się na zakres od -128 do 127. To jest nadal 256 liczb tak samo jak dla zmiennej bajt bez znaku jednak zakres ten podzieli się prawie po połowie względem liczby 0.

Samo oznaczenie najstarszego bitu jako znaku nie wystarcza do zapisania liczby jako ujemnej. Stosuje się pewien sposób kodowania zwany kodem uzupełnień do 2 (U2). Stosowanie tego kodu daje dość miłą własność przy dodawaniu i odejmowaniu. Przykładowo kodując liczbę +8 jako 00001000b, a -8 jako 10001000b po wykonaniu zwykłego dodawania opisanego wcześniej otrzymamy wynik 10010000b, który jak widać nie wynosi 0. Kod U2 ma na celu takie zakodowanie liczby ujemnej żeby przy stosowaniu zwykłego dodawania wynik był poprawny.

Dla liczb ujemnych w kodzie U2 poza ustawieniem najstarszego bitu znaku należy dokonać pewnej modyfikacji pozostałych bitów liczby. Dokładniej rzecz biorąc chcąc otrzymać liczbę -8 należy wykonać operację ( NOT 8 ) + 1. Zanegować wszystkie bity liczby o wartości 8 a następnie dodać wartość 1.

Tak więc:

liczba 8 00001000b
NOT 8 11110111b
(NOT 8)+1 11111000b

Liczba 11111000b jest liczbą -8 zapisaną w kodzie U2. Wykonując teraz operację -8 + 8 otrzymamy:

11111000b
+  00001000b
------------------
100000000b

Kod U2 pozwala na traktowanie liczb ze znakiem i bez znaku w sposób identyczny podczas wykonywania operacji dodawania lub odejmowania, co upraszcza budowę procesora. Dla dokładniejszego zrozumienia pokaże jeszcze jeden przykład. Tym razem będzie to odejmowanie, które realizuje się w ten sam sposób, jak nas uczono w szkole dla liczb w systemie dziesiętnym. Różnica polega oczywiście w systemie liczbowym gdzie jako pożyczkę bierzemy wartość 2=10b a nie wartość 10.

Działanie 5 - 8 = -3:

1111      
00000101b
-  00001000b
------------------
11111101b

Wartość 11111101b przeliczamy z kodu uzupełnień do 2 w odwrotny sposób. Na początku zauważamy, iż najstarszy bit jest ustawiony, więc wynik jest ujemny. Przeliczając liczbę odejmujemy od niej wartość 1 i negujemy wszystkie bity.

liczba X 11111101b
X-1 11111100b
NOT ( X-1 ) 00000011b

Po sprawdzeniu otrzymaliśmy liczbę 3, tak więc wynikiem jest liczba -3. To jest oczywiście prawidłowy wynik.

Z punktu widzenia procesora dzięki stosowaniu kodu U2 nie ma znaczenia, czy dodaje on liczby ze znakiem czy bez, bo wynik i tak będzie prawidłowy. To jest duża zaleta upraszczająca budowę procesora i ilość jego instrukcji. W przypadku mnożenia i dzielenia nie dało się osiągnąć takich korzyści. Algorytmicznie są to operacje dość skomplikowane i kod U2 nie zapewnia poprawnych wyników stosując taki sam algorytm dla liczb dodatnich i ujemnych. W wyniku tego procesor posiada oddzielny zestaw instrukcji mnożenia i dzielenia dla liczb ze znakiem i liczb bez znaku.