Asembler x86/Funkcje/GNU AS

Funkcje

GNU As
NASM
Spis treści

Definiowanie

edytuj

Funkcje w asemblerze definiujemy po prostu jako zwykłe etykiety. Gdy chcemy przeskoczyć w wykonaniu naszego programu do naszej funkcji posługujemy się nazwą etykiety jako argumentem dla odpowiedniej instrukcji modyfikującej rejestr EIP i ew. rejestr CS tak aby razem wskazywały na początek funkcji.

Wywoływanie

edytuj

Instrukcja JMP modyfikuje wartość rejestru EIP, zależnie od przekazanego parametru. Istnieją 3 odmiany tej instrukcji:

  • JMP short - korzystamy z niej, gdy procedura, którą chcemy wywołać jest oddalona o 128 bajtów w tył lub do przodu; najszybsza instrukcja z rodziny.
  • JMP near - korzystamy z niej, gdy procedura, którą chcemy wywołać jest w tym samym segmencie co procedura wywołująca (w przypadku płaskiego modelu pamięci - w tym samym programie).
  • JMP far - korzystamy z niej, gdy procedura, którą chcemy wywołać jest w innym segmencie, zmianie ulega również rejestr CS; w przypadku płaskiego modelu pamięci, korzystamy z niej gdy wywoływana funkcja znajduje się poza naszym procesem.

Instrukcja CALL działa identycznie do instrukcji JMP z tą różnicą, że przed przeskokiem układa na stosie bieżące wartości rejestrów EIP oraz ew. CS tak aby później było można wrócić do miejsca gdzie wykonany był skok przy użyciu którejś instrukcji z rodziny ret. Istnieją dwie główne odmiany instrukcji CALL:

  • CALL near - korzystamy z niej, gdy wywoływana procedura jest w tym samym segmencie kodu (w przypadku płaskiego modelu pamięci odnosi się to do tego samego procesu)
  • CALL far - korzystamy z niej, gdy wywoływana procedura jest poza naszym procesem.

przykład

edytuj

Poniższy program wypisuje na ekranie znak znajdujący się w rejestrze AL procesora. Właściwa funkcja systemowa wyświetlająca znak na ekranie znajduje się w podprogramie printChar.

# Wypisuje zawartość rejestru AL
.data
msg:      .string      "B"
.text
     .globl _start
_start:
   movb      "A", %al #al='A'
   CALL      printChar #wywołanie podprogramu printChar
   movb      "C", %al #al='C'
   CALL      printChar #wywołanie podprogramu printChar
   movb      $0ah, %al #al=0x0a (znak końca linii)
   CALL      printChar #wywołanie podprogramu printChar
# wyjście z programu
   movl      $1, %eax 
   xorl      %ebx, %ebx
   int       $80h #wywołanie funkcji systemowej 0x80
# KONIEC PROGRAMU
printChar:
   movb        ($msg), %al #skopiowanie zawartości rejestru AL do bufora msg,
   movl        $4, %eax #gdyż rejestr AL będący częścią rejestru EAX będzie potrzebny dla wywołania funkcji systemowej
   movl        $1, %ebx
   movl        $msg, %ecx
   movl        $1, %edx
   int         $80h
# wyjście z podprogramu
   ret
# KONIEC PODPROGRAMU

Ramki stosu

edytuj

Argumenty

edytuj

Funkcje wymagające do swojego działania określonych argumentów mogą je otrzymywać na kilka sposobów. Najbardziej intuicyjne z nich to przekazywanie przez:

  • rejestry
  • stos
  • określoną lokalizację w pamięci

Spośród tych trzech metod zdecydowanie najszybsze jest przekazywanie argumentów przez rejestry, jednak najpowszechniejszą praktyką przekazywania argumentów jest użycie do tego stosu. Funkcje zakodowane w niemal wszystkich językach wyższego poziomu przekazują argumenty przez stos. Swoją popularność metoda ta zawdzięcza nie swojej wydajności, lecz uniwersalności. Oto przykład obrazujący tę metodę:

movl %ebx, %edx
   pushl %edx
   pushl %eax
   CALL near funkcja
   ...
 funkcja:
   popl %ebx
   popl %ecx
   ...

Zanim wywołujemy naszą funkcję odkładamy wartości dla argumentów na stos przy użyciu instrukcji push, zaś następnie zdejmujemy je z niego przy użyciu instrukcji pop.

Zachowane rejestry

edytuj

Rejestr EIP zostaje wrzucony na stos wtedy, gdy skorzystamy z instrukcji CALL do przeskoku w miejsce naszej funkcji. W przypadku użycia JMP stos pozostaje bez zmian.

Rejestr CS zostaje utrwalony na stosie w tych samych przypadkach co EIP, lecz dodatkowo tylko gdy stosujemy skok daleki tj. CALL far.

