   #Start Prev Next Contents

   Jak pisac programy w jezyku asembler?

Czesc 2 - Pamiec, czyli gdzie upychac cos, co sie nie miesci w procesorze

   Poznalismy juz rejestry procesora. Jak widac, jest ich ograniczona
   liczba i nie maja one zbyt duzego rozmiaru. Rejestry ogolnego
   przeznaczenia sa co najwyzej 32-bitowe (czterobajtowe). Dlatego czesto
   programista musi niektore zmienne umieszczac w pamieci. Przykladem
   tego byl napis, ktory wyswietlalismy w poprzedniej czesci artykulu.
   Byl on zadeklarowany dyrektywa DB, co oznacza "declare byte". Ta
   dyrektywa niekoniecznie musi deklarowac dokladnie 1 bajt. Tak jak
   widzielismy, mozna nia deklarowac napisy lub kilka bajtow pod rzad.
   Teraz omowimy rodzine dyrektyw sluzacych wlasnie do rezerwowania
   pamieci.

   Ogolnie, zmienne mozna deklarowac jako bajty (dyrektywa DB, cos jak
   char  w jezyku C), slowa ( word  = 16 bitow = 2 bajty, cos jak
   short  w C) dyrektywa DW, podwojne slowa DD ( double word = dword  =
   32bity = 4 bajty, jak  long  w C), potrojne slowa pword = 6 bajtow -
   PW, poczworne slowa DQ ( quad word = qword  = 8 bajtow, typ  long
   long ), tbyte = 10 bajtow - DT (typ  long double  w C).

   Przyklady (zakomentowane zduplikowane linijki sa w skladni TASMa):
   (przeskocz przyklady)
        dwa             db 2
        szesc_dwojek    db 2, 2, 2, 2, 2, 2 ; tablica szesciu bajtow
        litera_g        db "g"
        _ax             dw 4c00h        ; dwubajtowa liczba calkowita
        alfa            dd 12348765h    ; czterobajtowa liczba calkowita

        ;liczba_a       dq 1125         ; osmiobajtowa liczba calkowita. NASM
                                        ; starszy niz wersja 2.00
                                        ; tego nie przyjmie, zamienimy to na
                                        ; postac rownowazna:
        liczba_a        dd 1125, 0      ; 2 * 4 bajty

        liczba_e        dq 2.71         ; liczba zmiennoprzecinkowa
                                        ; podwojnej precyzji (double)

        ;duza_liczba    dt 6af4aD8b4a43ac4d33h  ; 10-bajtowa liczba calkowita.
                                                ; NASM/FASM tego nie przyjmie,
                                                ; zrobimy to tak:
        duza_liczba     dd 43ac4d33h, 0f4aD8b4ah; czemu z zerem z przodu?
                                                ; Czytaj dalej
                        db 6ah

        pi              dt 3.141592
        ;nie_init       db ?            ; niezainicjalizowany bajt.
                                        ; Wartosc nieznana.
                                        ; NASM tak tego nie przyjmie.
                                        ; Nalezy uzyc:
        nie_init        resb 1
                                        ; zas dla FASMa:
        ;nie_init       rb 1

        napis1          db "NaPis1."
        xxx             db 1
                        db 2
                        db 3
                        db 4

   Zwroccie uwage na sposob rozbijania duzych liczb na poszczegolne
   bajty: najpierw deklarowane sa mlodsze bajty, a potem starsze (na
   przyklad "dd 11223344h" jest rownoznaczne z "db 44h, 33h, 22h, 11h").
   To dziala, gdyz procesory Intela i AMD (i wszystkie inne klasy x86) sa
   procesorami typu "little-endian", co znaczy, ze najmlodsze bajty
   danego ciagu bajtow sa umieszczane przez procesor w najnizszych
   adresach pamieci. Dlatego my tez tak deklarujemy nasze zmienne.

   Ale z kolei takie cos:
        beta    db aah

   nie podziala. Dlaczego? KAZDA liczba musi zaczynac sie od cyfry. Jak
   to obejsc? Tak:
        beta    db 0aah

   czyli poprzedzic zerem.
   Nie podziala rowniez to:
        0gamma  db      9

   Dlaczego? Etykiety (dotyczy to tak danych, jak i kodu programu) nie
   moga zaczynac sie od cyfr.

   Zapisanie kilku wartosci po dyrektywie Dx (DB, DW, DD, i tak dalej)
   automatycznie tworzy tablice elementow odpowiedniego rozmiaru o tych
   wartosciach, z ktorych kazda nastepna jest tuz po poprzedniej w
   pamieci. Na przyklad, nastepujaca dyrektywa tworzy tak naprawde
   tablice szesciu bajtow o wartosci 2, a nie probuje z szesciu dwojek
   utworzyc wartosc, ktora potem umiesci w pojedynczym bajcie:
        szesc_dwojek    db 2, 2, 2, 2, 2, 2

   A co, jesli chcemy zadeklarowac zmienna, powiedzmy, skladajaca sie z
   234 bajtow rownych zero? Trzeba je wszystkie napisac?
   Alez skad! Nalezy uzyc operatora "duplicate". Odpowiedz na pytanie
   brzmi (TASM):
        zmienna         db      234     dup(0)
        nazwa           typ     liczba       co zduplikowac

   Lub, dla NASMa i FASMa:
        zmienna:        TIMES    234    db      0
        nazwa                   liczba   typ    co zduplikowac

   A co, jesli chcemy miec dwuwymiarowa tablice podwojnych slow o
   wymiarach 25 na 34?
   Robimy tak (TASM):
        Tablica         dd      25      dup (34 dup(?))

   Lub, dla NASMa i FASMa na przyklad tak:
        Tablica:        TIMES    25*34  dd      0

   Do obslugi takich tablic przydadza sie bardziej skomplikowane sposoby
   adresowania zmiennych. O tym za moment.

   Zmiennych trzeba tez umiec uzywac.
   Do uzyskania adresu danej zmiennej uzywa sie operatora (slowa
   kluczowego) "offset" (TASM), tak jak widzielismy wczesniej. Zawartosc
   zmiennej otrzymuje sie poprzez umieszczenie jej w nawiasach
   kwadratowych. Oto przyklad:
        rejestr_ax      dw      4c00h
        rejestr_bx      dw      ?               ; nie w NASMie/FASMie.
                                                ; uzyc na przyklad 0 zamiast "?
