Jak pisać programy w języku asembler pod Linuksem?

Część 2 - Pamięć, czyli gdzie upychać coś, co się nie mieści w procesorze

Poznaliśmy już rejestry procesora. Jak widać, jest ich ograniczona liczba i nie mają one zbyt dużego rozmiaru. Rejestry ogólnego przeznaczenia są co najwyżej 32-bitowe (czterobajtowe). Dlatego często programista musi niektóre zmienne umieszczać w pamięci. Przykładem tego był napis, który wyświetlaliśmy w poprzedniej części artykułu. Był on zadeklarowany dyrektywą DB, co oznacza declare byte. Ta dyrektywa niekoniecznie musi deklarować dokładnie 1 bajt. Tak jak widzieliśmy, można nią deklarować napisy lub kilka bajtów pod rząd. Teraz omówimy rodzinę dyrektyw służących właśnie do rezerwowania pamięci.

Ogólnie, zmienne można deklarować jako bajty (dyrektywą DB, coś jak char w języku C), słowa (word = 16 bitów = 2 bajty, coś jak short w C) dyrektywą DW, podwójne słowa DD (double word = dword = 32bity = 4 bajty, jak long w C), potrójne słowa pword = 6 bajtów - PW, poczwórne słowa DQ (quad word = qword = 8 bajtów, typ long long), tbyte = 10 bajtów - DT (typ long double w C).

Sekcja kodu jest tylko do odczytu, więc zmienne, które chcemy móc rzeczywiście zmienić, musimy umieścić w sekcji danych. Od tej pory umawiamy się więc, że każda zmienna znajduje się w obszarze section .data (dla NASMa) lub segment readable writeable (dla FASMa).

Przykłady:


(przeskocz przykłady)
	section .data
	; FASM: segment readable writeable

	dwa		db 2
	szesc_dwojek	db 2, 2, 2, 2, 2, 2 ; tablica sześciu bajtów
	litera_g	db "g"
	_ax		dw 4c00h	; dwubajtowa liczba całkowita
	alfa		dd 12348765h	; czterobajtowa liczba całkowita

	;liczba_a	dq 1125		; ośmiobajtowa liczba całkowita.
					; FASM przyjmie, NASM
					; starszy niż wersja 2.00 nie.

					; dla NASMa zamienimy to na
					; postać równoważną: 2 razy
					; po 4 bajty:
	liczba_a	dd 1125, 0

	liczba_e	dq 2.71		; liczba zmiennoprzecinkowa
					; podwójnej precyzji (8 bajtów),

				; dziesięciobajtowa liczba całkowita:
	;duza_liczba  	dt 6af4aD8b4a43ac4d33h
				; FASM ani NASM tego nie przyjmie.
				; Zrobimy to tak:
	duza_liczba	dd 43ac4d33h, 0f4aD8b4ah; czemu z zerem z przodu?
						; Czytaj dalej
			db 6ah

	pi		dt 3.141592	; FASM i NASM

	;nie_init	db ?	; nie zainicjalizowany bajt.
				; Wartość nieznana. NASM ani FASM tak
				; tego nie przyjmie. Należy użyć:

	nie_init	resb 1		; NASM
	;nie_init	rb 1		; FASM

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

Zwróćcie uwagę na sposób rozbijania dużych liczb na poszczególne bajty: najpierw deklarowane są młodsze bajty, a potem starsze (na przykład dd 11223344h = db 44h, 33h, 22h, 11h). To działa, gdyż procesory Intela i AMD (i wszystkie inne klasy x86) są procesorami typu little-endian, co znaczy, że najmłodsze bajty danego ciągu bajtów są umieszczane przez procesor w najniższych adresach pamięci. Dlatego my też tak deklarujemy nasze zmienne.

Ale z kolei takie coś:

	beta	db aah

nie podziała. Dlaczego? KAŻDA liczba musi zaczynać się od cyfry. Jak to obejść? Tak:

	beta	db 0aah

czyli poprzedzić zerem.

Nie podziała również to:

	0gamma	db 	9

Dlaczego? Etykiety (dotyczy to tak danych, jak i kodu programu) nie mogą zaczynać się od cyfr.

