Ubuntu Light

Transkrypt

Ubuntu Light
Asembler x86 – notatki
Język (bardzo) niskiego poziomu
Asembler jest językiem programowania niskopoziomowego, co oznacza, że jest bezpośrednio
tłumaczony na kod wykonywalny dla procesora. Nie zawiera żadnych „wspomagaczy” pisania
programów, automatycznej optymalizacji itp. Jest tłumaczony dosłownie – każda instrukcja kodu
źródłowego odpowiada dokładnie jednej instrukcji procesora.
Budowa programu asemblerowego
Program asemblerowy składa się w zasadzie wyłącznie z deklaracji etykiet, dyrektyw, instrukcji i
komentarzy. Nie zawiera żadnych bloków, pętli ani innych konstrukcji programistycznych. Stąd też
sama składnia asemblera jest dosyć prosta:
•
[mnemonik] [argument1], [argument2], […]
Instrukcja języka. Argumenty po przecinkach, oddzielone od mnemoniku tabulacją.
•
[nazwa]:
Deklaracja etykiety. Wykorzystywana w charakterze „zmiennej”, również przy instrukcji skoku.
Etykiety na dobrą sprawę są adresami – można je traktować jak liczby, a więc – celem
obliczenia np. długości zmiennej – odejmować. Aktualne położenie w kodzie można pobrać z
etykiety z samą kropką w nazwie.
•
.[rodzaj]
[argument1], [argument2], […]
Dyrektywa. Zastosowań ma całkiem sporo, m.in. deklaracja danych, określenie punktu
startowego programu, rozgraniczenie bloków danych i instrukcji, zapewne jeszcze trochę
innych.
•
#[tekst]
Komentarz.
Rodzajów dyrektyw jest dosyć sporo. Zazwyczaj, choć nie zawsze, przyjmują argumenty.
Dyrektywy deklarujące zmienne mogą przyjmować dowolną ilość argumentów – wtedy deklarujemy
tablicę.
•
.equ – deklaruje stałe. Ma dwa argumenty – nazwę i wartość.
•
.data – określa miejsce rozpoczęcia bloku danych.
•
.space – przestrzeń. Ma dwa argumenty – długość oraz bajt, którym zostanie wypełniona.
•
.byte – jeden bajt.
•
.word – dwubajtowa wartość całkowita.
•
.long – czterobajtowa wartość całkowita.
•
.double – czterobajtowa wartość zmiennoprzecinkowa.
•
.ascii – ciąg znaków niezakończony zerem.
•
.asciz – ciąg znaków zakończony zerem.
•
.string – ciąg znaków zakończony zerem. Nie wiem, czy różni się czymś od .asciz.
•
.text – nie wiem, co to jest, ale nie ma argumentów i zawsze występuje przed .global.
•
.type – deklaruje typ symbolu. Między innymi deklaruje, że dany symbol jest nazwą funkcji.
Potrzebne do oznaczania funkcji, które wywołamy np. z poziomu języka C. Najczęściej
wykorzystywana w postaci .type funkcja, @function.
•
.global – określa etykietę, od której zaczynamy wykonanie programu. Standardowo ustawiamy
_start przy kombinacji narzędzi as i ld, zaś main przy wykorzystaniu GCC.
Odwoływanie się do danych
W asemblerze każdym rodzajem danych można się posłużyć praktycznie w dowolny sposób –
zinterpretować jako dane lub jako wskaźnik. Nie ma też żadnego sprawdzania typów ani długości
tablic. Sposoby odwołań:
•
$[wartość] – adres zmiennej lub wartość stałej.
•
[wartość] – wartość zmiennej.
•
%[nazwa] – rejestr procesora.
•
[przesunięcie]([początek], [indeks], [rozmiar komórki]) – adres. Tą składnię można wykorzystać od
np. pobierania konkretnego elementu z tablicy. Generalnie wzór na zwracany adres jest taki:
[początek] + [rozmiar komórki] * [indeks] + [przesunięcie].
Instrukcje
Instrukcji asemblera jest dosyć sporo – tyle, ile instrukcji procesora. Zestawiam te, które pojawiły
się na laboratoriach (Argumenty dla skrócenia będę oznaczał przez #numer):
•
Arytmetyczne
◦ ADD, ADDB, ADDL – dodaje #2 i #1. Wynik zapisany pod adresem #2.
◦ DEC, DECL – zmniejsza #1 o 1.
◦ DIV – dzieli %al przez #1. Wynik zapisuje do %ax.
◦ CMP, CMPB, CMPL – porównuje #1 i #2. Wynik zapisany przez modyfikację flag.
◦ INC, INCL – zwiększa #1 o 1.
◦ MUL, MULL – mnoży %al/%ax przez #1. Wynik zapisuje do %ax/%eax.
◦ SUB, SUBB, SUBL – odejmuje #1 od #2. Wynik zapisany pod adresem #2.
•
Logiczne
◦ AND, ANDB, ANDL – koniunkcja #2 i #1. Wynik zapisany pod adresem #2.
◦ OR, ORB, ORL – alternatywa #2 i #1. Wynik zapisany pod adresem #2.
◦ XOR, XORB, XORL – alternatywa wykluczająca #2 i #1. Wynik zapisany pod adresem #2.
◦ SHL, SHR – przesunięcie bitowe w lewo/prawo wartości #2 o #1 bitów. Wynik zapisany pod
adresem #2.
•
Skoki
◦ CALL – wywołuje etykietę jak funkcję, po napotkaniu RET wraca i kontynuuje.
◦ J[warunek] – cała seria skoków warunkowych. Skacze do etykiety #1, jeżeli warunek jest
spełniony. Wynik poprzednich operacji wczytuje z odpowiednich flag.
▪ JZ, JE, JNZ, JNE – jeżeli zero/równe/nie zero/nie równe
▪ JA, JB, JNA, JNB – jeżeli większe/mniejsze/nie większe/nie mniejsze
▪ JAE, JBE, JNAE, JNBE, jeżeli większe lub równe/mniejsze lub równe/nie większe lub
równe/ nie mniejsze lub równe.
◦ JMP – skok bezwarunkowy do adresu w #1.
◦ LOOP – skok do #1, jeżeli rejestr %ecx większy od zera. Zmniejsza o 1 %ecx.
◦ RET – wyskok z funkcji.
•
Systemowe
◦ INT – wywołuje przerwanie systemowe o numerze w #1.
•
Transferowe
◦ LODS, LODSB, LODSW, LODSL – pobiera kolejny znak (element tablicy?) z %ds/%ds:%si do
%al/%ax/%eax.
◦ MOV, MOVB, MOVW, MOVL – przenosi wartość #1 pod adres #2.
◦ POP, POPL – zdejmuje ze stosu i przenosi do #1.
◦ PUSH, PUSHL – odkłada #1 na stos.
◦ XCHG, XCHGL – zamienia miejscami #1 i #2.
•
Pozostałe
◦ NOP – nie robi nic
◦ REP – niestety nie wiem, co to jest. Raz tylko się pojawiło, w przykładzie NFS.
•
Instrukcje koprocesor zmiennoprzecinkowego x87
◦ FADD – wykonuje działanie stos(0) = stos(0) + #1.
◦ FADDP – wykonuje działanie stos(1) = stos(1) + stos(0), zdejmuje stos(0).
◦ FCOMI – porównuje stos(0) oraz #1. Wynik zapisywany we flagach procesora.
◦ FDIV – wykonuje działanie stos(0) = stos(0) / #1.
◦ FDIVP – wykonuje dzielenie stos(1) = stos(1) / stos(0), zdejmuje stos(0).
◦ FIDIV – wykonuje działanie stos(0) = stos(0) / #1 na liczbach całkowitych.
◦ FILD – ładuje liczbę całkowitą z #1 na stos(0).
◦ FIMUL – wykonuje działanie stos(0) = stos(0) * #1.
◦ FINIT – inicjalizacja koprocesora zmiennoprzecinkowego.
◦ FISTP – konwertuje stos(0) na liczbę całkowitą, zapisuje do #1 i zdejmuje ze stosu.
◦ FLDL – ładuje liczbę zmiennoprzecinkową z #1 na stos(0).
◦ FLDPI – wczytuje п na stos.
◦ FSIN – wykonuje działanie stos(0) = sin(stos(0)).
◦ FSTP, FSTPL – zapisuje stos(0) do #1. Zdejmuje stos(0).
◦ FSQRT – wciąga pierwiastek kwadratowy ze stos(0). Nadpisuje stos(0).
◦ FSUB – wykonuje działanie stos(0) = stos(0) - #1.
◦ FSUBP – wykonuje działanie stos(1) = stos(1) - stos(0), zdejmuje stos(0).
Rejestry procesora
Operacje wymagające argumentów pobierają dane ze ściśle określonych rejestrów procesora.
Każdy z rejestrów ma swoje przeznaczenie, jak i określoną długość. Niektóre rejestry umożliwiają
odwoływanie się do ich fragmentów, np.: do dwóch młodszych bajtów rejestrów %e_x możemy się
odwołać przez %_x, do najmłodszego bajtu poprzez %_l, a do drugiego bajtu od końca przez %_h.
Oto lista 32-bitowych rejestrów ogólnego przeznaczenia:
•
%eax (32) – akumulator. Przechowuje wyniki wielu operacji.
◦ Podrejestry: %ax (16), %ah (8), %al (8)
•
%ebx (32) – rejestr bazowy – służy do adresowania.
◦ Podrejestry: %bx (16), %bh (8), %bl (8)
•
%ecx (32) – rejestr licznikowy – wykorzystywany jako licznik pętli.
◦ Podrejestry: %cx (16), %ch (8), %cl (8)
•
%edx (32) – rejestr danych
◦ Podrejestry: %dx (16), %dh (8), %dl (8)
•
%esp (32) – wskaźnik na wierzchołek stosu.
◦ Podrejestry: %sp (16)
•
%ebp (32) – rejestr bazowy – służy do adresowania.
◦ Podrejestry: %bp (16)
•
%esi (32) – rejestr źródłowy – trzyma źródło łańcucha danych.
◦ Podrejestry: %si (16)
•
%edi (32) – rejestr docelowy – trzyma cel łańcucha danych.
◦ Podrejestry: %di (16)
Funkcje systemowe Linuksa
Z assemblera często wywołujemy funkcje systemowe Linuksa, z pomocą których operujemy na
plikach, wypisujemy dane na ekran, wczytujemy je itd. Ich wykorzystanie wymaga umieszczenia kodu
funkcji w rejestrze %eax, ewentualnych parametrów w rejestrach %ebx, %ecx, %edx, %esi oraz%edi, a
następnie wywołania przerwania systemowego o numerze 0x80. Oto lista tych funkcji wraz z opisami
ich wywołania:
•
0x01 – sys_exit
Zamyka program.
%ebx: kod zakończenia programu
•
0x03 – sys_read
Wczytuje dane z pliku lub strumienia.
%ebx: deskryptor pliku
%ecx: wskaźnik na bufor
%edx: ilość bajtów do wczytania
•
0x04 – sys_write
Zapisuje dane do pliku lub strumienia.
%ebx: deskryptor pliku
%ecx: wskaźnik na bufor
%edx: ilość bajtów do wczytania
•
0x05 – sys_open
Otwiera plik.
%ebx: wskaźnik na nazwę pliku
%ecx: flagi
%edx: tryb
•
0x06 – sys_close
Zamyka plik.
%ebx: deskryptor pliku
•
0x08 – sys_creat
Tworzy plik.
%ebx: wskaźnik na nazwę pliku
%ecx: tryb
•
0xa2 – sys_nanosleep
Wstrzymuje wykonanie na okres czasu.
%ebx: przespany okres czasu
%ecx: jakiś wskaźnik – na zajęciach wstawialiśmy tam zero
Konstrukcje
W tej sekcji trochę informacji o tym, jak pisać poszczególne elementy.
Szablon programu
Poniższy kod jest szablonem pustego programu. Zawiera on wszystkie deklaracje stałych, które
się pojawiały, dlatego też należy wybrać tylko te, które są potrzebne. W przypadku korzystania z GCC
należy zmienić etykietę startową na nazwę pisanej funkcji (main, jeżeli funkcja jest całym programem i
nie będzie wywoływana z funkcji napisanej w C).
# Deklaracje stałych
.equ kernel, 0x80 # Funkcje systemowe Linuksa
.equ stdin, 0x00 # Standardowe wejście
.equ stdout, 0x01
# Standardowe wyjście
.equ stderr, 0x02
# Standardowe wyjście błędów
.equ
.equ
.equ
.equ
.equ
.equ
.equ
sys_exit, 0x01
#
sys_read, 0x03
#
sys_write, 0x04 #
sys_open, 0x05
#
sys_close, 0x06 #
sys_creat, 0x08 #
sys_nanosleep, 0xa2
Funkcja wyłączająca program
Funkcja wczytująca dane z pliku
Funkcja zapisująca dane do pliku
Funkcja otwierająca plik
Funkcja zamykająca plik
Funkcja tworząca plik
# Funkcja czekająca
.equ mode, 0x180 # Atrybuty do tworzenia pliku
.equ flags, 0x00 # Atrybuty do otwierania pliku
.data
# Deklaracja zmiennych
.text
.global
_start
# Ustalenie punktu startowego
_start:
# Kod programu
Zamknięcie programu
MOVL $exit, %eax
MOVL $0, %ebx
# lub inna wartość, jeżeli chcemy zwrócić kod zakończnia
INT $kernel
Sytuacja wygląda inaczej, gdy korzystamy z GCC – w takim wypadku wywołujemy funkcję exit:
PUSHL $0
CALL exit
Wypisanie tekstu na ekran
MOVL
MOVL
MOVL
MOVL
INT
NOP
$sys_write, %eax
$stdout, %ebx
$_textStart_, %ecx
$_textSize_, %edx
$kernel
Warunki i pętle
Do tworzenia warunków i pętli należy wykorzystać instrukcje skoku (wypisane w sekcji
„instrukcje”) oraz etykiety. Warunek należy sprawdzić odpowiednią instrukcją arytmetyczną (np. CMP)
ew. inną. Natychmiast po tej operacji wywołujemy odpowiedni skok warunkowy. Najlepiej jest
umieszczać w kodzie najpierw instrukcje wykonywane, gdy warunek nie będzie spełniony (wtedy nie
będzie skoku po instrukcji warunkowej), następnie skok bezwarunkowy do dalszej części programu, a
na końcu „bloku warunkowego” etykietę oraz instrukcje wykonywane, gdy warunek jest spełniony.
# Sprawdzenie warunku
CMP %eax, %ebx
JA
eax_wieksze
# Instrukcje, gdy warunek nie jest spełniony (%eax <= %ebx)
JMP kontynuacja
eax_wieksze:
# Instrukcje, gdy warunek jest spełniony (%eax > %ebx)
kontynuacja:
# Dalsza część programu, niezależna od warunku
Również instrukcje skoku wykorzystuje się do tworzenia pętli. Przydaje się tu też rejestr %ecx w
roli licznika. Uwaga – iterujemy w dół, a nie do góry! Poniżej przykład pętli wykonującej się określoną
ilość razy:
licznik:
.long 0
# Instrukcje przed pętlą
MOVL $5, %ecx
forloop:
MOVL %ecx, licznik
# Zapisz licznik do zmiennej
# Wnętrze pętli.
MOVL $licznik, %ecx
# Wczytaj licznik ze zmiennej
LOOP forloop
# Przeskakuje do forloop i zmniejsza %ecx, jeżeli %ecx != 0
# Dalsza część programu, wykonana po zakończeniu pętli
W niektórych przypadkach we wnętrzu pętli wymagane będzie wykorzystanie rejestru %ecx,
który przechowuje licznik pętli. W takim wypadku musimy na początku pętli przepisać wartość tego
rejestru do zmiennej i wczytać ją z powrotem zaraz przed instrukcją LOOP.
Alternatywą jest wykorzystanie innej instrukcji skoku do zapętlenia programu. W przypadku, w
którym nie znamy ilości iteracji, jest to nawet wskazane.
Parametry wywołania
Jeżeli korzystamy z GCC, parametry wywołania są dostępne jak parametry funkcji main(int argc,
char **argv, char **env), którą stanowi cały nasz program. W pierwszym argumencie mamy więc
liczbę parametrów wywołania, dalej są wskaźniki do wszystkich argumentów w tablicy zakończonej
bajtem zerowym oraz wskaźniki do zmiennych środowiskowych również w tablicy zakończonej bajtem
zerowym.
Funkcje
Mechanizm funkcji w asemblerze opiera się na wykorzystaniu stosu oraz instrukcji CALL.
Odkłada ona na stos adres powrotu z funkcji i wywołuje ją. Napotkana wewnątrz funkcji instrukcja
RET zdejmie adres powrotu z funkcji i przekieruje na niego wykonanie programu. Aby zwrócić
wartość, zapisujemy ją w rejestrze %al, %ax lub %eax, jeżeli ma cztery bajty lub mniej, zaś wskaźnik do
niej, jeżeli ma więcej.
PUSHL $2
PUSHL $1
CALL _mojafunkcja
_mojafunkcja:
# Tworzymy ramkę stosu
PUSHL %ebp
MOVL %esp, %ebp
# Instrukcje funkcji
MOVL 8(%esp), %eax
MOVL 12(%esp), %ebx
# Przenosimy 1 do %eax
# Przenosimy 8 do %ebx
# Niszczymy ramkę stosu
MOVL %ebp, %esp
POPL %ebp
RET
Aby przekazać argumenty do funkcji, odkładamy je na stos zaraz przed wywołaniem instrukcji
CALL. Jeżeli wywołujemy funkcję napisaną w języku C, argumenty wrzucamy w kolejność odwrotnej
do tej podanej w deklaracji, a po zakończeniu funkcji zwalniamy miejsce na stosie zdejmując z niego
argumenty (lub zwiększając wskaźnik stosu o ich rozmiar). Jeżeli zaś w w języku Pascal – w kolejności
zgodnej, a miejsca już nie zwalniamy.
================ KOD C ================
void funkcja(int a, int b);
================ KOD Asemblera ================
a:
.long 1
b:
.long 2
.type funkcja, @function
# ...
PUSHL $b
PUSHL $a
CALL funkcja
Jeżeli piszemy w asemblerze funkcję, którą potem wywołamy z języka C, we wnętrzu tej funkcji
argumenty ustawione są zgodnej z ich kolejnością w deklaracji nagłówka tej funkcji dla C. Należy
jednak pamiętać, że na wierzchu stosu jest nie pierwszy argument, a adres powrotu. Aby zwrócić
wartość z asemblerowej funkcji, umieszczamy ją w rejestrze %eax przed wyjściem z funkcji.