"
        rejestr_cl      db      ?               ; jak wyzej
        ...
                mov     [rejestr_bx], bx
                mov     cl, [rejestr_cl]
                mov     ax, [rejestr_ax]
                int     21h

   Zauwazcie zgodnosc rozmiarow zmiennych i rejestrow.
   Mozemy jednak miec problem w skompilowaniu czegos takiego:
        mov     [jakas_zmienna], 2

   Dlaczego? Kompilator wie, ze gdzies zadeklarowalismy "jakas_zmienna",
   ale nie wie, czy bylo to
        jakas_zmienna   db      0

   czy
        jakas_zmienna   dw      22

   czy moze
        jakas_zmienna   dd      "g"

   Chodzi o to, aby pokazac, jaki rozmiar ma obiekt docelowy. Nie bedzie
   problemow, gdy napiszemy:
        mov     word ptr [jakas_zmienna], 2     ; TASM
        mov     word [jakas_zmienna], 2         ; NASM/FASM - bez PTR

   I to obojetnie, czy zmienna byla bajtem (wtedy nastepny bajt bedzie
   rowny 0), czy slowem (wtedy bedzie ono mialo wartosc 2) czy moze
   podwojnym slowem lub czyms wiekszym (wtedy 2 pierwsze bajty zostana
   zmienione, a pozostale nie). Dzieje sie tak dlatego, ze zmienne
   zajmuja kolejne bajty w pamieci, najmlodszy bajt w komorce o
   najmniejszym adresie. Na przyklad:
        xxx     dd      8

   jest rownowazne:
        xxx     db      8,0,0,0

   oraz:
        xxx     db      8
                db      0
                db      0
                db      0

   Te przyklady nie sa jedynymi sposobami adresowania zmiennych (poprzez
   nazwe). Ogolny schemat wyglada tak:

   Uzywajac rejestrow 16-bitowych:
   [ (BX albo BP) lub (SI albo DI) lub liczba ]

   slowo "albo" wyklucza wystapienie obu rejestrow na raz
   na przyklad
        mov     al, [ nazwa_zmiennej+2 ]
        mov     [ di-23 ], cl
        mov     al, [ bx + si + nazwa_zmiennej+18 ]

   nazwa_zmiennej to tez liczba, obliczana zazwyczaj przez linker.

   W trybie rzeczywistym (na przyklad pod DOSem) pamiec podzielona jest
   na segmenty, po 64kB (65536 bajtow) kazdy, przy czym kazdy kolejny
   segment zaczynal sie 16 bajtow dalej niz wczesniejszy (nachodzac na
   niego). Pamiec adresowalna wynosila maksymalnie 65536 (maks. liczba
   segmentow) * 16 bajtow/segment = 1MB. O tym limicie powiem jeszcze
   dalej.
   (przeskocz ilustracje ulozenia segmentow)
Ulozenie kolejnych segmentow wzgledem siebie

        segment o numerze 0
0       +-----------------+
        |                 |     segment o numerze 1
10h     +-----------------+     +-----------------+
        |                 |     |                 |     segment o numerze 2
20h     +-----------------+     +-----------------+     +-----------------+
        |                 |     |                 |     |                 |
