Wikipedysts:Doles/Brudnopis/Prototyp/Windows/Zmienne i pamiec

Zmienne edytuj

W lekcji nauczymy się jak tworzyć zmienne w Asemblerze oraz jak przemieszczać je pomiędzy różnymi komórkami pamięci czy rejestrami. Wyjaśnimy sobie też co nieco na temat stosu oraz rozkazów push i pop.

Zmienna jest to obszar pamięci zawierający pewne dane. Tak brzmi najkrótsza definicja tego terminu. O sposobie prezentacji decyduje typ zmiennej, innymi słowy w językach wysokiego poziomu jak C zmienna typu char zajmuje 1 bajt pamięci i przechowuje dokładnie jeden znak. W takich językach zmienne tworzy się na podstawie typu danych, które mają przechowywać: znaki, ciągi znaków, liczby całkowite, liczby rzeczywiste. W Asemblerze zmienne wymagają innego podejścia. Tutaj nie typ a rozmiar decyduje o tym co dana zmienna będzie zawierać. Możemy zadeklarować zmienną o rozmiarze 1 bajta, która może przechowywać zarówno znaki od a(A) do z(Z), ale również liczbę od 0 do 255 jeśli będzie liczbą bez znaku (dodatnią). Równie dobrze, możemy użyć zmiennej czterobajtowej aby zapisać w niej słowo "MAMA" lub pewien numer, przykładowo 8943843. Asembler chce tylko wiedzieć ile miejsca sobie życzymy w segmencie danych dla naszej zmiennej. Od programisty zależy, co tam będzie się znajdować i jak chce to wykorzystać, Asembler tutaj nie ingeruje. Poniższa tabela prezentuje mnemoniki służące za deklarowanie odpowiednio dużej zmiennej:

Mnemonik Opis Przykład
db deklaruj bajt znak db 'R'
dw deklaruj słowo (dwa bajty) liczba dw 45600
dd deklaruj dwusłowo (cztery bajty) zmienna dd 4387839342
dq deklaruj poczwórne słowo (osiem bajtów) napis dq "MojNapis"

Pierwszy program w tym rozdziale będzie prezentował jak przenosimy dane z pamięci do rejestrów oraz jakie operacje możemy na nich wykonać. Może i pierwszy programik wydaje Ci się długi i trudny, ale powolutku sobie go przeanalizujemy oraz wyjaśnimy wszystko abyś poczuł się pewnie używając zmiennych w Asemblerze.

Program nr 1: Przenoszenie danych i ich wyświetlanie edytuj

Oto i kod źródłowy. Program nic nie robi, oprócz wyświetlenia w jednej linijce kilku liczb oraz cyfr. Z pozoru to nic, jednak trzeba mieć na uwadze zastosowane techniki przenoszenia danych oraz ich reprezentacji na ekranie konsoli. Przeczytaj kod i zabierzmy się do jego analizy:

.386
.model flat, stdcall
option casemap:none

include	\masm32\include\kernel32.inc
include \masm32\include\user32.inc
include \masm32\include\masm32.inc

includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
includelib \masm32\lib\masm32.lib

.data
dziewiec	db	9,0
trzy		db	3,0
siedem		db	7,0
dwa			db	2,0
literka_A	db	"A",0
literka_L	db	"L",0
napis		db	"NAPIS",0

.data?
klawisz		db	?

.code
_start:
	;czyszczenie rejestrow 32 bitowych
	mov  eax, 0
	mov  ebx, 0
	mov  ecx, 0
	mov  edx, 0
	
	;zaczynamy zabawe
	mov	 ax, word ptr [dziewiec]
	mov	 bx, ax
	mov  cx, bx
	
	add	 cl, 30h				  ;to jest kluczowa sprawa
	mov  word ptr [dziewiec], cx
	push offset dziewiec
	call StdOut
	
	mov  dx, word ptr [siedem]
	mov  si, dx
	mov  di, si
	mov  cx, di
	mov  word ptr [dziewiec], cx
	add  byte ptr [dziewiec], 30h
	mov  word ptr [siedem], "00"
	push offset dziewiec
	call StdOut
	

	
	;ciekawy trik z uzyciem rozkazu XCHG
	mov	 bx, word ptr [dwa]
	mov	 cx, word ptr [trzy]
	xchg cx, bx
	mov  word ptr [dwa], bx
	mov  word ptr [trzy],cx
	
	add  byte ptr [dwa], 30h
	add  byte ptr [trzy],30h
	push offset dwa
	call StdOut
	push offset trzy
	call StdOut
	
	;pozostały tylko literki, wyświetlimy je bez niczego
	push offset literka_A
	call StdOut
	push offset literka_L
	call StdOut
	
	mov  eax, offset napis
	add  eax, 3
	push eax
	call StdOut
	
	push 1
	push offset klawisz
	call StdIn
	
	push 0
	call ExitProcess