Zapisanie kilku wartości po dyrektywie Dx (DB, DW, DD, i tak dalej) automatycznie tworzy tablicę elementów odpowiedniego rozmiaru o tych wartościach, z których każda następna jest tuż po poprzedniej w pamięci. Na przykład, następująca dyrektywa tworzy tak naprawdę tablicę sześciu bajtów o wartości 2, a nie próbuje z sześciu dwójek utworzyć wartość, którą potem umieści w pojedynczym bajcie:

	szesc_dwojek	db 2, 2, 2, 2, 2, 2

A co, jeśli chcemy zadeklarować zmienną, powiedzmy, składającą się z 234 bajtów równych zero? Trzeba je wszystkie napisać?
Ależ skąd! Należy użyć operatora TIMES. Odpowiedź na pytanie brzmi:

	section .data

	zmienna		TIMES	 234	db	0
	nazwa			liczba   typ    co zduplikować

lub, w składni FASMa:

	segment readable writeable

	; 234 razy zarezerwuj bajt wartości 0:
	zmienna2:	times	 234	db	0

Rezerwacja obszaru bez określania jego wartości wyglądałaby mniej więcej tak:

	section .data
	; FASM: segment readable writeable

	zmienna		resb	234		; NASM
	zmienna2	rb	234		; FASM

A co, jeśli chcemy mieć dwuwymiarową tablicę podwójnych słów o wymiarach 25 na 34?
Robimy dla NASMa na przykład tak:

	section .data

	Tablica		times	25*34	dd	0

a dla FASMa:

	segment readable writeable

	; 25*34 razy zarezerwuj dword wartości 0:
	Tablica2:	times	25*34	dd 	0

Do obsługi takich tablic przydadzą się bardziej skomplikowane sposoby adresowania zmiennych. O tym za moment.

Zmiennych trzeba też umieć używać.
Do uzyskania adresu danej zmiennej używa się nazwy tej zmiennej, tak jak widzieliśmy wcześniej. Zawartość zmiennej otrzymuje się poprzez umieszczenie jej nazwy w nawiasach kwadratowych. Oto przykład:

	section .data
	; FASM: segment readable writeable

	rejestr_eax	dd	1
	rejestr_bx	dw	0
	rejestr_cl	db	0
	...
		mov	[rejestr_bx], bx
		mov	cl, [rejestr_cl]
		mov	eax, [rejestr_eax]
		int	80h

Zauważcie zgodność rozmiarów zmiennych i rejestrów.
Możemy jednak mieć problem w skompilowaniu czegoś takiego:

	mov	[jakas_zmienna], 2

Dlaczego? Kompilator wie, że gdzieś zadeklarowaliśmy jakas_zmienna, ale nie wie, czy było to

	jakas_zmienna	db	0

czy

	jakas_zmienna	dw	22

czy może

	jakas_zmienna	dd	"g"

Chodzi o to, aby pokazać, jaki rozmiar ma obiekt docelowy. Nie będzie problemów, gdy napiszemy:

	mov	word [jakas_zmienna], 2

I to obojętnie, czy zmienna była bajtem (wtedy następny bajt będzie równy 0), czy słowem (wtedy będzie ono miało wartość 2) czy może podwójnym słowem lub czymś większym (wtedy 2 pierwsze bajty zostaną zmienione, a pozostałe nie). Dzieje się tak dlatego, że zmienne zajmują kolejne bajty w pamięci, najmłodszy bajt w komórce o najmniejszym adresie. Na przykład:

	xxx	dd	8

jest równoważne:

	xxx	db	8,0,0,0

oraz:

	xxx	db	8
		db	0
		db	0
		db	0

Te przykłady nie są jedynymi sposobami adresowania zmiennych (poprzez nazwę). Na procesorach 32-bitowych (od 386) odnoszenie się do pamięci może odbywać się wg schematu:


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

gdzie:

Przykłady:

	mov	al, [ nazwa_zmiennej+2 ]
	mov	[ edi-23 ], cl
	mov	dl, [ ebx + esi*2 + nazwa_zmiennej+18 ]

Na procesorach 64-bitowych odnoszenie się do pamięci może odbywać się wg schematu:


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

gdzie:

Dwie zasady:

