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.