30h     +-----------------+     +-----------------+     +-----------------+
        |                 |     |                 |     |                 |

   Slowo offset oznacza odleglosc jakiegos miejsca od poczatku segmentu.
   Adresy mozna bylo pisac w postaci SEG:OFF. Adres liniowy (prawdziwy)
   otrzymywalo sie mnozac segment przez 16 (liczba bajtow) i dodajac do
   otrzymanej wartosci offset, na przyklad adres segmentowy 1111h:2222h =
   adres bezwzgledny 13332h (h = szesnastkowy).
   Nalezy tez dodac, ze rozne adresy postaci SEG:OFF moga dawac w wyniku
   ten sam adres rzeczywisty. Oto przyklad: 0040h:0072h = (seg*16+off)
   400h + 72h = 00472h = 0000h:0472h.

   Na procesorach 32-bitowych (od 386) odnoszenie sie do pamieci moze (w
   kompilatorze TASM nalezy po dyrektywie ".code" dopisac linie nizej
   ".386") odbywac sie wg schematu:
   zmienna [rej_baz + rej_ind * skala +- liczba] (tylko TASM/MASM)

   lub
   [ zmienna + rej_baz + rej_ind * skala +- liczba ]

   gdzie:
     * "zmienna" oznacza nazwe zmiennej i jest to liczba obliczana przez
       kompilator lub linker
     * rej_baz (rejestr bazowy) = jeden z rejestrow EAX, EBX, ECX, EDX,
       ESI, EDI, EBP, ESP
     * rej_ind (rejestr indeksowy) = jeden z rejestrow EAX, EBX, ECX,
       EDX, ESI, EDI, EBP (bez ESP)
     * mnoznik (scale) = 1, 2, 4 lub 8 (gdy nie jest podany, przyjmuje
       sie 1)

   Tak, tego schematu tez mozna uzywac w DOSie.
   Przyklady:
        mov     al, [ nazwa_zmiennej+2 ]
        mov     [ edi-23 ], cl
        mov     dl, [ ebx + esi*2 + nazwa_zmiennej+18 ]

   Na procesorach 64-bitowych odnoszenie sie do pamieci moze (w
   kompilatorze TASM nie jest to obslugiwane) odbywac sie wg schematu:
   zmienna [rej_baz + rej_ind * skala +- liczba] (tylko TASM/MASM)

   lub
   [ zmienna + rej_baz + rej_ind * skala +- liczba ]

   gdzie:
     * "zmienna" oznacza nazwe zmiennej i jest to liczba obliczana przez
       kompilator lub linker
     * rej_baz (rejestr bazowy) = jeden z rejestrow RAX, RBX, RCX, RDX,
       RSI, RDI, RBP, RSP, R8, ..., R15, a nawet RIP (ale wtedy nie mozna
       uzyc zadnego rejestru indeksowego)
     * rej_ind (rejestr indeksowy) = jeden z rejestrow RAX, RBX, RCX,
       RDX, RSI, RDI, RBP, R8, ..., R15 (bez RSP i RIP)
     * mnoznik (scale) = 1, 2, 4 lub 8 (gdy nie jest podany, przyjmuje
       sie 1)

   Tak, tego schematu tez mozna uzywac w DOSie.
   Dwie zasady:
     * miedzy nawiasami kwadratowymi nie mozna mieszac rejestrow roznych
       rozmiarow
     * w trybie 64-bitowym nie mozna do adresowania uzywac rejestrow
       czastkowych: R*D, R*W, R*B.

   Przyklady:
        mov     al, [ nazwa_zmiennej+2 ]
        mov     [ rdi-23 ], cl
        mov     dl, [ rbx + rsi*2 + nazwa_zmiennej+18 ]
        mov     rax, [rax+rbx*8-34]
        mov     rax, [ebx]
        mov     r8d, [ecx-11223344]
        mov     cx, [r8]

   A teraz inny przyklad: sprobujemy wczytac 5 elementow o numerach 1, 3,
   78, 25, i 200 (pamietajmy, ze liczymy od zera) z tablicy "zmienna"
   (tej o 234 bajtach, zadeklarowanej wczesniej) do kilku rejestrow
   8-bitowych. Operacja nie jest trudna i wyglada po prostu tak:
        mov     al, [ zmienna + 1 ]
        mov     ah, [ zmienna + 3 ]
        mov     cl, [ zmienna + 78 ]
        mov     ch, [ zmienna + 25 ]
        mov     dl, [ zmienna + 200 ]

   Oczywiscie, kompilator nie sprawdzi za Was, czy takie elementy tablicy
   rzeczywiscie istnieja - o to musicie zadbac sami.

   W powyzszym przykladzie rzuca sie w oczy, ze ciagle uzywamy slowa
   "zmienna", bo wiemy, gdzie jest nasza tablica. Jesli tego nie wiemy
   (dynamiczne przydzielanie pamieci), lub z innych przyczyn nie chcemy
   ciagle pisac "zmienna", mozemy posluzyc sie bardziej zlozonymi
   sposobami adresowania. Po chwili zastanowienia bez problemu
   stwierdzicie, ze powyzszy kod mozna bez problemu zastapic czyms takim
   (i tez bedzie dzialac):
        mov     bx, OFFSET zmienna      ; w NASMie/FASMie: "mov bx, zmienna"
        mov     al, [ bx + 1 ]
        mov     ah, [ bx + 3 ]
        mov     cl, [ bx + 78 ]
        mov     ch, [ bx + 25 ]
        mov     dl, [ bx + 200 ]

   Teraz trudniejszy przyklad: sprobujmy dobrac sie do kilku elementow
   dwuwymiarowej tablicy dwordow zadeklarowanej wczesniej (tej o
   rozmiarze 25 na 34). Mamy 25 wierszy po 34 elementy kazdy. Aby do EAX
   wpisac pierwszy element pierwszego wiersza, piszemy oczywiscie tylko:
        mov     eax, [Tablica]

   Ale jak odczytac 23 element 17 wiersza? Otoz, sprawa nie jest taka
   trudna, jakby sie moglo wydawac. Ogolny schemat wyglada tak (zakladam,
   ze ostatni wskaznik zmienia sie najszybciej, potem przedostatni itd. -
   pamietamy, ze rozmiar elementu wynosi 4):
        Tablica[17][23] = [ Tablica + (17*dlugosc_wiersza + 23)*4 ]

   No wiec piszemy (uzyjemy tutaj wygodniejszego adresowania
   32-bitowego):
                mov     ebx, OFFSET Tablica     ; w NASMie/FASMie:
                                                ; mov ebx, Tablica
                mov     esi, 17
        jakas_petla:
                imul    esi, 34         ; ESI = ESI * 34 =
                                        ; 17 * dlugosc wiersza
                add     esi, 23         ; ESI = ESI + 23 =
                                        ; 17 * dlugosc wiersza + 23
                mov     eax, [ ebx + esi*4 ]    ; mnozymy numer elementu
                                                ; przez rozmiar elementu
                ...

   Mozna bylo to zrobic po prostu tak:
        mov     eax, [ Tablica + (17*34 + 23)*4 ]

   ale poprzednie rozwiazanie (na rejestrach) jest wprost idealne do
   petli, w ktorej robimy cos z coraz to innym elementem tablicy.

   Podobnie ("(numer_wiersza*dlugosc_wiersza1 +
   numer_wiersza*dlugosc_wiersza2 + ... )*rozmiar_elementu") adresuje sie
   tablice wielowymiarowe. Schemat jest nastepujacy:
        Tablica[d1][d2][d3][d4]   - 4 wymiary o dlugosciach wierszy
                                    d1, d2, d3 i d4

        Tablica[i][j][k][m] = [ Tablica + (i*d2*d3*d4+j*d3*d4+k*d4+m)*
                                *rozmiar elementu ]

   Teraz powiedzmy, ze mamy taka tablice:
        dword tab1[24][78][13][93]

   Aby dobrac sie do elementu tab1[5][38][9][55], piszemy:
        mov     eax, [ tab1 + (5*78*13*93 + 38*13*93 + 9*93 + 55)*4 ]

   Pytanie: do jakich segmentow odnosi sie to cale adresowanie? Przeciez
   mamy kilka rejestrow segmentowych, ktore moga wskazywac na zupelnie co
   innego.
   Odpowiedz:
   Na rejestrach 16-bitowych obowiazuja reguly:
     * jesli pierwszym rejestrem jest BP, uzywany jest SS
     * w pozostalych przypadkach uzywany jest DS

   Na rejestrach 32-bitowych mamy:
     * jesli pierwszym w kolejnosci rejestrem jest EBP lub ESP, uzywany
       jest SS
     * w pozostalych przypadkach uzywany jest DS

   W systemach 64-bitowych segmenty odchodza w zapomnienie.
   Domyslne ustawianie mozna zawsze obejsc uzywajac przedrostkow, na
   przyklad
        ; TASM:
                mov     ax, ss:[si]
                mov     gs:[eax+ebx*2-8], cx

        ; NASM/FASM:
                mov     ax, [ss:si]
                mov     [gs:eax+ebx*2-8], cx
     _________________________________________________________________