end _start

Poniżej znajduje się przykładowy program, który w celach demonstracyjnych wyświetli po prostu wynik pewnego prostego dodawania, adres zmiennej oraz jeden znak. Poznamy też jedną nową funkcję o nazwie wsprintf. O tym co ona robi oraz dlaczego jej potrzebujemy zaraz się dowiemy. Oto i program:

.386
.model flat, stdcall
option casemap:none

include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
include \masm32\include\user32.inc
include \masm32\include\masm32.inc

includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
includelib \masm32\lib\masm32.lib

.data
liczba1	 			 dd	67
liczba2  			 dd	38495
znak	 			 db "A",0
napis_poczatkowy	 db "Suma wynosi: %d",10,0
napis_adres	         db "Adres zmiennej to: %d",10, 0

.data?
napis_koncowy1		 db 20 dup(?)
napis_koncowy2		 db 20 dup(?)

.code
_start:
	mov	eax, offset liczba1
	
	;przygotowujemy wywolanie funkcji wsprintf
	push eax
	push offset napis_adres
	push offset napis_koncowy1
	call wsprintf
	
	;wyświetlamy sformatowany tekst przy pomocy StdOut
	push offset napis_koncowy1
	call StdOut
	
	;teraz dodamy do siebie obie liczby
	push [liczba1]
	pop  eax
	add  eax, [liczba2]
	
	;przygotowujemy wywolanie wsprintf
	push eax
	push offset napis_poczatkowy
	push offset napis_koncowy2
	call wsprintf
	
	;ponownie StdOut
	push offset napis_koncowy2
	call StdOut
	
	;teraz odlozymy na stos zawartosc literki oraz wyswietlimy
	push offset znak
	call StdOut
	
	push 0
	call ExitProcess
end _start

Analiza edytuj

Zacznijmy od segmentu danych, bowiem pojawiły się tam dość nietypowe zmienne. Przytoczę fragment kodu:

.data
liczba1	 		 dd	67
liczba2  	         dd	38495

Zmienne liczba1 oraz liczba2 są obszarami w pamięci, każdy kolejno o długości 4 bajtów (słówko kluczowe dd). To nic, że zmienna liczba1 ma wartość 67 co w zupełności zmieściłoby się w jednym bajcie, podobnie liczba2 której wartość jest mniejsza niż 65356 (na taką zmienną wystarczy dwusłowo). Domyślnie procesor działający w trybie chronionym używa rejestrów 32-bitowych zatem "najbardziej lubi" zmienne czterobajtowe. Możemy użyć oczywiście zmiennych skrojonych na miarę, ale dla uproszczenia programu zabierzemy wiecej miejsca na nasze liczby, niż faktycznie one potrzebują. Teraz czas na nowe napisy.

znak	 	         db     "A",0
napis_poczatkowy	 db "Suma wynosi: %d",10,0
napis_adres	         db "Adres zmiennej to: %d",10, 0

Zmienna znak jest de facto zmienną dwubajtową, pomimo słówka db. Dlaczego tak ? Dlatego, że wiekszość funkcji WinAPI potrzebuje ciągów napisów zakończonych zerem. Nie uznają po prostu pojedyńczego znaku, nawet takie coś traktują jak jednowyrazowy ciąg. Aby uczynić zadość, trzeba dodać znak "0" na koniec literki. Otrzymujemy w ten sposób ciąg o dwóch znakach (jeśli ktoś lubi matematyczne terminy - ciąg o dwóch wyrazach). To nic, że my chcemy wyświetlić tylko znak "A", musimy dodać także zero na koniec albo nie wyświetlimy znaku w ogóle (tudzież znak z pewnymi śmieciami). Nieco dziwny może być napis o nazwie napis_poczatkowy. Deklarujemy ciąg znaków zakończony liczbą 10 oraz 0. Do czego służy 0 już wiesz. Natomiast w tablicy kodów ASCII 10 stoi za znakiem nowej linii. Dla nas - programistów - to jest po prostu dziesiątka, ale konsola uzna to za nową linię (enter). W napisie pojawiła się także dziwna konstrukcja: %d. Jest to tzw znak formatujący dla funkcji wsprintf. Znaki formatujące polegają na tym, że w napisie wstawiamy pewien znacznik (jak %d) a funkcja wsprintf podstawi w jego miejsce pewne, inne dane. Symbol %d mówi wsprintf, że zamiast tego znaku ma wstawić liczbę dziesiętną. Podobnie jest z napisem napis_adres. On także zostanie sformatowany przez wsprintf, tak by zawierały w sobie pewną liczbę. W pierwszym przypadku będzie to wynik dodawania, zaś w drugim adres komórki pamięci. Mamy też pewne dodatkowe dwa napisy pomocnicze w segmencie danych niezainicjalizowanych:

