W tej części poznamy ważną grupę instrukcji - operacje na bitach. Te właśnie instrukcje
odróżniają asemblera od innych języków, gdzie rzadko pojawia się możliwość działania na
tych najmniejszych jednostkach informacji (odpowiednie operatory istnieją w językach
C i Pascal, ale inne języki, jak na przykład Fortran 77, są tego pozbawione).
Mimo iż o wszystkich instrukcjach opisanych w tej części już wspomniałem przy okazji
omawiania podstawowych rozkazów procesora, to instrukcje bitowe są ważne i zasługują na
oddzielny rozdział, poświęcony w całości tylko dla nich.
Zdawać by się mogło, że z takim jednym, maleńkim bitem niewiele da się zrobić: można go
wyczyścić (wyzerować), ustawić (wstawić do niego 1) lub odwrócić jego bieżącą wartość.
Ale te operacje mają duże zastosowania i dlatego ich poznanie jest niezbędne. Jeśli sobie
przypomnicie, to używaliśmy już wielokrotnie takich instrukcji jak
AND
czy XOR
.
Teraz przyszedł czas, aby poznać je bliżej.
Instrukcja NOT
(logiczna negacja - to NIE jest to samo,
co zmiana znaku liczby!) jest najprostszą z czterech podstawowych operacji logicznych
i dlatego to od niej rozpocznę wstęp do instrukcji bitowych.
NOT
jest instrukcją jednoargumentową, a jej działanie wygląda tak:
NOT 0 = 1 NOT 1 = 0
Używamy tej instrukcji wtedy, gdy chcemy naraz odwrócić wszystkie bity w zmiennej lub rejestrze.
Na przykład, jeśli AX zawiera 0101 0011 0000 1111 (530Fh), to po wykonaniu NOT AX
w rejestrze tym znajdzie się wartość 1010 1100 1111 0000 (ACF0h). Dodanie obu wartości
powinno dać FFFFh.
NOT
może mieć zastosowanie tam, gdzie wartość logiczna fałsz
ma przyporządkowaną wartość
zero, a prawda
- wartość FFFFh, gdyż NOT
w tym przypadku dokładnie przekłada prawdę
na fałsz
.
Instrukcji AND
(logicznej koniunkcji) najprościej
używać do wyzerowania bitów. Tabelka działania AND
wygląda tak:
0 AND 0 = 0 0 AND 1 = 0 1 AND 0 = 0 1 AND 1 = 1
No ale jakie to może mieć zastosowanie?
Powiedzmy teraz, że chcemy sprawdzić, czy bit
numer 4 (numerację będę podawał od zera) rejestru AX jest równy 1, czy 0. Tutaj
nie wystarczy proste
porównanie CMP
, gdyż reszta rejestru może zawierać nie wiadomo co. Z pomocą przychodzi nam
właśnie instrukcja AND
. Poniżej pseudo-przykład:
and ax, 0000 0000 0001 0000b ; (and ax, 16)
Teraz, jeśli bit numer 4 (odpowiadający wartości 2^4=16) był równy 1, to cały AX przyjmie
wartość 16, jeśli zaś był równy zero, to cały AX będzie zerem. Na nasze szczęście, instrukcja
AND
ustawia odpowiednio flagi procesora, więc rozwiązaniem naszego problemiku będzie kod:
and ax, 16 jz bit_4_byl_zerem ;jnz bit_4_nie_byl_zerem
A jakieś zastosowanie praktyczne?
Już podaję: zamiana małych liter na wielkie. W kodzie
ASCII
litery małe od wielkich różnią się
tylko tym, że mają ustawiony bit numer 5. Tak więc po wykonaniu:
mov al, "a" and al, 5fh ; 5fh = 0101 1111 - czyścimy bit 5 ; (i 7 przy okazji)
w rejestrze AL będzie kod wielkiej litery A.
Inne zastosowanie znajdziecie w moim kursie programowania
głośniczka:
in al, 61h and al, not 3 ; zerujemy bity 0 i 1 ; NASM: and al,~3 out 61h, al
W tym kodzie instrukcja AND
posłużyła nam do wyczyszczenia bitów 0 i 1
(NOT 3 = NOT 0000 0011 = 1111 1100).
Jak zauważyliście, instrukcja AND
niszczy zawartość rejestru,
oprócz interesujących nas bitów. Jeśli zależy Wam na zachowaniu rejestru, użyjcie instrukcji
TEST
. Działa ona identycznie jak AND
, ale nie
zapisuje wyniku działania. Po co nam więc taka instrukcja? Otóż, wynik nie jest
zapisywany, ale TEST
ustawia dla nas flagi identycznie jak AND
.
Pierwszy kod przepisany z instrukcją TEST
będzie wyglądał tak:
test ax, 16 jz bit_4_byl_zerem ;jnz bit_4_nie_byl_zerem
Teraz nasz program będzie ciągle działać prawidłowo, ale tym razem zawartość rejestru
AX została zachowana.
Jest jeszcze jedno ciekawe zastosowanie instrukcji TEST
:
test ax, ax
I co to ma niby robić? Wykonuje AND AX, AX
,
nigdzie nie zapisuje wyniku i tylko ustawia flagi.
No właśnie! Ustawia flagi, w tym flagę zera ZF. To, co widzicie powyżej to
najwydajniejszy
sposób na to, aby sprawdzić czy wartość rejestru nie jest zerem.
Instrukcja OR
(logiczna alternatywa) w prosty sposób służy do ustawiania bitów
(wpisywania do nich 1).
Tabelka działania wygląda następująco:
0 OR 0 = 0 0 OR 1 = 1 1 OR 0 = 1 1 OR 1 = 1
Jeśli na przykład chcemy, aby 2 najmłodsze bity rejestru BX były się równe 1, a nie chcemy naruszać
innych bitów (czyli MOV
jest wykluczone), możemy to zrobić tak:
or bx, 0000 0000 0000 0011 ; (or bx, 3)
Zastosowanie tego jest proste. Podam 2 przykłady. Pierwszy z nich jest wyjęty z mojej procedury wytwarzającej dźwięk w głośniczku (i kursu poświęconego temu zagadnieniu):
in al, 61h or al, 3 ; ustawiamy bity 0 i 1 out 61h, al
Przykład drugi jest odwróceniem operacji AND
na znakach ASCII:
mov al, "A" or al, 20h ; 20h = 0010 0000 - ustawiamy bit 5
teraz w AL powinien być kod małej literki a.
Instrukcja OR
nie ma swojego odpowiednika, jakim jest TEST
dla AND
.
Ale za to ma inne ciekawe zastosowanie - można nią sprawdzić, czy 2 rejestry naraz nie są zerami (to jest
najlepszy sposób - bez żadnych CMP
, JNZ/JZ
itp.):
or ax, bx
Podobnie, jak w instrukcji AND
, flaga zera będzie ustawiona, gdy wynik operacji jest zerem - a
to może się zdarzyć tylko wtedy, gdy AX i BX są jednocześnie zerami.
Zauważcie, że nie można do tego celu użyć instrukcji AND
. Dlaczego? Podam
przykład: niech AX=1 i BX = 8. AX i BX nie są oczywiście równe zero, ale:
0000 0000 0000 0001 (=AX) AND 0000 0000 0000 1000 (=BX) = 0000 0000 0000 0000
Dlatego zawsze należy przemyśleć efekt działania instrukcji.
Instrukcji XOR
(eXclusive OR, logiczna alternatywa wykluczająca)
używa się do zmiany stanu określonego bitu z 0 na 1 i odwrotnie.
Działanie XOR jest określone tak:
0 XOR 0 = 0 0 XOR 1 = 1 1 XOR 0 = 1 1 XOR 1 = 0
Zauważmy także, że dla dowolnych a i b mamy:
(a XOR b) XOR b = a
a XOR 0 = a
a XOR -1 = NOT a (-1 = FF w bajcie, FFFF w słowie i FFFFFFFF w dwordzie)
a XOR a = 0
Z tej ostatniej równości wynika natychmiast, że wyXORorwanie rejestru z samym sobą zawsze go
wyzeruje. W ten sposób otrzymujemy jeden z dwóch najwydajniejszych sposobów na
wyzerowanie rejestru:
xor rej, rej
Drugi sposób to SUB rej,rej
.
Teraz przykład: chcemy, aby wartość rejestru AX stała się równa 1 gdy rejestr był wyzerowany, a zerem, gdy była w tym rejestrze jedynka. Oto, jak możemy to zrobić:
cmp ax, 1 je wyzeruj mov ax, 1 jmp koniec wyzeruj: mov ax, 0 koniec:
Ale wersja optymalna wygląda tak:
xor ax, 1
gdyż mamy:
wartość AX: 0000 0000 0000 0001 0000 0000 0000 0000 XOR 0000 0000 0000 0001 0000 0000 0000 0001 = nowy AX: 0000 0000 0000 0000 0000 0000 0000 0001
Jak widać, jest to o wiele prostsze i wydajniejsze rozwiązanie. Dlatego właśnie dobrze jest, gdy pozna się instrukcje logiczne.
Instrukcje przesuwania bitów (shift
) przemieszczają bity, nie zmieniając ich wzajemnego
położenia (przesuwają grupowo
). To wyjaśnienie może się wydawać bardzo pokrętne, ale
spokojnie - zaraz wszystko się wyjaśni.
Na początek powiem, że jest kilka takich instrukcji (które też były podane w rozdziale
o podstawowych instrukcjach procesora):
SHL
- shift left (shift logical left) = przesunięcie (logicznie) w lewoSAL
- shift arithmetic left = przesunięcie (arytmetycznie) w lewoSHR
- shift logical right = przesunięcie (logiczne) w prawoSAR
- shift arithmetic right = przesunięcie (arytmetyczne)SHLD/SHRD
= przesunięcia logiczne w lewo/prawo o podwójnej precyzjiDziałanie każdej z tych instrukcji pokażę na przykładzie.
Niech na początku AX = 1010 0101 1010 0101 (A5A5h).
SHL
i równoważna SAL
działa tak (zakładając, że przesuwamy o jeden):
najstarszy bit jest we fladze CF, każdy inny bit wchodzi na miejsce bitu starszego o 1, a do bitu zerowego
wkładane jest zero.
Po wykonaniu SHL AX,3
wartość AX będzie więc wynosić 0010 1101 0010 1000 (2D28h), gdyż
wszystkie bity przesunęliśmy o 3 miejsca w lewo, oraz CF=1
(bo jako ostatnia z rejestru wyleciała jedynka).
Instrukcja SHR
działa w drugą stronę niż SHL
: bit zerowy jest umieszczany we fladze CF,
każdy inny bit wchodzi na miejsce bitu młodszego o 1, a do najstarszego bitu wkładane jest zero.
Dlatego teraz po wykonaniu SHR AX,1
w rejestrze AX będzie 0001 0110 1001 0100 (1694h),
bo poprzednie bity AX przesunęliśmy o 1 miejsce w prawo, oraz CF=0.
SAR
różni się od SHR
nie tylko nazwą, ale też działaniem.
Słowo arytmetyczne
w nazwie NIE jest tu bez znaczenia. Gdy SAR
działa na
liczbach ze znakiem, to zachowuje ich znak (bit7), czyli wykonuje to samo, co SHR
,
ale zamiast wkładać zero do najstarszego bitu, wstawia tam jego bieżącą wartość.
Z poprzedniego przykładu mamy, że AL = 94h = 1001 0100. Gdy teraz wykonamy SAR AL,2
to jako wynik
otrzymamy 1110 0101 (E5h), bo wszystkie bity poszły o 2 miejsca w prawo o bit 7 został
zachowany, i CF=0.
SHLD
i SHRD
wykonują to samo, co SHL
i SHR
,
ale na dwóch rejestrach naraz (no, prawie).
Na przykład wykonanie SHLD EAX,EBX,3
spowoduje że 3 najstarsze bity
EAX zostaną wyrzucone
(i CF=ostatni z wyrzuconych) oraz 3 najstarsze bity EBX przejdą na nowo powstałe miejsca w
3 najmłodszych bitach EAX. Ale uwaga: EBX pozostaje niezmieniony !
I to jest właśnie przyczyna użycia słów no prawie
.
Ale nie sposób powiedzieć o SHL
i SHR
bez podania najbardziej popularnego
zastosowania: szybkie mnożenie i dzielenie.
Jak można mnożyć i dzielić tylko przesuwając bity, pytacie?
Otóż, sprawa jest bardzo prosta. Wpiszcie do AX jedynkę i wykonajcie kilka razy SHL AX,1 za
każdym razem sprawdzając zawartość AX. Jak zauważycie, w AX będą kolejno 1,2,4,8,16,... Czyli
za każdym razem zawartość AX się podwaja.
Ogólnie, SHL rej,n
mnoży zawartość rejestru przez 2^n.
Na przykład SHL AX,4
przemnoży AX
przez 2^4 = 16.
Ale co zrobić, gdy chcemy mnożyć przez coś innego niż 2^n?
Odpowiedź jest równie prosta, na przykład AX * 10 = (AX*8) + (AX*2) - z tym się chyba zgodzicie. A od tego
już tylko 1 krok do
mov bx, ax shl ax, 3 ; AX = AX*8 shl bx, 1 ; BX = BX*2 = AX*2 add ax, bx ; AX = AX*10
Ale niekoniecznie musimy dodawać wyniki. Zauważcie, że AX * 15 = (AX*8) + (AX*4) + (AX*2) + AX.
Trzeba byłoby wykonać 3 SHL
i 3 ADD
. Ale my skorzystamy z innego rozwiązania:
AX * 15 = (AX*16) - AX. Już tylko 1 SHL
i 1 SUB
. Stąd mamy:
mov bx, ax shl ax, 4 ; AX = AX*16 sub ax, bx
Dokładnie w ten sam sposób działa dzielenie (tylko oczywiście przy dzieleniu używamy SHR/SAR
i niestety szybko możemy dzielić tylko przez potęgi dwójki).
Pilnujcie tylko, aby używać tej właściwej instrukcji! Jak wiemy, 65534 = 0FFFEh = -2 .
Teraz, oczywiście FFFE SHR 1 = 7FFFh = 32767 (=65534/2) a FFFE SAR 1 = FFFF = -1 (= -2/2).
Widać różnicę, prawda? Pamiętajcie, że SAR patrzy na znak i go zachowuje.
Używanie SHL
dla mnożenia i (zwłaszcza) SHR
dla dzielenia może znacznie
przyśpieszyć nasze programy, gdyż instrukcje MUL
i DIV
są dość wolne.
Teraz przedstawię kolejną grupę instrukcji bitowych - instrukcje rotacji bitów. W tej grupie są tylko 4 instrukcje:
ROL
- rotate left = obrót w lewo.Ta instrukcja robi tyle, co SHL
, lecz zamiast do bitu zerowego wkładać zero, wkłada tam
bieżącą wartość najstarszego bitu (przy okazji zachowując go także we fladze CF).
bit7 = bit6, ... , bit1 = bit0, bit0 = stary bit7
RCL
- rotate through carry left = obrót w lewo z użyciem flagi CF.
Ta instrukcja jest podobna do ROL
z jedną różnicą: wartość wstawiana do najmłodszego bitu
jest brana z flagi CF, a nie od razu z najstarszego bitu. Po wzięciu bieżącej wartości
CF, najstarszy bit jest do niej zapisywany.
carry flag CF = bit7, bit7 = bit6, ... , bit1 = bit0, bit0 = stara CF
ROR
- rotate right = obrót w prawo.
Ta instrukcja robi tyle, co SHR
, lecz zamiast do najstarszego bitu wkładać zero,
wkłada tam bieżącą wartość najmłodszego bitu (przy okazji zachowując go także we fladze CF).
bit0 = bit1, ... , bit6 = bit7, bit7 = stary bit0
RCR
- rotate through carry right = obrót w prawo z użyciem flagi CF.
Ta instrukcja jest podobna do ROR
z jedną różnicą: wartość wstawiana do najstarszego bitu
jest brana z flagi CF, a nie od razu z najmłodszego bitu. Po wzięciu bieżącej wartości
CF, najmłodszy bit jest do niej zapisywany.
CF = bit0, bit0 = bit1, ... , bit6 = bit7, bit7 = stara CF
Oczywiście, te instrukcje mogą działać na wartościach większych niż bajt - bit7 jako najstarszy jest tutaj tylko dla ilustracji.
Schematyczne działanie tych instrukcji na bajtach widać na tych rysunkach:
ROL: +-->------------->-------------->--+ | | CF <- 7 <- 6 <- 5 <- 4 <- 3 <- 2 <- 1 <- 0 RCL: +-->-----------> CF >----------->--+ | | 7 <- 6 <- 5 <- 4 <- 3 <- 2 <- 1 <- 0 ROR: +--<-------------<--------------<--+ | | 7 -> 6 -> 5 -> 4 -> 3 -> 2 -> 1 -> 0 -> CF RCR: +--<-----------< CF <-----------<--+ | | 7 -> 6 -> 5 -> 4 -> 3 -> 2 -> 1 -> 0
W przypadku ROL
i ROR
,
to ostatni wyjęty z jednej strony
a włożony z drugiej strony bit
zostaje też zapisany do flagi CF.
RCR
i RCL
działają tak, że bit, który ma zostać wstawiony, jest pobierany z CF, a
wypchnięty bit ląduje w CF, a nie od razu na nowym miejscu.
No to kilka przykładów:
0011 1100 ROL 2 = 1111 0000 (tak samo jak SHL) 0011 1100 ROL 3 = 1110 0001 1111 0000 ROR 1 = 0111 1000 (tak samo jak SHR) 1010 0011 ROR 5 = 0001 1101
Zastosowanie tych instrukcji znalazłem jedno: generowanie chaosu w rejestrach...
Po co to mi? Na przykład generatory liczb pseudo-losowych z mojej biblioteki korzystają z
tych właśnie instrukcji (a także z kilku poprzednich, na przykład XOR
).
Ostatnia już grupa rozkazów procesora to instrukcje testowania i szukania bitów. W tej grupie znajdują się:
BT
- Bit Test - sprawdź wartość bituBTC
- Bit Test and Complement - sprawdź wartość bitu i odwróćBTR
- Bit Test and Reset - sprawdź wartość bitu i wyzerujBTS
- Bit Test and Set - sprawdź wartość bitu i ustawBSF
- Bit Scan Forward - szukaj bitu rosnącoBSR
- Bit Scan Reverse - szukaj bitu malejącoTeraz po kolei omówię działanie każdej z nich.
Instrukcje BT*
przyjmują 2 argumenty: miejsce, gdzie mają znaleźć dany bit i numer tego bitu,
a zwracają wartość tego bitu we fladze CF. Ponadto, BTS
ustawia znaleziony bit na 1, BTR
czyści znaleziony bit, a BTC
odwraca znaleziony bit.
Kilka przykładów:
bt eax, 21 ; umieść 21. bit EAX w CF jc bit_jest_1 ... bts cl, 2 ; umieść 2. bit CL w CF i ustaw go jnc bit_2_byl_zerem ... btc dh, 5 ; umieść 5. bit DH w CF i odwróć go jc bit_5_byl_jeden
Instrukcje Bit Scan przyjmują 2 argumenty: pierwszy z nich to rejestr, w którym będzie
umieszczona pozycja (numer od zera począwszy) pierwszego bitu, którego wartość
jest równa 1 znalezionego w drugim argumencie instrukcji. Dodatkowo, BSF
szuka tego pierwszego
bitu zaczynając od bitu numer 0, a BSR
od najstarszego (numer 7, 15, 31, i tak dalej, w zależności od
rozmiaru drugiego argumentu).
Teraz szybki przykładzik:
mov ax, 1010000b bsf bx, ax bsr cx, ax
Po wykonaniu powyższych instrukcji w BX powinno być 4, a w CX - 6 (bity liczymy od zera).
Jak pewnie zauważyliście, w kilku miejscach
w tym tekście wyraźnie podkreśliłem słowa
najwydajniejszy
i im podobne. Chciałem w ten sposób uzmysłowić Wam, że operacje logiczne
/ binarne są bardzo ważną grupą instrukcji. Używanie ich, najlepiej wraz z instrukcją LEA
służącą do szybkich rachunków, może kilkakrotnie (lub nawet kilkunastokrotnie) przyśpieszyć
najważniejsze części Waszych programów (na przykład intensywne obliczeniowo pętle o milionach
powtórzeń - patrz na przykład program L_mag.asm
z 8. części tego kursu).
Dlatego zachęcam Was do dobrego opanowania instrukcji binarnych - po prostu umożliwia to pisanie programów o takiej wydajności, o której inni mogą tylko pomarzyć...
Po szczegółowy opis wszystkich instrukcji odsyłam, jak zwykle do : Intela i AMD
Ciekawe operacje na bitach (w języku C).