Organizacja pamieci w komputerze

   Po zaladowaniu systemu DOS, pamiec wyglada z grubsza tak (niektore
   elementy zostana zaraz opisane) :
   (przeskocz ilustracje pamieci w DOSie)
        FFFFF   +-----------------------------------------------+
                |       Pamiec urzadzen, HMA, UMB, czesc BIOSu  |
        BFFFF   +-----------------------------------------------+
                |       Pamiec karty graficznej                 |
        A0000   +-----------------------------------------------+
                |                                               |
                ..              ...                             ..
                ..              ...                             ..
                |       Uruchamiane programy                    |
                +-----------------------------------------------+
                |                                               |
                ..              ...                             ..
                ..              ...                             ..
                |       DOS - jego kod, dane i stos             |
        ~500h   +-----------------------------------------------+
                |       BIOS Data Area (segment 40h)            |
        400h    +-----------------------------------------------+
                |       Tablica wektorow przerwan               |
        0       +-----------------------------------------------+

   Od segmentu A0000 zaczyna sie pamiec karty graficznej. Pamiec ta jest
   bezposrednim odwzorowaniem ekranu i piszac tam, zmieniamy zawartosc
   ekranu (wiecej o tym w innych artykulach). Po przeliczeniu A0000 na
   system dziesietny dostajemy 655360, czyli ... 640kB. Stad wzial sie
   ten slawny limit pamieci konwencjonalnej.

   Powyzej znajduje sie DOSowy Upper Memory Block i High Memory Area. Na
   samym koncu granic adresowania (czyli tuz pod 1MB) jest jeszcze
   skrawek BIOSu i to miejsce (a wlasciwie to adres FFFF:0000) jest
   punktem startu procesora tuz po wlaczeniu zasilania. W okolicach tego
   adresu znajduje sie instrukcja skoku, ktora mowi procesorowi, gdzie sa
   dalsze instrukcje.
   Od adresu zero zaczyna sie Tablica Wektorow Przerwan (Interrupt Vector
   Table, IVT), majaca 256 adresow procedur obslugi przerwan po 4 bajty
   (segment+offset) kazdy.
   Potem jest BIOS Data Area (segment 40h), powyzej - kod DOSa, a po nim
   miejsce na uruchamiane programy.

   Ale chwileczke! DOS nie moze korzystac z wiecej niz 1 MB pamieci? A co
   z EMS i XMS?
   Megabajt pamieci to wszystko, co moze osiagnac procesor 16-bitowy.
   Procesory od 80386 w gore sa co najmniej 32-bitowe, co daje laczna
   mozliwosc zaadresowania 2^32 = 4GB pamieci, o ile tylko jest tyle
   zainstalowane.
   Menadzery EMS i XMS sa to programy (napisane dla procesorow
   32-bitowych), ktore umozliwiaja innym programom dostep do pamieci
   powyzej 1 MB. Sam DOS nie musi miec az tyle pamieci, ale inne programy
   moga korzystac z dobrodziejstw wiekszych ilosci pamieci RAM. Zamiast
   korzystac z przerwania DOSa do rezerwacji pamieci, programy te
   korzystaja z interfejsu udostepnianego przez na przyklad HIMEM.SYS czy
   EMM386.EXE i udokumentowanego w spisie przerwan Ralfa Brown'a.
   O tym, jak korzystac z pamieci EMS i XMS, mozecie przeczytac tez w
   moim mini-kursie o pamieci EMS i XMS.

   Struktura pamieci dla poszczegolnych programow zalezy od ich typu. Jak
   pamietamy z czesci pierwszej, program typu .com miesci sie w jednym
   segmencie, wykonywanie zaczyna sie od adresu 100h (256. bajt), a
   wczesniej jest miedzy innymi linia polecen programu.
   Wyglada to tak:
   (przeskocz ilustracje pamieci programu COM)
                        +-----------------------+
                        |       CS:FFFF         |       - tu zaczyna sie stos
                        |   Stos, zm. lokalne   |
                        |   argumenty funkcji   |
                        |                       |
                        +-       .....         -+
                        |                       |
                        +-       .....         -+
                        |                       |
                        +-       .....         -+
                        | CS:100h poczatek kodu |
                        +-----------------------+
                        |                       |
        CS=DS=ES=SS     +-----------------------+

   Kod zaczyna sie od CS:100h, wszystkie rejestry segmentowe maja rowne
   wartosci. Od CS:FFFF zaczyna sie stos rosnacy oczywiscie w dol, wiec
   piszac taki program trzeba uwazac, by ze stosem nie wejsc na kod lub
   dane.

   Programy .exe maja nieco bardziej zlozona strukture. Kod zaczyna sie
   pod adresem 0 w danym, wyznaczonym przez DOS, segmencie. Ale rejestry
   DS i ES maja inna wartosc niz CS i wskazuja na wspomniane przy okazji
   programow .com 256 bajtow zawierajacych linie polecen programu itp.
   Dane programu, jesli zostaly umieszczone w kodzie w osobnym segmencie,
   tez moga dostac wlasny segment pamieci. Segment stosu zas jest
   calkowicie oddzielony od pozostalych, zwykle za kodem. Jego polozenie
   zalezy od rozmiaru kodu i danych. Jako ze programy .exe posiadaja
   naglowek, DOS nie musi przydzielac im calego segmentu. Zamiast tego,
   rozmiar segmentu kodu (i stosu) odczyta sobie z naglowka pliku.
   Graficznie wyglada to tak:
   (przeskocz ilustracje pamieci programu EXE)
                        +-----------------------+
                        |   Stos, zm. lokalne   |
                        |   argumenty funkcji   |
        SS              +-----------------------+

                        +-----------------------+
                        |  Dane, zm. globalne   |
                        |      (statyczne)      |
                        +-----------------------+

                        +-----------------------+
                        |       CS:xxxx         |
                        +-       .....         -+
                        |                       |
                        +-       .....         -+
                        |                       |
                        +-       .....         -+
                        |                       |
                        +-       .....         -+
                        |   CS:0 poczatek kodu  |
        CS              +-----------------------+

                        +-----------------------+
                        |                       |
        DS=ES           +-----------------------+
     _________________________________________________________________

