Uczenie się języka asemblerowego
Język asemblerowy jest czytelnym dla człowieka odpowiednikiem najniższego poziomu oprogramowania komputerowego – kodu maszynowego.
Pomimo że komputer rozumie wszystkie programy jako liczby, gdzie różne różne liczby nakazują komputerowi wykonywać różne operacje, jest to zbyt nudne dla ludzkiej konsumpcji (nie mówiąc już o pisaniu). Dlatego ludzie programują używając języka asemblera, który ma prawie 1:1 zgodność z kodem maszynowym.
Naucz się języka programowania C. Jeśli znasz C (lub C++), montaż będzie łatwiejszy.
C# i Java są dobrymi językami, ale nie ma wielu narzędzi, które pokazują, jak wygląda kod C# i Java w języku asemblera. (W rzeczywistości Java i C# mają swój własny język asemblacji kodu bajtowego, który jest raczej inny i odległy od rzeczywistych architektur zestawu instrukcji procesora sprzętowego.)
Z kodu C (i C++) możemy użyć kompilatorów, aby pokazać nam, jak wygląda język asemblacji. C jest językiem niskiego poziomu i jeśli wiesz, jak każda konstrukcja w C przekłada się na język asemblera, będziesz znał język asemblera! Przykłady konstrukcji w języku C, które chcemy poznać dla języka asemblera to:
Konstrukcja C | Równoważnik języka asemblacji |
---|---|
zmienna lokalna | rejestr CPU lub lokalizacja pamięci stosu |
zmienna globalna | dostęp do zmiennej globalnej lub. const memory location |
array reference | wzorzec dodawania i dostępu do pamięci |
goto | unconditional branch instruction |
if statement | wzorzec w instrukcji if-.goto: test warunkowy & instrukcja rozgałęzienia |
pętlawhile | wzór w if- goto.goto |
przypisanie | modyfikacja rejestru lub pamięci |
instrukcje arytmetyczne | instrukcje arytmetyczne |
parametr przekazywanie | modyfikacja rejestru lub pamięci stosu |
odbiór parametru | dostęp do rejestru lub pamięci stosu |
funkcja wywołanie | instrukcja wywołania |
funkcja wejście | wzorzec prologu |
funkcja wyjście | wzorzec epilogu |
wartość zwracana | modyfikacja rejestru i epilog |
Jak język asemblerowy różni się od innych języków programowania
W języku asemblerowym, będziesz narażony na gołe zasoby podstawowego sprzętu CPU. (Co więcej, rzeczy, które możemy napisać w C w jednej linii zajmują kilka linii w asemblerze.)
-
Będziesz świadomy takich rzeczy jak rejestry procesora i stos wywołań – te zasoby muszą być zarządzane, aby program mógł działać (w innych językach te zasoby są automatycznie zarządzane za nas). (Rejestry procesora są mało pojemne w porównaniu z pamięcią, jednak są superszybkie, a także spójne w działaniu.)
-
W języku asemblera nie ma instrukcji strukturalnych – tak więc, wszystkie konstrukcje przepływu sterowania muszą być zaprogramowane w stylu „If-Goto” (w innych językach, kompilator tłumaczy instrukcje strukturalne na If-Goto). Styl if-goto używa etykiet jako celów. (Podczas gdy C wspiera użycie goto i etykiet, programiści wolą ustrukturyzowane stwierdzenia przez większość czasu, więc nie często widzimy goto i etykiety w C.)
Język asemblerowy vs. Kod maszynowy
Język asemblerowy jest przeznaczony dla ludzi do czytania i pisania.
Etykiety
W języku asemblerowym używamy wielu etykiet. Użycie etykiet przekłada się na liczby w kodzie maszynowym, a etykiety znikają w kodzie maszynowym. Etykiety (w porównaniu z bezpośrednim użyciem liczb) umożliwiają dokonywanie małych zmian w programach – bez nich nawet mała zmiana byłaby w rzeczywistości ogromną zmianą.
Procesor musi wiedzieć, gdzie znajduje się operand, być może operand danych lub być może lokalizacja kodu. W przypadku lokalizacji kodu, na przykład, zwykle procesor będzie chciał wiedzieć, jak daleko są te elementy od lokalizacji kodu, którą aktualnie wykonuje – nazywa się to adresowaniem pc-relative. Tak więc, aby wykonać instrukcję if-then, możemy powiedzieć procesorowi: pod pewnymi warunkami, pomiń N instrukcji naprzód, gdzie N reprezentuje rozmiar lub liczbę instrukcji, które mają być pominięte (lub nie), tj. N jest rozmiarem części then – to byłoby w kodzie maszynowym. Jeśli dodamy instrukcję do then-part, liczba instrukcji do pominięcia wzrośnie (a jeśli usuniemy instrukcję z then-part, spadnie).
Używanie etykiet automatycznie uwzględnia zmiany (np. dodawanie, usuwanie instrukcji) – instrukcja rozgałęzienia używająca etykiety jako celu akomoduje zmiany w liczeniu ile instrukcji ma być warunkowo pominiętych.
Pseudoinstrukcje
Większość intrukcji w języku asemblera tłumaczy się 1:1 na instrukcje kodu maszynowego. Jednakże, od czasu do czasu, musimy powiedzieć asemblerowi coś, co nie przekłada się bezpośrednio na kod maszynowy. Typowym przykładem jest powiedzenie asemblerowi, że następujące instrukcje są kodem (zwykle poprzez dyrektywę .text
) vs. danymi (zwykle poprzez dyrektywę .data
). Podobnie jak zwykłe instrukcje, te instrukcje są zwykle pisane w swoim własnym wierszu, jednak informują one asembler zamiast generować bezpośrednio instrukcje kodu maszynowego.
Pisanie języka asemblerowego
W języku C piszemy instrukcje; te instrukcje są wykonywane kolejno jedna po drugiej, domyślnie sekwencyjnie, dopóki jakaś konstrukcja przepływu sterowania (pętla, if) nie zmieni przepływu. W języku asemblera jest tak samo.
W C, instrukcje mają wpływ na zmienne – możemy uznać, że oddzielne instrukcje komunikują się ze sobą poprzez zmienne. To samo jest prawdą w języku asemblera: instrukcje komunikują się ze sobą poprzez wpływ, jaki mają na zmienne, chociaż ponieważ jest więcej instrukcji (koniecznie tak, ponieważ instrukcje są prostsze), będzie również więcej zmiennych, wiele z nich to bardzo krótkotrwałe tymczasowości używane do komunikowania się z jednej instrukcji do następnej.
Basic Assembly Langauge Programming
Na przemian wybieramy rejestr i wybieramy instrukcję. Ponieważ instrukcje języka asemblera są dość proste, często musimy używać kilku instrukcji, tak, połączonych ze sobą poprzez wartości jako zmienne w rejestrach cpu. Kiedy to się stanie, będziemy musieli wybrać rejestr do przechowywania pośrednich wyników.
Wybór rejestru
Aby wybrać rejestr, potrzebujemy mentalnego modelu, które rejestry są zajęte, a które są wolne. Z tą wiedzą możemy wybrać wolny rejestr, którego użyjemy do przechowywania tymczasowego (krótkotrwałego) wyniku. Rejestr ten pozostanie zajęty aż do naszego ostatniego użycia, po czym wróci do stanu wolnego – raz wolny może być ponownie użyty do czegoś zupełnie innego. (W pewnym sensie, procesor tak naprawdę nie wie, że dokonujemy tej ciągłej zmiany przeznaczenia, ponieważ nie zna intencji programu.)
Wybór instrukcji
Po ustaleniu zasobów, które mają być użyte, możemy wybrać konkretną instrukcję, której potrzebujemy. Często nie znajdziemy instrukcji, która robi dokładnie to, co chcemy, więc będziemy musieli wykonać zadanie za pomocą 2 lub więcej instrukcji, co oznacza, yep, więcej wyboru rejestru dla tymczasowych.
Na przykład, MIPS i RISC V są podobnymi architekturami, które zapewniają porównanie & instrukcji oddziału, ale mogą porównać tylko dwa rejestry cpu. Tak więc, jeśli chcemy sprawdzić, czy bajt w łańcuchu jest pewnym znakiem (jak nowa linia), to musimy dostarczyć (załadować) tę wartość nowej linii (stałą numeryczną) do rejestru cpu, zanim będziemy mogli użyć instrukcji compare & branch.
Wywoływanie funkcji & Powrót
Program składa się być może z tysięcy funkcji! Podczas gdy te funkcje muszą dzielić się pamięcią, pamięć jest ogromna, więc mogą się dogadać. Jednak w jednym procesorze jest ograniczona i stała liczba rejestrów (np. 32 lub 16), a wszystkie funkcje muszą dzielić się tymi rejestrami!!!
Aby to zadziałało, projektanci oprogramowania definiują konwencje – o których można myśleć jak o regułach – dla dzielenia się tymi ograniczonymi stałymi zasobami. Ponadto, konwencje te są niezbędne, aby jedna funkcja (dzwoniąca) mogła wywołać inną funkcję (dzwoniącą). Reguły są określane jako konwencja wywołania, która identyfikuje dla wywołującego/odbiorcy, gdzie umieścić/znaleźć argumenty i wartości zwrotne. Konwencja wywołania jest częścią interfejsu binarnego aplikacji, który ogólnie mówi nam, jak funkcja powinna komunikować się ze światem zewnętrznym (innymi funkcjami) w kodzie maszynowym. Konwencja wywołania jest zdefiniowana do użytku programowego (sprzętu to nie obchodzi).
Te dokumenty mówią nam, co robić w przekazywaniu parametrów, używaniu rejestru, alokacji/dealokacji stosu i zwracaniu wartości. Dokumenty te są publikowane przez dostawcę zestawu instrukcji lub dostawcę kompilatora i są dobrze znane, aby kod z różnych miejsc mógł prawidłowo współdziałać. Opublikowane konwencje wywoływania powinny być brane jako dane, nawet jeśli sprzęt o tym nie wie.
Bez takiej dokumentacji, nie wiedzielibyśmy jak robić te rzeczy (przekazywanie parametrów, itp.). Nie musimy używać standardowych konwencji wywoływania, ale niepraktyczne jest wymyślanie własnych dla czegokolwiek poza prostym programem zabawkowym uruchamianym na symulatorze. Stworzenie własnej konwencji wywołania oznacza brak interoperacyjności z innym kodem, co musimy zrobić w każdym realistycznym programie, jak wywołania systemowe i biblioteczne.
Sprzęt nie wie lub nie dba o konwencje użycia oprogramowania. Po prostu ślepo wykonuje instrukcję za instrukcją (naprawdę szybko). Na przykład, dla sprzętu, większość rejestrów jest równoważna, nawet jeśli rejestry są podzielone lub przypisane do konkretnych zastosowań przez konwencje programowe.
Rejestry dedykowane
Niektóre rejestry procesora są dedykowane do konkretnych celów. Na przykład, większość procesorów w dzisiejszych czasach ma jakąś formę wskaźnika stosu. Na MIPS i RISC V wskaźnik stosu jest zwykłym rejestrem nieodróżnialnym od innych, z wyjątkiem definicji konwencji programowych – wybór, który rejestr jest arbitralny, ale rzeczywiście jest wybrany, a ten wybór jest publikowany, więc wszyscy wiemy, który rejestr to jest. Na x86, z drugiej strony, wskaźnik stosu jest obsługiwany przez dedykowane push
& pop
instrukcje (a także instrukcje wywołania i powrotu), więc dla rejestru wskaźnika stosu istnieje tylko jeden logiczny wybór.
Partionowanie rejestrów
Aby podzielić pozostałe rejestry między tysiące funkcji, są one partycjonowane, zgodnie z konwencją programową, zwykle na cztery zestawy:
- Rejestry do przekazywania parametrów
- Rejestry do odbierania wartości zwracanych
- Rejestry, które są „lotne”
- Rejestry, które są „nielotne”
Rejestry parametrów&Rejestry zwrotne
Rejestry parametrów służą do przekazywania parametrów! Dzięki przestrzeganiu konwencji wywoływania, odbiorcy wiedzą, gdzie znaleźć swoje argumenty, a wywołujący wiedzą, gdzie umieścić wartości argumentów. Rejestry parametrów są używane do przekazywania parametrów zgodnie z zasadami określonymi w konwencji wywoływania, a gdy więcej parametrów jest przekazywanych niż pozwala na to konwencja procesora, reszta parametrów jest przekazywana przy użyciu pamięci stosu.
Rejestry wartości zwrotnej przechowują wartości zwrotne funkcji, więc gdy funkcja kończy, jej zadaniem jest umieszczenie wartości zwrotnej w odpowiednim rejestrze, przed zwróceniem przepływu sterowania do wywołującego. Dzięki przestrzeganiu konwencji wywoływania, zarówno wywołujący, jak i wywołujący wiedzą, gdzie znaleźć wartość zwrotną funkcji.
Rejestry lotne & Rejestry nielotne
Rejestry lotne mogą być używane przez dowolną funkcję bez względu na poprzednie użycie lub wartości w tym rejestrze. Są one dobre dla rejestrów tymczasowych potrzebnych do połączenia sekwencji instrukcji, a także mogą być używane dla zmiennych lokalnych. Jednakże, ponieważ rejestry te mogą być swobodnie używane przez dowolną funkcję, nie możemy liczyć na to, że zachowają one swoje wartości podczas wywołania funkcji – z definicji (konwencji programowej)! Tak więc po powrocie funkcji jej wywołujący nie może powiedzieć, jakie wartości znajdowałyby się w tych rejestrach – każda funkcja może je dowolnie wykorzystać, choć ze świadomością, że nie możemy liczyć na to, że ich wartości zostaną zachowane po powrocie wywołanej funkcji. Tak więc, te rejestry są używane dla zmiennych i tymczasowych, które nie muszą obejmować wywołania funkcji.
Dla zmiennych, które muszą obejmować wywołanie funkcji, są nieulotne rejestry (i pamięć stosu, też). Te rejestry są zachowane przez wywołanie funkcji (przez oprogramowanie, które przestrzega konwencji wywoływania). Tak więc, jeśli funkcja chce mieć zmienną w szybkim rejestrze CPU, ale ta zmienna jest żywa w całym wywołaniu (ustawiona przed wywołaniem i używana po wywołaniu), możemy wybrać rejestr nieulotny, z (zdefiniowanym przez konwencję wywołującą) wymogiem, że poprzednia wartość rejestru jest przywracana po powrocie do wywołującego. Wymaga to zapisania bieżącej wartości nieulotnego rejestru (do pamięci stosu) przed ponownym użyciem rejestru i przywrócenia jej (używając poprzedniej wartości z pamięci stosu) po powrocie.