.data?
napis_koncowy1		 db 20 dup(?)
napis_koncowy2		 db 20 dup(?)

Funkcja wsprintf tylko formatuje, niczego nie wyświetla. Podajesz jej jako argumenty napis wejściowy służący do "obróbki", oraz dane które chcesz "wrzucić" w ten napis oraz napis który będzie wynikiem pracy tejże funkcji. Dlatego definiujemy sobie dwa pomocnicze (właściwie wyjściowe) ciągi znaków o długości 20 bajtów. Ciekawy jest tutaj operator dup(). Jest to operator (właściwie dyrektywa) asemblera, nie zaś rozkaz procesora. Dup() (ang. duplicate - duplikuj) służy do zduplikowania (skopiowania) pewnej wartości. Dzięki temu jednym prostym słówkiem możemy powiedzieć asemblerowi, aby zadeklarował ciąg bajtów w pamięci o pewnej długości i od razu wypełnić ją daną liczbą. W tym przypadku dup() skopiuje 20 razy w pamięci jeden bajt (słówko db) który będzie niezainicjalizowany (znaczek ?). Gdybyśmy użyli dup(4) nasz napis_koncowy1 byłby złożony z dwudziestu bajtów, a każdy z nich miałby liczbę 4. Resztę magii poznasz przy omówieniu funkcji wsprintf. To tyle jeśli chodzi o zmienne, czas na kod.

	mov	eax, offset liczba1
	
	;przygotowujemy wywolanie funkcji wsprintf
	push eax
	push offset napis_adres
	push offset napis_koncowy1
	call wsprintf
	
	;StdOut
	push offset napis_koncowy1
	call StdOut

Poniższy fragment kodu ma za zadanie obliczyć adres zmiennej liczba1, następnie wyświetlić go na ekranie konsoli. Można by użyć już klasycznie takie oto listy kroków:

  1. Pobierz adres zmiennej liczba1 przy pomocy operatora offset.
  2. Wywołaj funkcję wsprintf aby sformatować napis, w którym będzie adres zmiennej.
  3. Sformatowany już napis wyświetl na ekranie.

Rozkazem...

mov eax, offset liczba1

...ładujemy rejestr EAX adresem zmiennej liczba1. Będzie nam on potrzebny oczywiście do wyświetlenia go na ekranie. Teraz czas na sformatowanie napisu, zanim jednak go omówimy, opiszę działanie funkcji wsprintf.

Funkcja tak jak wiele razy już wspomniałem formatuje dany napis i umieszcza wynik swojej pracy w innym miejscu. Jednak takiej funkcji musimy pomóc, czyli w formatowanym napisie muszą być specjalne znaki, które powiedzą wsprintf co ma wstawić w ich miejsce. Oto i deklaracja wsprintf w języku C w WinAPI:

int wsprintf(      
    LPTSTR lpOut,
    LPCTSTR lpFmt,
     ...
);

Pierwszy argument o nazwie lpOut jest wskaźnikiem na napis wyjściowy, gdzie zostanie umieszczony sformatowany tekst. Innymi słowy wsprintf chce tutaj adres napisu końcowego. Drugi argument lpFmt jest wskaźnikiem (adresem) na ciąg znaków do sformatowania. Ostatni argument będący trzema magicznymi kropkami oznacza zmienną liczbę argumentów. Taki zapis mówi, że funkcja tutaj może przyjąć jeszcze jeden argument (łacznie 3) albo jeszce dwa (łącznie 4) albo dowolną inną. Wszystko zależy od tego ile jest znaków formatujących w napisie lpFmt. Jeżeli są w nim dwa znaki formatujące - przykładowo dwa razy %d - wówczas podamy funkcji dwie liczby w ramach wielokropka. Brzmii to nieco skomplikowanie, ale zasada działania wsprintf jest bardzo prosta. Zwróc uwage na ten przykład:

"Ala ma %d lat, a Jacek %d",0

To jest przykład ciągu do sformatowania. Pamiętając o konwencji STDCALL pseudokod w Asemblerze wyglądałby tak:

odłóż na stos wiek Jacka
odłóż na stos wiek Ali
odłóż na stos adres ciągu do sformatowania
odłóż na stos adres ciągu wyjściowego

Inny przykład to taki napis:

"Polski alfabet zaczyna się na literkę %c, kończy na literkę %c i ma %d liter"