Przykłady:

	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 przykład: spróbujemy wczytać 5 elementów o numerach 1, 3, 78, 25, i 200 (pamiętajmy, że liczymy od zera) z tablicy zmienna (tej o 234 bajtach, zadeklarowanej wcześniej) do kilku rejestrów 8-bitowych. Operacja nie jest trudna i wygląda po prostu tak:

	mov	al, [ zmienna + 1 ]
	mov	ah, [ zmienna + 3 ]
	mov	cl, [ zmienna + 78 ]
	mov	ch, [ zmienna + 25 ]
	mov	dl, [ zmienna + 200 ]

Oczywiście, kompilator nie sprawdzi za Was, czy takie elementy tablicy rzeczywiście istnieją - o to musicie zadbać sami.

W powyższym przykładzie rzuca się w oczy, że ciągle używamy słowa zmienna, bo wiemy, gdzie jest nasza tablica. Jeśli tego nie wiemy (dynamiczne przydzielanie pamięci), lub z innych przyczyn nie chcemy ciągle pisać zmienna, możemy posłużyć się bardziej złożonymi sposobami adresowania. Po chwili zastanowienia bez problemu stwierdzicie, że powyższy kod można bez problemu zastąpić czymś takim i też będzie działać:

	mov	ebx, zmienna
	mov	al, [ ebx + 1 ]
	mov	ah, [ ebx + 3 ]
	mov	cl, [ ebx + 78 ]
	mov	ch, [ ebx + 25 ]
	mov	dl, [ ebx + 200 ]

Teraz trudniejszy przykład: spróbujmy dobrać się do kilku elementów dwuwymiarowej tablicy dwordów zadeklarowanej wcześniej (tej o rozmiarze 25 na 34). Mamy 25 wierszy po 34 elementy każdy. Aby do EAX wpisać pierwszy element pierwszego wiersza, piszemy oczywiście tylko:

	mov	eax, [Tablica]

Ale jak odczytać 23 element 17 wiersza? Otóż, sprawa nie jest taka trudna, jakby się mogło wydawać. Ogólny schemat wygląda tak (zakładam, że ostatni wskaźnik zmienia się najszybciej, potem przedostatni itd. - pamiętamy, że rozmiar elementu wynosi 4):

	Tablica[17][23] = [ Tablica + (17*długość wiersza + 23)*4 ]

No to piszemy:

	mov	ebx, Tablica
	mov	esi, 17
   jakas_petla:
	imul	esi, 34		; ESI=ESI*34=17 * długość wiersza
	add	esi, 23		; ESI=ESI+23=17 * długość wiersza + 23
	mov	eax, [ ebx + esi*4 ]	; mnożymy numer elementu przez
					; rozmiar elementu
	...

Można było to zrobić po prostu tak:

	mov	eax, [ Tablica + (17*34 + 23)*4 ]

ale poprzednie rozwiązanie (na rejestrach) jest wprost idealne do pętli, w której robimy coś z coraz to innym elementem tablicy.

Podobnie ((numer_wiersza*długość_wiersza1 + numer_wiersza*długość_wiersza2 + ... )*rozmiar_elementu) adresuje się tablice wielowymiarowe. Schemat jest następujący:

	Tablica[d1][d2][d3][d4]	   - 4 wymiary o długościach 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, że mamy taką tablicę:

	dword tab1[24][78][13][93]

Aby dobrać się 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 segmentów się to odnosi? Przecież mamy kilka rejestrów segmentowych, które mogą wskazywać na zupełnie co innego.
Odpowiedź:
Na rejestrach 32-bitowych mamy:

  1. jeśli pierwszym w kolejności rejestrem jest EBP lub ESP, używany jest SS
  2. w pozostałych przypadkach używany jest DS

W systemach 64-bitowych segmenty odchodzą w zapomnienie.


Domyślne ustawianie można zawsze obejść używając przedrostków, na przykład
	mov	ax, [ss:si]
	mov	[gs:eax+ebx*2-8], cx


Organizacja pamięci w Linuksie

W systemie Linux każdy program dostaje swoją własną przestrzeń, nie jest możliwe zapisywanie zmiennych lub kodu innych programów (z wyjątkami, na przykład debugery). Teoretycznie rozmiar owej przestrzeni wynosi tyle, ile można zaadresować w ogóle całym procesorem, czyli 2^32 = 4 GB na procesorach 32-bitowych. Obszar ten jest jednak od góry trochę ograniczony przez sam system, ale nie będziemy się tym zajmować.

