728 x 90

Programujemy w Assemblerze #2

Programujemy w Assemblerze #2

Funkcje to istotny element każdego języka programowania. W tym wpisie przekonamy się, jak jest ona skonstruowana i wywoływana w asemblerze.

Post ten został podzielony na trzy części. Pierwsza omówi zasadę działania stosu, druga przedstawi jedną z metod wywołania funkcji (Linux x86_64), natomiast w trzeciej części wspomniane zostaną pozostałe konwencje wywołań funkcji.

STOS

Sama idea działania stosu zapewne nikomu nie jest obca. Stos, o którym będziemy w dalszej części rozmawiać, rośnie w dół (do zera). Zostało to zaprezentowane na poniższym obrazku. Początek stosu znajduje się po prawej stronie, a jego koniec po lewej. Kolejne elementy umieszczane są pod niższym adresem pamięci, jak wskazuje to strzałka.

O rejestrach procesora możemy myśleć jak o pewnego rodzaju zmiennych – miejscach, w których można przechowywać dane. Wierzchołek stosu (adres elementu umieszczonego najpóźniej) przechowywany jest w rejestrze rsp (sp – stack pointer). Pamięć na stosie rezerwuje się poprzez odjęcie od wartości umieszczonej w rsp rozmiaru elementu, który zamierzamy na nim umieścić.

Wyobraźmy sobie, że mamy na stosie pewne dane (na obrazku kolor czerwony) i chcemy na nim umieścić kolejne dwa elementy (kolor zielony i niebieski) o rozmiarze na przykład 16 (każdy po 8). W tym celu od aktualnej wartości rsp odejmujemy 8 i w zarezerwowanej pamięci, której adres mamy właśnie w rsp, umieszczamy pierwszy element (kolor zielony). Następnie alokując miejsce, poprzez ponowne odjęcie 8 od rsp możemy umieścić drugi element (kolor niebieski). Zauważmy, że elementy na stosie znajdują się w odwrotnej kolejności, niż kolejność ich odkładania.

Usunięcie elementu ze stosu jest jeszcze prostsze niż jego umieszczenie. W tym celu wystarczy dodać odpowiednią wartość do rsp, czyli zmienić adres wierzchołka na wyższy. Wracając do przykładu, dodając 16 do rsp powodujemy usunięcie ze stosu dwóch elementów.

Celowo rozgraniczyłem odjęcie wartości od rsp (alokację pamięci) od mieszczenia danych, gdyż możemy to zapisać za pomocą dwóch instrukcji asemblera:

subq $8, %rsp
movq $3, (%rsp)

Pierwsza instrukcja odpowiada za alokację pamięci na stosie – od wartości w rsp odejmujemy 8 (jednostka to bajty), a wynik umieszczamy w rsp. Druga instrukcja odpowiada za umieszczenie danych – wartość 3 umieszczamy w miejscu o adresie zawartym w rsp.

Powyższe dwie instrukcje są tak często używane, że zostały połączone i tworzą także jedną instrukcję – pushq:

pushq $3

Ta instrukcja oznacza dokładnie: odejmij 8 od rsp i wynik umieść w rsp, a następnie przekazaną wartość skopiuj do pamięci wskazywanej przez adres umieszczony w rsp.

Zdjęcie wartości ze stosu wymaga wykonania operacji odwrotnej – skopiowania wartości wskazywanej przez adres umieszczony w rsp do innego rejestru (np. rax) i dodania 8 do rsp w celu zwolnienia pamięci:

movq (%rsp), %rax
addq $8, %rsp

Oczywiście również te instrukcje zostały uproszczone:

popq %rax

Instrukcja ta skopiuje wartość z wierzchołka stosu do rejestru rax oraz zwolni pamięć.

WYWOŁANIE FUNKCJI

Wywołanie funkcji możemy podzielić na kilka etapów. Na samym początku argumenty odkładane są do odpowiednich rejestrów. W przypadku, gdy argument będzie za duży lub będzie ich zbyt wiele, to trafią one na stos. Następnie następuje wywołanie funkcji – na stos zostaje odłożony adres powrotu (adres następnej instrukcji, pod który należy skoczyć, gdy funkcja zakończy działanie), po czym wykonywany jest skok do właściwego kodu funkcji.

Na poniższym obrazku kolorem czerwonym zostały oznaczone dane już znajdujące się na stosie. Kolorem fioletowym zostały oznaczone argumenty funkcji, zielonym – adres powrotu, niebieskim i żółtym – dane funkcji. Przestrzeń przeznaczona dla funkcji (adres powrotu, dane lokalne, argumenty dla następnej wywołanej funkcji) nazywana jest ramką stosu (stack frame). Argumenty obecnej funkcji (kolor fioletowy) należą do poprzedniej ramki stosu (System V Application Binary Interface AMD64 Architecture Processor Supplement, s. 17).

Pierwsza instrukcja znajdująca się w ciele funkcji odpowiada za odłożenie zawartości rejestru rbpna stos (na obrazku kolor niebieski), w kolejnej instrukcji rbp jest ustawiany na wartość rsp. Te dwie instrukcje nazywane są prologiem funkcji, występują one zawsze na początku każdej funkcji. W kodzie asemblera może to wyglądać w ten sposób (poniższe instrukcje mogą zostać zastąpione ich skróconą wersją – instrukcją enter):

pushq %rbp
movq %rsp, %rbp