Stos

   Przyszla pora na omowienie, czym jest stos.
   Otoz, stos jest po prostu kolejnym segmentem pamieci. Sa na nim
   umieszczane dane tymczasowe, na przyklad adres powrotny z funkcji, jej
   parametry wywolania, jej zmienne lokalne. Sluzy tez do zachowywania
   zawartosci rejestrow.
   Obsluga stosu jest jednak zupelnie inna.
   Po pierwsze, stos jest "budowany" od gory na dol! Rysunek bedzie
   pomocny:
   (przeskocz rysunek stosu)

        Adres
                        SS
                +-------------------+
        100h    |                   |
                +-------------------+   <----- SP = 100h
        0FEh    |                   |
                +-------------------+
        0FCh    |                   |
                +-------------------+
        0FAh    |                   |
                +-------------------+
        0F8h    |                   |
                +-------------------+
        0F6h    |                   |
        ...             ....

   Na tym rysunku SP=100h, czyli SP wskazuje na komorke o adresie 100h w
   segmencie SS.
   Dane na stosie umieszcza sie instrukcja PUSH a zdejmuje instrukcja
   POP. PUSH jest rownowazne parze pseudo-instrukcji:
        sub     sp, ..  ; rozmiar zalezy od rozmiaru obiektu w bajtach
        mov     ss:[sp], ..

   a POP:
        mov     .., ss:[sp]
        add     sp, ..

   Tak wiec, po wykonaniu instrukcji PUSH AX i PUSH DX powyzszy stos
   bedzie wygladal tak:
   (przeskocz rysunek dzialania stosu)
        Stos po wykonaniu PUSH AX i PUSH DX, czyli
                sub     sp, 2
                mov     ss:[sp], ax
                sub     sp, 2
                mov     ss:[sp], dx

                        SS
                +-------------------+
        100h    |                   |
                +-------------------+
        0FEh    |       AX          |
                +-------------------+
        0FCh    |       DX          |
                +-------------------+   <----- SP = 0FCh
        ...             ....

   SP=0FCh, pod [SP] znajduje sie wartosc DX, a pod [SP+2] - wartosc AX.
   A po wykonaniu instrukcji POP EBX (tak, mozna zdjac dane do innego
   rejestru, niz ten, z ktorego pochodzily):
   (przeskocz drugi rysunek dzialania stosu)
        Stos po wykonaniu POP EBX, czyli
                mov     ebx, ss:[sp]
                add     sp, 4

                        SS
                +-------------------+
        100h    |                   |
                +-------------------+   <----- SP = 100h
        0FEh    |       AX          |
                +-------------------+
        0FCh    |       DX          |
                +-------------------+
        ...             ....

   Teraz ponownie SP=100h. Zauwazcie, ze dane sa tylko kopiowane ze
   stosu, a nie z niego usuwane. Ale w zadnym przypadku nie mozna na nich
   juz polegac. Dlaczego? Zobaczycie zaraz.
   Najpierw bardzo wazna uwaga, ktora jest wnioskiem z powyzszych
   rysunkow.
   Dane (ktore chcemy z powrotem odzyskac w niezmienionej postaci)
   polozone na stosie instrukcja PUSH nalezy zdejmowac kolejnymi
   instrukcjami POP W ODWROTNEJ KOLEJNOSCI niz byly kladzione. Zrobienie
   czegos takiego:
        push    ax
        push    dx
        pop     ax
        pop     dx

   nie przywroci rejestrom ich dawnych wartosci!
     _________________________________________________________________

