Przed uruchomieniem programu w języku C należy przetłumaczyć go na język poleceń procesora. Proces ten nazywa się kompilacją, a program który temu służy - kompilatorem . Znając podstawy języka poleceń procesora( czyli asemblera), można fragmenty programu zaprogramować od razu na tym poziomie. Ta umiejętność przydaje się, gdy chcemy wygenerować kod szybszy, niż zrobiłby to kompilator.
Poznamy podstawy łączenia kodu w języku C z kodem asemblera przy
użyciu tzw. wstawek asemblerowych. Zobaczymy jak z poziomu asemblera operować na zmiennych, tablicach i wskaźnikach, wykonywać skoki, kodować pętle i wywołania procedur. Użyjemy kompilatora GCC w środowisku Linux uruchomionym na maszynie kompatybilnej z 80x86.
Pierwszy program
Zacznijmy od zakodowania w asemblerze instrukcji przypisania zmiennej i
typu int
wartości 7
:
1
2
3
4
5
6
7
8
9
10
11
| //typ int ma 4 bajty
int i;
int main(void)
{
asm(" mov eax, 7; \
mov i, eax; \
");
printf("%i\n", i); //i==7
return 0;
} |
Instrukcja asm
wygląda jak zwykłe wywołanie funkcji przyjmującej argument typu char*. Tak jednak nie jest. Kompilator GCC natrafiwszy na instrukcję asm("rozkaz_asm")
umieści rozkaz_asm w generowanym przez siebie kodzie asemblerowym. Argument musi być stałą napisową!
Znak łamania wiersza "\"
zastosowaliśmy w celu poprawy czytelności.
Instrukcję z wierszy 5-6 można równie dobrze zapisać tak:
1
| asm(" mov eax,7; mov i,eax; "); |
.
Wewnątrz procesora wbudowano kilkaset bajtów bardzo szybkiej pamięci służącej do przechowywania
argumentów instrukcji asemblerowych, wyników pośrednich, adresów pamięci. Są to tak zwane rejestry procesora.
W naszym przykładzie użyliśmy 32-bitowego rejestru eax
( nazywanego niekiedy akumulatorem). Instrukcja
przypisała temu rejestrowi wartość
7
. Następnie wartość rejestru
eax
została przepisana do miejsca w pamięci skojarzonego ze zmienną
i
za pomocą instrukcji
Ogólna postać instrukcji przypisania:
Rozkaz skopiuje wartość operandu ( czyli inaczej argumentu)
źródlo
do operandu
cel
. Operand
źródło
może być (jak w pierwszej instrukcji) stałą, rejestrem (jak w drugiej) lub adresem w pamięci. Operand
cel
to rejestr (jak w pierwszej) lub adres w pamięci (jak w drugiej). Obydwa operandy nie mogą być jednocześnie adresami. Niemożliwym jest zatem przesłanie wartości pomiędzy dwoma miejscami w pamięci bez pośrednictwa rejestrów. Taka sama reguła obowiązuje dla pozostałych instrukcji asemblerowych.
Dwie kolejne instrukcje pozwalają na wykonywanie prostych operacji arytmetycznych.
- Dodaje wartość operandu
źródło
do wartości operandu cel
i wynik umieszcza w operandzie cel
.
- Odejmuje wartość operandu
źródło
do wartości operandu cel
i wynik umieszcza w operandzie cel
.
W powyższych instrukcjach operand źródło może być stałą, rejestrem lub adresem w pamięci. Operand cel to rejestr lub adres.
|
Ćwiczenie 1. Niech i,j,k będą zmiennymi typu int . Napisz wstawkę asemblerową wykonującą operację k = i + j . Użyj rejestrów eax i ebx .
Wpisz tylko instrukcje asemblerowe oddzielone średnikiem i znakiem "\" , jak w przykładzie.
|
|
Rozmiar ma znaczenie
Dlaczego w naszym przykładowym programie nie użyliśmy instrukcji mov i,7
? Otóż na poziomie asemblera nie mamy dostępu do informacji o typach zmiennych. Nie wiadomo zatem, czy pod adresem oznaczonym przez i
zarezerwowano bajt, dwa, cztery czy jeszcze więcej pamięci i na ilu bajtach trzeba zapisać tam liczbę 7
. Natomiast z rozkazu mov i,eax
kompilator wnioskuje, że będzie
ich tyle, ile wynosi rozmiar rejestru eax
, czyli 4 bajty. Możemy też poinstruować kompilator co do żądanego rozmiaru zapisywanych wartości używając specyfikatorów:
byte ptr, word ptr, dword ptr
oznaczających odpowiednio bajt, dwa bajty (słowo - word) i cztery bajty (podwójne słowo - double word). Zatem w naszym przykładzie wystarczy użyć instrukcji: mov dword ptr i,7
:
1
2
3
4
5
6
7
| int i;
int main(void)
{
asm("mov dword ptr i,7");
printf("%i\n", i); //i==7
return 0;
} |
Więcej o rejestrach
Pierwsze procesory firmy Intel miały 8-bitowe rejestry. W następnych generacjach ten rozmiar podwajał się osiągając ostatnio rozmiar 64-bitów. Jednocześnie wymóg zachowania kompatybilności wstecznej (możliwość uruchamiania kodu ze starszych generacji procesorów na nowszych) wymusił np. to, że w każdym procesorze 32-bitowym musi znajdować się 32, 16 i 8 bitowy akumulator.
Problem ten rozwiązano tak: Dwa mniej znaczące (tzw. młodsze) bajty eax
tworzą akumulator 16 bitowy - ax
. Rejestr ax
natomiast
składa się z dwóch bajtów bardziej znaczącego (tzw. starszego) ah
i mniej znaczącego 8-bitowego akumulatora al
.
Podobnie jest z innymi rejestrami arytmetycznymi: ebx,ecx,edx
.Instrukcje działają zwykle na argumentach o tej samej długości. Zatem np. mov ax,eax
oraz
mov eax,bx
są niepoprawne a add al,ch
- tak.
Przykład: poniższy program jest równowazny instrukcji
k = (i + j) % 256
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| //typ short int ma 2 bajty
short int i,j,k;
int main(void)
{
i = 67; j = 257;
asm(" mov ax, i; \
add ax, j; \
mov ah, 0; \
mov k, ax; \
");
printf("%i\n", k); //k==68
return 0;
} |
Skok bezwarunkowy
Kod wykonującego się programu umieszczony jest w spójnym bloku pamięci operacyjnej. Adres (czyli numer) komórki zawierającej
aktualnie wykonywaną instrukcję znajduje się w rejestrze ip
(ang. Instruction Pointer). Jeśli bieżący rozkaz nie zmienił wartości ip
,
to jest ona zwiększana automatycznie tak, by wskazywać na następną instrukcję. Rejestr ip
nie jest poprawnym argumentem dla poznanych dotychczas instrukcji
mov, add, sub
. Do jego zmiany służą specjalne rozkazy, z których najprostszym jest rozkaz skoku
bezwarunkowego jmp
.
1
2
3
| asm("start:");
printf("Nie lubię się powtarzać.");
asm("jmp start"); |
Tłumacząc ten fragment kodu kompilator zapamięta adres pierwszej
instrukcji po etykiecie start i wstawi go jako operand instrukcji
jmp
. Gdy uruchomimy program, funkcja
printf
wykonywać się będzie w nieskończoność. Zobaczyliśmy tu przykład
skoku wstecz, czyli do wcześniejszego miejsca w kodzie. Można też
skakać wprzód, czyli do etykiety zdefiniowanej później, niż jej występuje jej użycie. Rozkaz
jmp
sam w sobie jest mało przydatny. Chcielibyśmy uzależnić wykonanie skoku np. od wyniku operacji arytmetycznej.
Flagi i testowanie flag
Instrukcje arytmetyczne zmieniają wartość nie tylko rejestru/adresu będącego argumentem docelowym. Powodują też zmianę wartości specjalnych bitów ( czyli flag lub znaczników ) w rejestrze stanu procesora. Jeżeli wynikiem ostatniej operacji
arytmetycznej było zero to ustawiony zostanie bit ZF
(ang Zero Flag) tego rejestru, w przeciwnym przypadku będzie on wyzerowany. Jeśli wynikiem była liczba mniejsza od zera to ustawiony będzie bit SF
(ang. Sign Flag) a jeśli większa bądź równa zeru to wyzerowany. Bity ZF
i SF
są to odpowiednio bity 6 i 7 rejestru stanu, któy nazywa się flags
.
Skoki warunkowe
Przy pomocy poznanego właśnie rejestru
flags
możemy wykonywać skoki warunkowe.
Poniższy program przypisuje zmiennej
max
maksimum z wartości zmiennych
i
i
j
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| int i,j, max;
int main(void)
{
i = 7; j = 9;
asm(" mov eax, i; \
mov ebx, eax; \
mov ecx, j; \
sub eax, ecx; \
jg end; \
mov ebx, ecx; \
end: \
mov max, ebx; \
");
printf("%i\n", max); //max==9
return 0;
} |
W wierszu 8 wykonywane jest odejmowanie
i-j
. W zależności od znaku wyniku tego działania ustawiana bądź zerowana jest flaga
SF
rejestru
flags
. Instukcja
jg end;
wykona skok do etykiety
end
jeżeli ta flaga jest wyzerowana, tzn. wtedy gdy wynik działania
i-j
był dodatni.
Inne przydatne rozkazy skoków:
- (ang. jump if equal) - skocz gdy wynikiem operacji arytmetycznej było zero (znacznik
ZF
jest ustawiony).
- (ang. jump if greater or equal) - skocz gdy wynik był większy bądź równy zeru ( znacznik
SF
wyzerowany)
- (ang. jump if less than)
- skocz gdy wynik był mniejszy od zera(
SF
ustawiony)
- (ang. jump if less or equal) - skocz gdy wynik był mniejszy bądź równy zeru(
ZF
ustawiony lub SF
ustawiony)
- (ang. jump if not equal) - skocz gdy wynik był różny od zera (
ZF
wyzerowany)
Powyższe rozkazy nie zmieniają rejestru
flags
.
Zauważmy, że zmiana wartości rejestru
eax
instrukcją z wiersza 8 nie jest przez nigdzie wykorzystywana. Interesowało nas jedynie ustawienie flag.
Nawet więcej, gdybyśmy nie zniszczyli wartości tego rejestru
to moglibyśmy użyć go zamiast
ebx
. Takie sytuacje są na tyle częste, że wprowadzono rozkaz
który wykonuje operację
operand1-operand2
ale nie zapisuje nigdzie wyniku. Ustawia jedynie rejestr flag. Uproszczony kod wygląda tak:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| int i,j, max;
int main(void)
{
i = 7; j = 9;
asm(" mov eax, i; \
mov ecx, j; \
cmp eax, ecx; \
jg end; \
mov eax, ecx; \
end: \
mov max, eax; \
");
printf("%i\n", max); //max==9
return 0;
} |
Pętle
Zobaczmy teraz przykład wstawki z pętlą. Poniższy program
wykona zapisze w
k
wartość
i*j
obliczoną w dosyć naiwny sposób - dodając
j
razy liczbę
i
do
ax
po czym umieszczając wartość
ax
w
k
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| short int i,j, k;
int main(void)
{
i = 7; j = 9;
asm(" mov ax,0; \
mov cx,j; \
start: \
cmp cx,0; \
je end; \
add ax,i; \
sub cx,1; \
jmp start; \
end: \
mov k, ax; \
");
printf("%i\n", k); //k==63
return 0;
} |
W rejestrze
ax
obliczana jest żądana suma. Rejestr
cx
ustawiony początkowo na
j
służy jako licznik pętli. Program wykona
j
dodawań zmiennej
i
do rejestru
ax
.
Wśród programistów asemblerowych przyjął się zwyczaj używania
rejestru
(e)cx
jako licznika w rozmaitych pętlach.
Często zachodzi potrzeba, jak w naszym przykładzie, wykonania skoku gdy
(e)cx
jest równy zeru. W nowszych procesorach wprowadzono odpowiednie ku temu instrukcje:
- (Jump if
cx
register is zero) skocz gdy cx
jest wyzerowany
- (Jump if
ecx
register is zero) skocz gdy ecx
jest wyzerowany
Rozkazy te nie używają rejestru
flags
.
W wierszu 10 widzimy instrukcję
add ax,i
. Wykonuje
się ona w pętli
j
razy.
Ponieważ wartość
i
nie ulega modyfikacji, to powinno się składować ją w rejestrze. Zamiast
sub cx,1
można użyć instrukcji dekrementacji
dec cx
. Dla porządku trzeba dodać, że również
zamiast
add rejestr,1
zwykle lepiej jest napisać
inc rejestr
. Oto poprawiony kod:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| short int i,j, k;
int main(void)
{
i = 7; j = 9;
asm(" mov ax,0; \
mov bx,i; \
mov cx,j; \
start: \
jcxz end; \
add ax,bx; \
dec cx; \
jmp start; \
end: \
mov k, ax; \
");
printf("%i\n", k); //k==63
return 0;
} |
Poniższy program, dla
N>0
, wypisuje na ekranie wartość
N
-tej
liczby Fibonacciego:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| const unsigned short N = ?;
unsigned short i,j;
int main(void)
{
unsigned short k = 1;
unsigned short tmp;
i = 0; j = 1;
//----------------------------
while (k++ < N ) {
tmp = j;
j += i;
i = tmp;
}
//----------------------------
printf("%i\n", j);
return 0;
} |
Operacje na wskaźnikach
Jaki będzie efekt wykonania poniższego kodu?
1
2
3
4
5
6
7
8
9
10
| unsigned int i;
int main(void)
{
asm(" mov eax, offset i; \
mov ebx, 7 ; \
mov [eax], ebx; \
");
printf("%i\n", i); // i == ?
return 0;
} |
Otóż instrukcja
mov eax,offset i
załaduje do rejestru
eax
adres zmiennej
i
. Natomiast
mov [eax],ebx
spowoduje
załadowanie
pod adres zapisany w
eax
wartości rejestru
ebx
. Na ekranie zostanie więc wypisana liczba 7. Możemy wyeliminować użycie rejestru
ebx
za cenę zastosowania specyfikatora rozmiaru:
1
2
3
4
5
6
7
8
9
| unsigned int i;
int main(void)
{
asm(" mov eax, offset i; \
mov dword ptr [eax], 7; \
");
printf("%i\n", i); // i == 7
return 0;
} |
W naszym systemie (i w Twoim pewnie też) adresy mają rozmiar
32 bitów. Pamiętaj więc, żeby do ich przechowywania używać 32-bitowych rejestrów(czyli np.
eax,ebx,ecx,edx
). Adres taki może wskazywać na obiekty o różnym rozmiarze. W naszym przypadku w
eax
jest adres czterobajtowej zmiennej. Stąd konieczność użycia specyfikatora by poinformować asembler, na jakiej ilości bajtów zapisać liczbę
7
.
Bardziej skomplikowany przykład
Poniższy program sumuje elementy tablicy
N-elementowej
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| #define N 50
const char k = N;
char tab[N] = {1,2,3,4,250};
//pozostałe elementy tablicy są domyslnie równe 0
unsigned short sum;
int main(void)
{
asm(" mov cl, 0; \
mov ax, 0; \
mov dh, 0; \
mov ebx, offset tab; \
start: \
mov dl, byte ptr [ebx]; \
add ax, dx; \
inc ebx; \
inc cl; \
cmp k, cl; \
jg start; \
mov sum, ax; \
");
printf("%i\n", sum); // i == 260
return 0;
} |
Założyliśmy tu, że tablica
tab
będzie dosyć niewielka. Jej rozmiar przechowywany jest w zmiennej
k
na jednym bajcie, czyli może być maksymalnie równy 255. Zatem wystarczy, że
do przechowywania bieżącego indeksu w tej tablicy użyjemy
jednobajtowego rejestru
cl
. Także jej elementy są
pojedyńczymi bajtami. Maksymalna wartość sumy elementów
takiej tablicy mieści się w dwóch bajtach. Sumę tą będziemy więc liczyli w 16-bitowym rejestrze, w przykładzie jest
to
ax
. Do młodszego bajtu rejestru
dx
, czyli do
dl
ładujemy bajt spod adresu zapamiętanego w
ebx
rozkazem
mov dl,byte ptr [ebx]
. Następnie dodajemy do rejestru
ax
rejestr
dx
. Ponieważ tylko młodszy bajt
dx
zawiera wartość do zsumowania, to w wierszu 10 zerujemy starszy bajt tego rejestru. W języku C zmienna identyfikująca tablicę jest adresem jej pierwszego elementu. Do
ebx
ładujemy
ten adres w wierszu 11. Ciało pętli stanowią wiersze 12-18.
Po wykonaniu dodawania zwiększamy zarówno licznik
cl
jak i adres następnego elementu do przetworzenia w
bx
. To, że
bx
zwiększamy o
1
wynika z jednobajtowego rozmiaru elementu tablicy. Gdybyśmy mieli do czynienia z tablicą elementów typu np. dwubajtowego
short int
to należałoby zwiększyć ten indeks o
2
. Kod ten można nieco uprościć eliminując inkrementację rejestru
ebx
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| #define N 50
const char k = N;
char tab[N] = {1,2,3,4,250};
//pozostałe elementy tablicy są domyslnie równe 0
unsigned short sum;
int main(void)
{
asm(" mov ecx, 0; \
mov ax, 0; \
mov dh, 0; \
mov ebx, offset tab; \
start: \
mov dl, byte ptr [ebx + ecx]; \
add ax, dx; \
inc cl; \
cmp k, cl; \
jg start; \
mov sum, ax; \
");
printf("%i\n", sum); // i == 260
return 0;
} |
Teraz obliczanie adresu bieżącego elementu i jego pobranie dokonuje się jednym rozkazem procesora:
mov dl,byte ptr [ebx + ecx]
. Wyrażenie w nawiasach
[ ]
określa tzw.
tryb adresowania. Szczegóły są dosyć skomplikowane. Nam wystarczy intuicja, że suma wartości rejestrów (pomnożonnych ewentualnie przez potęgi 2) i niewielkich stałych może być zwykle użyta w takim wyrażeniu.
Organizacja pamięci programu
Z punktu widzenia programisty pamięć uruchomionego programu składa się przynajmniej
z dwóch spójnych bloków pamięci. Są to segment kodu
oraz segment danych. Segment kodu zawiera skompilowany program, w jego obrębie porusza się licznik rozkazów ip
. Natomiast segment danych tworzą:
- obszar danych statycznych - są to zmienne zadeklarowane
globalnie, lub z modyfikatorem
static
.
Rozmiar danych statycznych jest ustalany podczas kompilacji
i nie zmienia się w toku wykonania programu.
- sterta - obszar zarządzany przez tzw. alokator
pamięci służący do dynamicznego
przydziału pamięci, np. funkcją malloc.
Wraz z biegiem programu zwykle zmienia się rozmiar
sterty.
-
stos - struktura służąca przechowywaniu zmiennych
lokalnych, parametrów i innych danych tymczasowych
aktualnie wywołanych funkcji.
Przykład:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| int N = 50;
int *alokuj(int num, int e) {
int *tab;
static int brzeg = 5;
if( (num > brzeg) && (num <= N) )
{
tab = malloc(num*sizeof(int));
*tab = e;
}
else tab = NULL;
return tab;
} |
W powyższym fragmencie kodu zmienne
N
oraz
brzeg
zostaną umieszczone w obszarze danych statycznych,
num,e
oraz wskaźnik
tab
na stosie w tzw.
ramce funkcji
alokuj
a tablica dynamiczna
wskazywana przez
tab
- na stercie.
Stos
Do obsługi stosu służą dwa rozkazy:
- odkłada
operand
na wierzchołek stosu, operand
może być
16- lub 32-bitowym rejestrem, adresem zmiennej,
również 16- lub 32-bitowej
(aby to rozróżnić używa się specyfikatora rozmiaru)
lub stałą
- zdejmuje z wierzchołka stosu
tyle bajtów, ile zmieści się w argumencie
operand
i przepisuje tam te bajty. Podobnie
jak dla poprzedniej instrukcji może być
rejestrem lub adresem, z takimi samymi zastrzeżeniami.
Dno stosu znajduje się na końcu segmentu danych. Stos rośnie w kierunku malejących adresów. Rejestr
esp
procesora zawiera adres wierzchołka stosu. Rozkaz
pop
po przepisaniu odpowiedniej liczby bajtów do swojego argumentu zwiększa rejestr
esp
o tę liczbę. Natomiast
push
najpierw zmniejsza odpowiednio
esp
a później zapisuje pod nowym adresem swój operand. Na pamięci zajmowanej przez stos można też operować bezpośrednio instrukcją
mov
. Np. rozkaz
push ax
jest równoważny
1
| sub esp,2; mov [esp],ax |
a
pop ax
-
1
| mov ax,[esp]; add esp,2 |
Wywołania procedur
Wywołanie procedury polega na wpisaniu do licznika rozkazów
ip
adresu pierwszej instrukcji tej procedury. Po wykonaniu procedura powinna zwrócić sterowanie w miejsce, z którego została wywołana. Trzeba więc pamiętać adres następnej instrukcji po instrukcji wywołania procedury. Służy do tego rozkaz call operand
, który odkłada ten adres(4 lub 8 bajtów) na stosie i wpisuje
operand
do rejestru ip
. Ostatnią instrukcją
procedury jest zwykle ret
. Rozkaz ten zdejmuje ze stosu adres powrotu i wpisuje go do ip
. Wywoływana procedura proc
zobowiązana jest do takiego zarządzania stosem, by adres pod który skacze ret
był adresem odłożonym wcześniej na stos przez call proc
. Przykład wywołania procedury bezparametrowej:
1
2
3
4
5
6
7
8
9
10
11
12
| int f(void) {
return 7;
}
int i;
int main(void)
{
asm(" call f; \
mov i, eax; \
");
printf("%i\n", i); // i == 7
return 0;
} |
Wartość zwracana przez procedurę umieszczana jest w rejestrze
eax
. Sposób przekazywania argumentów do funkcji zależy od ich rozmiarów. Dla uproszczenia ograniczymy się do procedur przyjmujących argumenty czterobajtowe, np. typu
int
. Oto przykład:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| int sum(int a, int b, int c) {
return a+b+c;
}
int i;
int p1,p2,p3;
int main(void)
{
p1 = 2; p2 = 2; p3= 3;
asm(" push dword ptr p3; \
push dword ptr p2; \
push dword ptr p1; \
call sum; \
add esp,12; \
mov i, eax; \
");
printf("%i\n", i); // i == 7
return 0;
} |
Wstawka asemblerowa w powyższym programie odpowiada instrukcji
i = sum(p1,p2,p3)
. Widzimy więc, że parametry aktualne odkładane są na stos przeciwnie do kolejności wystąpienia w wywołaniu funkcji. Po powrocie z funkcji należy usunąć argumenty ze stosu. Można ściągnąć je
instrukcjami
pop
lub odpowiednio zwiększyć wskaźnik
esp
.
Parametry i zmienne lokalne w asemblerze
Funkcja sum
z poprzedniego przykładu zakodowana w asemblerze wygląda tak:
1
2
3
4
5
6
| int sum(int a, int b, int c) {
asm(" mov eax, [ebp + 8] ; \
add eax, [ebp + 12]; \
add eax, [ebp + 16]; \
");
} |
Przy tłumaczeniu kompilator wygeneruje pewne dodatkowe instrukcje. Całość kodu dla naszej funkcji
może wyglądać tak:
1
2
3
4
5
6
7
8
9
10
| push ebp
mov ebp, esp
//------------------
mov eax, [ebp + 8]
add eax, [ebp + 12]
add eax, [ebp + 16]
//------------------
mov esp, ebp
pop ebp
ret |
W rejestrze
ebp
pamięta się początek
ramki stosu funkcji
sum
. Adresy wyższe niż wartość
ebp
zawierają ramki stosu poprzednich funkcji. W szczególności będzie tam adres powrotu i parametry
aktualne naszej funkcji. Pierwsze 8 bajtów w pamięci począwszy od adresu zapisanego w
ebp
to właśnie ten adres. Pod adresem
ebp+8
znajduje się pierwszy parametr aktualny funkcji, później co 4 bajty - kolejne.
Ramka stosuto obszar pamięci rozpoczynający się pod adresem z
esp
a kończący pod adresem z
ebp
.
Funkcja
sum
ma pustą ramkę stosu. Rejestr
esp
ma taką samą wartość na początku, jak i na końcu funkcji.
Stąd instrukcje wygenerowane przez kompilator są nadmiarowe.
Zobaczymy teraz przykład funkcji z niepustą ramką stosu:
1
2
3
4
5
| int sum(int a, int b, int c) {
int tmp;
tmp = a+b+c;
return tmp;
} |
Kompilator natomiast przetłumaczył funkcję
sum
tak:
1
2
3
4
5
6
7
8
9
10
11
| push ebp
mov ebp, esp
sub esp, 4
mov ebx, [ebp + 8]
add ebx, [ebp + 12]
add ebx, [ebp + 16]
mov [ebp-4], ebx
mov eax, [ebp-4]
mov esp,ebp
pop ebp
ret |
Zmienne lokalne funkcji przechowywane są na stosie. W tym przykładzie rozkazem
sub esp, 4
z wiersza
3
rezerwujemy na stosie 4-bajtowy obszar pamięci na zmienną lokalną. Ponieważ w wierszu
3
zapamiętaliśmy poprzednią wartość
esp
w
ebp
to nasza zmienna przez cały czas działania funkcji
sum
będzie znajdować się pod adresem
ebp-4
. Jeżeli funkcja deklaruje więcej zmiennych lokalnych to "najbliżej" adresu spod
ebp
znajdzie się zmienna zadeklarowana jako ostatnia.
Jak to skompilować?
Przykładowe programy z tego artykułu zostały skompilowane
poleceniem:
gcc -masm=intel myprog.c -o myprog
Opcja
-masm=intel
przełącza asembler GCC z jego domyślnej składni (tzw. składni AT\&T) na składnię Intela.
Niekiedy chcielibyśmy wiedzieć, jak kompilator przetłumaczył dany fragment
kodu źródłowego. Informacji tej dostarcza debugger GDB. Uruchamia się go poleceniem
gdb
. Sterowanie GDB odbywa się z linii poleceń. Na początek trzeba przełączyć się na składnie Intela pisząc:
set disassembly-flavor intel
Można teraz załadować program wykonywalny poleceniem
load program
Pisząc
disassemble proc
uzyskamy deasemblację procedury
proc
z naszego programu.
I co dalej?
Poznaliśmy bardzo niewielki, lecz użyteczny podzbiór instrukcji
asemblera procesorów
x86
. Potrafimy zakodować proste instrukcje
arytmetyczne, testowanie warunków, skoki i pętle. Umiemy modyfikować rejestry
i wartości zmiennych globalnych, używać stosu i wywoływać procedury.
W drugiej części artykułu zobaczymy przykłady bardziej zaawansowanych rozkazów łańcuchowych, tzw. instrukcje SIMD oraz poznamy składnię rozszerzoną instrukcji
asm
.