Struktura programu po uruchomieniu jest dość prosta: cały kod, dane i stos (o tym za chwilę) znajdują się w jednym segmencie, rozciągającym się na całą wspomnianą przestrzeń. Na moim systemie wykonywanie zaczyna się pod adresem 08048080h w tej przestrzeni.


(przeskocz ilustrację pamięci programu w Linuksie)
		+-----------------------+
		|	BFFFFFFF    	|
		|    Stos, argumenty   	|
		+-     zm. lokalne     -+
		|	 .....    	|
		+-	 .....	       -+
		|  Dane, zm. globalne   |
		|      (statyczne)	|
		+-	 .....	       -+
		| 	 kod		|
		+-	 .....	       -+
		|	08048080h    	|
       CS=DS=SS +-----------------------+

Najniżej w pamięci znajduje się kod, za nim dane, a na końcu - stos.

Jak w takim razie realizowana jest ochrona kodu przed zapisem?
W samym procesorze istnieje mechanizm stronicowania, który umożliwia przyznanie odpowiednich praw do danych stron pamięci (zwykle strona ma 4kB). Tak więc, nasz duży segment jest podzielony na strony z kodem, danymi i stosem.



Stos

Przyszła pora na omówienie, czym jest stos.
Otóż, stos jest po prostu kolejnym segmentem pamięci. Są na nim umieszczane dane tymczasowe, na przykład adres powrotny z funkcji, jej parametry wywołania, jej zmienne lokalne. Służy też do zachowywania zawartości rejestrów.
Obsługa stosu jest jednak zupełnie inna.

Po pierwsze, stos jest budowany od góry na dół! Rysunek będzie bardzo pomocny:


(przeskocz rysunek stosu)

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

Na tym rysunku ESP=100h, czyli ESP wskazuje na komórkę o adresie 100h w segmencie SS.

Dane na stosie umieszcza się instrukcją PUSH a zdejmuje instrukcją POP. PUSH jest równoważne parze instrukcji:

	sub	esp, ..	   ; odejmowana liczba zależy od
			   ; rozmiaru obiektu w bajtach
	mov	[ss:esp], ..

a POP:

	mov	.., [ss:esp]
	add	esp, ..

Tak więc, po wykonaniu instrukcji PUSH AX i PUSH DX powyższy stos będzie wyglądał tak:


(przeskocz ilustrację działania PUSH)
	Stos po wykonaniu  PUSH AX i PUSH DX, czyli
		sub	esp, 2
		mov	[ss:esp], ax
		sub	esp, 2
		mov	[ss:esp], dx

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

SP=0FCh, pod [SP] znajduje się wartość DX, a pod [SP+2] - wartość AX. A po wykonaniu instrukcji POP EBX (tak, można zdjąć dane do innego rejestru, niż ten, z którego pochodziły):


(przeskocz ilustrację działania POP)
	Stos po wykonaniu POP EBX, czyli
		mov	ebx, [ss:esp]
		add	esp, 4

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

Teraz ponownie SP=100h. Zauważcie, że dane są tylko kopiowane ze stosu, a nie z niego usuwane. Ale w żadnym przypadku nie można na nich już polegać. Dlaczego? Zobaczycie zaraz.
Najpierw bardzo ważna uwaga, która jest wnioskiem z powyższych rysunków.
Dane (które chcemy z powrotem odzyskać w niezmienionej postaci) położone na stosie instrukcją PUSH należy zdejmować kolejnymi instrukcjami POP W ODWROTNEJ KOLEJNOŚCI niż były kładzione. Zrobienie czegoś takiego:

	push	eax
	push	edx
	pop	eax
	pop	edx

nie przywróci rejestrom ich dawnych wartości!



Przerwania i procedury a stos

Używaliśmy już instrukcji przerwania, czyli INT. Przy okazji omawiania stosu nadeszła pora, aby powiedzieć, co ta instrukcja w ogóle robi. Otóż, INT jest (w przybliżeniu) równoważne temu pseudo-kodowi:

	pushfd			; włóż na stos rejestr stanu procesora
				; czyli flagi
	push	cs		; segment, w którym aktualnie pracujemy
	push	eip_next	; adres instrukcji po INT
	jmp	procedura_obslugi_przerwania