Jeśli chodzi o rejestr EBP możemy go zachować sami ręcznie na stosie w celu odzyskania jego poprzedniej wartości. Rejestr EBP wskazuje na swoją zachowaną wartość i w sam w sobie w obrębie działania funkcji jest niezmienny (w przeciwieństwie do ESP - szczytu stosu, który jest ruchomy) i dzięki temu ułatwione jest manipulowanie w obrębie stosu dzięki niemu. Aby odnieść się do dowolnej wartości na stosie jedynie dodajemy/odejmujemy od rejestru EBP odpowiednie wartości.

Zmienne lokalne

edytuj

Zmienne lokalne w asemblerze to po prostu dane, które dana funkcja wrzuca na stos w czasie swojego działania, odliczając wyżej wymienione elementy. Dostęp do nich osiągamy poprzez dodawanie odpowiednich wartości do wartości rejestru EBP (o ile wcześniej zaktualizowaliśmy odpowiednio jego wartość) lub odejmując wartości od rejestru ESP co jest nieco utrudnione ze względu na to, że jego wartość jest zmienna w związku z ruchomością szczytu stosu.

Inne języki

edytuj

Główne problemy

edytuj

Łącząc użycie asemblera z innymi językami można napotkać na różne problemy zależne od języka. Aby móc tworzyć funkcje możliwe do wywoływania przez inne języki lub móc wywoływać funkcje zakodowane w innym języku musimy dokładnie znać mechanizm wywoływania funkcji oraz stałe elementy ich działania. Poniżej znajdziesz opis struktur funkcji zakodowanych w poszczególnych językach. Nie jest to opis łączenia modułów napisanych w asemblerze z językami wyższego poziomu, gdyż ta tematyka została omówiona w rozdziale Łączenie z językami wysokiego poziomu!

Język C

edytuj

Wywoływanie funkcji

edytuj

Aby wywołać funkcję zakodowaną w języku C najpierw musimy przekazać jej argumenty poprzez stos, przy czym przekazujemy je w odwrotnej kolejności (od końca) w stosunku do kolejności przedstawionej w jej prototypie. Dodatkowo po wywołaniu funkcji musimy zwolnić miejsce zajmowane przez argumenty na stosie dodając do rejestru ESP odpowiednią wartość (przesuwając tym samym szczyt stosu i uszczuplając stos). Oto przykładowe wywołanie funkcji C:

 void funkcja(short a, char b);
 ...
 pushl $05h
 pushl $0389h
 CALL funkcja
 add $3, %esp

Jako że do funkcji przekazaliśmy argumenty o łącznej wielkości 3 bajtów, po wywołaniu funkcji zwalniamy zajęte miejsce przesuwając szczyt stosu.

Struktura funkcji
edytuj

Funkcje zakodowane w C mają stałą procedurę działania:

  • zachowanie na stosie wartości rejestru EBP i nadanie mu nowej wartości
  • zachowanie na stosie wartości rejestrów z których będzie korzystać nasza funkcja w czasie wykonania
  • wykonanie właściwego kodu funkcji
  • zdjęcie ze stosu wcześniej zapisanych rejestrów
  • zdjęcie ze stosu rejestru EBP

Pisząc włąsne funkcje w asemblerze możliwe do wywołania z użyciem języka C nie musimy stosować się do powyższego schematu. Jedyne nasze ograniczenie to otrzymywanie argumentów poprzez stos w opisanej wyżej kolejności (chyba że powiemy kompilatorowi, że przekazujemy przez rejestry, więcej w podrodziale Konwencja szybkiego wywołania), jednak jeśli w toku naszej funkcji zmianie ulegną jakieś rejestry bez odzyskania ich wartości początkowej może to doprowadzić do nieprawidłowego działania programu.

Konwencja szybkiego wywołania

edytuj

Konwencja szybkiego wywołania, tak jak sama nazwa wskazuje, jest wyjątkiem, gdyż przyjmuje argumenty poprzez rejestry procesora ECX-EDX. W przeciwieństwie do zwykłej konwencji __cdecl robi to w kolejności zgodnej z własnym zapisem, tzn. od początku do końca. Dodatkowo nie jesteśmy zmuszeni do zwalniania miejsca stosu zajętego przez przekazane argumenty, za to musimy ilość bajtów podać za nazwą funkcji po znaku @, zaś na początku musimy dodać @. Oto przykład:

 
 void __fastCALL funkcja(int a, int b);
 ...
 mov $05h, %ecx
 mov $0389h, %edx
 CALL @funkcja@3

Jako że do funkcji przekazaliśmy argumenty o łącznej wielkości 3 bajtów, na końcu nazwy funkcji dopisujemy @3, zaś na początku samo @.

Struktura funkcji
edytuj
  • wykonanie kodu funkcji