Tydzień temu zacząłem wprowadzenie do assemblera x86_64 i skończyłem opowiedziawszy o rejestrach, deklarowaniu zmiennych i operacjach arytmetycznych. Dziś przejdziemy przez kolejne instrukcje, a za tydzień napiszemy prosty kalkulator konsolowy.
Aby nasz kod miał ręce i nogi we właściwych miejscach, to będziemy trzymali się ABI (binarny interfejs aplikacji) zgodnego z kodem produkowanym przez kompilator C.
Instrukcje i skoki
Dla procesora, kod maszynowy to bajty w pamięci. W rejestrze rip
trzymamy adres w pamięci dla kolejnej do wywołania instrukcji. Po jej odczytaniu, procesor zwiększa ten rejestr i czyta kolejne instrukcje. Jeśli chcemy w którymś momencie przejść z jednego miejsca w kodzie do innego, to użyjemy w tym celu jednej z instrukcji skoków.
Najprostsza jest instrukcja jmp
, która niezależnie od stanu rejestru flag skacze w wyznaczone miejsce. W poniższym kodzie wykonujemy instrukcję xor
, która tutaj zeruje nam rejestr rax
, potem robimy skok pod adres o etykiecie dodaj2
i wykonujemy następną instrukcję.
start:
xor rax, rax
jmp dodaj2
add eax, 1
dodaj2:
add eax, 2
Efektem jest to, że pominęliśmy instrukcję add eax, 1
.
Oprócz takiego zwykłego jumpa, są też skoki warunkowe. Po porównaniu dwóch rejestrów/komórek pamięci/wartości za pomocą instrukcji cmp
ustawiane są odpowiednie flagi. Następnie możemy użyć instrukcji:
je
- skocz jeśli równejg
/jnle
- skocz jeśli większe/nie mniejsze lub równejl
/jnge
- skocz jeśli mniejsze/nie większe lub równejge
/jnl
- skocz jeśli większe lub równe/nie mniejszejle
/jng
- skocz jeśli mniejsze lub równe/nie większe
Jest też sporo innych skoków (m.in. odpowiedniki dla liczb bez znaku), o których możecie znaleźć informacje np. tu Intel x86 JUMP quick reference oraz tu Understand flags and conditional jumps.
Stos
Nie wiem nawet kto wpadł na pomysł stosu, ale przyjęło się i działa bardzo sprawnie. Na stosie będziemy odkładać zmienne lokalne funkcji, argumenty jeśli jest ich dużo i wskaźniki powrotu. W rejestrze rsp
znajduje się wartość wskaźnika stosu. Sam stos jest nam zadeklarowany na końcu przydzielonej pamięci dla procesu i jeśli będzie zbyt duży to aplikacja może zakończyć się błędem Stack Overflow.
Kiedy odkładamy coś na stosie to zmniejszamy wartość wskaźnika stosu. Najprościej chyba wyobrazić to sobie tak, że mamy taki bloczek pamięci pionowo i na górze jest kod naszej aplikacji, wszystkie sekcje, itp. Na dole jest trochę wolnego miejsca i na samym spodzie zaczyna się stos. Ponieważ pamięć zaczyna się od 0, u góry, to kiedy odkładamy coś na stos, to zmniejszamy wartość wskaźnika.
Więc jak coś odłożyć na stos? Możemy użyć gotowych instrukcji
swap:
push ebx
push ecx
pop ebx
pop ecx
Albo możemy manualnie edytować rsp
myFun:
push rbp
mov rbp, rsp
sub rsp, 3
mov word [rbp], 0x0528
mov byte [rbp + 2], 'A'
;....
add rsp, 3
pop rbp
ret
Najpierw odkładamy na stosie wartość rejestru rbp
, którego (nie zawsze) się używa do określenia początku stosu, a więc i zmiennych lokalnych, po wejściu do funkcji. Nadpisujemy wartość rbp
obecną wartością rsp
. Potem zmniejszamy rsp
o 3, czyli deklarujemy miejsce na 3 bajty. Powiedzmy, że mamy jedną zmienną short
i jedną char
i przypisujemy im wartości. Następnie nasza funkcja coś będzie robić. Na koniec musimy posprzątać stos. Dodajemy spowrotem 3, zapominając nasze zmienne lokalne i przywracamy rbp
(który może być używany przez funkcję, która nas wywowała). Na koniec zwracamy.
Call i Ret
No właśnie co to znaczy “zwracamy”? Jak właściwie wołamy jakąś funkcję? Poznaliśmy już instrukcję jmp
, ale to nam nie wystarcza. Ogólnie trzeba pamiętać, że dla procesora nie ma czegoś takiego jak funkcje. On widzi tylko i wyłącznie kolejne instrukcje. Więc skąd wiadomo, gdzie wrócić jak gdzieś skoczymy? Możemy odłożyć próbować odłożyć adres kolejnej instrukcji na stos, skoczyć do funkcji, a ona na koniec weźmie ten adres i skoczy spowrotem. To jest dokładnie to co robią instrukcje call
i ret
.
myFun1:
mov rdi, 1
call add1
add1:
mov rax, rdi
add rax, 1
ret
Powyższy przykład jest chyba w miarę jasny. W funkcji myFun1
wywołujemy funkcję add1
z argumentem 1. Funkcja add1
oblicza rdi + 1
, a wynik zwraca w rejestrze rax
. Taka jest umowa, że zwracana wartość funkcji jest w rejestrze rax
.
Nasz kalkulator
W poprzednim poście obiecałem, że rzucimy okiem na wywoływaniu funkcji z innych plików, więc będziemy mieli dwa: math.asm
i calc.asm
. Dziś przejdziemy przez prostszą część, czyli nasze funkcje matematyczne.
;math.asm
;kilka funkcji matematycznych
section .text
global dodaj
global odejmij
global pomnoz
global podziel
Dyrektywy global
eksportują funkcje i umożliwiają korzystanie z nich w innych plikach.
Każdy z naszych funkcji przyjmuje dwa argumenty, jeden w rejestrze rdi
i drugi w rejestrze rsi
, a następnie zwraca wynik działania w rax
. Tak robią też funkcje w C.
dodaj:
mov rax, rdi
add rax, rsi
ret
odejmij:
mov rax, rdi
sub rax, rsi
ret
pomnoz:
mov rax, rdi
imul rax, rsi
ret
Dzielenie wymaga dodatkowych rejestrów, których wartości będziemy pamiętać na stosie, na czas ich użycia. Ogólnie dzielenie jest wielokrotnie wolniejsze od poprzednich operacji arytmetycznych i kiedy dzielimy przez jakąś stałą to stosuje się różne triki aby to przyśpieszyć. Jednak my będziemy mieli ogólną wersję.
podziel:
push rdx
push rcx
mov rax, rdi
xor rdx, rdx
mov rcx, rsi
idiv rcx
pop rcx
pop rdx
ret
Korzystam tu z xor rdx, rdx
, które ma to samo znaczenie co mov rdx, 0
, tylko że zajmuje mniej bajtów po skompilowaniu programu.
Pisząc ten post, zacząłem pisać ten kalkulator i okazało się, że w assemblerze trzeba się mocno napracować aby uzyskać dość proste operacje, więc zobaczymy dalszy kawałek kodu dopiero za tydzień. Ale za to znajdziemy tam przykład użycia pętli, wywołań systemowych i zmiennych globalnych. Powiem też o korzystaniu z połówek, ćwiartek i ósmych części rejestrów.