Każda procedura obsługi przerwania (Interrupt Service Routine, ISR) kończy się instrukcją IRET (interrupt return), która odwraca powyższy kod, czyli z ISR procesor wraca do dalszej obsługi naszego programu.

Jednak oprócz instrukcji INT przerwania mogą być wywołane w inny sposób - przez sprzęt. Tutaj właśnie pojawiają się IRQ. Do urządzeń wywołujących przerwania IRQ należą między innymi karta dźwiękowa, modem, zegar, kontroler dysku twardego, itd...

Bardzo istotną rolę gra zegar, utrzymujący aktualny czas w systemie. Jak napisałem w jednym z artykułów, tyka on z częstotliwością ok. 18,2 Hz. Czyli ok. 18 razy na sekundę wykonywane są 3 PUSHe a po nich 3 POPy. Nie zapominajmy o push i pop wykonywanych w samej ISR tylko po to, aby zachować modyfikowane rejestry. Każdy PUSH zmieni to, co jest poniżej ESP.

Dlatego właśnie żadne dane poniżej ESP nie mogą być uznawane za wiarygodne.

Gdzie zaś znajdują się adresy procedur obsługi przerwań?
W pamięci, w Tabeli Deskryptorów Przerwań (Interrupt Descriptor Table, IDT), do której dostęp ma wyłącznie system operacyjny. Na pojedynczy deskryptor przerwania składa się oczywiście adres procedury obsługi przerwania, jej deskryptor, prawa dostępu do niej i kilka innych informacji, które z punktu widzenia programisty nie są (na razie) istotne.

Mniej skomplikowana jest instrukcja CALL, która służy do wywoływania zwykłych procedur, na przykład:

	call proc1		; wywołanie proste
	call [adres_proc1]	; wywołanie procedury, której adres
				; jest w zmiennej adres_proc1
	...
proc1:
	...
	ret

W zależności od rodzaju procedury (near - zwykle w tym samym pliku/programie, far - na przykład w innym pliku/segmencie), instrukcja CALL wykonuje takie coś:

	push	cs i kilka innych rzeczy ; tylko jeśli FAR
	push	eip_next	; adres instrukcji po CALL

Procedura może zawierać dowolne (nawet niesymetryczne ilości instrukcji PUSH i POP), ale pod koniec ESP musi być taki sam, jak był na początku, czyli wskazywać na prawidłowy adres powrotu, który ze stosu jest zdejmowany instrukcją RET (lub RETF). Dlatego nieprawidłowe jest takie coś:

	zla_procedura:
		push	eax
		push	ebx
		add	eax, ebx
		ret

gdyż w chwili wykonania instrukcji RET na wierzchu stosu jest EBX, a nie adres powrotny! Błąd stosu jest przyczyną wielu trudnych do znalezienia usterek w programie.
Jak to poprawić bez zmiany sensu? Na przykład tak:

	moja_procedura:
		push	eax
		push	ebx
		add	eax, ebx
		add	esp, 8
		ret

Teraz już wszystko powinno być dobrze. ESP wskazuje na dobry adres powrotny. Dopuszczalne jest też takie coś:

	proc1:
		push	eax
		cmp	eax, 0		; czy EAX jest zerem?
		je	koniec1		; jeśli tak, to koniec1

		pop	ebx
		ret
	koniec1:
		pop	ecx
		ret

ESP ciągle jest dobrze ustawiony przy wyjściu z procedury mimo, iż jest 1 PUSH a 2 POPy.
Po prostu ZAWSZE należy robić tak, aby ESP wskazywał na poprawny adres powrotny, niezależnie od sposobu. W skład tego wchodzi definiowanie procedur pod głównym programem (po ostatnich instrukcjach zamykających program). Dlaczego? Niektóre (najprostsze) formaty plików wykonywalnych nie pozwalają na określenie początku programu i takie programy są wykonywane po prostu z góry na dół. Jeśli u góry kodu umieści się procedury, zostaną one wykonane, po czym instrukcja RET (lub RETF) spowoduje zamknięcie programu (w najlepszym przypadku) lub wejście procesora na nieprawidłowe lub losowe instrukcje w pamięci.