Przerwania i procedury a stos

   Uzywalismy juz instrukcji przerwania, czyli INT. Przy okazji omawiania
   stosu nadeszla pora, aby powiedziec, co ta instrukcja w ogole robi.
   Otoz, INT jest rownowazne temu pseudo-kodowi:
        pushf                   ; wloz na stos rejestr stanu procesora (flagi)
        push    cs              ; segment, w ktorym aktualnie pracujemy
        push    ip_next         ; adres instrukcji po INT
        jmp     procedura_obslugi_przerwania

   Kazda procedura obslugi przerwania (Interrupt Service Routine, ISR)
   konczy sie instrukcja IRET (interrupt return), ktora odwraca powyzszy
   kod, czyli z ISR procesor wraca do dalszej obslugi naszego programu.

   Jednak oprocz instrukcji INT przerwania moga byc wywolane w inny
   sposob - przez sprzet. Tutaj wlasnie pojawiaja sie IRQ. Do urzadzen
   wywolujacych przerwania IRQ naleza miedzy innymi karta dzwiekowa,
   modem, zegar, kontroler dysku twardego, itd...

   Bardzo istotna role gra zegar, utrzymujacy aktualny czas w systemie.
   Jak napisalem w jednym z artykulow, tyka on z czestotliwoscia ok. 18,2
   Hz. Czyli ok. 18 razy na sekunde wykonywane sa 3 PUSHe a po nich 3
   POPy. Nie zapominajmy o PUSH i POP wykonywanych w samej ISR tylko po
   to, aby zachowac modyfikowane rejestry. Kazdy PUSH zmieni to, co jest
   ponizej SP.

   Dlatego wlasnie zadne dane ponizej SP nie moga byc uznawane za
   wiarygodne.

   Gdzie zas znajduja sie procedury obslugi przerwan?
   W pamieci, pod adresami od 0000:0000 do 0000:03ff wlacznie znajduja
   sie czterobajtowe adresy (pary CS oraz IP) odpowiednich procedur. Jest
   ich 256.
   Pierwszy adres jest pod 0000:0000 - wskazuje on na procedure obslugi
   przerwania int 0
   Drugi adres jest pod 0000:0004 - int 1
   Trzeci adres jest pod 0000:0008 - int 2
   Czwarty adres jest pod 0000:000c - int 3
   ...
   255-ty adres jest pod 0000:03fc - int 0FFh
   W taki wlasnie sposob dziala mechanizm przerwan w DOSie.

   Mniej skomplikowana jest instrukcja CALL, ktora sluzy do wywolywania
   zwyklych procedur, na przyklad:
        call proc1              ; wywolanie proste
        call [adres_proc1]      ; wywolanie procedury, ktorej adres
                                ; jest w zmiennej adres_proc1
        ...
