Wprowadzenie do assemblera x86_64 (2)

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ówne
  • jg/jnle - skocz jeśli większe/nie mniejsze lub równe
  • jl/jnge - skocz jeśli mniejsze/nie większe lub równe
  • jge/jnl - skocz jeśli większe lub równe/nie mniejsze
  • jle/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.