Wyobraźcie sobie, jakby to było móc programować maszynę bezpośrednio - rozmawiać
z
procesorem bez pośrednictwa struktur wysokiego poziomu, na przykład takich jak spotykamy w języku C.
Bezpośrednie operowanie na procesorze umożliwia przecież pełną kontrolę jego działań! Bez
zbędnych instrukcji i innych śmieci
spowalniających nasze programy.
Czy już czujecie chęć pisania najkrótszych i najszybszych programów na świecie?
Programów, których czasem w ogóle NIE MOŻNA napisać w innych językach? Brzmi wspaniale, prawda?
Tylko pomyślcie o tym, co powiedzieliby znajomi, gdybyście się im pochwalili. Widzicie już te ich zdumione miny?
Miła perspektywa, prawda? No, ale dość już gadania. Zabierajmy się do rzeczy!
Zacznijmy od krótkiego wprowadzenia:
Najprostszy dla komputera, gdzie coś jest albo włączone, albo wyłączone. System ten operuje na
liczbach zwanych bitami (bit = binary digit
= cyfra dwójkowa). Bit przyjmuje jedną z dwóch wartości: 0 lub 1.
Na bajt składa się 8 bitów.
Jednym bajtem można przedstawić więc 2^8=256 możliwości.
Przeliczenie liczby zapisanej w systemie dwójkowym na dziesiętny jest proste. Podobnie jak w systemie dziesiętnym, każdą cyfrę mnożymy przez odpowiednią potęgę podstawy (podstawa wynosi 2 w systemie dwójkowym, 10 w systemie dziesiętnym).
Oto przykład (daszek ^ oznacza potęgowanie):
1010 1001 dwójkowo =
1*(2^7) + 0*(2^6) + 1*(2^5) + 0*(2^4) + 1*(2^3) + 0*(2^2) + 0*(2^1) + 1*(2^0) =
128 + 32 + 8 + 1 =
169 dziesiętnie (lub dec
, od decimal
).
Działanie odwrotne też nie jest trudne: naszą liczbę dzielimy ciągle (do chwili uzyskania ilorazu równego 0) przez 2, po czym zapisujemy reszty z dzielenia wspak:
169 | 84 | 1 42 | 0 21 | 0 10 | 1 5 | 0 2 | 1 1 | 0 0 | 1
Wspak dostajemy: 1010 1001, czyli wyjściową liczbę.
Jako że system dwójkowy ma mniej cyfr niż dziesiętny, do przedstawienia względnie małych
liczb trzeba użyć dużo zer i jedynek. Jako że bajt ma 8 bitów, podzielono go na dwie równe,
czterobitowe części. Teraz bajt można już reprezentować dwoma znakami, a nie ośmioma. Na każdy
taki znak składa się 2^4=16 możliwości. Stąd wzięła się nazwa szesnastkowy
.
Powstał jednak problem: cyfr jest tylko 10, a trzeba mieć 16. Co zrobić?
Postanowiono liczbom 10-15 przyporządkować odpowiednio znaki A-F.
Na przykład
Liczba 255 dziesiętnie = 1111 1111 binarnie = FF szesnastkowo (1111 bin = 15 dec = F hex)
Liczba 150 dziesiętnie = 1001 0110 binarnie = 96 szesnastkowo.
Należy zauważyć ścisły związek między systemem dwójkowym i szesnastkowym:
1 cyfra szesnastkowa to 4 bity, co umożliwia błyskawiczne przeliczanie między obydwoma
systemami: wystarczy tłumaczyć
po 4 bity (1 cyfrę hex) na raz i zrobione.
Przeliczenie liczby zapisanej w systemie szesnastkowym na dziesiętny jest równie proste, jak tłumaczenie z dwójkowego na dziesiętny. Każdą cyfrę mnożymy przez odpowiednią potęgę podstawy (podstawa wynosi 16 w systemie szesnastkowym).
Oto przykład:
10A szesnastkowo =
1*16^2 + 0*16^1 + A*16^0 =
256 + 0 + 10 =
266 dziesiętnie.
Działanie odwrotne też nie jest trudne: naszą liczbę dzielimy ciągle (do chwili uzyskania ilorazu równego 0) przez 16, po czym zapisujemy reszty z dzielenia wspak:
266 | 16 | 10 1 | 0 0 | 1Wspak dostajemy kolejno: 1, 0, 10, czyli 10A, czyli wyjściową liczbę.
Podczas pisania programów, liczby w systemie szesnastkowym oznacza się przez dodanie
na końcu litery h (lub z przodu 0x),
a liczby w systemie dwójkowym - przez dodanie litery b na końcu.
Tak więc, 101 oznacza dziesiętną liczbę o wartości 101, 101b oznacza liczbę 101 w
systemie dwójkowym (czyli 5 w systemie dziesiętnym), a 101h lub 0x101 oznacza liczbę
101 w systemie szesnastkowym (czyli 257 dziesiętnie).
Co to jest asembler?
Asembler jest to język programowania, należący do języków niskiego poziomu.
Znaczy to tyle, że jednej komendzie asemblera odpowiada dokładnie jeden rozkaz procesora.
Asembler operuje na rejestrach procesora.
A co to jest rejestr procesora?
Rejestr procesora to zespół układów elektronicznych, mogący przechowywać informacje (taka
własna pamięć wewnętrzna procesora).
Zaraz podam Wam podstawowe rejestry, na których
będziemy operować. Wiem, że ich liczba może przerazić, ale od razu mówię, abyście
NIE uczyli
się tego wszystkiego na pamięć! Najlepiej zrobicie, czytając poniższą listę tylko 2 razy, a
potem wracali do niej, gdy jakikolwiek rejestr pojawi się w programach, które
będę później prezentował w ramach tego kursu.
Oto lista interesujących nas rejestrów:
Użycie litery R przed symbolem rejestru, na przykład RCX, oznacza rejestr 64-bitowy, dostępny tylko na procesorach 64-bitowych.
Użycie litery E przed symbolem rejestru, na przykład EAX, oznacza rejestr 32-bitowy, dostępny tylko na procesorach rodziny 80386 lub lepszych. Nie dotyczy to rejestru ES.
Napisy
RAX = EAX+starsze 32 bity; EAX=AX+ starsze 16 bitów; AX=AH+AL
oznaczają:
RAX (64 bity) | EAX (32b) 00000000000000000000000000000000 | 0000000000000000 | 00000000 | 00000000 32b | 16b | AX(16b) | | AH(8b) | AL(8b)
RSI = ESI+starsze 32 bity; ESI = SI + starsze 16 bitów; SI = SIL+starsze 8 bitów
oznaczają:
RSI (64 bity) | ESI (32b) 00000000000000000000000000000000 | 0000000000000000 | 00000000 | 00000000 32b | 16b | SI(16b) | | 8b | SIL(8b)
Jedna ważna uwaga - między nazwami rejestrów może pojawić się dwukropek w dwóch różnych znaczeniach:
DX : AX(lub 2 dowolne zwykłe rejestry) będzie oznaczać liczbę, której starsza część znajduje się w rejestrze po lewej stronie (DX), a młodsza - w tym z prawej (AX). Wartość liczby wynosi DX*65536 + AX.
CS : SI(rejestr segmentowy + dowolny zwykły) będzie najczęściej oznaczać wskaźnik do jakiegoś obiektu w pamięci (o pamięci opowiem następnym razem). Rejestr segmentowy zawiera oczywiście segment, w którym znajduje się ów obiekt, a rejestr zwykły - offset (przesunięcie, adres w tym segmencie) tegoż obiektu.
Na razie nie musicie się przejmować tymi dwukropkami. Mówię to tylko dlatego, żebyście nie byli zaskoczeni, gdyż w przyszłości się pojawią.
Programista może odnosić się bezpośrednio do wszystkich wymienionych rejestrów, z wyjątkiem *IP oraz flag procesora (z wyjątkami).
Jak widać po ich rozmiarach, do rejestrów 8-bitowych można wpisać liczbę z przedziału 0-255 (lub od -128 do 127, gdy najwyższy, siódmy bit służy nam jako bit oznaczający znak liczby), w 16-bitowych zmieszczą się liczby 0-65535 (od -32768 do 32767), a w 32-bitowych - liczby od 0 do 4.294.967.295 (od -2.147.483.648 do 2.147.483.647).
Dobrym, choć trudnym w odbiorze źródłem informacji są: Intel Architecture Software Developer's Manual (IASDM) dostępny ZA DARMO ze stron Intela oraz DARMOWE podręczniki AMD64 Architecture Programmer's Manual firmy AMD
Jak pisać programy w asemblerze?
Należy zaopatrzyć się w:
konsolidator, ang. linker), chyba że kompilator ma już taki wbudowany, jak na przykład FASM
Wtedy wystarczy napisać w edytorze tekstu plik zawierający komendy procesora (o tym później), zapisać go z rozszerzeniem .asm lub .s (GNU as), po czym użyć kompilatora, aby przetworzyć program na kod rozumiany przez procesor.
Jakiego kompilatora użyć?
Istnieje wiele kompilatorów języka asembler pod Linuksa. Do najpopularniejszych należą
Netwide Asembler Project (NASM),
Flat Asembler (FASM),
High-Level Asembler (HLA) i Gnu As.
Można je ściągnąć z internetu:
Po skompilowaniu pliku z kodem źródłowym należy użyć programu łączącego - będziemy używać standardowego LD (tego, którego używają inne kompilatory), gdyż również powinien się znajdować w każdej dystrybucji Linuksa.
Mamy więc już wszystko, co potrzeba. Zaczynamy pisać. Będę tutaj używał składni NASMa i FASMa
(gdyż kompilują one programy w składni Intela, która jest bardziej przejrzysta, mimo iż może
się wydawać odwrotna
).
Najpierw programy na systemy 32-bitowe:
; wersja NASM na system 32-bitowy section .text ; początek sekcji kodu. global _start ; linker ld chce mieć ten symbol globalny _start: ; punkt startu programu mov eax, 4 ; numer funkcji systemowej: ; sys_write - zapisz do pliku mov ebx, 1 ; numer pliku, do którego piszemy. ; 1 = standardowe wyjście = ekran mov ecx, tekst ; ECX = adres (offset) tekstu mov edx, dlugosc ; EDX = długość tekstu int 80h ; wywołujemy funkcję systemową mov eax, 1 ; numer funkcji systemowej ; (sys_exit - wyjdź z programu) int 80h ; wywołujemy funkcję systemową section .data ; początek sekcji danych. tekst db "Czesc", 0ah ; nasz napis, który wyświetlimy dlugosc equ $ - tekst ; długość napisu
; wersja FASM na system 32-bitowy format ELF executable ; typ pliku entry _start ; punkt startu programu segment readable executable ; początek sekcji kodu _start: ; punkt startu programu mov eax, 4 ; numer funkcji systemowej: ; sys_write - zapisz do pliku mov ebx, 1 ; numer pliku, do którego piszemy. ; 1 = standardowe wyjście = ekran mov ecx, tekst ; ECX = adres (offset) tekstu mov edx, [dlugosc] ; EDX = długość tekstu int 80h ; wywołujemy funkcję systemową mov eax, 1 ; numer funkcji systemowej ; (sys_exit - wyjdź z programu) int 80h ; wywołujemy funkcję systemową segment readable writeable ; początek sekcji danych. tekst db "Czesc", 0ah ; nasz napis, który wyświetlimy dlugosc dd $ - tekst ; długość napisu
Teraz program 64-bitowy (x86-64) dla NASMa:
; wersja NASM na system 64-bitowy (x86-64) section .text ; początek sekcji kodu. global _start ; linker ld chce mieć ten symbol globalny _start: ; punkt startu programu mov rax, 1 ; numer funkcji systemowej: ; sys_write - zapisz do pliku mov rdi, 1 ; numer pliku, do którego piszemy. ; 1 = standardowe wyjście = ekran mov rsi, tekst ; RSI = adres (offset) tekstu mov rdx, dlugosc ; RDX = długość tekstu syscall ; wywołujemy funkcję systemową mov rax, 60 ; numer funkcji systemowej ; (sys_exit - wyjdź z programu) syscall ; wywołujemy funkcję systemową section .data ; początek sekcji danych. tekst db "Czesc", 0ah ; nasz napis, który wyświetlimy dlugosc equ $ - tekst ; długość napisu
I w końcu program 64-bitowy dla FASMa:
; wersja FASM na system 64-bitowy (x86-64) format ELF64 executable ; typ pliku entry _start ; punkt startu programu segment readable executable ; początek sekcji kodu _start: ; punkt startu programu mov rax, 1 ; numer funkcji systemowej: ; sys_write - zapisz do pliku mov rdi, 1 ; numer pliku, do którego piszemy. ; 1 = standardowe wyjście = ekran mov rsi, tekst ; RSI = adres (offset) tekstu mov rdx, [dlugosc] ; RDX = długość tekstu syscall ; wywołujemy funkcję systemową mov rax, 60 ; numer funkcji systemowej ; (sys_exit - wyjdź z programu) syscall ; wywołujemy funkcję systemową segment readable writeable ; początek sekcji danych. tekst db "Czesc", 0ah ; nasz napis, który wyświetlimy dlugosc dq $ - tekst ; długość napisu w trybie 64-bitowym
Bez paniki! Teraz omówimy dokładnie, co każda linia robi.
Traktowane są jako komentarze i są całkowicie ignorowane przy kompilacji. Rozmiar skompilowanego programu wynikowego nie zależy od ilości komentarzy. Dlatego najlepiej wstawiać tyle komentarzy, aby inni (również my) mogli później zrozumieć nasz kod.
format ELF executable
/
format ELF64 executable
Określa format (typ) pliku wyjściowego: wykonywalny plik ELF (format używany w Linuksie). FASM nie potrzebuje programów łączących, aby utworzyć program. Format ELF64 jest używany oczywiście pod systemem 64-bitowym.
entry _start
Określa, gdzie program się zaczyna. Po uruchomieniu programu procesor zaczyna wykonywać komendy zaczynające się pod podaną tutaj etykietą (_start) znajdującą się w sekcji kodu.
segment readable executable
Określa nowy segment programu - segment kodu, któremu ustawiamy odpowiednie atrybuty:
do odczytu i do wykonywania. Innym atrybutem jest writeable
(do zapisu), który
powinien być używany tylko do sekcji danych.
Mimo, iż FASM zaakceptuje atrybut writeable
dla sekcji kodu, nie
powinniśmy go tam umieszczać. Zapisanie czegokolwiek do sekcji kodu może skończyć
się błędem naruszenia ochrony pamięci (segmentation fault).
Można jednak w tym segmencie
umieszczać dane. Ale należy to robić tak, aby nie stały się one częścią programu, zwykle
wpisuje się je za ostatnią komendą kończącą program. Procesor przecież nie wie, co jest pod
danym adresem i z miłą chęcią potraktuje to coś jako instrukcję, co może prowadzić do
przykrych konsekwencji. Swoje dane umieszczajcie tak, aby w żaden sposób strumień
wykonywanych instrukcji nie wszedł na nie.
Dane będziemy więc zazwyczaj umieszczać w oddzielnej sekcji.
section .text
Wskazuje początek segmentu, gdzie znajduje się kod programu. Można jednak w tym segmencie umieszczać dane. Ale należy to robić tak, aby nie stały się one częścią programu, zwykle wpisuje się je za ostatnią komendą kończącą program. Procesor przecież nie wie, co jest pod danym adresem i z miłą chęcią potraktuje to coś jako instrukcję, co może prowadzić do przykrych konsekwencji. Swoje dane umieszczajcie tak, aby w żaden sposób strumień wykonywanych instrukcji nie wszedł na nie. Zapisanie czegokolwiek do sekcji kodu może skończyć się błędem naruszenia ochrony pamięci (segmentation fault), dlatego dane będziemy zazwyczaj umieszczać w oddzielnej sekcji.
global _start
Sprawiamy, że nazwa _start
będzie widziana poza tym programem (konkretnie to przez
linker ld, który skompilowaną wersję programu przerobi na wersję wykonywalną).
_start:
(z dwukropkiem)
Etykieta określająca początek programu.
mov eax, 4
/ mov rax, 1
Do rejestru EAX (32-bitowy) lub RAX (64-bitowy) wstaw
(MOV
= move, przesuń) wartość 4
(1 na systemach x86-64).
Jest to numer funkcji systemu Linux, którą
chcemy uruchomić. Jeśli chcemy skorzystać z funkcji systemowych, to zawsze EAX zawiera
numer takiej funkcji.
Numery funkcji różnią się na różnych architekturach procesorów. Poczytajcie mój
spis funkcji systemowych.
Komenda MOV
ma 3 ważne ograniczenia:
MOV
komórki pamięci do innej komórki pamięci,
takie coś:
mov [a], [b](gdzie a i b - dwie zmienne w pamięci) jest zabronione.
MOV
jednego rejestru segmentowego (cs,ds,es,ss,fs,gs)
do innego rejestru segmentowego, czyli taka operacja
mov es, dsjest zabroniona. W ogóle najlepiej unikać jakichkolwiek operacji na rejestrach segmentowych.
mov ds, 0Ale można:
mov bx, 0 mov ds, bx
mov ebx, 1
/ mov rdi, 1
Do rejestru EBX (32-bitowy) lub RDI (64-bitowy) wstaw 1. Dlaczego akurat 1? Zaraz się przekonamy.
mov ecx,tekst
/ mov rsi, tekst
Do rejestru ECX (32-bitowy) lub RSI (64-bitowy) wstaw offset (adres)
etykiety tekst
. Można obliczać
adresy nie tylko danych, ale etykiet znajdujących się w kodzie programu.
mov edx, dlugosc / mov edx, [dlugosc]
/ mov rdx, dlugosc / mov rdx, [dlugosc]
Do rejestru EDX (32-bitowy) lub RDX (64-bitowy) wstaw długość naszego tekstu. W pierwszym przypadku jest to stała, w drugim wartość pobieramy ze zmiennej.
int 80h
/ syscall
Int
= interrupt = przerwanie.
Nie jest to jednak znane na przykład z kart dźwiękowych
przerwanie typu IRQ.
Wywołując przerwanie 80h (128 dziesiętnie) lub instrukcję syscall
(w trybie 64-bitowym) uruchamiamy jedną
z funkcji Linuksa. Którą? O tym zazwyczaj mówi rejestr akumulatora.
W tym przypadku EAX = 4 (lub RAX = 1 w trybie 64-bitowym) i jest to funkcja zapisu do pliku - sys_write. Funkcja ta przyjmuje 3 argumenty:
Funkcje systemowe 32-bitowego Linuksa przyjmują co najwyżej 6 argumentów,
kolejno w rejestrach:
EBX, ECX, EDX, ESI, EDI, EBP. W EAX oczywiście jest numer funkcji, tak jak tutaj 4.
Funkcje systemowe 64-bitowego Linuksa przyjmują także co najwyżej 6 argumentów,
kolejno w rejestrach:
RDI, RSI, RDX, R10, R8, R9. W RAX oczywiście jest numer funkcji, tak jak tutaj 1.
Rejestry RCX i R11 są zamazywane.
mov eax, 1
/ mov rax, 60
Do EAX lub RAX wpisujemy numer kolejnej funkcji systemowej - sys_exit, która spowoduje zamknięcie naszego programu.
int 80h
/ syscall
Przerwanie systemowe - uruchomienie funkcji wyjścia z programu. Numer błędu (taki DOS-owski errorlevel) zwykle umieszczamy w EBX/RDI, czego tutaj jednak nie zrobiliśmy.
(NASM) section .data / (FASM) segment readable writeable
Określa początek sekcji danych. Dane muszą być w osobnej części programu, bo inaczej nie można do nich zapisywać (a na jądrze 2.6 nawet odczytać).
tekst db "Czesc",0ah
Definicja napisu i znaku przejścia do nowej linii. O tym, jak deklarować zmienne powiem następnym razem.
dlugosc equ $ - tekst
/ dlugosc dd $ - tekst
/ dlugosc dq $ - tekst
Definiujemy stałą, która przyjmuje wartość: adres bieżący
- adres początku napisu
,
czyli długość napisu. W pierwszym przypadku jest to stała kompilacji,
w drugim i trzecim - zmienna, która będzie umieszczona w programie.
Programik nazywamy hello.asm
i kompilujemy poleceniem (FASM):
fasm hello.asm hello
lub, dla NASMa:
nasm -f elf hello.asm ld -o hello hello.o
lub, dla NASMa na systemie 64-bitowym:
nasm -f elf64 hello.asm ld -o hello hello.o
Wyjaśnienie opcji:
-f elf
powoduje, że plik będzie skompilowany na 32-bitowy plik obiektowy typu ELF
(Executable-Linkable Format, typowy dla większości Linuksów).
Aby kompilować programy pod systemem 64-bitowym, należy użyć formatu elf64
-f elf64
powoduje, że plik będzie skompilowany na 64-bitowy plik obiektowy typu ELF-o nazwa
spowoduje nazwanie programu wynikowego.Na systemach 64-bitowych można też uruchamiać programy 32-bitowe, ale w takim przypadku trzeba je odpowiednio skompilować i zlinkować. Dla FASMa komenda się nie zmienia, gdyż wszystkie informacje są wewnątrz pliku. Zmienia się za to kompilacja na NASMa:
nasm -f elf hello.asm ld -melf_i386 -o hello hello.o
Kompilacja, nawet programu w asemblerze (zwana czasem asemblacją), ma kilka etapów:
tekst
) są zadeklarowane, sprawdza, czy skoki mieszczą się w granicach i wykonuje
inne niezbędne czynności, w tym optymalizację. Pozostawia jednak adresy symboli
nieuzupełnione.Jeśli do programu nie dołączamy innych już skompilowanych plików ani bibliotek, to niektóre kompilatory nie wymagają osobnego linkera i mogą same sobie poradzić z wygenerowaniem programu wyjściowego. Widać to na przykład w wywołaniach FASMa powyżej.
Jeśli linker LD wyświetla ostrzeżenie o wykonywalnym stosie (np.
missing .note.GNU-stack section implies executable stack
),
wystarczy do kodu programu dopisać na końcu
section .note.GNU-stack noalloc noexec nowrite progbitspo czym ponownie skompilować i zlinkować.
Teraz uruchamiamy ./hello
i cieszymy się swoim dziełem.
W dalszej części kursu będę przedstawiał programy często tylko 32-bitowe
i często tylko dla jednego kompilatora.
Ta część będzie służyć Wam pomocą, jeśli chcielibyście pisać
programy pod systemy 64-bitowe lub pod inny kompilator.
Miłego eksperymentowania.
Na świecie jest 10 rodzajów ludzi:
ci, którzy rozumieją liczby binarne i ci, którzy nie.
info db "Czesc.", 00, 01, 02, 07, 10, 13, 10, 13