Alokacja zmiennych lokalnych procedury

Nie musi się to Wam od razu przydać, ale przy okazji stosu omówię, gdzie znajdują się zmienne lokalne funkcji (na przykład takich w języku C) oraz jak rezerwować na nie miejsce.

Gdy program wykonuje instrukcję CALL, na stosie umieszczany jest adres powrotny (o czym już wspomniałem). Jako że nad nim mogą być jakieś dane ważne dla programu (na przykład zachowane rejestry, inne adresy powrotne), nie wolno tam nic zapisywać. Ale pod adresem powrotnym jest dużo miejsca i to tam właśnie programy umieszczają swoje zmienne lokalne.

Samo rezerwowanie miejsca jest dość proste: liczymy, ile łącznie bajtów nam potrzeba na własne zmienne i tyle właśnie odejmujemy od rejestru ESP, robiąc tym samym miejsce na stosie, które nie będzie zamazane przez instrukcje INT i CALL (gdyż one zamazują tylko to, co jest pod ESP).

Na przykład, jeśli nasze zmienne zajmują 8 bajtów (np.dwa DWORDy lub dwie 32-bitowe zmienne typu "int" w języku C), to odejmujemy te 8 od ESP i nasz nowy stos wygląda tak:

			SS
		+-------------------+
	100h	|  adres powrotny   |
		+-------------------+	<----- stary ESP = 100h
	0FEh	|	wolne	    |
		+-------------------+
	0FCh	|	wolne	    |
		+-------------------+
	0FAh	|	wolne	    |
		+-------------------+
	0F8h	|	wolne	    |
		+-------------------+	<----- ESP = 0F8h

ESP wynosi 0F8h, nad nim jest 8 bajtów wolnego miejsca, po czym adres powrotny i inne stare dane.

Nie trzeba podawać typów zmiennych lokalnych, ich liczby ani ich nazywać - wystarczy obliczyć ich łączny rozmiar i ten rozmiar odjąć od ESP. To, gdzie która zmienna faktycznie w pamięci się znajdzie (lub inaczej: który obszar pamięci będzie przypisany której zmiennej), zależy całkowicie od programisty - na przykład [ESP] może przechowywać pierwszą zmienną, a [ESP+4] - drugą, ale może być też całkiem na odwrót.

Miejsce już mamy, korzystanie z niego jest proste - wystarczy odwoływać się do [ESP], [ESP+2], [ESP+4], [ESP+6]. Ale stanowi to pewien problem, bo po każdym wykonaniu instrukcji PUSH, te cyferki się zmieniają (bo przecież adresy się nie zmieniają, ale ESP się zmienia). Dlatego właśnie do adresowania zmiennych lokalnych często używa się innego rejestru niż ESP. Jako że domyślnym segmentem dla EBP jest segment stosu, wybór padł właśnie na ten rejestr (oczywiście, można używać dowolnego innego, tylko trzeba dostawiać SS: z przodu, co kosztuje za każdym razem 1 bajt).

Aby móc najłatwiej dostać się do swoich zmiennych lokalnych, większość funkcji na początku zrównuje EBP z ESP, potem wykonuje rezerwację miejsca na zmienne lokalne, a dopiero potem - zachowywanie rejestrów itp. (czyli swoje PUSHe). Wygląda to tak:

	push	ebp		; zachowanie starego EBP
	mov	ebp, esp	; EBP = ESP

	sub	esp, xxx	; rezerwacja miejsca na zmienne lokalne
	push	rej1		; tu ESP się zmienia, ale EBP już nie
	push	rej2
	...

	...
	pop	rej2		; tu ESP znów się zmienia, a EBP - nie
	pop	rej1

	mov	esp, ebp	; zwalnianie zmiennych lokalnych
				;   można też (ADD ESP,xxx)
	pop	ebp

	ret

Niektóre kompilatory umożliwiają deklarację 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

Można wtedy odwoływać się do parametrów i zmiennych lokalnych przez ich nazwy, zamiast przez wyrażenia typu [ESP+nnn] i [ESP-nnn].

Przy instrukcji MOV ESP, EBP napisałem, że zwalnia ona zmienne lokalne. Zmienne te oczywiście dalej są na stosie, ale teraz są już poniżej ESP, a niedawno napisałem: żadne dane poniżej ESP nie mogą być uznawane za wiarygodne.

