1. Hello world!
Czas napisać pierwszy program. Utwórz nowy plik tekstowy o nazwie
01_hello.nasm
. Właśnie takie rozszerzenie - NASM
- mają pliki z kodem źródłowym programów w języku NoASM. Do pisania takich
plików możesz używać dowolnego edytora tekstu niesformatowanego, jak
choćby systemowy Notatnik. Nie może to być natomiast program typu WordPad
albo Word, chyba że specjalnie postarasz się o to, żeby plik został
zapisany w czystym formacie tekstowym.
Następnie wklej do niego kod naszego pierwszego programu:
; 01 Hello World! ; Ustawienia początkowe mov1u 5 A1 ; Urządzenie: ekran mov1u 5 A2 ; Komunikat: zapisanie znaku zero1 A4 ; Podkreślenie: nie zero4 BY ; Y: 0 mov4u 0x00FFFFFF CX ; Kolor tekstu: biały zero4 CY ; Kolor tła: czarny ; Wypisanie liter zero4 BX mov1u 'H' A3 out inc4i BX mov1u 'e' A3 out inc4i BX mov1u 'l' A3 out inc4i BX out inc4i BX mov1u 'o' A3 out add4i BX 2 BX mov1u 'W' A3 out inc4i BX mov1u 'o' A3 out inc4i BX mov1u 'r' A3 out inc4i BX mov1u 'l' A3 out inc4i BX mov1u 'd' A3 out inc4i BX mov1u '!' A3 out ; Zaprezentowanie mov1u 5 A1 ; Urządzenie: ekran mov1u 4 A2 ; Komunikat: zaprezentowanie zmian out Koniec: wait ; Czekam na komunikat in ; Pobieram go jmp Koniec ; I od nowa :)
Później omówimy sobie dokładnie zasadę jego działania. Najpierw skupmy
się na tym, co zrobić z takim kodem. Trzeba go skompilować, by z tego pliku
tekstowego z kodem źródłowym w języku NoASM powstał plik binarny DISK
zawierający program w kodzie NoCode, który będzie mógł zostać uruchomiony
w wirtualnej maszynie.
W tym celu trzeba uruchomić kompilator. Znajduje się on w podkatalogu
NoASM
. Niestety, to jest narzędzie konsolowe. Dlatego musisz
uruchomić wiersz poleceń (w Windows 98 nazywa się to "Tryb MS-DOS"),
np. wybierając Menu Start > Uruchom...
i wpisując
cmd
. Przejdź w konsoli do katalogu ze swoim programem używając
znanych jeszcze z DOSa poleceń cd
(np. d:
,
a potem cd NoSense\Moje
).
Kompilator trzeba uruchomić z dwoma parametrami. Pierwszym jest nazwa istniejącego pliku w NoASM z kodem źródłowym, a drugim nazwa dla docelowego pliku dysku, który ma powstać. Polecenie może więc wyglądać tak:
noasm.exe D:\NoSense\Moje\01_hello.nasm D:\NoSense\Moje\01_hello.disk
Po chwili pokazuje się lista komunikatów o błędach oraz podsumowanie kompilacji. Jeśli wszystko zrobiłeś poprawnie, nie powinno być żadnych błędów. Pokaże się wtedy coś takiego:
D:\NoSense\NoASM>noasm.exe D:\NoSense\Moje\01_hello.nasm D:\NoSense\Moje\01_hello.disk Instructions : 44 Macros : defined - 89, used - 30 Labels : defined - 1, used - 1 Source : lines - 134, files - 2, size - 3683 (3kB) Output size : 254 (254B) Total time : 0.04s Errors : 0 (SUCCESS!)
Jeśli kompilacja się udała, powstał plik 01_hello.disk
.
Uruchom wirtualną maszynę, włóż ten plik do pierwszego napędu i zrestartuj
tak, jak to zostało opisane w poprzedniej części. Powinien się uruchomić
nasz program i wypisać na ekranie Hello World!
.
Z pewnością zauważyłeś, że taki sposób kompilacji i uruchamiania nie jest zbyt wygodny. Szczególnie, jeśli w programie są błędy i trzeba to wszystko powtarzać wiele razy. Dlatego zajmiemy się teraz pewnym małym ułatwieniem, jakie można sobie zrobić.
W katalogu, w którym znajdują się twoje programy utwórz plik tekstowy
o nazwie build.bat
. Będzie to plik wsadowy Windows zawierający
polecenia do uruchomienia. Wklej do niego takie dwie linijki:
D:\NoSense\NoASM\noasm.exe %1 %2 if errorlevel 0 D:\NoSense\NoSense.exe -disk 0 %2 -noask
Teraz wystarczy, że będąc na konsoli w katalogu ze swoimi programami wpiszesz polecenie:
build 01_hello.nasm 01_hello.disk
a twój program zostanie automatycznie skompilowany i jeśli kompilacja zakończyła się sukcesem - uruchomiony w NoSense. Tylko jak to działa?
Pierwsze z tych tajemniczych i groźnie wyglądających poleceń uruchamia
kompilator NoASM. Symbole %1
i %2
oznaczają,
że jako parametry zostają mu przekazane te same łańcuchy znaków, które
przekazujemy do pliku build.bat
uruchamiając go.
if errorlevel 0
to warunek. Znajdujące się dalej polecenie
zostanie wykonane tylko jeśli program uruchomiony poprzednio zwrócił
liczbę 0. Każdy program może po zakończeniu zwrócić jakąś liczbę,
a kompilator NoASM (bo właśnie on zostaje uruchomiony w poprzedniej
linijce) zwraca 0 kiedy kompilacja zakończy się sukcesem.
Polecenie D:\NoSense\NoSense.exe -disk 0 %2 -noask
uruchamia wirtualną maszynę ze specjalnymi parametrami. Zacznijmy
od omówienia drugiego, bo jest prostszy. Parametr -noask
powoduje, że uruchomiona z nim maszyna nie będzie pytała, czy na pewno
chcesz wyjść, kiedy będziesz ją zamykał. To oszczędzi trochę czasu i
kilka kliknięć.
Parametr -disk 0 %2
informuje maszynę, że do napędu o
numerze 0 (czyli do pierwszego) powinien zostać włożony podany plik dysku.
Nazwą tego pliku, podaną tu w postaci symbolu %2
, będzie
drugi z parametrów przekazanych do naszego pliku BAT
- czyli
plik DISK
(który wcześniej był plikiem docelowym dla
kompilatora).
Wiesz już, jak kompilować i uruchamiać programy. Czas na omówienie kodu samego programu. Na początek chciałbym podać kilka informacji wstępnych.
NoASM jest asemblerem. Oznacza to, że w odróżnieniu od języków programowania takich jak Pascal/Delphi czy C++ jest niskopoziomowy. Nie ma w nim klas i obiektów, funkcji, pętli, warunków, zmiennych czy stałych. Są tylko instrukcje, z których każda tłumaczona jest przez kompilator na pojedynczą instrukcję kodu maszynowego NoCode. Instrukcje trzeba pisać w osobnych linijkach jedna pod drugą. Oczywiście mogą też być linijki puste.
Komentarze to tekst w kodzie pisany tylko dla twojej własnej informacji
i ignorowany przez kompilator. Warto pisać komentarze wyjaśniające, co się
dzieje w danym miejscu. Komentarz w NoASM rozciąga się od średnika
;
do końca linii. Nie ma komentarzy wielolinijkowych. Komentarz
może być w osobnym wierszu albo ma końcu linijki z instrukcją.
Jak widać w przedstawionym na początku kodzie, każda instrukcja składa
się z nazwy (np. mov1u
, zero1
) i oddzielanych
odstępem parametrów. W skład nazwy instrukcji często wchodzi typ danych,
na jakich operuje. Większość instrukcji występuje w wielu odmianach dla
różnych typów. Poznamy je przy okazji. Jeśli już teraz chcesz zapoznać się
z różnymi typami danych, zajrzyj do dokumentacji pod hasło w indeksie:
types
.
Przypomnijmy początkowy fragment kodu:
; Ustawienia początkowe mov1u 5 A1 ; Urządzenie: ekran mov1u 5 A2 ; Komunikat: zapisanie znaku zero1 A4 ; Podkreślenie: nie zero4 BY ; Y: 0 mov4u 0x00FFFFFF CX ; Kolor tekstu: biały zero4 CY ; Kolor tła: czarny ; Wypisanie liter zero4 BX mov1u 'H' A3 out
Nie zdziw się, że w dokumentacji wszystkie instrukcje są pisane dużymi literami, a tutaj małymi. Tak jest wygodniej pisać, a w NoASM wielkość liter nie jest rozróżniana.
Najważniejsza jest tutaj instrukcja out
.
Służy do wszelkiej komunikacji z urządzeniami wirtualnej maszyny.
Nie przyjmuje ona żadnych parametrów, ale do swojego działania wymaga,
by ustawione zostały wcześniej odpowiednie wartości rejestrów.
No właśnie - co to są rejestry? Są to specjalne komórki pamięci, które
intensywnie się wykorzystuje programując w NoASM. Są cztery 8-bajtowe
rejestry uniwersalne, które mają swoje nazwy: AR, BR, CR, DR
.
Każdy z nich dzieli się na dwa rejestry 4-bajtowe, np. AR
dzieli się na AX
i AY
. Z kolei każdy z nich
dzieli się na rejestry 2-bajtowe, których w AR
jest już 4:
AA, AB, AC, AD
, a te z kolei na 8 rejestrów 1-bajtowych:
A1, A2, A3, A4, A5, A6, A7, A8
. Tak samo dzielą się pozostałe
trzy rejestry uniwersalne.
Są jeszcze trzy 4-bajtowe rejestry specjalne: SR, IR, OR
,
ale nimi nie będziemy się na razie zajmowali. Jeśli chcesz dowiedzieć się
więcej o rejestrach i zobaczyć rozrysowaną ich strukturę, otwórz
dokumentację na temacie Devices > Memory
.
Koniecznie trzeba zrozumieć i zapamiętać, że te mniejsze i te większe
to nie są zupełnie różne rejestry, ale inne nazwy na części tego samego
rejestru. Tak więc jeśli wpiszesz coś do AR
, automatycznie
zmieni się zawartość wszystkich rejestrów AX, AY, AA, AB, AC, AD, A1,
A2
itd... Z kolei jeśli zmienisz np. A2
, zmieni się
część informacji zapisanych w AA, AX, AR
.
Jakie informacje powinniśmy wpisać do rejestrów przed wywołaniem
instrukcji out
? To zależy od komunikatu, jaki chcemy wysłać
do danego urządzenia. Otwórz dokumentację na temacie Devies > Screen
> Output messages > Set character
. To właśnie ten komunikat będziemy
wysyłali wypisując kolejne litery na ekranie. To jest jeden
z tych komunikatów, które wymagają bardzo wielu informacji. Prześledźmy
je po kolei.
Pierwszy parametr, wspomniany w dokumentacji w postaci zwięzłego
oznaczenia A1 [in,1u]
mówi nam, że do rejestru A1
trzeba wpisać liczbę 5. Podczas wysyłania komunikatów wyjściowych w tym
rejestrze zawsze podaje się numer urządzenia, do którego komunikat jest
adresowany. Liczba 5 oznacza ekran.
Symbol [in
oznacza, że jest to parametr wejściowy, czyli
musi zostać ustawiony przed wywołaniem instrukcji out
. Z kolei
1u]
informuje nas o typie informacji, jaką musimy wpisać do
danego rejestru. [1u]
to typ liczby całkowitej bez znaku
(ang. "unsigned") zajmującej jeden bajt, o zakresie od 0 do 255, znany
z języków wysokiego poziomu jako BYTE
.
Wpisania tej informacji dokonujemy za pomocą instrukcji:
mov1u 5 A1
Wbrew swojej nazwie instrukcja taka nie przenosi, ale kopiuje informację
podaną w pierwszym parametrze do miejsca podanego w drugim parametrze.
W naszym przypadku jest to liczba 5 kopiowana do rejestru o nazwie
A1
. Istnieją odmiany instrukcji MOV
do różnych
typów danych. Tutaj używamy typu [1u]
.
W ten sam sposób wpisujemy do rejestrów kolejne informacje potrzebne
dla naszego komunikatu zgodnie z jego opisem w dokumentacji.
W A2
powinna się znaleźć liczba określająca rodzaj komunikatu
wysyłanego do danego urządzenia. Do A3
wpisujemy znak, jaki
chcemy wyświetlić. Używamy do tego takiej konstrukcji:
mov1u 'H' A3
Zamiast liczby jest tutaj znak. Znaki zapisujemy w apostrofach albo
w cudzysłowach. Jeśli w danym miejscu oczekiwana jest liczba (tak jak tutaj
- bo kopiowana jest informacja typu [1u]
), kompilator
odczyta kod ASCII, który odpowiada podanemu znakowi.
W A4
oczekiwana jest informacja typu [1b]
.
Ten typ jest tak naprawdę równoważny [1u]
, ale posiada inne
znaczenie. Jest to typ logiczny (ang. "boolean"), czyli wartość informacji
tego typu interpretowana jest jako jeden z dwóch możliwych stanów. Liczba 0
oznacza "fałsz", a każda inna wartość oznacza "prawdę". Wpisujemy do
A4
zero, bo nie chcemy, żeby wypisywane znaki były
podkreślone.
Następna informacja wymagana przez nasz komunikat wypisujący znak jest
oznaczona jako [out
. To znaczy, że to nie my mamy wpisać
ją przed wysłaniem komunikatu instrukcją out
, ale to ta instrukcja
po zakończeniu, zwraca nam informację w tym rejestrze. Jest to informacja,
czy zapisanie znaku się powiodło. Nie będziemy jej sprawdzali.
W rejestrach BX
i BY
mają się znaleźć
współrzędne znaku, który chcemy zmienić.
Tym razem mają to być liczby typu [4u]
, czyli 4-bajtowe.
Znaki indeksowane są od zera.
Znak (0,0) znajduje się w lewym górnym rogu ekranu, współrzędna pozioma
(X) rośnie w prawo, a współrzędna pionowa (Y) w dół.
Odpowiedni rysunek możesz zobaczyć w dokumentacji w rozdziale
Devices > Screen
.
Do wpisywania zera moglibyśmy używać instrukcji MOV
.
Jednak jest osobna instrukcja przeznaczona specjalnie do zerowania.
W skład jej nazwy nie wchodzi całe oznaczenie typu (zero to jest zero,
niezależnie od typu), tylko sam rozmiar (ilość bajtów).
Pierwszy znak wyświetlimy w pozycji (0,0), a więc trzeba wyzerować
BX
i BY
:
zero4 BY ; Y: 0 ; ... zero4 BX
Wreszcie do CX
i CY
wpisujemy odpowiednio
kolor tekstu i kolor tła dla wyświetlanego znaku. Kolor to liczba typu
[4u]
, której pierwszy bajt powinien być równy zero, a trzy
pozostałe określają jasność trzech podstawowych barw składowych -
czerwonej, zielonej i niebieskiej. Wyzerowanie CY
:
zero4 CY ; Kolor tła: czarny
ustala kolor tła na czarny, ponieważ wszystkie jego trzy składowe będą miały jasność 0. Z kolei kolor biały wpisujemy taką konstrukcją:
mov4u 0x00FFFFFF CX ; Kolor tekstu: biały
0x00FFFFFF
to liczba zapisana w systemie szesnastkowym.
Każdym ośmiu bitom (jednemu bajtowi) odpowiadają w nim dokładnie dwie
cyfry. Cyframi w systemie szesnastkowym są: 0123456789ABCDEF
.
Ustawienie wszystkich składowych koloru na FF
oznacza
największą możliwą wartość bajtu równą 255, co daje w efekcie kolor
biały.
Kiedy już wszystkie informacje znajdują się w rejestrach, można wywołać instrukcję:
out
która wyśle je do odpowiedniego urządzenia. Tak zostanie zapisany pierwszy znak. Teraz trzeba wpisać następne.
Informacje oznaczone jako Ustawienia początkowe
nie
zmieniają się w kolejnych znakach, więc nie trzeba ich wpisywać od nowa.
Istotne jest, żeby wiedzieć, czy wartości tych rejestrów faktycznie nie
zostaną zmienione bez naszej wiedzy.
Dokumentacja instrukcji out
gwarantuje, że nie zmienia ona
żadnych rejestrów z wyjątkiem tych oznaczonych w dokumentacji danego
komunikatu jako [out
. U nas jest to tylko rejestr A5
,
którego i tak nie używamy.
Tak więc faktycznie możemy zaufać, że wpisane do pozostałych informacje
pozostaną.
Modyfikować trzeba tylko znak oraz pozycję X. Po takiej poprawce można wypisać kolejną literkę na ekran.
inc4i BX mov1u 'e' A3 out
Jak widać, wpisanie nowego znaku odbywa się za pomocą znanej już
instrukcji mov1u
. Natomiast co do pozycji, zamiast wpisywać
jej nową wartość, zwiększamy liczbę znajdującą się w rejestrze o 1.
Służy do tego polecenie inc4i
(od ang. "increment" -
powiększenie).
Między pierwszym, a drugim wyrazem wypisywanego przez nas, kultowego tekstu musi być przerwa. Zamiast wypisać tam spację, jak każdy inny znak, pomijamy to miejsce zwiększając pozycję o dwa. Robimy to instrukcją arytmetyczną służącą do dodawania:
add4i BX 2 BX
Pobiera ona liczbę z rejestru BX
(parametr pierwszy),
dodaje ją do liczby 2 (parametr drugi) i wynik umieszcza z powrotem
w rejestrze BX
(parametr trzeci).
Ekran jest tak skonstruowany, że wypisywanych znaków nie widać od razu. Trzeba zaprezentować dokonane zmiany. Dzięki temu ekran działa szybko i można wyświetlać na nim animacje. Zapisuje się wiele znaków, a potem dokonuje się ich zaprezentowania - wszystkich na raz.
Do zaprezentowania służy komunikat wyjściowy opisany w dokumentacji
w rozdziale Devices > Screen > Output messages > Present
.
Jak widać, jest on znacznie prostszy od poprzednio omówionego.
Informację wyjściową o tym, czy zaprezentowanie się udało znowu zignorujemy.
Wystarczy więc wpisać do rejestrów tylko dwie informacje:
; Zaprezentowanie mov1u 5 A1 ; Urządzenie: ekran mov1u 4 A2 ; Komunikat: zaprezentowanie zmian out
Być może domyślasz się, że pierwsza instrukcja tego fragmentu nie jest
konieczna, bo liczba 5 (odpowiadająca urządzeniu ekranowemu) pozostała
w rejestrze A1
z poprzednich komunikatów.
Na końcu trzeba napisać jeszcze jedną rzecz. Program nie może się bowiem ot tak po prostu skończyć. Maszyna cały czas musi wykonywać jakiś kod i gdyby sterowanie (czyli miejsce, w którym aktualnie "jest" procesor - skąd pobiera następną instrukcję do wykonania) wyszło poza program, nastąpiłby błąd.
Objawem takiego błędu byłoby zatrzymanie wykonywania programu i wypisanie komunikatu z błędem na pasku tytułowym okna NoSense. Obraz na ekranie pozostałby, ale mimo tego to nie jest eleganckie rozwiązanie. Dlatego warto napisać dodatkowy kod kończący:
Koniec: wait ; Czekam na komunikat in ; Pobieram go jmp Koniec ; I od nowa :)
Rozpoczyna go definicja etykiety. Etykietę można zdefiniować w dowolnym miejscu - przed każdą instrukcją. Może się znaleźć w osobnej linii (tak jak tutaj) albo na początku linii z daną instrukcją. Etykieta to nic innego, jak nazwa, którą nadajemy pewnemu miejscu w naszym programie.
Pozostałe instrukcje zapisane są z wcięciem w postaci dwóch spacji. Takie wcięcie ma jedynie funkcję estetyczną - zwiększa czytelność kodu. Wszelkie odstępy na początku i na końcu każdej linijki są przez kompilator ignorowane.
Widzimy tutaj trzy nowe, nieznane jeszcze instrukcje. Prześledźmy ich
działanie. Najpierw jednak muszę trochę napisać o komunikatach wejściowych.
Oprócz komunikatów wyjściowych - tych, które wysyłaliśmy instrukcją
out
- są jeszcze komunikaty wejściowe odbierane instrukcją
in
. Takie komunikaty przepływają jakby w drugą stronę - są
generowane przez urządzenia i trafiają do naszego programu.
Przykładem takich komunikatów są naciśnięcia klawiszy albo ruchy kursorem myszy. Trafiają one do kolejki komunikatów wejściowych, skąd mogą być wczytywane.
Instrukcja wait
zawiesza wykonywanie programu do czasu,
aż w kolejce komunikatów wejściowych znajdzie się przynajmniej jeden
komunikat (od ang. "wait" - czekać). Jeśli już jakiś tam jest, instrukcja
ta nie robi nic. Nie robi także niczego z tym komunikatem.
Do pobrania komunikatu służy instrukcja in
. Wczytuje ona
informacje niesione przez komunikat do rejestrów i usuwa go z kolejki.
Komunikaty wejściowe są opisane w dokumentacji podobnie jak poznane
wcześniej komunikaty wyjściowe - każdy ma w A1
numer
urządzenia, które go wygenerowało, w A2
typ komunikatu
itd.
Nas jednak nie interesują żadne informacje z tych komunikatów.
Instrukcja in
wczytuje je do rejestrów, a my w ogóle z nich nie
korzystamy. Chodzi nam tylko o opróżnienie kolejki, by można było czekać
na następny komunikat.
Ostatnia instrukcja - jmp
- to instrukcja skoku.
Powoduje ona przeskok do podanej etykiety, co zapętla w nieskończoność
naszą sekwencję trzech omówionych już instrukcji.
Powstaje jednak pytanie, jaki jest ogólny cel tej pętli? Jeden z nich już poznaliśmy wyżej - chodzi o to, by maszyna cały czas coś robiła, a nie "wysypała się" wychodząc poza program. Drugim jest oszczędzanie czasu rzeczywistego procesora na komputerze, na którym działa wirtualna maszyna.
Gdyby usunąć albo wykomentować (stawiając na początku linijki średnik
;
) instrukcję wait
, pętla wykonywałaby się
cały czas pożerając 100% mocy rzeczywistego procesora, bowiem instrukcja
in
w momencie, kiedy kolejka komunikatów wejściowych jest
pusta, po prostu zwraca stosowną informację przez rejestry.
Natomiast kiedy instrukcja wait
jest obecna, program
zawiesza swoje działanie w oczekiwaniu na każdy kolejny komunikat
wejściowy. Kiedy taki nadejdzie, zostaje wczytany i po zapętleniu od nowa
rozpoczyna się oczekiwanie. W ten sposób nasz program po
spełnieniu swojej funkcji (wypisaniu tekstu) nie zamęcza rzeczywistego
procesora.
2004-07-02
Adam Sawicki "Regedit"
WWW: NoSense,
HomePage,
ProgrameX