Znamy już rejestry, trochę instrukcji i zasad. No ale teoria jest niczym bez praktyki. Dlatego w tej części przedstawię kilka względnie prostych programów, które powinny rozbudzić wyobraźnię tworzenia.
Ten program spyta się użytkownika o imię i przywita się z nim:
; Program witający się z użytkownikiem po imieniu ; ; Autor: Bogdan D. ; kontakt: bogdandr (at) op (dot) pl ; ; kompilacja: ; nasm -f bin -o czesc.com czesc.asm ; ; kompilacja FASM: ; fasm czesc.asm czesc.com org 100h mov ah, 9 ; funkcja wyświetlania na ekran mov dx, jak_masz ; co wyświetlić int 21h ; wyświetl mov ah, 0ah ; funkcja pobierania danych z klawiatury mov dx, imie ; bufor na dane int 21h ; pobierz dane mov ah, 9 mov dx, czesc int 21h ; wyświetl napis "Cześć" mov ah, 9 mov dx, imie+2 ; adres wpisanych danych int 21h ; wyświetl wpisane dane mov ax, 4c00h int 21h jak_masz db "Jak masz na imie? $" imie db 20 ; maksymalna liczba znaków do pobrania db 0 ; tu dostaniemy, ile znaków pobrano times 22 db "$" ; miejsce na dane czesc db 10, 13, 10, 13, "Czesc $"
Powyższy program korzysta z jeszcze nieomówionej funkcji numer 10 (0Ah) przerwania DOSa. Oto jej opis z listy przerwań Ralfa Brown'a - RBIL:
INT 21 - DOS 1+ - BUFFERED INPUT
AH = 0Ah
DS:DX -> buffer (see #01344)
Return: buffer filled with user input
Format of DOS input buffer:
Offset Size Description (Table 01344)
00h BYTE maximum characters buffer can hold
01h BYTE (call) number of chars from last input which
may be recalled
(ret) number of characters actually read,
excluding CR
02h N BYTEs actual characters read, including the
final carriage return
Jak widać, korzystanie z niej nie jest trudne. Wystarczy stworzyć tablicę bajtów na znaki czytane z klawiatury. Na początku tablicy podajemy, ile maksymalnie znaków chcemy wczytać. Drugi bajt ustawiamy na zero, by czytać tylko na bieżąco wprowadzane znaki, a nie to, co jeszcze może tkwić w DOS-owym buforze wejściowym.
Kolejny program wypisuje na ekranie rejestr flag w postaci dwójkowej. Zanim mu się przyjrzymy, potrzebna będzie nam informacja o funkcji 0Eh przerwania 10h (opis bierzemy oczywiście z RBIL):
INT 10 - VIDEO - TELETYPE OUTPUT
AH = 0Eh
AL = character to write
BH = page number
BL = foreground color (graphics modes only)
Return: nothing
Desc: display a character on the screen, advancing the
cursor and scrolling the screen as necessary
Notes: characters 07h (BEL), 08h (BS), 0Ah (LF), and 0Dh (CR)
are interpreted and do the expected things
Dla nas zawartość BX nie będzie istotna. A ta funkcja po prostu wypisuje na ekran jakiś znak. No, teraz wreszcie możemy przejść do programu. Oto on (flagi.asm):
; Program wypisujący flagi w postaci dwójkowej ; ; Autor: Bogdan D. ; kontakt: bogdandr (at) op (dot) pl ; ; kompilacja: ; tasm flagi.asm ; tlink flagi.obj /t .model tiny ; to będzie mały program .code ; tu zaczyna się segment kodu .386 ; będziemy tu używać rejestrów 32-bitowych. ; .386 MUSI być po .code ! org 100h ; to będzie program typu .com main: ; etykieta jest dowolna, byleby zgadzała się ; z tą na końcu pushfd ; 32 bity flag idą na stos mov ax,0e30h ; AH = 0eh, czyli funkcja wyświetlania, ; AL = 30h = kod ASCII cyfry zero pop esi ; flagi ze stosu do ESI mov cx,32 ; tyle bitów i tyle razy trzeba przejść ; przez pętlę petla: ; etykieta oznaczająca początek pętli. and al,30h ; upewniamy się, że AL zawiera tylko 30h, ; co zaraz się może zmienić. A dokładniej, ; czyścimy bity 0-3, z których bit 0 może ; się zaraz zmienić shl esi,1 ; Przesuwamy bity w ESI o 1 w lewo. ; 31 bit ESI idzie ; do Carry Flag (CF) adc al,0 ; ADC - add with carry. Do AL dodaj ; 0 + wartość CF. ; jeśli CF (czyli 31 bit ESI) = 1, ; to AL := AL+1, inaczej AL bez zmian int 10h ; funkcja 0e, wyświetl znak w AL, ; czyli albo zero (30h) albo jedynkę (31h) loop petla ; przejdź na początek pętli, ; jeśli nie skończyliśmy mov ah,4ch ; funkcja wyjścia do DOS int 21h ; wychodzimy end main ; koniec programu. Ta sama etykieta, co na początku.
; Program wypisujący flagi w postaci dwójkowej ; ; Autor: Bogdan D. ; kontakt: bogdandr (at) op (dot) pl ; ; kompilacja NASM: ; nasm -o flagi.com -f bin flagi.asm ; ; kompilacja FASM: ; fasm flagi.asm flagi.com org 100h ; to będzie program typu .com main: ; etykieta dowolna, nawet niepotrzebna pushfd ; 32 bity flag idą na stos mov ax,0e30h ; AH = 0eh, czyli funkcja wyświetlania, ; AL = 30h = kod ASCII cyfry zero pop esi ; flagi ze stosu do ESI mov cx,32 ; tyle bitów i tyle razy trzeba przejść ; przez pętlę petla: ; etykieta oznaczająca początek pętli. and al,30h ; upewniamy się, że AL zawiera tylko 30h, ; co zaraz się może zmienić. A dokładniej, ; czyścimy bity 0-3, z których bit 0 ; może się zaraz zmienić shl esi,1 ; Przesuwamy bity w ESI o 1 w lewo. ; 31 bit ESI idzie do flagi CF adc al,0 ; ADC - add with carry. Do AL dodaj ; 0 + wartość CF. ; jeśli CF (czyli 31 bit ESI) = 1, ; to AL := AL+1, inaczej AL bez zmian int 10h ; funkcja 0e, wyświetl znak w AL, ; czyli albo zero (30h) albo jedynkę (31h) loop petla ; przejdź na początek pętli, ; jeśli nie skończyliśmy mov ah,4ch ; funkcja wyjścia do DOS int 21h ; wychodzimy
Kompilujemy go następująco (wszystkie programy będziemy tak kompilować, chyba że powiem inaczej):
tasm flagi.asm tlink flagi.obj /t
Lub, dla NASMa:
nasm -o flagi.com -f bin flagi.asm
Lub, dla FASMa:
fasm flagi.asm flagi.com
Nie ma w tym programie wielkiej filozofii. Po prostu 25 bajtów radości...
Dociekliwy zapyta, z jakim kodem wyjścia wychodzi ten program. Odpowiedź brzmi oczywiście:
- Albo 30h albo 31h, w zależności od ostatniego bitu oryginalnych flag.
Teraz krótki programik, którego jedynym celem jest wyświetlenie na ekranie cyfr od 0 do 9, każda w osobnej linii:
; tylko wersja NASM/FASM ; ; Program wypisuje na ekranie cyfry od 0 do 9 ; ; kompilacja NASM: ; nasm -O999 -o cyfry.com -f bin cyfry.asm ; kompilacja FASM: ; fasm cyfry.asm cyfry.com ; definiujemy stałe: %define lf 10 ; Line Feed %define cr 13 ; Carriage Return ; stałe w wersji FASM: ; lf = 10 ; cr = 13 org 100h ; robimy program typu .com mov eax, 0 ; pierwsza wypisywana cyfra wyswietlaj: call _pisz_ld ; uruchom procedurę wyświetlania ; liczby w EAX call _nwln ; uruchom procedurę, która przechodzi ; do nowej linii add eax, 1 ; zwiększamy cyfrę cmp eax, 10 ; sprawdzamy, czy ciągle EAX < 10 jb wyswietlaj ; jeśli EAX < 10, to wyświetlamy ; cyfrę na ekranie mov ax, 4c00h ; funkcja wyjścia z programu int 21h ; wychodzimy ; następujące procedury (wyświetlanie liczby i przechodzenie ; do nowego wiersza) nie są aż tak istotne, aby omawiać je ; szczegółowo, gdyż w przyszłości będziemy używać tych samych ; procedur, ale z biblioteki, a te wstawiłem tutaj dla ; uproszczenia kompilacji programu. ; Ogólny schemat działania tej procedury wygląda tak: ; weźmy liczbę EAX=12345. Robimy tak: ; 1. dzielimy EAX przez 10. reszta = EDX = DL = 5. ; Zapisz do bufora. EAX = 1234 (iloraz) ; 2. dzielimy EAX przez 10. reszta = DL = 4. ; Zapisz do bufora. EAX=123 (iloraz) ; 3. dzielimy EAX przez 10. reszta = DL = 3. ; Zapisz do bufora. EAX=12 ; 4. dziel EAX przez 10. DL = 2. zapisz. iloraz = EAX = 1 ; 5. dziel EAX przez 10. DL = 1. zapisz. iloraz = EAX = 0. ; Przerywamy pętlę. ; Teraz w buforze są znaki:54321. Wystarczy wypisać ; wspak i oryginalna liczba pojawia się na ekranie. _pisz_ld: ;pisz32e ;we: EAX=liczba bez znaku do wypisania pushfd ; zachowujemy modyfikowane rejestry push ecx push edx push eax push esi xor si,si ; SI będzie wskaźnikiem do miejsca, ; gdzie przechowujemy cyfry. ; Teraz SI=0. mov ecx,10 ; liczba, przez która będziemy dzielić _pisz_ld_petla: xor edx,edx ; wyzeruj EDX, bo instrukcja DIV ; go używa div ecx ; dzielimy EAX przez 10 mov [_pisz_bufor+si],dl ; do bufora idą reszty z dzielenia ; przez 10, czyli cyfry wspak inc si ; zwiększ wskaźnik na wolne miejsce. ; Przy okazji, SI jest też ilością ; cyfr w buforze or eax,eax ; sprawdzamy, czy liczba =0 jnz _pisz_ld_petla ; jeśli nie, to dalej ją dzielimy ; przez 10 mov ah,0eh ; funkcja wypisywania _pisz_ld_wypis: mov al,[_pisz_bufor+si-1] ; wypisujemy reszty wspak or al,"0" ; z wartości 0-9 zrobimy cyfrę "0"-"9" int 10h ; wypisujemy cyfrę dec si ; przechodzimy na wcześniejszą cyfrę jnz _pisz_ld_wypis ; jeśli SI=0, to nie ma już cyfr pop esi ; przywracamy zmienione rejestry pop eax pop edx pop ecx popfd ret ; powrót z procedury _pisz_bufor: times 40 db 0 ; miejsce na 40 cyferek (bajtów) _nwln: ;wyświetla znak końca linii (Windows) push ax push bp mov ax,(0eh << 8) | lf ; AX = 0e0ah int 10h ; wyświetlamy znak LF mov al,cr int 10h ; wyświetlamy znak CR pop bp pop ax ret
Następny twór
nie jest wolno stojącym programem,
ale pewną procedurą. Pobiera ona informacje z rejestru AL i wypisuje, co trzeba. Oto ona:
_pisz_ch: ;we: AL=cyfra heksadecymalna do wypisania 0...15 ; CF=1 jeśli błąd push bp ; zachowaj modyfikowane rejestry: BP, AX, Flagi push ax pushf cmp al,9 ; Sprawdzamy dane wejściowe : AL jest w ; 0-9 czy w 10-15? ja _ch_hex ; AL > 9. Skok do _ch_hex or al,30h ; 0 < AL < 9. Or ustawia 2 bity, ; czyniąc z AL liczbę z ; przedziału 30h - 39h, czyli od "0" ; do "9". Można było napisać ; "ADD al,30h", ale zdecydowałem się ; na OR, bo jest szybsze a efekt ten sam. jmp short _ch_pz ; AL już poprawione. Skacz do miejsca, ; gdzie wypisujemy znak. _ch_hex: ; AL > 9. Może będzie to cyfra hex, ; może nie. cmp al,15 ; AL > 15? ja _blad_ch ; jeśli tak, to mamy błąd add al,"A"-10 ; Duży skok myślowy. Ale wystarczy to rozbić ; na 2 kroki i wszystko staje się jasne. ; Najpierw odejmujemy 10 od AL. Zamiast ; liczby od 10 do 15 mamy już liczbę ; od 0 do 5. Teraz tę liczbę dodajemy do ; "A", czyli kodu ASCII litery A, ; otrzymując znak od "A" do "F" _ch_pz: ; miejsce wypisywania znaków. mov ah,0eh ; numer funkcji: 0Eh int 10h ; wypisz znak popf ; zdejmij ze stosu flagi clc ; CF := 0 dla zaznaczenia braku błędu ; (patrz opis procedury powyżej) jmp short _ch_ok ; skok do wyjścia _blad_ch: ; sekcja obsługi błędu (AL > 15) popf ; zdejmij ze stosu flagi stc ; CF := 1 na znak błędu _ch_ok: ; miejsce wyjścia z procedury pop ax ; zdejmij modyfikowane rejestry pop bp ret ; return, powrót
To chyba nie było zbyt trudne, co?
Szczegóły dotyczące pisania procedur (i bibliotek) znajdują się w moim
artykule o pisaniu bibliotek.
Teraz pokażę pewien program, który wybrałem ze względu na dużą liczbę różnych instrukcji i sztuczek. Niestety, nie jest on krótki. Ale wspólnie spróbujemy przez niego przejść. Jest to wersja dla TASMa, ale obok instrukcji postaram się zamieścić ich NASMowe odpowiedniki. Oto on:
; Program liczy liczby pierwsze w przedziałach ; 2-10, 2-100, 2-1000,... 2-100.000 ; ; Autor: Bogdan D. ; kontakt: bogdandr (at) op (dot) pl ; ; kompilacja TASM: ; tasm ile_pier.asm ; tlink ile_pier.obj /t ; ; kompilacja NASM: ; nasm -f bin -o ile_pier.com ile_pier.asm ; ; kompilacja FASM: ; fasm ile_pier.asm ile_pier.com .model tiny ; to będzie mały program. NASM/FASM: usunąć. .code ; początek segmentu kodu. NASM: ; "section .text" lub nic. FASM: nic .386 ; będziemy używać rejestrów 32-bitowych. ; NASM: "CPU 386" lub nic, FASM: nic org 100h ; to będzie program typu .com start: ; początek... xor ebx,ebx ; EBX = liczba, którą sprawdzamy, czy jest ; pierwsza. Zaczniemy od 3. Poniżej jest 3 ; razy INC (zwiększ o 1). Najpierw EBX = 0, ; bo "XOR rej,rej" zeruje dany rejestr. xor edi,edi ; EDI = bieżący licznik liczb pierwszych xor ecx,ecx ; ECX = stary licznik liczb (z poprzedniego ; przedziału). ; Chwilowo, oczywiście 0. inc ebx ; EBX = 1 mov esi,10 ; ESI = bieżący koniec przedziału: 10, 100, .. inc edi ; EDI = 1. uwzględniamy 2, która jest ; liczbą pierwszą inc ebx ; EBX = 2, pierwsza liczba będzie = 3 petla: ; pętla przedziału cmp ebx,esi ; czy koniec przedziału? (ebx=liczba, ; esi=koniec przedziału) jae pisz ; EBX >= ESI - idź do sekcji wypisywania ; wyników mov ebp,2 ; EBP - liczby, przez które będziemy dzielić. ; pierwszy dzielnik = 2 inc ebx ; zwiększamy liczbę. EBX=3. Będzie to ; pierwsza sprawdzana. spr: ; pętla sprawdzania pojedynczej liczby mov eax,ebx ; EAX = sprawdzana liczba xor edx,edx ; EDX = 0 div ebp ; EAX = EAX/EBP (EDX było=0), ; EDX=reszta z dzielenia or edx,edx ; instrukcja OR tak jak wiele innych, ; ustawi flagę zera ZF na 1, gdy jej wynik ; był zerem. W tym przypadku pytamy: ; czy EDX jest zerem? jz petla ; jeżeli dzieli się bez reszty (reszta=EDX=0), ; to nie jest liczbą pierwszą ; i należy zwiększyć liczbę sprawdzaną ; (inc ebx) inc ebp ; zwiększamy dzielnik cmp ebp,ebx ; dzielniki aż do liczby jb spr ; liczba > dzielnik - sprawdzaj dalej tę ; liczbę. Wiem, że można było sprawdzać tylko ; do SQRT(liczba) lub LICZBA/2, ale ; wydłużyłoby to program i brakowało mi już ; rejestrów... juz: ; przerobiliśmy wszystkie dzielniki, ; zawsze wychodziła reszta, ; więc liczba badana jest pierwsza inc edi ; zwiększamy licznik liczb znalezionych jmp short petla ; sprawdzaj kolejną liczbę aż do końca ; przedziału. ; sekcja wypisywania informacji pisz: mov edx,offset przedzial ; NASM/FASM: bez "offset" mov ah,9 int 21h ; wypisujemy napis "Przedział 2-...." mov eax,esi ; EAX=ESI=koniec przedziału call _pisz_ld ; wypisz ten koniec (EAX) ; NASM: mov ax,(0eh << 8) | ":" ; << to shift left, | to logiczne OR mov ax,(0eh shl 8) or ":" ; to wygląda zbyt skomplikowanie, ; ale jest o dziwo prawidłową instrukcją. ; Jest tak dlatego, że wyrażenie z prawej ; strony jest obliczane przez kompilator. ; 0eh przesunięte w lewo o 8 miejsc daje ; 0E00 w AX. Dalej, dopisujemy do tego ; dwukropek, którego kod ASCII nas nie ; interesuje a będzie obliczony przez ; kompilator. Ostatecznie, to wyrażenie ; zostanie skompilowane jako "mov ax,0e3a". ; Chodzi o to po prostu, aby ; nie uczyć się tabeli kodów ASCII na pamięć. int 10h ; wypisujemy dwukropek add ecx,edi ; dodajemy poprzednią liczbę znalezionych ; liczb pierwszych mov eax,ecx ; EAX = liczba liczb pierwszych od 2 do ; końca bieżącego przedziału call _pisz_ld ; wypisujemy tę liczbę. mov ah,1 ; int 16h, funkcja nr 1: czy w buforze ; klawiatury jest znak? int 16h jz dalej ; ZF = 1 oznacza brak znaku. Pracuj dalej. xor ah,ah int 16h ; pobierz ten znak z bufora ; (int 16h/ah=1 tego nie robi) koniec: mov ax,4c00h int 21h ; wyjdź z programu z kodem wyjścia = 0 dalej: ; nie naciśnięto klawisza cmp esi,100000 ; 10^5 je koniec ; ESI = 100.000? Tak - koniec, bo dalej ; liczy zbyt długo. mov eax,esi ; EAX=ESI shl eax,3 ; EAX = EAX*8 shl esi,1 ; ESI=ESI*2 add esi,eax ; ESI = ESI*2 + EAX*8 =ESI*2+ESI*8= ESI*10. ; Znacznie szybciej niż MUL xor edi,edi ; bieżący licznik liczb jmp short petla ; robimy od początku... przedzial db 10,13,"Przedzial 2-$" ; NASM/FASM: ; _pisz_bufor: times 6 db 0 _pisz_bufor db 6 dup (0) ; miejsce na cyfry dla następującej procedury: _pisz_ld: ;we: EAX=liczba bez znaku do wypisania push ecx ; zachowujemy modyfikowane rejestry push edx push eax push esi xor si,si ; SI=0. Będzie wskaźnikiem w powyższy bufor. mov ecx,10 ; będziemy dzielić przez 10, aby uzyskiwać ; kolejne cyfry. Reszty z dzielenia pójdą ; do bufora, potem będą wypisane wspak, bo ; pierwsza reszta jest przecież cyfrą jedności _pisz_ld_petla: xor edx,edx ; EDX=0 div ecx ; EAX=EAX/ECX, EDX = reszta, która mieści się ; w DL, bo to jest tylko 1 cyfra dziesiętna mov [_pisz_bufor+si],dl ; Cyfra do bufora. inc si ; Zwiększ numer komórki w buforze, do której ; będziemy teraz pisać or eax,eax ; EAX = 0 ? jnz _pisz_ld_petla ; Jeśli nie (JNZ), to skok do początku pętli mov ah,0eh ; funkcja wypisania _pisz_ld_wypis: mov al,[_pisz_bufor+si-1] ; SI wskazuje poza ostatnią cyfrę, ; dlatego jest -1. Teraz AL= ostatnia cyfra, ; czyli ta najbardziej znacząca w liczbie ; Zamień liczbę 0-9 w AL na gotową do wypisania cyfrę: or al,"0" ; lub "OR al,30h" lub "ADD al,30h". int 10h ; wypisz AL dec si ; zmniejsz wskaźnik do bufora. jnz _pisz_ld_wypis ; Jeśli ten wskaźnik (SI) nie jest zerem, ; wypisuj dalej pop esi ; odzyskaj zachowane rejestry pop eax pop edx pop ecx ret ; powrót z procedury end start ; NASM/FASM: usunąć tę linijkę
MOV EBX,2
a potem INC EBX
, które musiało być w pętli?XOR EBX, EBX
jest krótsze i szybsze.xor ebx,ebx inc ebx inc ebx
Te instrukcje operują na tym samym rejestrze i każda musi poczekać, aż poprzednia się zakończy. Współczesne procesory potrafią wykonywać niezależne czynności równolegle, dlatego wcisnąłem w środek jeszcze kilka niezależnych instrukcji.
Można było. Używając zmiennych w pamięci. Niechętnie to robię, bo w porównaniu z prędkością operacji procesora, pamięć jest wprost NIEWIARYGODNIE wolna. Zależało mi na szybkości.
mov ax,(0eh shl 8) or ":"nie prościej byłoby zapisać
mov ah,0eh mov al,":" ; lub 3ah
Jasne, że byłoby prościej... zrozumieć. Ale nie wykonać dla procesora. Jedną instrukcję wykonuje się szybciej niż 2 i to jeszcze pośrednio operujące na tym samym rejestrze (AX).
SHL
zapisać jedno
MUL
lub IMUL
?Jasne, że prościej. Przy okazji dobre kilka[naście] razy wolniej.
XOR rej,rej
?Szybsze niż MOV rej,0
, gdzie to zero musi być często zapisane
4 bajtami zerowymi. Tak więc i krótsze. Oprócz tego, dzięki instrukcji XOR
lub SUB
wykonanej na tym samym rejestrze, procesor wie, że ten rejestr już
jest pusty. Może to przyśpieszyć niektóre operacje.
Niektóre procedury są żywcem wyjęte z mojej biblioteki, pisząc którą musiałem zadbać, by
przypadkowo nazwa jakieś mojej procedury nie była identyczna z nazwą jakiejś innej
napisanej w programie korzystającym z biblioteki.
Czy nie mogłem tego potem zmienić?
Jasne, że mogłem. Ale nie było takiej potrzeby.
OR rej,rej
a nie CMP rej,0
?OR jest krótsze i szybsze. Można też używać TEST rej,rej
, które nie zmienia
zawartości rejestru.
OR al, "0"
?Bardziej czytelne niż ADD/OR al,30h
. Chodzi o to, aby dodać kod ASCII zera. I można
to zrobić bardziej lub mniej czytelnie.
Wiem, że ten program nie jest doskonały. Ale taki już po prostu napisałem...
Nie martwcie się, jeśli czegoś od razu nie zrozumiecie. Naprawdę, z czasem samo przyjdzie.
Ja też przecież nie umiałem wszystkiego od razu.
Inny program do liczb pierwszych znajdziecie tu: prime.txt.
Następnym razem coś o ułamkach i koprocesorze.
Podstawowe prawo logiki:
Jeżeli wiesz, że nic nie wiesz, to nic nie wiesz.
Jeżeli wiesz, że nic nie wiesz, to coś wiesz.
Więc nie wiesz, że nic nie wiesz.