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 :)

Kompilacja

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!.

Dla wygody

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).

NoASM - wprowadzenie

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.

Rejestry

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.

Komunikaty wyjściowe

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.

Wypisywanie znaków

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).

Zaprezentowanie

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 zakończenie

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