proc1:
        ...
        ret

   W zaleznosci od rodzaju procedury (near - zwykle w tym samym
   pliku/programie, far - na przyklad w innym pliku/segmencie),
   instrukcja CALL wykonuje takie cos:
        push    cs              ; tylko jesli FAR
        push    ip_next         ; adres instrukcji po CALL

   Procedura moze zawierac dowolne (nawet rozne ilosci instrukcji PUSH i
   POP), ale pod koniec SP musi byc taki sam, jak byl na poczatku, czyli
   wskazywac na prawidlowy adres powrotu, ktory ze stosu jest zdejmowany
   instrukcja RET (lub RETF). Dlatego nieprawidlowe jest takie cos:
        zla_procedura:
                push    ax
                push    bx
                add     ax, bx
                ret

   gdyz w chwili wykonania instrukcji RET na wierzchu stosu jest BX, a
   nie adres powrotny! Blad stosu jest przyczyna wielu trudnych do
   znalezienia usterek w programie.
   Jak to poprawic bez zmiany sensu? Na przyklad tak:
        dobra_procedura:
                push    ax
                push    bx
                add     ax, bx
                add     sp, 4
                ret

   Teraz juz wszystko powinno byc dobrze. SP wskazuje na dobry adres
   powrotny. Dopuszczalne jest tez takie cos:
   (przeskocz przyklad innej dobrej procedury)
        ; TASM:
        proc1   proc    near
                push    ax
                cmp     ax, 0           ; czy AX jest zerem?
                je      koniec1         ; jesli tak, to koniec1

                pop     bx
                ret
        koniec1:
                pop     cx
                ret
        proc1   endp

   (przeskocz ten sam przyklad w wersji NASM i FASM)
        ; NASM/FASM:
        proc1:                          ; bez PROC i NEAR
                push    ax
                cmp     ax, 0           ; czy AX jest zerem?
                je      koniec1         ; jesli tak, to koniec1

                pop     bx
                ret
        koniec1:
                pop     cx
                ret
        ; bez ENDP

   SP ciagle jest dobrze ustawiony przy wyjsciu z procedury mimo, iz jest
   1 PUSH a 2 POPy.
   Po prostu ZAWSZE nalezy robic tak, aby SP wskazywal na poprawny adres
   powrotny, niezaleznie od sposobu. W sklad tego wchodzi definiowanie
   procedur pod glownym programem (po ostatnich instrukcjach zamykajacych
   program). Dlaczego? Niektore (najprostsze) formaty plikow
   wykonywalnych nie pozwalaja na okreslenie poczatku programu i takie
   programy sa wykonywane po prostu z gory na dol. Jesli u gory kodu
   umiesci sie procedury, zostana one wykonane, po czym instrukcja RET
   (lub RETF) spowoduje zamkniecie programu (w najlepszym przypadku) lub
   wejscie procesora na nieprawidlowe lub losowe instrukcje w pamieci.
     _________________________________________________________________

