Zrozumienie tego, jak komputery są zorganizowane, jak wydają się działać na bardzo niskim poziomie, jest potrzebne do zrozumienia, jak działa program w języku asemblera. Na najbardziej uproszczonym poziomie, komputery mają trzy główne części:
- pamięć główna lub RAM, w której przechowywane są dane i instrukcje,
- procesor, który przetwarza dane poprzez wykonanie instrukcji, oraz
- wejście i wyjście (czasami skracane do I/O), które pozwalają komputerowi komunikować się ze światem zewnętrznym i przechowywać dane poza pamięcią główną, aby można je było później odzyskać.
Pamięć główna
W większości komputerów pamięć jest podzielona na bajty. Każdy bajt zawiera 8 bitów. Każdy bajt w pamięci ma również adres, który jest liczbą, która mówi, gdzie bajt jest w pamięci. Pierwszy bajt w pamięci ma adres 0, następny ma adres 1, i tak dalej. Podział pamięci na bajty sprawia, że jest ona adresowalna bajtowo, ponieważ każdy bajt otrzymuje unikalny adres. Adresy pamięci bajtowych nie mogą być używane do odnoszenia się do pojedynczego bitu bajtu. Bajt jest najmniejszym fragmentem pamięci, który może być adresowany.
Mimo że adres odnosi się do konkretnego bajtu w pamięci, procesory pozwalają na użycie kilku bajtów pamięci w rzędzie. Najczęstszym zastosowaniem tej funkcji jest użycie 2 lub 4 bajtów w rzędzie do reprezentowania liczby, zwykle całkowitej. Pojedyncze bajty są czasami również używane do reprezentowania liczb całkowitych, ale ponieważ mają one tylko 8 bitów długości, mogą pomieścić tylko 2 8lub 256 różnych możliwych wartości. Użycie 2 lub 4 bajtów w rzędzie zwiększa liczbę różnych możliwych wartości do odpowiednio 2 16, 65536 lub 2 32, 4294967296.
Kiedy program używa bajtu lub pewnej liczby bajtów w rzędzie do reprezentowania czegoś takiego jak litera, liczba lub cokolwiek innego, te bajty nazywane są obiektem, ponieważ wszystkie są częścią tej samej rzeczy. Nawet jeśli wszystkie obiekty są przechowywane w identycznych bajtach pamięci, są one traktowane tak, jakby miały "typ", który mówi, jak bajty powinny być rozumiane: albo jako liczba całkowita, albo znak, albo jakiś inny typ (jak wartość niecałkowita). O kodzie maszynowym można również myśleć jako o typie, który jest interpretowany jako instrukcje. Pojęcie typu jest bardzo, bardzo ważne, ponieważ definiuje, jakie rzeczy można i nie można zrobić z obiektem i jak interpretować bajty obiektu. Na przykład, nie jest ważne przechowywanie liczby ujemnej w obiekcie liczby dodatniej i nie jest ważne przechowywanie ułamka w liczbie całkowitej.
Adres, który wskazuje (jest adresem) obiektu wielobajtowego jest adresem do pierwszego bajtu tego obiektu - bajtu, który ma najniższy adres. Na marginesie, jedną ważną rzeczą, którą należy zauważyć jest to, że nie można określić typu obiektu - lub nawet jego rozmiaru - na podstawie jego adresu. W rzeczywistości, nie można nawet powiedzieć, jakiego typu jest obiekt, patrząc na niego. Program w języku asemblera musi śledzić, które adresy pamięci przechowują jakie obiekty i jak duże są te obiekty. Program, który to robi, jest bezpieczny dla typu, ponieważ robi tylko rzeczy z obiektami, które są bezpieczne dla ich typu. Program, który tego nie robi, prawdopodobnie nie będzie działał poprawnie. Zauważ, że większość programów w rzeczywistości nie przechowuje jawnie, jaki jest typ obiektu, po prostu dostęp do obiektów jest spójny - ten sam obiekt jest zawsze traktowany jako ten sam typ.
Procesor
Procesor uruchamia (wykonuje) instrukcje, które są przechowywane jako kod maszynowy w pamięci głównej. Oprócz możliwości dostępu do pamięci w celu przechowywania danych, większość procesorów posiada kilka małych, szybkich, o stałym rozmiarze przestrzeni do przechowywania obiektów, z którymi aktualnie pracuje. Przestrzenie te nazywane są rejestrami. Procesory zazwyczaj wykonują trzy typy instrukcji, chociaż niektóre instrukcje mogą być kombinacją tych typów. Poniżej przedstawiono kilka przykładów każdego typu w języku asemblera x86.
Instrukcje, które odczytują lub zapisują pamięć
Poniższa instrukcja języka asemblera x86 odczytuje (ładuje) 2-bajtowy obiekt z bajtu o adresie 4096 (0x1000 w systemie szesnastkowym) do 16-bitowego rejestru o nazwie 'ax':
mov ax, [1000h]
W tym języku asemblera, nawiasy kwadratowe wokół liczby (lub nazwy rejestru) oznaczają, że liczba ta powinna być użyta jako adres do danych, które powinny być użyte. Użycie adresu do wskazania danych nazywane jest indirekcją. W następnym przykładzie, bez nawiasów kwadratowych, inny rejestr, bx, faktycznie otrzymuje wartość 20, która jest do niego załadowana.
mov bx, 20
Ponieważ nie zostało użyte żadne pośrednictwo, do rejestru została wpisana sama wartość rzeczywista.
Jeśli operandy (rzeczy, które następują po mnemoniku), pojawiają się w odwrotnej kolejności, instrukcja, która ładuje coś z pamięci, zamiast tego zapisuje to do pamięci:
mov [1000h], ax
Tutaj pamięć pod adresem 1000h otrzyma wartość ax. Jeśli ten przykład zostanie wykonany zaraz po poprzednim, to 2 bajty pod adresami 1000h i 1001h będą 2-bajtową liczbą całkowitą o wartości 20.
Instrukcje, które wykonują operacje matematyczne lub logiczne
Niektóre instrukcje robią takie rzeczy jak odejmowanie lub operacje logiczne jak nie:
Przykład kodu maszynowego wcześniej w tym artykule byłby tym w języku asemblera:
dodać ax, 42
Tutaj 42 i ax są sumowane, a wynik jest przechowywany z powrotem w ax. W asemblerze x86 możliwe jest również połączenie dostępu do pamięci i operacji matematycznej w ten sposób:
add ax, [1000h]
Instrukcja ta dodaje do ax wartość 2-bajtowej liczby całkowitej przechowywanej pod adresem 1000h i zapisuje odpowiedź w ax.
lub ax, bx
Instrukcja ta oblicza or zawartości rejestrów ax i bx i zapisuje wynik z powrotem do ax.
Instrukcje, które decydują o tym, jaka będzie następna instrukcja
Zazwyczaj instrukcje są wykonywane w kolejności, w jakiej pojawiają się w pamięci, czyli w kolejności, w jakiej są wpisane w kodzie asemblera. Procesor po prostu wykonuje je jedna po drugiej. Jednak, aby procesory mogły wykonywać skomplikowane rzeczy, muszą wykonywać różne instrukcje w zależności od tego, jakie dane zostały im przekazane. Zdolność procesorów do wykonywania różnych instrukcji w zależności od wyniku czegoś nazywa się rozgałęzianiem. Instrukcje, które decydują o tym, jaka powinna być następna instrukcja nazywamy instrukcjami rozgałęzienia.
W tym przykładzie, załóżmy, że ktoś chce obliczyć ilość farby, której będzie potrzebował do pomalowania kwadratu o pewnej długości boku. Jednak ze względu na ekonomię skali sklep z farbami nie sprzeda im mniej niż ilość farby potrzebnej do pomalowania kwadratu o wymiarach 100 x 100.
Aby dowiedzieć się, ile farby będą musieli zdobyć w oparciu o długość kwadratu, który chcą pomalować, wymyślają ten zestaw kroków:
- odjąć 100 od długości boku
- jeśli odpowiedź jest mniejsza od zera, ustaw długość boku na 100
- pomnożyć długość boku przez siebie
Algorytm ten można wyrazić w następującym kodzie, gdzie ax jest długością boku.
mov bx, ax sub bx, 100 jge continue mov ax, 100 continue: mul ax
Ten przykład wprowadza kilka nowych rzeczy, ale pierwsze dwie instrukcje są znajome. Kopiują one wartość ax do bx, a następnie odejmują 100 od bx.
Jedną z nowych rzeczy w tym przykładzie jest etykieta, pojęcie występujące w językach asemblerowych w ogóle. Etykiety mogą być czymkolwiek programista chce (chyba, że jest to nazwa instrukcji, co mogłoby zmylić asembler). W tym przykładzie, etykietą jest 'continue'. Jest ona interpretowana przez asembler jako adres instrukcji. W tym przypadku jest to adres mult ax.
Kolejną nową koncepcją są flagi. W procesorach x86 wiele instrukcji ustawia "flagi" w procesorze, które mogą być użyte przez następną instrukcję do podjęcia decyzji, co zrobić. W tym przypadku, jeśli bx było mniejsze niż 100, sub ustawi flagę, która mówi, że wynik był mniejszy niż zero.
Następną instrukcją jest jge, która jest skrótem od 'Jump if Greater than or Equal to'. Jest to instrukcja rozgałęzienia. Jeśli flagi w procesorze określają, że wynik był większy lub równy zero, to zamiast przejść do następnej instrukcji, procesor skoczy do instrukcji z etykietą continue, czyli mul ax.
Ten przykład działa dobrze, ale nie jest tym, co napisałaby większość programistów. Instrukcja odejmowania ustawia flagę poprawnie, ale zmienia również wartość, na której operuje, co wymagało skopiowania ax do bx. Większość języków asemblerowych pozwala na instrukcje porównania, które nie zmieniają żadnego z przekazywanych argumentów, ale nadal ustawiają flagi poprawnie i asembler x86 nie jest tu wyjątkiem.
cmp ax, 100 jge continue mov ax, 100 continue: mul ax
Teraz, zamiast odejmować 100 od ax, sprawdzać czy ta liczba jest mniejsza od zera i przypisywać ją z powrotem do ax, ax pozostaje niezmieniona. Flagi są nadal ustawiane w ten sam sposób, a skok jest wykonywany w tych samych sytuacjach.
Wejście i wyjście
Podczas gdy wejście i wyjście są fundamentalną częścią obliczeń, nie ma jednego sposobu, w jaki są one wykonywane w języku asemblera. Dzieje się tak dlatego, że sposób działania I/O zależy od konfiguracji komputera i systemu operacyjnego, a nie tylko od rodzaju procesora. W sekcji przykładów przykład Hello World używa wywołań systemu operacyjnego MS-DOS, a przykład po nim używa wywołań systemu BIOS.
Możliwe jest wykonywanie operacji wejścia/wyjścia w języku asemblera. W rzeczy samej, język asemblera może wyrazić wszystko, co komputer jest w stanie zrobić. Jednakże, nawet jeśli istnieją instrukcje dodawania i rozgałęziania w języku asemblera, które zawsze będą robić to samo, nie ma instrukcji w języku asemblera, które zawsze wykonują operacje wejścia/wyjścia.
Ważną rzeczą, którą należy zauważyć jest to, że sposób działania I/O nie jest częścią żadnego języka asemblera, ponieważ nie jest częścią działania procesora.