Korzystając z fragmentu kodu w Asemblerze, sformatowanie i wyświetlenie wyglądałoby mniej wiecej tak:

 push  [liczbaliter]
 push  [literka_Z]
 push  [literka_A]
 push  offset napis_do_sformatowania
 push  offset napis_sformatowany
 call  wsprintf

Mam nadzieję, że teraz rozumiesz jak działa wsprintf. Wyjaśnię tylko w tabelce podstawowe znaki formatujące:

Znak Opis
%d Podstaw liczbę dziesiętną
%c Podstaw pojedyńczy znak
%s Podstaw ciąg znaków
%x Podstaw liczbę heksadecymalną

Myślę, że po tym wszystkim co teraz przeczytałeś (albo i przeczytałaś) ten fragment kodu nie sprawi Ci problemu:

 mov	eax, offset liczba1
	
 ;przygotowujemy wywolanie funkcji wsprintf
 push eax
 push offset napis_adres
 push offset napis_koncowy1
 call wsprintf

 ;StdOut
 push offset napis_koncowy1
 call StdOut

Oczywiście sformatowany napis wyświetlamy przy pomocy funkcji StdOut. Teraz czas na małą zabawę z dodawaniem liczby zapisanej w pamięci. Wykorzystamy rozkaz add aby dodać do siebie dwie liczby, jednak zanim to zrobimy będziemy je sobie troszkę przenościć pomiędzy segmentem danych a stosem czy rejestrami. Oto i zastrzyk kodu:

 push [liczba1]
 pop  eax
 add  eax,[liczba2]

Obie wartości są zapisane w zmiennych liczba1 oraz liczba2. Najpierw odkładamy rozkazem push zawartość zmiennej liczba1 na stos. Odkładamy tylko po to aby zdjąć ją ze stosu przy pomocy pop. Pamiętasz zapewne jak wygląda budowa stosu, oraz jak on działa. Przypomnę tylko, że procesor "wie" co wrzucić do EAX. Odłożyliśmy na sam szczyt stosu (zresztą w inne miejsce przy pomocy PUSH się nie da) wartość liczby liczba1. Ta wartość jest na szczycie stosu zaś rozkaz pop zdejmuje z szczytu stosu to co się tam znajduje i umieszcza w wybranej lokacji. W naszym przypadku na szczycie była pewna liczba i zostaje ona zdjęta oraz odłożona do EAX. Co to znaczy zdjęta i odłożona ? Mianowicie tyle, że rozkaz pop dodaje do wskaźnika ESP (domyślnie) 4 bajty. Innymi słowy wskaźnik ESP zawiera adres o 4 bajty większy niż wcześniej pokazywał, zatem ESP nie pokazuje już na szczyt stosu, lecz na jego przedostatni element. Od tego momentu de facto ten przedostatni element jest już nowym szczytem. Wartość z starego szczytu przedtem jest kopiowana w wybrane miejsce. Na tym własnie polega zdjęcie czegoś z stosu w Asemblerze.

Tak zdjętą liczbą dodajemy do drugiej przy pomocy rozkazu add. W następnym rozdziale opowiemy sobie o podstawach matematyki w Asemblerze, jednak tutaj powiem tylko, że add dodaje do siebie operand numer 1 i 2, zaś wynik dodawania umieszcza w operandzie nr 1. Zatem nasz zapis:

 add eax, [liczba2]

można przetłumaczyć w ten sposób: Dodaj do siebie zawartość rejestru EAX i tego co znajduje się w komórkach pamięci zmiennej liczba2, następnie zapisz wynik w rejestrze EAX, zamazując jego starą wartość.

Jako, że teraz już mamy sumę dwóch liczb, to musimy jeszce sformatować napis z tą liczbą oraz go wyświetlić. Myślę, że nie ma sensu tłumaczyć jak działa ten fragment kodu, ponieważ jest absolutnie analogiczny jak ten parę akapitów temu:

;przygotowujemy wywolanie wsprintf
push eax
push offset napis_poczatkowy
push offset napis_koncowy2
call wsprintf
 
;ponownie StdOut
push offset napis_koncowy2
call StdOut

Na wszelki wypadek powiem bardzo krótko jak to działa. W EAX jest liczba którą podstawimy pod znak formatowania w napisie napis_poczatkowy, wynik formatowania zaś będzie w zmiennej napis_koncowy2 (drugi przygotowany bufor, pierwszy był na wyświetlenie adresu). Gdy nasz bufor jest sformatowany czas go wyświetlić przy pomocy StdOut.

Na sam koniec terminujemy (kończymy) naszą aplikację poniższym kodem:

push 0
call ExitProcess

Oczywiście kodem błędu jest liczba 0 co w praktyce - paradoksalnie - oznacza brak błędu (program zakończony pomyślnie).