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).
Przykłady (zakomentowane zduplikowane linijki są w składni TASMa):
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. NASM ; starszy niż wersja 2.00 ; tego nie przyjmie, zamienimy to na ; postać równoważną: liczba_a dd 1125, 0 ; 2 * 4 bajty liczba_e dq 2.71 ; liczba zmiennoprzecinkowa ; podwójnej precyzji (double) ;duza_liczba dt 6af4aD8b4a43ac4d33h ; 10-bajtowa liczba całkowita. ; 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. ; Wartość nieznana. ; NASM tak tego nie przyjmie. ; Należy użyć: nie_init resb 1 ; zaś dla FASMa: ;nie_init rb 1 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
jest równoznaczne z
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 duplicate
. Odpowiedź na pytanie brzmi (TASM):
zmienna db 234 dup(0) nazwa typ liczba co zduplikować
Lub, dla NASMa i FASMa:
zmienna: TIMES 234 db 0 nazwa liczba typ co zduplikować
A co, jeśli chcemy mieć dwuwymiarową tablicę podwójnych słów o wymiarach 25 na 34?
Robimy tak (TASM):
Tablica dd 25 dup (34 dup(?))
Lub, dla NASMa i FASMa na przykład tak:
Tablica: 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ę operatora (słowa kluczowego) offset
(TASM), tak
jak widzieliśmy wcześniej. Zawartość zmiennej otrzymuje się poprzez umieszczenie jej w nawiasach
kwadratowych. Oto przykład:
rejestr_ax dw 4c00h rejestr_bx dw ? ; nie w NASMie/FASMie. ; użyć na przykład 0 zamiast "?" rejestr_cl db ? ; jak wyżej ... mov [rejestr_bx], bx mov cl, [rejestr_cl] mov ax, [rejestr_ax] int 21h
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 ptr [jakas_zmienna], 2 ; TASM mov word [jakas_zmienna], 2 ; NASM/FASM - bez PTR
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ę). Ogólny schemat wygląda tak:
Używając rejestrów 16-bitowych:
[ (BX albo BP) lub (SI albo DI) lub liczba ] słowo albo
wyklucza wystąpienie obu rejestrów na raz
na przykład
mov al, [ nazwa_zmiennej+2 ] mov [ di-23 ], cl mov al, [ bx + si + nazwa_zmiennej+18 ]
nazwa_zmiennej to też liczba, obliczana zazwyczaj przez linker.
W trybie rzeczywistym (na przykład pod DOSem) pamięć podzielona jest na segmenty, po 64kB (65536 bajtów) każdy, przy czym każdy kolejny segment zaczynał się 16 bajtów dalej niż wcześniejszy (nachodząc na niego). Pamięć adresowalna wynosiła maksymalnie 65536 (maks. liczba segmentów) * 16 bajtów/segment = 1MB. O tym limicie powiem jeszcze dalej.
Ułożenie kolejnych segmentów względem siebie segment o numerze 0 0 +-----------------+ | | segment o numerze 1 10h +-----------------+ +-----------------+ | | | | segment o numerze 2 20h +-----------------+ +-----------------+ +-----------------+ | | | | | | 30h +-----------------+ +-----------------+ +-----------------+ | | | | | |
Słowo offset oznacza odległość
jakiegoś miejsca od początku segmentu. Adresy można było pisać w
postaci SEG:OFF. Adres liniowy (prawdziwy) otrzymywało się mnożąc segment przez 16 (liczba bajtów) i
dodając do otrzymanej wartości offset, na przykład adres segmentowy
1111h:2222h = adres bezwzględny 13332h (h = szesnastkowy).
Należy też dodać, że różne adresy postaci SEG:OFF mogą dawać w wyniku ten sam adres
rzeczywisty. Oto przykład: 0040h:0072h = (seg*16+off) 400h + 72h = 00472h = 0000h:0472h.
Na procesorach 32-bitowych (od 386) odnoszenie się do pamięci może (w kompilatorze TASM
należy po dyrektywie .code
dopisać linię niżej .386
) odbywać się wg schematu:
lub
[ zmienna + rej_baz + rej_ind * skala +- liczba ]gdzie:
zmiennaoznacza nazwę zmiennej i jest to liczba obliczana przez kompilator lub linker
Tak, tego schematu też można używać w DOSie.
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 (w kompilatorze TASM nie jest to obsługiwane) odbywać się wg schematu:
zmienna [rej_baz + rej_ind * skala +- liczba] (tylko TASM/MASM)lub
[ zmienna + rej_baz + rej_ind * skala +- liczba ]gdzie:
zmiennaoznacza nazwę zmiennej i jest to liczba obliczana przez kompilator lub linker
Tak, tego schematu też można używać w DOSie.
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 bx, OFFSET zmienna ; w NASMie/FASMie:mov bx, zmiennamov al, [ bx + 1 ] mov ah, [ bx + 3 ] mov cl, [ bx + 78 ] mov ch, [ bx + 25 ] mov dl, [ bx + 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 więc piszemy (użyjemy 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 * 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 odnosi się to całe adresowanie? Przecież mamy kilka rejestrów
segmentowych, które mogą wskazywać na zupełnie co innego.
Odpowiedź:
Na rejestrach 16-bitowych obowiązują reguły:
Na rejestrach 32-bitowych mamy:
W systemach 64-bitowych segmenty odchodzą w zapomnienie.
Domyślne ustawianie można zawsze obejść używając przedrostków, na przykład
; 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
Po załadowaniu systemu DOS, pamięć wygląda z grubsza tak (niektóre elementy zostaną zaraz opisane) :
FFFFF +-----------------------------------------------+ | Pamięć urządzeń, HMA, UMB, część BIOSu | BFFFF +-----------------------------------------------+ | Pamięć karty graficznej | A0000 +-----------------------------------------------+ | | .. ... .. .. ... .. | Uruchamiane programy | +-----------------------------------------------+ | | .. ... .. .. ... .. | DOS - jego kod, dane i stos | ~500h +-----------------------------------------------+ | BIOS Data Area (segment 40h) | 400h +-----------------------------------------------+ | Tablica wektorów przerwań | 0 +-----------------------------------------------+
Od segmentu A0000 zaczyna się pamięć karty graficznej. Pamięć ta jest bezpośrednim odwzorowaniem ekranu i pisząc tam, zmieniamy zawartość ekranu (więcej o tym w innych artykułach). Po przeliczeniu A0000 na system dziesiętny dostajemy 655360, czyli ... 640kB. Stąd wziął się ten sławny limit pamięci konwencjonalnej.
Powyżej znajduje się DOSowy Upper Memory Block
i High Memory Area. Na samym końcu granic adresowania
(czyli tuż pod 1MB) jest jeszcze skrawek BIOSu
i to miejsce (a właściwie to adres FFFF:0000) jest
punktem startu procesora tuż po włączeniu zasilania. W okolicach tego adresu znajduje się
instrukcja skoku, która mówi procesorowi, gdzie są dalsze instrukcje.
Od adresu zero zaczyna się Tablica Wektorów Przerwań (Interrupt Vector Table,
IVT), mająca 256 adresów procedur obsługi przerwań po 4 bajty (segment+offset) każdy.
Potem jest BIOS Data Area (segment 40h), powyżej - kod DOSa, a
po nim miejsce na uruchamiane programy.
Ale chwileczkę! DOS nie może korzystać z więcej niż 1 MB pamięci? A co z
EMS i
XMS?
Megabajt pamięci to wszystko, co może osiągnąć procesor 16-bitowy. Procesory od 80386 w górę są
co najmniej 32-bitowe, co daje łączną możliwość zaadresowania 2^32 = 4GB
pamięci, o ile tylko jest tyle zainstalowane.
Menadżery EMS i XMS są to programy (napisane dla procesorów 32-bitowych), które umożliwiają innym
programom dostęp do pamięci powyżej 1 MB. Sam DOS nie musi mieć aż tyle pamięci, ale inne
programy mogą korzystać z dobrodziejstw większych ilości pamięci
RAM. Zamiast korzystać z przerwania DOSa
do rezerwacji pamięci, programy te korzystają z interfejsu udostępnianego przez na przykład HIMEM.SYS czy
EMM386.EXE i udokumentowanego w
spisie przerwań Ralfa Brown'a.
O tym, jak korzystać z pamięci EMS i XMS, możecie przeczytać też w moim
mini-kursie o pamięci EMS i XMS.
Struktura pamięci dla poszczególnych programów zależy od ich typu. Jak pamiętamy z części pierwszej, program
typu .com mieści się w jednym segmencie, wykonywanie zaczyna się od adresu 100h (256. bajt), a wcześniej
jest między innymi linia poleceń programu.
Wygląda to tak:
+-----------------------+ | CS:FFFF | - tu zaczyna się stos | Stos, zm. lokalne | | argumenty funkcji | | | +- ..... -+ | | +- ..... -+ | | +- ..... -+ | CS:100h początek kodu | +-----------------------+ | | CS=DS=ES=SS +-----------------------+
Kod zaczyna się od CS:100h, wszystkie rejestry segmentowe mają równe wartości. Od CS:FFFF zaczyna się stos rosnący oczywiście w dół, więc pisząc taki program trzeba uważać, by ze stosem nie wejść na kod lub dane.
Programy .exe mają
nieco bardziej złożoną strukturę. Kod zaczyna się pod adresem 0 w danym, wyznaczonym
przez DOS, segmencie. Ale rejestry DS i ES mają inną wartość niż CS i wskazują na wspomniane przy
okazji programów .com 256 bajtów zawierających linię poleceń programu itp. Dane programu, jeśli
zostały umieszczone w kodzie w osobnym segmencie, też mogą dostać własny segment pamięci.
Segment stosu zaś jest całkowicie oddzielony od pozostałych, zwykle za kodem.
Jego położenie zależy od rozmiaru kodu i danych. Jako że programy .exe posiadają nagłówek,
DOS nie musi przydzielać im całego segmentu. Zamiast tego,
rozmiar segmentu kodu (i stosu) odczyta sobie z nagłówka pliku.
Graficznie wygląda to tak:
+-----------------------+ | Stos, zm. lokalne | | argumenty funkcji | SS +-----------------------+ +-----------------------+ | Dane, zm. globalne | | (statyczne) | +-----------------------+ +-----------------------+ | CS:xxxx | +- ..... -+ | | +- ..... -+ | | +- ..... -+ | | +- ..... -+ | CS:0 początek kodu | CS +-----------------------+ +-----------------------+ | | DS=ES +-----------------------+
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 pomocny:
Adres SS +-------------------+ 100h | | +-------------------+ <----- SP = 100h 0FEh | | +-------------------+ 0FCh | | +-------------------+ 0FAh | | +-------------------+ 0F8h | | +-------------------+ 0F6h | | ... ....
Na tym rysunku
SP=100h, czyli SP 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 pseudo-instrukcji:
sub sp, .. ; rozmiar zależy od rozmiaru obiektu w bajtach mov ss:[sp], ..
a POP
:
mov .., ss:[sp] add sp, ..
Tak więc, po wykonaniu instrukcji PUSH AX
i PUSH DX
powyższy stos będzie wyglądał tak:
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 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):
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.
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 ax push dx pop ax pop dx
nie przywróci rejestrom ich dawnych wartości!
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 równoważne temu pseudo-kodowi:
pushf ; włóż na stos rejestr stanu procesora (flagi) push cs ; segment, w którym aktualnie pracujemy push ip_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
PUSH
e a po nich 3 POP
y. 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 SP.
Dlatego właśnie żadne dane poniżej SP nie mogą być uznawane za wiarygodne.
Gdzie zaś znajdują się procedury obsługi przerwań?
W pamięci, pod adresami od 0000:0000 do 0000:03ff włącznie znajdują się czterobajtowe adresy (pary
CS oraz IP) odpowiednich procedur. Jest ich 256.
Pierwszy adres jest pod 0000:0000 - wskazuje on na procedurę obsługi 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 właśnie sposób działa mechanizm przerwań w DOSie.
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 ; tylko jeśli FAR push ip_next ; adres instrukcji po CALL
Procedura może zawierać dowolne
(nawet różne ilości instrukcji PUSH
i POP
), ale
pod koniec SP 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 ax push bx add ax, bx ret
gdyż w chwili wykonania
instrukcji RET
na wierzchu stosu jest BX, 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:
dobra_procedura: push ax push bx add ax, bx add sp, 4 ret
Teraz już wszystko powinno być dobrze. SP wskazuje na dobry adres powrotny. Dopuszczalne jest też takie coś:
; TASM: proc1 proc near push ax cmp ax, 0 ; czy AX jest zerem? je koniec1 ; jeśli tak, to koniec1 pop bx ret koniec1: pop cx ret proc1 endp
; NASM/FASM: proc1: ; bez PROC i NEAR push ax cmp ax, 0 ; czy AX jest zerem? je koniec1 ; jeśli tak, to koniec1 pop bx ret koniec1: pop cx ret ; bez ENDP
SP ciągle jest dobrze ustawiony
przy wyjściu z procedury mimo, iż jest 1 PUSH
a 2 POP
y.
Po prostu ZAWSZE należy robić tak, aby SP 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.
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 SP, 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 SP).
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 SP i nasz nowy stos wygląda 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 bajtów wolnego miejsca, po czym adres powrotny i inne stare dane.3
Nie trzeba podawać typów zmiennych lokalnych, ich liczby ani ich nazywać - wystarczy obliczyć ich łączny rozmiar i ten rozmiar odjąć od SP. 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 [SP] może przechowywać pierwszą zmienną, a [SP+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
[SP], [SP+2], [SP+4], [SP+6]. Ale stanowi to pewien problem, bo po każdym wykonaniu
instrukcji PUSH
lub POP
,
te cyferki się zmieniają (bo przecież adresy się
nie zmieniają, ale SP się zmienia). Dlatego właśnie do adresowania zmiennych lokalnych
często używa się innego rejestru niż SP. Jako że domyślnym segmentem dla BP
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 BP z SP, potem wykonuje rezerwację miejsca na zmienne lokalne, a dopiero potem
- zachowywanie rejestrów itp. (czyli swoje PUSH
e). Wygląda to tak:
push bp ; zachowanie starego BP mov bp, sp ; BP = SP sub sp, xxx ; rezerwacja miejsca na zmienne lokalne push rej1 ; tu SP się zmienia, ale BP już nie push rej2 ... ... pop rej2 ; tu SP znów się zmienia, a BP - nie pop rej1 mov sp, bp ; zwalnianie zmiennych lokalnych ; można też (ADD SP,xxx) pop bp 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 [SP+nnn] i [SP-nnn].
Przy instrukcji MOV SP, BP
napisałem, że zwalnia ona zmienne lokalne.
Zmienne te oczywiście dalej są na stosie, ale teraz są już poniżej SP, a niedawno
napisałem: żadne dane poniżej SP nie mogą być uznawane za wiarygodne.
Po pięciu pierwszych instrukcjach nasz stos wygląda tak:
SS +-----------------------+ | adres powrotny | +-----------------------+ | stary BP | +-----------------------+ <----- BP | xxx bajtów | | | | | +-----------------------+ | rej1 | +-----------------------+ | rej2 | +-----------------------+ <----- SP
Rejestr BP wskazuje na starą wartość BP, zaś SP - na ostatni element włożony na stos.
I widać teraz, że zamiast odwoływać się do zmiennych lokalnych poprzez [SP+liczba]
przy ciągle
zmieniającym się SP, o wiele wygodniej odwoływać się do nich przez [BP-liczba]
(zauważcie: minus), bo BP pozostaje niezmienione.
Często na przykład w disasemblowanych programach widać instrukcje typu AND SP, NOT 16
(lub AND SP, ~16
w składni NASM). Jedynym celem takich instrukcji jest
wyrównanie SP 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 BP 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 BP): zmienne
lokalne są pod [BP-liczba]
, parametry funkcji przekazane z zewnątrz -
pod [BP+liczba]
, zaś pod [BP]
jest stara wartość BP. Jeśli wszystkie
funkcje w programie zaczynają się tym samym prologiem: PUSH BP / MOV BP, SP
, to
po wykonaniu instrukcji MOV BP, [BP]
w BP 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 lub 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.