Alokacja zmiennych lokalnych procedury

   Nie musi sie to Wam od razu przydac, ale przy okazji stosu omowie,
   gdzie znajduja sie zmienne lokalne funkcji (na przyklad takich w
   jezyku C) oraz jak rezerwowac na nie miejsce.

   Gdy program wykonuje instrukcje CALL, na stosie umieszczany jest adres
   powrotny (o czym juz wspomnialem). Jako ze nad nim moga byc jakies
   dane wazne dla programu (na przyklad zachowane rejestry, inne adresy
   powrotne), nie wolno tam nic zapisywac. Ale pod adresem powrotnym jest
   duzo miejsca i to tam wlasnie programy umieszczaja swoje zmienne
   lokalne.

   Samo rezerwowanie miejsca jest dosc proste: liczymy, ile lacznie
   bajtow nam potrzeba na wlasne zmienne i tyle wlasnie odejmujemy od
   rejestru SP, robiac tym samym miejsce na stosie, ktore nie bedzie
   zamazane przez instrukcje INT i CALL (gdyz one zamazuja tylko to, co
   jest pod SP).

   Na przyklad, jesli nasze zmienne zajmuja 8 bajtow (np.dwa DWORDy lub
   dwie 32-bitowe zmienne typu "int" w jezyku C), to odejmujemy te 8 od
   SP i nasz nowy stos wyglada tak:
                        SS
                +-------------------+
        100h    |  adres powrotny   |
                +-------------------+   <----- stary SP = 100h
        0FEh    |       wolne       |
                +-------------------+
        0FCh    |       wolne       |
                +-------------------+
        0FAh    |       wolne       |
                +-------------------+
        0F8h    |       wolne       |
                +-------------------+   <----- SP = 0F8h

   SP wynosi 0F8h, nad nim jest 8 bajtow wolnego miejsca, po czym adres
   powrotny i inne stare dane.3

   Nie trzeba podawac typow zmiennych lokalnych, ich liczby ani ich
   nazywac - wystarczy obliczyc ich laczny rozmiar i ten rozmiar odjac od
   SP. To, gdzie ktora zmienna faktycznie w pamieci sie znajdzie (lub
   inaczej: ktory obszar pamieci bedzie przypisany ktorej zmiennej),
   zalezy calkowicie od programisty - na przyklad [SP] moze przechowywac
   pierwsza zmienna, a [SP+4] - druga, ale moze byc tez calkiem na
   odwrot.

   Miejsce juz mamy, korzystanie z niego jest proste - wystarczy
   odwolywac sie do [SP], [SP+2], [SP+4], [SP+6]. Ale stanowi to pewien
   problem, bo po kazdym wykonaniu instrukcji PUSH lub POP, te cyferki
   sie zmieniaja (bo przeciez adresy sie nie zmieniaja, ale SP sie
   zmienia). Dlatego wlasnie do adresowania zmiennych lokalnych czesto
   uzywa sie innego rejestru niz SP. Jako ze domyslnym segmentem dla BP
   jest segment stosu, wybor padl wlasnie na ten rejestr (oczywiscie,
   mozna uzywac dowolnego innego, tylko trzeba dostawiac SS: z przodu, co
   kosztuje za kazdym razem 1 bajt).

   Aby moc najlatwiej dostac sie do swoich zmiennych lokalnych, wiekszosc
   funkcji na poczatku zrownuje BP z SP, potem wykonuje rezerwacje
   miejsca na zmienne lokalne, a dopiero potem - zachowywanie rejestrow
   itp. (czyli swoje PUSHe). Wyglada to tak:
        push    bp              ; zachowanie starego BP
        mov     bp, sp          ; BP = SP

        sub     sp, xxx         ; rezerwacja miejsca na zmienne lokalne
        push    rej1            ; tu SP sie zmienia, ale BP juz nie
        push    rej2
        ...

        ...
        pop     rej2            ; tu SP znow sie zmienia, a BP - nie
        pop     rej1

        mov     sp, bp          ; zwalnianie zmiennych lokalnych
                                ;   mozna tez (ADD SP,xxx)
        pop     bp

        ret

   Niektore kompilatory umozliwiaja deklaracje procedury z parametrami,
   zmiennymi lokalnymi i ich typami:
        proc2 proc a:DWORD,b:DWORD
                LOCAL c:DWORD
                LOCAL d:DWORD
                LOCAL e:DWORD
                ...
                ret
        proc2 endp

   Mozna wtedy odwolywac sie do parametrow i zmiennych lokalnych przez
   ich nazwy, zamiast przez wyrazenia typu [SP+nnn] i [SP-nnn].

   Przy instrukcji MOV SP, BP napisalem, ze zwalnia ona zmienne lokalne.
   Zmienne te oczywiscie dalej sa na stosie, ale teraz sa juz ponizej SP,
   a niedawno napisalem: zadne dane ponizej SP nie moga byc uznawane za
   wiarygodne.

   Po pieciu pierwszych instrukcjach nasz stos wyglada tak:
                           SS
                +-----------------------+
                |    adres powrotny     |
                +-----------------------+
                |       stary BP        |
                +-----------------------+       <----- BP
                |      xxx bajtow       |
                |                       |
                |                       |
                +-----------------------+
                |         rej1          |
                +-----------------------+
                |         rej2          |
                +-----------------------+       <----- SP

   Rejestr BP wskazuje na stara wartosc BP, zas SP - na ostatni element
   wlozony na stos.
   I widac teraz, ze zamiast odwolywac sie do zmiennych lokalnych poprzez
   [SP+liczba] przy ciagle zmieniajacym sie SP, o wiele wygodniej
   odwolywac sie do nich przez [BP-liczba] (zauwazcie: minus), bo BP
   pozostaje niezmienione.

   Czesto na przyklad w disasemblowanych programach widac instrukcje typu
   AND SP, NOT 16 (lub AND SP, ~16 w skladni NASM). Jedynym celem takich
   instrukcji jest wyrownanie SP do pewnej pozadanej granicy, na przyklad
   16 bajtow (wtedy AND z wartoscia NOT 16, czyli FFFFFFF0h), zeby dostep
   do zmiennych lokalnych trwal krocej. Gdy adres zmiennej na przyklad
   czterobajtowej jest nieparzysty, to potrzeba dwoch dostepow do
   pamieci, zeby ja cala pobrac (bo mozna pobrac 32 bity z na raz w
   procesorze 32-bitowym i tylko z adresu podzielnego przez 4).

   Ogol danych: adres powrotny, parametry funkcji, zmienne lokalne i
   zachowane rejestry nazywany jest czasem ramka stosu (ang. stack
   frame).
   Rejestr BP jest czasem nazywany wskaznikiem ramki, gdyz umozliwia od
   dostep do wszystkich istotnych danych poprzez stale przesuniecia
   (offsety, czyli te liczby dodawane i odejmowane od BP): zmienne
   lokalne sa pod [BP-liczba], parametry funkcji przekazane z zewnatrz -
   pod [BP+liczba], zas pod [BP] jest stara wartosc BP. Jesli wszystkie
   funkcje w programie zaczynaja sie tym samym prologiem: PUSH BP / MOV
   BP, SP, to po wykonaniu instrukcji MOV BP, [BP] w BP znajdzie sie
   wskaznik ramki ... procedury wywolujacej. Jesli znamy jej strukture,
   mozna w ten sposob dostac sie do jej zmiennych lokalnych.
     _________________________________________________________________

   Zainteresowanych szczegolami adresowania lub instrukcjami odsylam do
   Intela lub AMD

   Nastepnym razem o podstawowych instrukcjach jezyka asembler.

     - Ilu programistow potrzeba, aby wymienic zarowke?
     - Ani jednego. To wyglada na problem sprzetowy.

   Poprzednia czesc kursu (klawisz dostepu 3)
   Kolejna czesc kursu (klawisz dostepu 4)
   Spis tresci off-line (klawisz dostepu 1)
   Spis tresci on-line (klawisz dostepu 2)
   Ulatwienia dla niepelnosprawnych (klawisz dostepu 0)
     _________________________________________________________________

Cwiczenia

    1. Zadeklaruj tablice 12 zmiennych majacych po 10 bajtow:
         1. zainicjalizowana na zera (pamietaj o ograniczeniach
            kompilatora)
         2. niezainicjalizowana
    2. Zadeklaruj tablice 12 slow (16-bitowych) o wartosci BB
       (szesnastkowo), po czym do kazdego z tych slow wpisz wartosc FF
       szesnastkowo (bez zadnych petli). Mozna (a nawet trzeba) uzyc
       wiecej niz 1 instrukcji. Pamietaj o odleglosciach miedzy
       poszczegolnymi elementami tablicy. Naucz sie roznych sposobow
       adresowania: liczba (nazwa zmiennej + numer), baza (rejestr bazowy
       + liczba), baza + indeks (rejestr bazowy + rejestr indeksowy).
    3. Zadeklaruj dwuwymiarowa tablice bajtow o wartosci 0 o wymiarach 13
       wierszy na 5 kolumn, po czym do elementu numer 3 (przedostatni) w
       wierszu o numerze 12 (ostatni) wpisz wartosc FF. Sprobuj uzyc
       roznych sposobow adresowania.
