Wprowadzenie do assemblera x86_64

31 Marca 2017

Na przedmiocie Systemy Operacyjne dostaliśmy zadania z assemblera z niewielką ilością informacji podanych na tacy. Jednak nie ma w internecie zbyt dużo materiałów związanych z programowaniem w assemblerze na poziomie wyższym niż bardzo podstawowym, także postanowiłem napisać coś samemu.

W tym tygodniu nie napisałem postu o Mars-Buggy, ponieważ przestałem się wyrabiać z obowiązkami. W dodatku weekend spędziłem w Belgii biorąc udział w Europejskim Pucharze Quidditcha (EQC). Mam jednak nadzieję, że szybko uda mi się nadrobić zaległości i postawić łazika na Marsie 😄

Kod maszynowy

Zacznijmy od tego czym w ogóle jest assembler? Jest to taki bardzo nisko-poziomowy język, który opisuje rozkazy procesora. Same rozkazy, czyli ten kod maszynowy, są w postaci binarnej (np. 0xeb to krótka instrukcja JMP). Natomiast język assemblera zawiera słowa (instrukcje) i etykiety co nieco usprawnia pisanie i czytanie kodu.

Jeśli by chcieć być bardzo poprawnym, to assembler to program, który kompiluje język assemblera do kodu maszynowego.

Procesor i architektura

Co to jest to magiczne x86_64? No więc dawno, dawno temu Intel stworzył procesor 8086, który był 16 bitowym rozszerzeniem procesora 8080. Następnie zaczęły się pojawiać kolejne wersje (80186, 80286, …, i386, …) i każdy z tych procesorów jest w stanie uruchamiać kod napisany na poprzedni. Więc w sumie nazwano tę rodzinę procesów mianem x86. A potem się pojawiła architektura 64 bitowa i stworzono rozszerzenie nazywane x64 lub x86_64 (lub AMD64).

Więc procesor łyka kod maszynowy i coś robi. A, skąd on ten kod ma? Podczas uruchomienia aplikacji, jej kod jest ładowany do pamięci, a adres początkowy jest przekazywany procesorowi. Zwiększając ten wskaźnik, procesor idzie przez pamięć, czytając kod i wykonując instrukcje.

Rejestry

Procesor i pamięć RAM są od siebie odseparowane. Korzystanie z pamięci jest więc wolniejsze niż korzystanie z “micro-pamięci” siedzącej w procesorze. Ta “micro-pamięc” dzieli się na rejestry i cache. Cache jest nieco wolniejszy od rejestrów i służy do przechowywania zmiennych załadowanych z pamięci RAM, dopóki ich używamy. Rejestry natomiast są podstawową jednostką operacyjną procesora (tak to nazwałem).

W rejestrze rip mamy adres kolejnej instrukcji. r oznacza, że jest to rejestr x64, a ip to skrót od instruction pointer. Innym ważnym rejestrem jest rsp, czyli stack pointer, który przechowuje informacje o stosie naszej aplikacji. W rax zapisana jest zwracana wartość funkcji. W rdi, rsi, rdx, rcx, r8, r9 (w tej kolejności) umieszcza się argumenty funkcji. Jeśli byłoby ich więcej to odkłada się je na stosie.

Te rejestry mają też 32bitowe części, które zamiast od r zaczynają się od e (oprócz r8…, bo te mają sufiksy). Ładną tabelkę znajdziecie na MSDN

Jest jescze więcej rejestrów, ale na ten momencik wystarczy.

Sekcje i kompilowanie kodu

Nie zawsze i nie wszędzie się spotyka sekcje, ale są one raczej powszechnie używane. W poniższym kodzie będę korzystał ze składni Intela, która jest w miarę przyjemna do czytania.

Jest sekcja .data, w której umieszczamy zmienne globalne. Robi się to przez polecenie db/dw/dd/dq (declare byte/word/double word/quad word), które kolejno deklarują wartość z dopełnieniem do 1/2/4/8 bajtów.

section .data
    myVar: db 5
    myText: db "Hello World!", 0 ;null terminated string

Jest jeszcze sekcja .bss, która również deklaruje zmienne, ale oszczędza ilość danych w pliku, wypełniając odpowiednie miejsce w pamięci zerami podczas ładowania programu do pamięci. Tutaj mamy polecenie resb/resw/resd/resq (reserve byte/word/double word/quad word) z ilością danych do zadeklarowania

section .bss
    myZerodVar: resb 2   ;reserve 2 bytes
    myBigVar: resq 10    ;reserve 80 bytes

Potem mamy w końcu sekcję .text, która zawiera nasz kod, o którym powiemy sobie za moment. Przykładowo może wyglądać tak:

section .text

global _start

_start:
    mov eax, 1
    mov esi, 2
    add eax, esi

Ok, załóżmy, że mamy napiszemy nasz kod i mamy go w pliku program.asm. Chcemy go teraz skompilować. Do tego użyjemy programu NASM.

nasm -f elf64 -o program.o program.asm

Flaga -f określa format, czy 32/64bit, czy binarny (czyste instrukcje), czy ELF obsługiwany przez Linuxa, czy co tam jeszcze innego. Flaga -o określa nazwę pliku wyjściowego.

Następnie taki program linkujemy albo za pomocą GCC (wtedy chcemy mieć main, a nie _start, bo GCC generuje _start), albo za pomocą LD.

ld -o program program.o

Podstawowe operacje

W notacji intela wszystkie instrukcje działają w notacji

opcode cel, źródło

Najbardziej podstawową operacją jest mov, czyli kopiowanie wartości.

mov eax, 5

Kiedy używamy 32 bitowej połówki rejestru, to druga połowa jest zerowana.

Możemy też kopiować wartości między rejestrami

mov rsi, rdi

Możemy się też odwoływać do pamięci przez []. Definujemy sobie zmienną w sekcji .data i korzystamy z jej wartości.

section .data
    var: db 7

;...

    mov eax, [var]  ;skopiuj 7 do eax
    mov rsi, var    ;skopiuj adres zmiennej var do rsi
                    ;   adresy mają wielkość 8 bajtów

Kolejną instrukcją jest add, która dodaje

mov eax, 5
mov edx, 8
add eax, edx    ;eax = 13
add edx, 7      ;edx = 15

Analogicznie do add mamy sub, który odejmuje i imul, który mnoży. Z dzieleniem jest ciut bardziej skomplikowanie: Zapisujemy naszą wartość do eax, ustawiamy edx na 0, dzielimy i wynik dzielenia jest w eax, a reszta w edx.

mov eax, 100
xor edx, edx     ;zerowanie krótką instrukcją
mov ecx, 4
idiv ecx         ;eax = 25

imul i idiv to operatory na liczbach ze znakiem. mul i div operują na liczbach bez znaków.

inc i dec zwiększają lub zmniejszają wartość o 1.

Na tym zakończymy to wprowadzenie, ale już niedługo kolejna część w której powiemy sobie o skokach, stosie, wywoływaniu funkcji i wywoływaniu funkcji z innych plików.