Na co wskazuje zatem rbp? Wskazuje na istotne miejsce ramki stosu. Znając rbp możemy w bardzo prosty sposób dostać się do argumentów funkcji. Taki zabieg może nie być prosty, jeśli miałby zostać wykorzystany rejestr rsp (w końcu ilość alokacji i dealokacji na stosie nie jest stała). rbp pozwala także na swobodne poruszanie się debugerowi po kolejnych ramkach stosu (wywołaniach funkcji), jest to zadanie proste, do którego (aby pójść w górę) wystarczy odczytywać rekurencyjnie kolejne wartości umieszczone pod wskazanym adresem na stosie.

Epilog funkcji, czyli jej zakończenie może wyglądać różnie. Ogólnie mówiąc epilog to odwrotność prologu. W najprostszym przypadku (gdy rsp nie jest modyfikowany) ze stosu zdejmowana jest poprzednia wartość rbp, następnie zdejmowany ze stosu jest adres powrotu i wykonywany jest skok pod ten adres:

popq %rbp
ret

Instrukcja popq odpowiedzialna jest za aktualizację wartości rejestru rbp, natomiast instrukcja ret pobiera ze stosu adres powrotu i wykonuje odpowiedni skok.

Epilog może przebiegać również w ten sposób:

leave
ret

Drugą połowę powyższego wydruku powinniśmy już rozumieć, pozostała nam tylko jedna instrukcja – leave. Kopiuje ona zawartość rejestru ebp do esp (zwalnia pamięć) oraz aktualizuje wartość rbp(instrukcja popq z poprzedniego wydruku).

Zobaczmy przykład:

int funkcja9(int _1, int _2, int _3, int _4, int _5, int _6, int _7, int _8, int _9) { 
_9 = 1234;
return 33;
}
 
 void funkcjaA() {
 funkcja9(1, 2, 3, 4, 5, 6, 7, 8 , 9); 
}

Do pełnego zrozumienia kodu asemblera powyższego przykładu należy powiedzieć, że wartość zwracana z funkcji przekazywana jest za pomocą rejestru eax, natomiast argumenty funkcji przekazywane są w kolejności odwrotnej, niż jest to opisane w kodzie C++.

funkcja9(int, int, int, int, int, int, int, int, int): 
 pushq %rbp ; prolog
movq %rsp, %rbp
 movl %edi, -4(%rbp) ; od wartości rbp odejmowane są 4 bajty (rozmiar int) ; jest to nowe miejsce na stosie, do którego skopiowany ; zostanie argument umieszczonego w rejestrze edi
 movl %esi, -8(%rbp)
 movl %edx, -12(%rbp)
 movl %ecx, -16(%rbp)
 movl %r8d, -20(%rbp)
 movl %r9d, -24(%rbp)
 movl $1234, 32(%rbp) ; wstawienie wartości 1234 do zmiennej _9, ; która została umieszczona na stosie ; 332 bajty dalej, niż adres zawarty w rbp
 movl $33, %eax ; skopiowanie wartości zwracanej 33 do rejestru eax
 popq %rbp ; epilog
ret
 
funkcjaA();
 pushq %rbp ; prolog
movq %rsp, %rbp
 pushq $9 ; odłożenie ostatniego argumentu na stos
 pushq $8
 pushq $7
 movl $6, %r9d ; to jest ostatni argument, który mógł ; zostać umieszczony w rejestrze
 movl $5, %r8d
 movl $4, %ecx
 movl $3, %edx
 movl $2, %esi
 movl $1, %edi

call funkcja9(int, int, int, int, int, int, int, int, int) ; wywołanie funkcji 
 addq $24, %rsp
nop ; epilog
leave
ret

Powyżej widzimy, że część argumentów przekazywanych do funkcji trafia do rejestrów, a część na stos. Opisem przebiegu wywołania funkcji zajmuje się konwencja wywołań (calling convention). Cytując “Introduction to X86-64 Assembly for Compiler Writers (Douglas Thain)”, przekazywanie argumentów można w prostych słowach opisać następująco:

  • Integer arguments (including pointers) are placed in the registers %rdi, %rsi, %rdx, %rcx, %r8, and %r9, in that order.
  • Floating point arguments are placed in the registers %xmm0-%xmm7, in that order.
  • Arguments in excess of the available registers are pushed onto the stack.
  • If the function takes a variable number of arguments (like printf) then the %eax register must be set to the number of floating point arguments.
  • The called function may use any registers, but it must restore the values of the registers %rbx, %rbp, %rsp, and %r12-%r15, if it changes them.
  • The return value of the function is placed in %eax.

KONWENCJE WYWOŁAŃ FUNKCJI

Konwencja wywołań opisuje między innymi:

  • sposób przekazywania argumentów,
  • rejestry, których wartość należy przywrócić po zakończeniu funkcji, jeśli ta ma ich używać
  • strukturę storu i sposób jego przygotowania.

Informacje przedstawione w tym i kolejnych wpisach dotyczą 64-bitowego Linuksa, czyli systemu zgodnego z System V AMD64 ABI. Konwencji jest oczywiście więcej, możemy o nich przeczytać na stronie firmy Microsoft lub Wikipedii.

Po tych kilku słowach nie powinniśmy mieć problemu ze zrozumieniem kodu z poprzedniego artykułu!

Leave a Comment

Your email address will not be published. Required fields are marked with *

Cancel reply

Inne artykuły

Ostatnie artykuły

Nasze szkolenia

iOS11 Design Patterns: szkolenie w Warszawie, 22-24.09.2017


Python – i Ty możesz programować: szkolenie dla nauczycieli, w pażdzierniku 2017 w Toruniu


Od zera do Apple kodera – szkolenie dla początkujących, 6-8 10 2017 w Warszawie

Zapraszamy na UMK