Po pięciu pierwszych instrukcjach nasz stos wygląda tak:

			   SS
		+-----------------------+
		|    adres powrotny	|
		+-----------------------+
		|       stary EBP	|
		+-----------------------+	<----- EBP
		|      xxx bajtów	|
		|			|
		|			|
		+-----------------------+
		|  	  rej1		|
		+-----------------------+
		|	  rej2		|
		+-----------------------+	<----- ESP

Rejestr EBP wskazuje na starą wartość EBP, zaś ESP - na ostatni element włożony na stos.
I widać teraz, że zamiast odwoływać się do zmiennych lokalnych poprzez [ESP+liczba] przy ciągle zmieniającym się ESP, o wiele wygodniej odwoływać się do nich przez [EBP-liczba] (zauważcie: minus), bo EBP pozostaje niezmienione.

Często na przykład w disasemblowanych programach widać instrukcje typu AND ESP, NOT 16 (lub AND ESP, ~16 w składni NASM). Jedynym celem takich instrukcji jest wyrównanie ESP do pewnej pożądanej granicy, na przykład 16 bajtów (wtedy AND z wartością NOT 16, czyli FFFFFFF0h), żeby dostęp do zmiennych lokalnych trwał krócej. Gdy adres zmiennej na przykład czterobajtowej jest nieparzysty, to potrzeba dwóch dostępów do pamięci, żeby ją całą pobrać (bo można pobrać 32 bity z na raz w procesorze 32-bitowym i tylko z adresu podzielnego przez 4).

Ogół danych: adres powrotny, parametry funkcji, zmienne lokalne i zachowane rejestry nazywany jest czasem ramką stosu (ang. stack frame).
Rejestr EBP jest czasem nazywany wskaźnikiem ramki, gdyż umożliwia od dostęp do wszystkich istotnych danych poprzez stałe przesunięcia (offsety, czyli te liczby dodawane i odejmowane od EBP): zmienne lokalne są pod [EBP-liczba], parametry funkcji przekazane z zewnątrz - pod [EBP+liczba], zaś pod [EBP] jest stara wartość EBP. Jeśli wszystkie funkcje w programie zaczynają się tym samym prologiem: PUSH EBP / MOV EBP, ESP, to po wykonaniu instrukcji MOV EBP, [EBP] w EBP znajdzie się wskaźnik ramki ... procedury wywołującej. Jeśli znamy jej strukturę, można w ten sposób dostać się do jej zmiennych lokalnych.


Zainteresowanych szczegółami adresowania lub instrukcjami odsyłam do Intela i AMD

Następnym razem o podstawowych instrukcjach języka asembler.

- Ilu programistów potrzeba, aby wymienić żarówkę?
- Ani jednego. To wygląda na problem sprzętowy.


Poprzednia część kursu (klawisz dostępu 3)
Kolejna część kursu (klawisz dostępu 4)
Spis treści off-line (klawisz dostępu 1)
Spis treści on-line (klawisz dostępu 2)
Ułatwienia dla niepełnosprawnych (klawisz dostępu 0)



Ćwiczenia

  1. Zadeklaruj tablicę 12 zmiennych mających po 10 bajtów:
    1. zainicjalizowaną na zera (pamiętaj o ograniczeniach kompilatora)
    2. niezainicjalizowaną

  2. Zadeklaruj tablicę 12 słów (16-bitowych) o wartości BB (szesnastkowo), po czym do każdego z tych słów wpisz wartość FF szesnastkowo (bez żadnych pętli). Można (a nawet trzeba) użyć więcej niż 1 instrukcji. Pamiętaj o odległościach między poszczególnymi elementami tablicy. Naucz się różnych sposobów adresowania: liczba (nazwa zmiennej + numer), baza (rejestr bazowy + liczba), baza + indeks (rejestr bazowy + rejestr indeksowy).

  3. Zadeklaruj dwuwymiarową tablicę bajtów o wartości 0 o wymiarach 13 wierszy na 5 kolumn, po czym do elementu numer 3 (przedostatni) w wierszu o numerze 12 (ostatni) wpisz wartość FF. Spróbuj użyć różnych sposobów adresowania.