Wstawki asemblerowe w języku C, część 1

02.02.2010 - Piotr Witkowski
TrudnośćTrudnośćTrudność

Stos

Do obsługi stosu służą dwa rozkazy:

  • 1
    
    push operand
    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łą
  • 1
    
    pop operand
    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.
Snippet icon

Ćwiczenie 7. W pewnej funkcji zadeklarowano zmienne lokalne tak: int a,b,c,d,e,f;, następnie dokonano na nich pewnych obliczeń. Napisz wstawkę asemblerową podstawiającą do rejestru eax wartość zmiennej c.

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.
Snippet icon

Możesz też wpisać dowolny program w języku C ze wstawkami asemblerowymi w poniższym oknie. Po naciśnięciu przycisku Wyślij zobaczysz jego wynik działania.

 

 

 

 

 

 

 

 

 

5
Twoja ocena: Brak Ocena: 5 (3 ocen)

Copyright © 2008-2010 Wrocławski Portal Informatyczny

design: rafalpolito.com