POLITECHNIKA GDAŃSKA
WYDZIAŁ ELEKTRONIKI, TELEKOMUNIKACJI i INFORMATYKI
KATEDRA ARCHITEKTURY SYSTEMÓW KOMPUTEROWYCH
Architektura komputerów
Materiały pomocnicze do wykładu dla kierunku Informatyka
cz. I
Opracował dr inż. Andrzej Jędruch
Gdańsk 2008
Literatura do przedmiotu "Architektura komputerów"
Tanenbaum A.S.: Strukturalna organizacja systemów komputerowych. Wyd. Helion 2006.
Stallings W.: Organizacja i architektura systemu komputerowego. Warszawa WNT 2000.
Chalk B.S.: Organizacja i architektura komputerów. Warszawa WNT 1998.
Null L., Lobur J.: Struktura organizacyjna i architektura systemów komputerowych. Wyd. Helion 2004.
Lewis D.W.: Między asemblerem a językiem C. Wyd. RM. 2004.
Schmit L.: Procesory Pentium. Narzędzia optymalizacji. Warszawa wyd. Mikom 1997.
Biernat J.: Architektura komputerów. Wrocław 2005. Oficyna Wydawnicza Politechniki Wrocławskiej.
Komorowski W.: Krótki kurs architektury i organizacji komputerów. Mikom, Warszawa 2004.
Błaszczyk A.: Win32ASM. Asembler w Windows. Gliwice wyd. Helion 2003.
Literatura dodatkowa
Hyde R.: Profesjonalne programowanie. 2006, wyd. Helion.
Irvine K.R.: Asembler dla procesorów Intel. Gliwice wyd. Helion 2003.
Dudek A.: Jak pisać wirusy. Warszawa wyd. Read Me 1994.
Pirogow V.: Asembler. Podręcznik programisty. Wyd. Helion 2005.
Wróbel E. i in.: Praktyczny kurs asemblera. Wyd. Helion 2004.
Wróbel E. i in.: Asembler. Ćwiczenia praktyczne. Wyd. Helion 2002.
Komorowski W.: Instrumenta computatoria (wybrane architektury komputerów). Gliwice wyd. Helion 2000.
Rozwój konstrukcji komputerów i oprogramowania
1834 Babbage — projekt urządzenia "Analytical Engine"
1854 Boole: "Laws of thought"
1930 Laboratorium firmy Bell: komputer elektromechaniczny
1938 Urządzenie liczące w pełni elektroniczne "Colossus" (Turing, Flowers, Newman)
1941 Kalkulator elektromechaniczny (K. Zuse), mnożenie 3 s
1942 - 1946 pierwsze komputery elektroniczne
1944 MARK I (H. Aiken), Harvard University
1945 ENIAC
1945 koncepcje J. von Neumanna
1948 Opracowanie tranzystora
1949 Rozwój oprogramowania: biblioteki podprogramów, asembler
1951 Komputer EDVAC (von Neumann) — program przechowywany w pamięci
1954 Język programowania FORTRAN
1954 IBM 650 — pierwszy komputer produkowany masowo
1955 Pierwszy komputer tranzystorowy
1958 Język Algol
1958 komputery tzw. drugiej generacji (tranzystorowe)
1958 Komputer Atlas z pamięcią wirtualną
1959 Komputer PDP-1
1962 Systemy z podziałem czasu
1968 komputery tzw. trzeciej generacji (układy scalone)
1969 System Unix
lata 70 minikomputery
1971 Procesor Intel 4004
1972 Język C
1977 Komputery osobiste: Apple, Commodore
1980 komputery osobiste
1981 Komputer IBM PC (16 KB RAM)
1982 Turbo-Pascal
1985 Język C++
1990 System Windows 3.0
Język Java
1995 Rozwój systemów sieciowych, Internet
Komputery w Polsce
1958 Komputer XYZ (Zakład Aparatów Matematycznych PAN)
1960 Komputer ZAM 2
1960 Język programowania SAKO
1964 Komputery ZAM 21
1972 Komputery ODRA (Elwro Wrocław)
1975 Komputer Momik
Procesory zgodne z architekturą IA-32
8086/88 1978
80286 1982
386 1985
486 1989
Intel Pentium 1993
AMD K6 1996
Intel Pentium III 1999
AMD Athlon 1999
Intel Pentium 4 2000
AMD Athlon 64 2004
Intel Pentium D 2005
Intel Core Duo 2006
Model komputera wg von Neumanna
w roku 1945 matematyk amerykański von Neumann wraz ze współpracownikami zaproponował pewien model wykonywania obliczeń — mimo upływu wielu lat prawie wszystkie współczesne komputery ogólnego przeznaczenia stanowią realizację tego modelu;
zasadniczą i centralną część każdego komputera stanowi procesor jego własności decydują o pracy całego komputera;
procesor steruje podstawowymi operacjami komputera, wykonuje operacje arytmetyczne i logiczne, przesyła i odbiera sygnały, adresy i dane z jednego podzespołu komputera do drugiego; procesor pobiera kolejne instrukcje programu i dane z pamięci głównej (operacyjnej) komputera, przetwarza je i ewentualnie odsyła wyniki do pamięci;
komunikacja ze światem zewnętrznym realizowana jest za pomocą urządzeń wejścia/wyjścia;
do budowy współczesnych komputerów używane są elementy elektroniczne — inne rodzaje elementów (np. mechaniczne) są znacznie wolniejsze (o kilka rzędów); ponieważ elementy elektroniczne pracują pewnie i stabilnie jako elementy dwustanowe, informacje przechowywane i przetwarzane przez komputer mają postać ciągów zerojedynkowych;
procesor składa się z wielu różnych podzespołów wykonawczych, które wykonują określone działania (np. sumowanie liczb) — podzespoły te na rysunku reprezentowane są przez jednostkę arytmetyczno-logiczną (ang. arithmetic logic unit); podzespoły wykonawcze podejmują działania wskutek sygnałów otrzymywanych z jednostki sterującej;
we współczesnych procesorach wykonanie nawet najprostszej operacji dodawania wymaga wysłania sygnałów do co najmniej kilkunastu podzespołów procesora; tak więc algorytm wykonywania obliczeń powinien być zakodowany w formie ciągu poleceń, które przechowywane są w pamięci i sukcesywnie odczytywane przez procesor; po otrzymaniu polecenia procesor wysyła sygnał elektryczny do odpowiedniego podzespołu;
taki sposób kodowania algorytmów wymaga dokładnej znajomości zasad funkcjonowania poszczególnych podzespołów procesora, jest bardzo rozwlekły i kłopotliwy;
ażeby uprościć programowanie, przyjęto pewien podstawowy zbiór operacji (dla konkretnego typu procesora) i każdej operacji przypisano ustalony kod w postaci ciągu zero-jedynkowego; do zbioru operacji podstawowych należą zazwyczaj cztery działania arytmetyczne, operacje logiczne na bitach (negacja, suma logiczna, iloczyn logiczny), operacje przesyłania, operacje porównywania i wiele innych; dla różnych typów procesorów liczba zdefiniowanych operacji zawiera się w granicach od kilkudziesięciu do kilkuset;
operacje zdefiniowane w zbiorze podstawowym nazywane są rozkazami lub instrukcjami procesora; każdy rozkaz ma przypisany ustalony kod zero-jedynkowy; podstawowy zbiór operacji procesora jest zwykle nazywany listą rozkazów procesora;
w takim ujęciu algorytm obliczeń przedstawiany jest za pomocą operacji ze zbioru podstawowego; algorytm zakodowany jest w postaci sekwencji ciągów zero-jedynkowych zdefiniowanych w podstawowym zbiorze operacji — tak zakodowany algorytm nazywać będziemy programem w języku maszynowym;
program przechowywany jest w pamięci; wykonywanie programu polega na przesyłaniu kolejnych ciągów zero-jedynkowych z pamięci głównej do układu sterowania procesora; zadaniem układu sterowania, po odczytaniu takiego ciągu, jest wygenerowanie odpowiedniej sekwencji sygnałów kierowanych do poszczególnych podzespołów, tak by w rezultacie wykonać wymaganą operację (np. dodawanie);
program ten musi bezpośrednio dostępny, tak by niezwłocznie po zakończeniu jednej operacji można było zacząć następną; oznacza to, że program musi być przechowywany w pamięci ściśle współdziałającej z procesorem; zatem pamięć współpracująca z procesorem musi być dostatecznie szybka, tak by oczekiwanie na odczytanie potrzebnych informacji nie powodowało przestojów w pracy procesora;
koncepcja programu przechowywanego w pamięci stanowi kluczowy element modelu von Neumanna;
program używający tych rozkazów nazywany jest programem w języku maszynowym; można więc powiedzieć, że moduł sterowania procesora przekształca każdy rozkaz (instrukcję) języka maszynowego w odpowiednią sekwencję sygnałów koniecznych do wykonania danego rozkazu;
instrukcje (rozkazy) przekazywane do procesora mają postać jednego lub kilku bajtów o ustalonej zawartości; i tak na przykład przekazanie procesorowi Pentium (lub Athlon) rozkazu (instrukcji) w formie bajtu 01000010 spowoduje zwiększenie liczby umieszczonej w (opisanym dalej) rejestrze EDX o 1, natomiast przekazanie bajtu 01001010 zmniejszenie tej liczby o 1; często polecenia przekazywane procesorowi składają się z kilku bajtów, np. bajty 10000000 11000111 00100101 są traktowane przez procesor jako polecenie dodania liczby 37 do liczby znajdującej się w rejestrze BH;
język maszynowy jest kłopotliwy w użyciu nawet dla specjalistów; znacznie wygodniejszy jest spokrewniony z nim język asemblera, który będzie omawiany dalej.
Pamięć główna (operacyjna)
pamięć główna (operacyjna) składa z dużej liczby komórek (np. kilkadziesiąt milionów), a każda komórka utworzona jest z pewnej liczby bitów (gdy komórkę tworzy 8 bitów, to mówimy, że pamięć ma organizację bajtową); poszczególne komórki mogą zawierać dane, na których wykonywane są obliczenia, jak również mogą zawierać rozkazy (instrukcje) dla procesora;
w większości współczesnych komputerów pamięć ma organizację bajtową; poszczególne bajty (komórki) pamięci są ponumerowane od 0 — numer komórki pamięci nazywany jest jej adresem fizycznym; adres fizyczny przekazywany jest przez procesor (lub inne urządzenie) do podzespołów pamięci w celu wskazania położenia bajtu, który ma zostać odczytany lub zapisany; zbiór wszystkich adresów fizycznych nazywa się fizyczną przestrzenią adresową;
w wielu współczesnych procesorach adresy fizyczne są 32-bitowe, co określa od razu maksymalny rozmiar zainstalowanej pamięci: 232 = 4 294 967 296 bajtów (4 GB); w procesorach 8086/88, które stosowane były w pierwszych komputerach IBM PC, adresy są 20-bitowe, skąd wynika, że maksymalny rozmiar zainstalowanej pamięci wynosił 220 = 1 048 576 bajtów (1 MB);
w trakcie pracy procesor komunikuje się z pamięcią operacyjną, wykonując operacje zapisu i odczytu danych, a także pobierając kolejne rozkazy do wykonania;
pojedynczy bajt umożliwia tylko zapisywanie liczb nie przekraczających 255; tworzone są więc zespoły bajtów:
16-bitowe słowa,
32-bitowe podwójne słowa,
64-bitowe poczwórne słowa,
w miarę potrzeby tworzy się także większe zespoły bajtów;
producenci procesorów ustalają konwencję numeracji bitów w bajtach i słowach — numeracja przyjęta m. in. w procesorach zgodnych z architekturą IA-32 (np. Pentium, Athlon) pokazana jest na rysunku;
Architektura IA-32
w komputerze IBM PC (r. 1981) zastosowano procesor 8088 firmy Intel; w coraz to nowszych konstrukcjach komputerów miejsce tego procesora zajmowały kolejno procesory 80286, 80386, 80486 (ściśle: i486), różne wersje Pentium i w ostatnich latach Core Duo, pojawiające się zazwyczaj co 4 lata; każdy z nich charakteryzuje się coraz większą szybkością i złożonością;
stopniowo, wytwarzanie procesorów kompatybilnych z wymienionymi podejmowały także inne firmy, spośród których najbardziej znana jest firma AMD; zarówno procesory firmy Intel, jak i AMD (np. Athlon) realizują prawie identyczny zestaw operacji, tak że z punktu widzenia oprogramowania nie potrzeba ich odróżniać; występują natomiast istotne różnice w organizacji wewnętrznej procesorów, co ma istotny wpływ na wydajność procesora w różnych zastosowaniach;
omawiane procesory klasyfikowane są jako procesy zgodne z architekturą IA-32; charakterystyczną cechą tych procesorów jest kompatybilność wsteczna, co oznacza że każdy nowy model procesora realizuje funkcje swoich poprzedników; m.in. programy dla komputera IBM PC opracowane na początku lat osiemdziesiątych mogą być wykonywane także w komputerze wyposażonym w procesor Pentium; niekiedy dla wygody, mając na myśli architekturę IA-32, będziemy po prostu mówić o procesorze Pentium czy o procesorze Athlon.
Pamięć fizyczna i wirtualna
rozkazy (instrukcje) programu odczytujące dane z pamięci operacyjnej (czy też zapisujące wyniki) zawierają informacje o położeniu danej w pamięci, czyli zawierają adres danej; w wielu procesorach adres ten ma postać adresu fizycznego, czyli wskazuje jednoznacznie komórkę pamięci, gdzie znajduje się potrzebna dana; w trakcie operacji odczytu adres fizyczny kierowany do układów pamięci poprzez linie adresowe, a ślad za tym układy pamięci odczytują i odsyłają potrzebną daną;
taki nieskomplikowany sposób adresowania okazał się dość niepraktyczny, utrudniając efektywne wykorzystanie pamięci, szczególnie w systemach wielozadaniowych (np. MS Windows, Linux); w rezultacie wieloletniego rozwoju architektury procesorów i systemów operacyjnych wyłoniła się koncepcja pamięci wirtualnej, będącej pewną iluzją pamięci rzeczywistej (fizycznej);
programista, tworząc nowy program przyjmuje, że ma do dyspozycji pewien obszar pamięci, którego rozmiar w przypadku procesora zgodnego z architekturą IA-32 może dochodzić do 4 GB; jednak rozmiar pamięci rzeczywiście zainstalowanej w komputerze może być mniejszy i w typowych komputerach zawiera się w przedziale między 256 MB i 1 GB; odpowiednie układy procesora, sterowane przez system operacyjny, dokonują transformacji adresów, którymi posługuje się programista na adresy w istniejącej pamięci fizycznej, zwykle wspomaganej przez pamięć dyskową;
pamięć operacyjna komputera w kształcie widzianym przez programistę nosi nazwę pamięci wirtualnej, a zbiór wszystkich możliwych adresów w pamięci wirtualnej nosi nazwę wirtualnej przestrzeni adresowej; czasami używany jest termin pamięć logiczna w znaczeniu pamięci wirtualnej; analogiczne znaczenie, jak w przypadku pamięci fizycznej, ma termin adres wirtualny (logiczny);
transformacja adresów z przestrzeni wirtualnej na adresy fizyczne (rzeczywiście istniejących komórek pamięci) jest technicznie dość skomplikowana i nie może przy tym nadmiernie przedłużać wykonywania rozkazu; problemy te zostały jednak skutecznie rozwiązane, a związane z tym wydłużenie czasu wykonywania programu zwykle nie przekracza kilku procent;
jednocześnie programista może sobie wyobrażać, że pamięć wirtualna jest rzeczywiście istniejącą pamięcią — taki właśnie punkt widzenia przyjęto w początkowej części niniejszego opracowania; stopniowo, w dalszej części spróbujemy wyjaśnić zasady działania pamięci wirtualnej i mechanizmy transformacji adresów;
w systemie Windows pamięć wirtualna ma rozmiar 4 GB, ale dla poszczególnych aplikacji dostępny jest obszar nieco mniejszy niż 2 GB (zob. rys.); pełny obszar 4 GB dla aplikacji udostępniany jest dopiero w systemach 64-bitowych;
Adresowanie pamięci
przypuśćmy, że w pewnym programie (np. w języku C) zdefiniowano dwie zmienne 32-bitowe: a, b; w trakcie wykonywania programu zmienne te zajmować będą dwa (zazwyczaj przyległe) obszary 4-bajtowe;
położenie tych zmiennych w pamięci wirtualnej określane jest poprzez podanie położenia bajtu o najniższym adresie w obszarze 4-bajtowym — ilustruje to rysunek; adres tego bajtu nazywany jest także przesunięciem lub offsetem zmiennej; innymi słowy offset, jest odległością zmiennej, liczoną w bajtach, od początku obszaru pamięci (wirtualnej);
zatem adres zawarty w rozkazie (instrukcji) nie zawiera adresu fizycznego danej, czyli nie wskazuje bezpośrednio jej położenia w pamięci fizycznej, lecz jedynie odległość zmiennej od początku pamięci wirtualnej.
Reprezentacja danych w pamięci komputera
współczesne komputery przetwarzają dane reprezentujące wartości liczbowe, teksty, dane opisujące dźwięki i obrazy i wiele innych; wszystkie one w pamięci komputera mają postać ciągów złożonych z zer i jedynek;
dane liczbowe przedstawiane są w różnych formatach, ale z punktu widzenia działania procesora szczególne znaczenie mają liczby całkowite — opisy techniczne wielu rozkazów procesora odnoszą się bowiem do operacji wykonywanych na liczbach całkowitych kodowanych w naturalnym kodzie binarnym (liczby bez znaku) albo do operacji wykonywanych na liczbach całkowitych binarnych ze znakiem;
nie oznacza to, że rozkazy procesora nie mogą wykonywać działań na liczbach ułamkowych — programista może odpowiednio dostosować algorytm obliczeń w taki sposób, ażeby działania na ułamkach zostały zastąpione przez działania na liczbach całkowitych;
niezależnie od tego, z głównym procesorem stowarzyszony jest procesor pomocniczy, nazywany koprocesorem arytmetycznym, który wykonuje działania na liczbach na liczbach zmiennoprzecinkowych (zmiennopozycyjnych); liczby te kodowane są w specyficzny sposób — zagadnienia związane z liczbami zmiennoprzecinkowymi omawiane będą w dalszej części wykładu;
rozpatrzymy teraz podstawowe formaty kodowania liczb całkowitych i tekstów powszechnie stosowane we współczesnych komputerach.
Kodowanie liczb całkowitych bez znaku
w wielu współczesnych procesorach, w tym w procesorach zgodnych z architekturą IA-32, wyróżnia się:
liczby całkowite bez znaku, kodowane w naturalnym kodzie binarnym;
liczby całkowite ze znakiem, kodowane zazwyczaj w kodzie U2;
wartość liczby binarnej bez znaku określa poniższe wyrażenie
gdzie m oznacza liczbę bitów rejestru lub komórki pamięci;
poniższe rysunki pokazują numerację bitów i przyporządkowanie wag dla liczb 8- i 16-bitowych; kodowanie liczb 16- i 32-bitowych jest analogiczne;
Przykłady
Liczba dziesiętna |
Reprezentacja binarna w formacie liczby bez znaku |
|
253 |
8-bit. |
1111 1101 |
|
16-bit. |
0000 0000 1111 1101 |
|
32-bit. |
0000 0000 0000 0000 0000 0000 1111 1101 |
45 708 |
16-bit. |
1011 0010 1000 1100 |
|
32-bit. |
0000 0000 0000 0000 1011 0010 1000 1100 |
2 007 360 447 |
32-bit. |
0111 0111 1010 0101 1110 0011 1011 1111 |
Zakresy liczb bez znaku:
liczby 8-bitowe: <0, 255>
liczby 16-bitowe <0, 65535>
liczby 32-bitowe <0, 4 294 967 295>
liczby 64-bitowe <0, 18 446 744 073 709 551 615>
(osiemnaście trylionów
czterysta czterdzieści sześć biliardów
siedemset czterdzieści cztery biliony
siedemdziesiąt trzy miliardy
siedemset dziewięć milionów
pięćset pięćdziesiąt jeden tysięcy
sześćset piętnaście)
1 trylion = 1018
Kodowanie liczb całkowitych ze znakiem
wyróżnia się dwa typowe sposoby kodowania liczb binarnych ze znakiem: kodowanie w systemie znak-moduł i kodowanie w systemie U2 (uzupełnienia do dwóch); ten drugi rodzaj kodowania, ze względu na znacznie łatwiejszą realizację sprzętową operacji dodawania i odejmowania jest powszechnie stosowany we współczesnych procesorach;
wartość liczby binarnej kodowanej w systemie znak_moduł określa poniższe wyrażenie
gdzie m oznacza liczbę bitów rejestru lub komórki pamięci, zaś s stanowi wartość bitu znaku.
Zakresy liczb w systemie znak-moduł:
liczby 8-bitowe: <127, +127>
liczby 16-bitowe <32767, +32767>
liczby 32-bitowe <2 147 483 647, +2 147 483 647>
liczby 64-bitowe
<9 223 372 036 854 775 807, +9 223 372 036 854 775 807>
wartość liczby binarnej kodowanej w systemie U2 określa poniższe wyrażenie
gdzie m oznacza liczbę bitów rejestru lub komórki pamięci.
Przykłady
Liczba dziesiętna |
Reprezentacja binarna jako liczby ze znakiem w kodzie U2 |
|
−3 |
8-bit. |
1111 1101 |
|
16-bit. |
1111 1111 1111 1101 |
|
32-bit. |
1111 1111 1111 1111 1111 1111 1111 1101 |
-19828 |
16-bit. |
1011 0010 1000 1100 |
|
32-bit. |
1111 1111 1111 1111 1011 0010 1000 1100 |
2 007 360 447 |
32-bit. |
0111 0111 1010 0101 1110 0011 1011 1111 |
Zakresy liczb w systemie U2:
liczby 8-bitowe: <128, +127>
liczby 16-bitowe <32768, +32767>
liczby 32-bitowe <2 147 483 648, +2 147 483 647>
liczby 64-bitowe
<9 223 372 036 854 775 808, +9 223 372 036 854 775 807>
Przechowywanie liczb w pamięci komputera
liczby występujące w programach często przekraczają 255 i muszą być zapisywane na dwóch, czterech lub na większej liczbie bajtów;
w produkowanych obecnie procesorach przyjęto dwa podstawowe schematy rozmieszczenia poszczególnych bajtów liczby w pamięci:
mniejsze niżej (ang. little endian)
mniejsze wyżej (ang. big endian);
schematy te wyjaśnia poniższy rysunek.
w architekturze IA-32 stosowany jest schemat mniejsze niżej (ang. little endian); schemat mniejsze wyżej stosowany jest m.in. w procesorach firmy Motorola, w wielu procesorach o architekturze RISC;
schemat mniejsze wyżej stosowany jest także w oprogramowaniu sieciowym: liczby całkowite zawarte w nagłówkach pakietów generowanych przez protokoły TCP/IP kodowane są wg schematu mniejsze wyżej; z tego powodu definiuje się funkcje konwersji, np. w języku C dostępna jest funkcja biblioteczna htonl ( ), która przekształca liczby long integer kodowane wg schematu przyjętego w danym komputerze na liczby w formacie stosowanym w sieci;
identyfikację schematu reprezentacji liczb stosowanego w danym komputerze można przeprowadzić za pomocą niżej podanego fragmentu programu w języku C;
// zakładamy, że wartości typu int są 32-bitowe
unsigned int liczba = 0x12345678;
unsigned char ∗ wsk = (unsigned char ∗) & liczba;
if (wsk[0] == 0x12 )
printf ("\nFormat mniejsze wyżej (big endian)");
else
printf ("\nFormat mniejsze niżej (little endian)");
Kodowanie tekstów
od wielu lat do kodowania tekstów zapisanych w alfabetach łacińskich stosuje się kod ASCII (ang. American Standard Code for Information Interchange); pierwotnie do kodowania używano 7 bitów, co pozwalało na zakodowanie 128 znaków; w wersji podstawowej kod ASCII obejmuje małe i wielkie litery alfabetu angielskiego (łacińskiego), cyfry, znaki przestankowe, itp.; poniżej podano przykładowe kody ASCII różnych znaków;
A |
0100 0001 |
41H |
B |
0100 0010 |
42H |
C |
0100 0011 |
43H |
D |
0100 0100 |
44H |
E |
0100 0101 |
45H |
F |
0100 0110 |
46H |
— — — — — — |
||
Y |
0101 1001 |
59H |
Z |
0101 1010 |
5AH |
a |
0110 0001 |
61H |
b |
0110 0010 |
62H |
c |
0110 0011 |
63H |
d |
0110 0100 |
64H |
e |
0110 0101 |
65H |
f |
0110 0110 |
66H |
— — — — — |
||
y |
0111 1001 |
79H |
z |
0111 1010 |
7AH |
0 |
0011 0000 |
30H |
1 |
0011 0001 |
31H |
2 |
0011 0010 |
32H |
3 |
0011 0011 |
33H |
— — — — — — |
||
8 |
0011 1000 |
38H |
9 |
0011 1001 |
39H |
! |
0010 0001 |
21H |
" |
0010 0010 |
22H |
# |
0010 0011 |
23H |
$ |
0010 0100 |
24H |
— — — — — |
||
{ |
0111 1011 |
7BH |
| |
0111 1100 |
7CH |
znaki ASCII o kodach zawartych w przedziale <0, 31> zostały zdefiniowane jako znaki sterujące urządzeniami wejścia/wyjścia; najczęściej używane są znaki powrotu karetki CR (ang. Carriage Return, kod 13 = 0DH) i nowej linii LF (ang. LineFeed, kod 10 = 0AH); rzadziej stosuje się, np. znak FF (ang. FormFeed, kod 12 = 0CH), który powoduje przesunięcie papieru w drukarce do nowej strony;
poprzez wykorzystanie ósmego bitu pojawiła się możliwość kodowania dodatkowych znaków; w ten powstało wiele rozszerzeń podstawowego kodu ASCII dostosowanych do alfabetów narodowych, przy czym podstawowy zestaw znaków ASCII pozostał niezmieniony; poniższa tabela zawiera kilka przykładowych sposobów kodowania liter alfabetu polskiego;
Kodowanie wybranych liter alfabetu polskiego
Znak |
a |
ą |
A |
Ą |
kody znaków podano w zapisie szesnastkowym |
||||
Latin 2 |
61 |
A5 |
41 |
A4 |
Windows 1250 |
61 |
B9 |
41 |
A5 |
ISO 8859-2 |
61 |
B1 |
41 |
A1 |
Mazovia |
61 |
86 |
41 |
8F |
maksymalna liczba znaków przy stosowaniu kodów 8-bitowych wynosi 256, z czego początkowe 32 kody są kodami sterującymi i nie są używane do kodowania znaków widocznych — z pewnością nie wystarczy to do kodowania liter alfabetów europejskich, nie mówiąc już o alfabetach krajów dalekiego wschodu; z tego względu podjęto na początku lat dziewięćdziesiątych ubiegłego stulecia podjęto prace nad rozwojem kodów 16- i 32-bitowych — tematyka ta opisana jest na następnych stronach.
Uniwersalny zestaw znaków
ustalenie jednolitych standardów kodowania znaków stało się szczególnie ważne ze względu na rozwój technik przesyłania informacji przez sieci komputerowe, a zwłaszcza przez Internet;
standard międzynarodowy ISO 10646 definiuje Uniwersalny Zestaw Znaków USC - ang. Universal Character Set; można uważać, że UCS pokrywa wszystkie istniejące dotychczas standardy kodowania znaków; praktycznie USC jest identyczny z zestawem Unicode, który jest tworzony przez organizacje producentów oprogramowania;
standardy UCS/Unicode zawierają znaki potrzebne do reprezentacji tekstów praktycznie we wszystkich znanych językach; obejmują nie tylko znaki alfabetu łacińskiego, greki, cyrylicy, arabskiego, ale także znaki chińskie, japońskie i wiele innych;
znaki Unicode/UCS o kodach U+0000 do U+007F są identyczne ze znakami kodu ASCII (standard ISO 646 IRV); z kolei znaki z przedziału U+0000 do U+00FF są identyczne ze znakami kodu ISO 8859-1 (kod Latin-1);
poniżej podano przykładowe kody znaków w zapisie szesnastkowym w standardzie UCS/Unicode;
a |
0061 |
A |
0041 |
ą |
0105 |
Ą |
0104 |
b |
0062 |
B |
0042 |
c |
0063 |
C |
0043 |
ć |
0107 |
Ć |
0106 |
d |
0064 |
D |
0044 |
e |
0065 |
E |
0045 |
ę |
0119 |
Ę |
0118 |
standard ISO 10646 definiuje zestaw znaków 31-bitowych; jak dotychczas używany jest 16-bitowy podzbiór obejmujący 65534 początkowych pozycji (czyli 0x0000 do 0xFFFD), oznaczany skrótem BMP (ang. Basic Multilingual Plane);
wprawdzie standardy ISO 10646 i Unicode stosują identyczne kody znaków, to jednak pojawiają się różne oznaczenia zbliżonych formatów, co powoduje pewien zamęt terminologiczny; wydaje się, że częściej używana jest terminologia Unicode;
współczesne języki programowania definiują typy danych przeznaczone do przechowywania 16-bitowych znaków UCS/Unicode („wide character”), np. w języku C++ stosowany jest typ wchar_t.;
zauważmy, że zakodowanie typowego znaku z podzbioru BMP wymaga użycia dwóch bajtów, które mogą być przechowywane wg schematu mniejsze niżej lub mniejsze wyżej (ang. little endian / big endian); poniższa tablica pokazuje różne reprezentacje kodów początkowych liter alfabetu języka polskiego;
Znak |
a |
ą |
A |
Ą |
Unicode (mniejsze niżej) |
61 00 |
05 01 |
41 00 |
04 01 |
Unicode (mniejsze wyżej) |
00 61 |
01 05 |
00 41 |
01 04 |
ze względu na stosowanie dwóch formatów przechowywania liczb mniejsze niżej / mniejsze wyżej, stosowane są dodatkowe bajty identyfikujące rodzaj kodowania; bajty te oznaczane są skrótem BOM (ang. Byte Order Mark — znacznik kolejności bajtów) przesyłane są przed właściwym tekstem; postać znacznika BOM podano w poniższej tabeli;
Dodatkowe bajty na początku strumienia (BOM — ang. byte order mark) identyfikujące rodzaj kodowania)
Unicode |
Znaki 16-bitowe |
Znaki 32-bitowe |
mniejsze niżej (ang. little endian) |
FF FE |
FF FE 00 00 |
mniejsze wyżej (ang. big endian) |
FE FF |
00 00 FE FF |
UTF-8 |
EF BB BF *) |
*) znacznik BOM w kodowaniu UTF-8 można pominąć
Formaty UTF
tablice kodów UCS/Unicode przyporządkowują poszczególnym znakom ustalone liczby całkowite; istnieje kilka sposobów przedstawiania tych liczb w postaci bajtów; spośród tych sposobów najbardziej rozpowszechniony jest format UTF-8 (ang. 8-bit Unicode Transformation Format), natomiast rzadziej spotyka się formaty UTF-16 i UTF-32;
podstawową zaletą formatu UTF-8 jest zwarte kodowanie, w szczególności kody ASCII są nadal kodowane na jednym bajcie, a znacznie rzadziej używane znaki narodowe kodowane są za pomocą dwóch lub trzech bajtów — w rezultacie tekst w języku polskim zakodowany w formacie UTF-8 jest zazwyczaj o około 5% dłuższy od tekstu w formacie ISO 8859-2; kodowanie w formacie UTF-8 jest szeroko stosowane w Internecie;
dodatkową zaletą kodowania UTF-8 jest to, że w kodach znaków nie występują bajty zerowe; w systemie Unix (Linux), jak również w języku C, bajt o wartości 0 użyty w ciągu znaków oznacza zakończenie tego ciągu; ponieważ w przypadku tekstu w alfabecie łacińskim zakodowanego w standardzie UCS-2 (lub UCS-4) liczba 0 występuje w kodzie każdego znaku z podstawowego zbioru ASCII, konieczne jest dostosowanie systemu do takiego sposobu kodowania, co nastręcza sporo kłopotów;
kodowanie w formacie UTF-8 oparte jest na następujących regułach:
Znaki UCS/Unicode o kodach U+0000 do U+007F (czyli znaki kodu ASCII) są kodowane jako pojedyncze bajty o wartościach z przedziału 0x00 do 0x7F. Oznacza to, że pliki zawierające wyłącznie 7-bitowe kody ASCII mają taką samą postać zarówno w kodzie ASCII jak i w UTF-8.
Wszystkie znaki o kodach większych od U+007F są kodowane jako sekwencja kilku bajtów, z których każdy ma ustawiony najstarszy bit na 1. Pierwszy bajt w sekwencji kilku bajtów jest zawsze liczbą z przedziału 0xC0 do 0xFD i określa ile bajtów następuje po nim. Wszystkie pozostałe bajty zawierają liczby z przedziału 0x80 do 0xBF. Takie kodowanie pozwala, w przypadku utraty jednego z bajtów, na łatwe zidentyfikowanie kolejnej sekwencji bajtów (ang. resynchronization).
Kodowanie UTF-8 teoretycznie pozwala na utworzenie 6 bajtów, ale przypadku znaków BMP generowane są trzy bajty. Obowiązuje kolejność bajtów wg zasad "mniejsze wyżej". Bajty 0xFE i 0xFF są nieużywane w kodzie UTF-8.
Opcjonalnie, dla wskazania, że ciąg bajtów zawiera znaki zakodowane w formacie UTF-8 stosuje się (wcześniej omawiany) znacznik BOM, który w tym przypadku ma postać: EF BB BF
podana niżej tablica określa sposób kodowania UTF-8 dla różnych wartości kodów znaków; bity oznaczone xxx zawierają reprezentację binarną kodu znaku; dla każdego znaku może być użyta tylko jedna, najkrótsza sekwencja bajtów; warto zwrócić uwagę, że liczba jedynek z lewej strony pierwszego bajtu jest równa liczbie bajtów reprezentacji UTF-8.
Zakresy kodów |
Reprezentacja w postaci UTF-8 |
|
od |
do |
|
U-00000000 |
U-0000007F |
0xxxxxxx |
U-00000080 |
U-000007FF |
110xxxxx 10xxxxxx |
U-00000800 |
U-0000FFFF |
1110xxxx 10xxxxxx 10xxxxxx |
U-00010000 |
U-001FFFFF |
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
U-00200000 |
U-03FFFFFF |
111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxx |
U-04000000 |
U-7FFFFFFF |
1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx |
poniższa tabela zawiera wartości kodów w różnych formatach dla znaku © i kilku początkowych liter alfabetu języka polskiego;
Znak |
© |
a |
ą |
A |
Ą |
Unicode (mniejsze niżej) |
A9 00 |
61 00 |
05 01 |
41 00 |
04 01 |
Unicode (mniejsze wyżej) |
00 A9 |
00 61 |
01 05 |
00 41 |
01 04 |
UTF-8 |
C2 A9 |
61 |
C4 85 |
41 |
C4 84 |
przykładowo, znak © (copyright) ma przypisany kod U+00A9 = 0000 0000 1010 1001; w reprezentacji UTF-8 kod ten należy do zakresu <U-00000080 ÷ U-000007FF> i jest przedstawiany w postaci 11 bitów — wobec tego pomijamy początkowe 5 bitów i kodujemy dalej liczbę 000 1010 1001; z podanej tabeli wynika, że pierwsze pięć bitów wpiszemy do pierwszego bajtu, a następne sześć bitów - do drugiego bajtu. Tak więc otrzymamy:
pierwszy bajt: 110 000 10
drugi bajt: 10 10 1001
czyli C2H i A9H;
podobnie, znak ≠ ("różny") o kodzie U+2260 kodowany jest w postaci trzech bajtów: E2H, 89H, A0H;
w przypadku formatu UTF-16 kod znaku podany jest na dwóch bajtach — taki sposób kodowania w standardzie ISO 10646 oznaczany jest symbolem UCS-2 i obejmuje zakres <0, FFFDH> (z wyłączeniem zakresu <D800H, DFFFH>); w standardzie Unicode odpowiednikiem formatu UCS-2 jest UTF-16, który jest identyczny z UCS-2 dla wartości mniejszych od 10000H;
format UTF-16 może być też stosowany do kodowania wartości z przedziału <10000H, 10FFFFH> — w tym przypadku przesyłane są dwie wartości 16-bitowe kodowane w niżej pokazany sposób, przy czym kodowana liczba przez zapisaniem w postaci 20-bitowej zostaje pomniejszona o 10000H;
110110 ... (10 starszych bitów)... |
110111 ... (10 młodszych bitów) ... |
znak w kodzie ASCII lub w kodzie Latin-1 może być łatwo przekodowany na kod USC-2 poprzez wprowadzenie dodatkowego bajtu zerowego przed kod ASCII;
kod znaku może być także podany w postaci liczby binarnej zapisanej czterech bajtach — taki sposób kodowania w standardzie ISO 10646 oznaczany jest symbolem UCS-4; kod obejmują zakres <0, 7FFFFFFFH>; jeśli nie określono inaczej, stosuje się konwencję mniejsze wyżej (ang. big endian), tzn. starszy bajt (bajty) przesyłany jest najpierw;
odpowiednikiem tego kodowania w standardzie Unicode jest format UTF-32; w tym przypadku kodowanie obejmuje zakres <0, 10FFFFH>, a ponadto z podanego zakresu wyłączone są wartości <D800H, DFFFH> oraz <FFFEH, FFFFH>; w standardzie Unicode wartościom z przedziału <D800H, DFFFH> nie są przypisane żadne znaki;
w przypadku kodu USC-4, znak w kodzie ASCII (lub w kodzie Latin-1) może być łatwo przekodowany na kod USC-4 poprzez wprowadzenie dodatkowych trzech bajtów zerowych przed kod ASCII.
Rejestry ogólnego przeznaczenia w procesorach
zgodnych z architekturą IA-32
w trakcie wykonywania obliczeń często wyniki pewnych operacji stają się danymi dla kolejnych operacji — w takim przypadku nie warto odsyłać wyników do pamięci operacyjnej, a lepiej przechować te wyniki w komórkach pamięci wewnątrz procesora; komórki pamięci wewnątrz procesora zbudowane są w postaci rejestrów (ogólnego przeznaczenia);
początkowo wszystkie rejestry ogólnego przeznaczenia były 16-bitowe i oznaczone AX, BX, CX, DX, SI, DI, BP, SP, F; wszystkie te rejestry w procesorze 386 i wyższych zostały rozszerzone do 32 bitów i oznaczone dodatkową literą E na początku, np. EAX, EBX, ECX, itd.;
rejestry 16-bitowe są nadal dostępne, np. młodsza część rejestru EAX nazywa AX, a młodsza część rejestru EBX nazywa się BX; ponadto w kilku rejestrach wyodrębniono mniejsze rejestry 8-bitowe, oznaczone AL, AH, BL, BH, itd.; omawiane tu rejestry pokazane są na rysunku:
31 |
|
15 |
7 0 |
EAX |
|||
|
AX |
||
|
|
AH |
AL |
31 |
|
15 |
7 0 |
EBX |
|||
|
BX |
||
|
|
BH |
BL |
31 |
|
15 |
7 0 |
ECX |
|||
|
CX |
||
|
|
CH |
CL |
31 |
|
15 |
7 0 |
EDX |
|||
|
DX |
||
|
|
DH |
DL |
31 |
|
15 |
7 0 |
ESI |
|||
|
SI |
31 |
|
15 |
7 0 |
EDI |
|||
|
DI |
31 |
|
15 |
7 0 |
EBP |
|||
|
BP |
31 |
|
15 |
7 0 |
ESP |
|||
|
SP |
kilka lat temu zdefiniowano 64-bitowe rozszerzenie architektury IA-32, które oznaczane jest symbolem AMD64 lub IA-32e; w rozszerzeniu tym dokonano licznych zmian w stosunku do pierwowzoru, a najbardziej widoczne dla programisty są rejestry 64-bitowe;
istniejące dotychczas 32-bitowe rejestry ogólnego przeznaczenia EAX, EBX, ECX,... (na rysunku oznaczone szarymi polami) zostały rozszerzone do 64 bitów — rozszerzone rejestry oznaczane są symbolami RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP; oprócz tego wprowadzono osiem nowych 64-bitowych rejestrów ogólnego przeznaczenia oznaczonych R8 ÷ R15;
w trybie 64-bitowym stosowane są operandy 32- i 64-bitowe, ale domyślny rozmiar operandu wynosi 32 bity; operandami 32-bitowymi mogą być typowe rejestry EAX, EBX, ..., ESP, oraz nowe rejestry R8D ÷ R15D; operandami 64-bitowymi mogą być rejestry RAX, RBX, ..., RSP, R8 ÷ R15;
dostępne są młodsze części nowych rejestrów R8 ÷ R15, np. R8B, R8W, R8D, R8; istnieje także możliwość odwołania się do najmłodszych bajtów rejestrów 64-bitowych: RBP, RSP, RDI, RSI poprzez nazwy, odpowiednio, BPL, SPL, DIL, SIL; rejestry AH, BH, CH, DH są niedostępne;
w wielu przypadkach zawartości rejestrów 16-, 32- i 64-bitowych przesyłane są do pamięci głównej (operacyjnej) i odwrotnie; w tych operacjach obowiązują omawiane wcześniej reguły mniejsze niżej (w architekturze IA-32) lub mniejsze wyżej (w innych architekturach); przykład takiej operacji pokazany jest na poniższym rysunku;
Rejestr znaczników
w wielu współczesnych procesorach występuje rejestr opisujący aktualny stan procesora, nazywany rejestrem stanu procesora; w procesorach IA-32 informacje o stanie procesora przechowywane są w kilku rejestrach, wśród których najczęściej używany jest rejestr znaczników; termin znacznik oznacza rejestr jednobitowy;
niektóre bity rejestru znaczników opisują wynik ostatnio wykonanego rozkazu (np. czy wystąpił nadmiar), inne bity specyfikują sposób wykonywania rozkazów czy też tryb pracy procesora;
w procesorach IA-32 omawiane znaczniki, zebrane razem, tworzą 32-bitowy rejestr znaczników EF (lub EFLAGS) o strukturze podanej na rysunku:
Najczęściej używane znaczniki są następujące:
CF (ang. carry) znacznik przeniesienia do znacznika tego wpisywane jest przeniesienie (pożyczka) z najbardziej znaczącego bitu;
PF (ang. parity) znacznik parzystości znacznik ten ustawiany jest w stan 1, gdy osiem najmłodszych bitów wyniku operacji arytmetycznej lub logicznej zawiera parzystą liczbę jedynek;
AF (ang. auxiliary carry) znacznik przeniesienia pomocniczego, używany w operacjach na liczbach zakodowanych w systemie dwójkowo-dziesiętnym (BCD);
ZF (ang. zero) znacznik zera znacznik ten ustawiany jest w stan 1, gdy wynik operacji arytmetycznej lub logicznej jest równy 0 i zerowany w przypadku przeciwnym;
SF (ang. sign) znacznik reprezentujący znak wyniku obliczenia;
TF (ang. trap) pułapka znacznik ten umożliwia krokowe wykonywanie programu (używany przez debuggery);
IF (ang. interrupt enable) zezwolenie na przerwanie znacznik ten włącza lub wyłącza system przerwań; jeśli znacznik IF zawiera 0, to przerwania sprzętowe są ignorowane aż do chwili, gdy IF zawierać będzie 1; zawartość znacznika IF nie wpływa na wykonywanie przerwań programowych;
DF (ang. direction) znacznik kierunku stan tego znacznika wpływa na sposób wykonywania operacji na łańcuchach znaków;
OF (ang. overflow) znacznik nadmiaru.
Rejestry segmentowe
w architekturze IA-32 zdefiniowano obszary pamięci przeznaczone na rozkazy, dane statyczne i dane dynamiczne — obszary te nazwano segmentami; położenia poszczególnych segmentów w pamięci komputera wskazywane są przez zawartości 16-bitowych rejestrów procesora:
rejestr CS wskazuje położenie segmentu zawierającego rozkazy programu,
rejestr DS (a także ES, FS, GS) wskazuje położenie segmentu zawierającego dane statyczne,
rejestr SS wskazuje położenie segmentu danych dynamicznych (obszar stosu);
w typowych trybach pracy procesora wymienione rejestry nie zawierają adresów pamięci, ale indeksy do deskryptorów w tablicach systemowych;
15 0 |
CS |
15 0 |
SS |
15 0 |
FS |
15 0 |
DS |
15 0 |
ES |
15 0 |
GS |
w systemie Windows rejestry segmentowe skonfigurowane są w dość specyficzny sposób: każdy z deskryptorów wskazywanych przez rejestry CS, DS, ES i SS opisuje cały obszar pamięci udostępniony dla programu; w szczególności zawartości rejestrów DS, ES i SS są jednakowe, co oznacza, że wskazują ten sam deskryptor; rejestr CS wskazuje na inny deskryptor, który jednak opisuje również cały obszar pamięci udostępniony dla programu;
wewnątrz obszaru pamięci przydzielonego dla programu, w rozłącznych fragmentach umieszczone są rozkazy programu, dane statyczne i dane dynamiczne (stos programu); zazwyczaj kod programu (rozkazy programu) lokowany jest począwszy od adresu 401000H (4 198 400), dalej umieszczane są dane statyczne i dynamiczne (stos);
w omawianym dalej trybie rzeczywistym procesora rejestry segmentowe, po rozszerzeniu do 20 bitów (poprzez dopisanie czterech zer z prawej strony) wskazują bezpośrednio adresy w pamięci fizycznej;
we współczesnych systemach komputerowych, w których stosowana jest pamięć wirtualna, odpowiednie wartości do rejestrów segmentowych wpisywane są przez system operacyjny i w odniesieniu do tych rejestrów programy nie muszą podejmować żadnych działań; używane dawniej programy 16-bitowe w pewnym zakresie same ustalały zawartości rejestrów segmentowych;
w języku asemblera zdefiniowano dyrektywę ASSUME, która wiąże segmenty programu i rejestry segmentowe, tj. dla każdego segmentu wymienionego na liście tej dyrektywy podaje nazwę rejestru segmentowego (CS, DS, ES, FS, GS, SS), którego zawartość wskazuje początek tego segmentu; bliższe informacje dotyczące dyrektywy ASSUME podane będą w dalszej części wykładu.
Rozwój architektury IA-32
projekt procesora 8086/88, opracowany w końcu lat siedemdziesiątych, przewidywał, że procesor ten współpracować będzie z pamięcią główną (operacyjną) zawierającą co najwyżej 220 = 1 048 576 bajtów; po kilku latach okazało się, że taki rozmiar pamięci jest już niewystarczający i zachodzi konieczność zastąpienia dotychczas używanego procesora przez inny, umożliwiający współpracę z znacznie większą pamięcią;
jednak wprowadzenie całkowicie nowego typu procesora mogłoby nie zostać zaakceptowane przez użytkowników komputerów, których dotychczasowe oprogramowanie stałoby się bezużyteczne — w tej sytuacji postanowiono skonstruować procesor posiadający możliwość pracy w dwóch trybach:
w trybie "starym", który nazywany jest trybem rzeczywistym (ang. real mode), procesor zachowuje się podobnie do swojego poprzednika 8086/88;
w trybie "nowym", określany jako tryb chroniony (ang. protected mode) procesor stosuje inne techniki adresowania pamięci, co pozwala zainstalować w komputerze pamięć główną o rozmiarze do 4 GB (gigabajtów), a nowszych procesorach nawet do 64 GB;
przełączenie między trybami wykonywane jest w sposób programowy;
tryb chroniony w ograniczonym zakresie został wprowadzony w procesorze 80286, i szerzej rozwinięty w procesorach 386, 486 i w kolejnych wersjach procesora Pentium; podstawowa lista rozkazów jest stopniowo rozszerzana nowe rozkazy, wśród których najczęściej wymienia się operacje grup MMX i SSE, specjalnie zaprojektowane do szybkiego przetwarzania danych w operacjach multimedialnych;
w okresie przejściowym (trwającym do chwili obecnej), w komputerze mogą być wykonywane zarówno programy przewidziane dla trybu chronionego, jak też wychodzące z użycia programy przewidziane dla trybu rzeczywistego; należy więc przypuszczać, że wykonanie programu w trybie rzeczywistym wymaga przełączenia procesora do trybu rzeczywistego;
takie przełączenie jest wprawdzie możliwe, ale wiąże się z tymczasową utratą przywilejów systemu operacyjnego w stosunku do zwykłego programu — z tego względu w architekturze IA-32 zdefiniowano specjalny tryb pracy oznaczony symbolem V86, który można uważać za podtryb trybu chronionego;
w trybie V86, mimo że stanowi on odmianę trybu chronionego, procesor z punktu widzenia wykonywanego programu zachowuje się prawie dokładnie tak samo jak w trybie rzeczywistym;
tak więc programy "DOSowe" wywoływane na poziomie systemu Windows są wykonywane w trybie V86, a nie w trybie rzeczywistym (chociaż zazwyczaj nie zdają sobie z tego sprawy);
ze względu na brak mechanizmów ochrony w trybie rzeczywistym, programy tworzone dla tego trybu pozwalają na wykonywanie ciekawych eksperymentów w zakresie funkcjonowania urządzeń komputera; dość wygodne są także sposoby komunikacji programów z systemem operacyjnym na poziomie języka asembler; z tego względu prawie wszystkie podręczniki programowania w asemblerze dla procesorów IA-32 skupiają się przykładach dla trybu V86;
jednak programy dla trybu V86 stopniowo wychodzą z użycia, a najnowszy system MS Windows XP 64 nie jest przystosowany do wykonywania programów w trybie V86 — programy mogą być wykonywane wyłącznie w 32- i 64-bitowym trybie chronionym;
w ciągu ostatnich kilku lat w architekturze procesorów pojawiły się nowe elementy, spośród których najważniejsze znaczenie mają:
wprowadzenie architektury 64-bitowej,
wprowadzenie przetwarzania wielowątkowego,
rozpoczęcie produkcji procesorów wielordzeniowych;
wymienione elementy zostaną omówione w dalszej części wykładu.
Cykl rozkazowy
główną właściwością procesora jest zdolność do wykonywania obliczeń, m.in. zdolność do wykonywania działań arytmetycznych na liczbach;
w celu wykonania potrzebnych obliczeń procesor musi sukcesywnie otrzymywać informacje o kolejnych czynnościach, które mają zostać wykonane; instrukcje (rozkazy) przekazywane do procesora mają postać jednego lub kilku bajtów o ustalonej zawartości;
znaczenie różnych bajtów stanowiących instrukcje (rozkazy) zostało ustalone przez projektantów konkretnego typu procesora i jest powszechnie dostępne w literaturze technicznej; tak więc rozmaite czynności, które może wykonywać procesor, zostały zakodowane w formie ustalonych kombinacji zer i jedynek, składających się na jeden lub kilka bajtów;
zakodowany ciąg bajtów umieszcza się w pamięci operacyjnej komputera, a następnie poleca się procesorowi odczytywać z pamięci i wykonywać kolejne instrukcje; w rezultacie procesor wykonana szereg operacji, w wyniku których uzyskamy wyniki końcowe programu;
ze względu na to, że posługiwanie się w procesie kodowania programu wartościami zero-jedynkowymi byłoby bardzo kłopotliwe, wprowadzono skróty literowe (tzw. mnemoniki) dla poszczególnych instrukcji (rozkazów) procesora; i tak podane wcześniej instrukcje 01000010 i 01001010 zastępuje się mnemonikami INC EDX (ang. increment zwiększenie) i DEC EDX (ang. decrement zmniejszenie);
oczywiście, mnemoniki są niezrozumiałe dla procesora i przed wprowadzeniem programu do pamięci muszą być zamienione na kody zero-jedynkowe programy dokonujące takiej konwersji nazywane są asemblerami;
proces pobierania kolejnych instrukcji z pamięci operacyjnej i ich wykonywania musi być precyzyjnie zorganizowany, tak by natychmiast po wykonaniu kolejnej instrukcji procesor pobierał z pamięci następną; aby pobrać tę instrukcję, procesor musi oczywiście znać jej położenie w pamięci operacyjnej — informacje o położeniu kolejnej instrukcji są umieszczone w specjalnym rejestrze, nazywanym wskaźnikiem instrukcji;
31 |
|
15 |
7 0 |
EIP |
|||
|
IP |
w architekturze IA-32 wskaźnik instrukcji jest rejestrem 32-bitowym oznaczonym symbolem EIP.
jeśli wykonywany rozkaz (instrukcja) nie jest rozkazem sterującym, to w trakcie jego wykonywania zawartość wskaźnika instrukcji EIP jest zwiększana o liczbę bajtów zajmowanych przez ten rozkaz; przykładowo, jeśli omawiany wcześniej rozkaz dodawania zajmuje 3 bajty, to w trakcie jego wykonywania rejestr EIP zostanie zwiększony o 3; w rezultacie, po wykonaniu każdego rozkazu rejestr EIP wskazuje położenie kolejnego rozkazu do wykonania;
czynności wykonywane przez procesor w trakcie pobierania i wykonywania poszczególnych rozkazów powtarzane są cyklicznie, a cały proces nosi nazwę cyklu rozkazowego;
Rozkazy sterujące i niesterujące
podstawowe operacje przetwarzania: przesyłanie, operacje arytmetyczne i logiczne, przesunięcia, itd., realizują rozkazy (instrukcje) określane jako niesterujące; charakterystyczną cechą tej klasy rozkazów jest to, że nie zmieniają one naturalnego porządku wykonywania rozkazów, co oznacza, że po wykonaniu rozkazu z tej klasy procesor rozpoczyna wykonywać rozkaz bezpośrednio przylegający w pamięci do rozkazu właśnie wykonanego; zatem kolejna zawartość rejestru EIP jest obliczana wg formuły:
EIP EIP + <liczba bajtów aktualnie wykonywanego rozkazu>
dodatkową cechą rozkazów wykonujących operacje arytmetyczne i logiczne w procesorze zgodnym z architekturą IA-32 jest to, że ustawiają one niektóre bity w rejestrze znaczników w zależności od wyniku operacji; przykładowo, jeśli wynik operacji wynosi 0, to znacznik zera ZF przyjmuje wartość 1; w miarę potrzeby stan tych znaczników może być testowany przez (opisane dalej) instrukcje sterujące;
jednak ww. instrukcje, mimo że posiadają kluczowe znaczenie dla kodowania algorytmów, wykonywane są po kolei, a więc są niewystarczające dla zakodowania wielu algorytmów, np. rozwiązywania równania kwadratowego;
w trakcie rozwiązywania równania kwadratowego wyznacza się pewien wynik pośredni, zwany wyróżnikiem (delta), którego wartość determinuje sposób dalszych obliczeń. — m.in. zwykle rezygnujemy z dalszych działań, jeśli obliczony wyróżnik ma wartość ujemną;
przekładając ten problem na poziom rozkazów procesora można stwierdzić, że w przypadku ujemnego wyróżnika należy zmienić naturalny porządek ("po kolei") wykonywania instrukcji i spowodować, by procesor pominął ("przeskoczył") dalsze obliczenia;
można to bardzo łatwo zrealizować, jeśli do rejestru EIP zostanie dodana odpowiednio duża liczba (np. dodanie liczby 143 oznacza, że procesor pominie wykonywanie rozkazów zawartych w kolejnych 143 bajtach pamięci operacyjnej); oczywiście, takie pominięcie znacznej liczby rozkazów powinno nastąpić tylko w przypadku, gdy obliczony wyróżnik był ujemny;
potrzebne są więc specjalne rozkazy (instrukcje), które w zależności od własności uzyskanego wyniku (np. czy jest ujemny) zmienią zawartość rejestru EIP, dodając lub odejmując jakąś liczbę, albo też zmienią zawartość EIP w konwencjonalny sposób — rozkazy takie nazywane są rozkazami sterującymi (skokowymi);
rozkazy sterujące warunkowe na ogół nie wykonują żadnych obliczeń, ale tylko sprawdzają, czy uzyskane wyniki mają oczekiwane własności; w zależności od rezultatu sprawdzenia wykonywanie programu może być kontynuowane przy zachowaniu naturalnego porządku rozkazów (instrukcji) albo też porządek ten może być zignorowany poprzez przejście do wykonywania rozkazu znajdującego się w odległym miejscu pamięci operacyjnej; zazwyczaj jednak zakres „przeskakiwanych” adresów nie przekracza kilkudziesięciu, jedynie wyjątkowo osiągając wartości tysięcy czy milionów;
w szczególności, jeśli rozkaz sterujący spowoduje zmniejszenie zawartości wskaźnika instrukcji EIP, to zazwyczaj procesor ponownie wykona rozkazy, które już przed chwilą wykonywał — powstaje więc pętla rozkazowa; poprzez wprowadzenie odpowiedniego licznika wykonywanie pętli kończy się po wykonaniu zadanej liczby powtórzeń;
istnieją też rozkazy sterujące, zwane bezwarunkowymi, których jedynym zadaniem jest zmiana porządku wykonywania rozkazów (nie wykonują one żadnego sprawdzenia);
w procesorach IA-32 rozmaite rozkazy sterujące testują zawartość pojedynczych bitów w rejestrze znaczników, a niekiedy obliczają pewne wyrażenia logiczne określone na tych bitach; dla każdego rozkazu sterującego (warunkowego) określono testowany warunek; poniżej podano przykładowo rozkazy sterujące, które testują stan znaczników ZF i CF w rejestrze znaczników:
rozkaz jz przyjmuje, że warunek jest spełniony, gdy znacznik ZF = 1;
rozkaz jnz przyjmuje, że warunek jest spełniony, gdy znacznik ZF = 0;
rozkaz ja przyjmuje, że warunek jest spełniony, gdy znaczniki CF = 0 i ZF = 0;
w zależności od tego czy warunek jest spełniony czy nie, do wskaźnika instrukcji EIP wpisywane są inne wartości, jak podano poniżej;
Obliczanie EIP przez rozkazy sterujące
1. gdy testowany warunek jest spełniony:
EIP EIP + <liczba bajtów aktualnie wykonywanego rozkazu> +
+ <zawartość pola adresowego rozkazu>
2. gdy testowany warunek nie jest spełniony
EIP EIP + <liczba bajtów aktualnie wykonywanego rozkazu>
Wprowadzenie do programowania
przypuśćmy, że w pamięci komputera, począwszy od adresu wirtualnego 72308H, znajduje się tablica zawierająca pięć liczb 16-bitowych całkowitych bez znaku; tablica ta stanowi część obszaru danych programu;
spróbujmy napisać fragment programu, który przeprowadzi sumowanie liczb zawartej w tej tablicy;
dla uproszczenia problemu przyjmiemy, że w trakcie sumowania wszystkie wyniki pośrednie dadzą się przedstawić w postaci liczby binarnej co najwyżej 16-bitowej — innymi słowy w trakcie sumowania na pewno nie wystąpi przepełnienie (nadmiar);
operacje sumowania zapiszemy najpierw w postaci symbolicznej; najpierw do rejestru AX zostaje załadowana wartość pierwszego elementu tablicy, i następnie do rejestru AX dodawane są wartości kolejnych elementów;
AX [72308H]
AX AX + [7230AH]
AX AX + [7230CH]
AX AX + [7230EH]
AX AX + [72310H]
zapis [72308H] oznacza zawartość komórki pamięci znajdującej się w obszarze danych o offsecie podanym w nawiasach kwadratowych; litera H oznacza, że liczba podana jest w zapisie szesnastkowym;
na poziomie rozkazów procesora, operacja przesłania zawartości komórki pamięci do rejestru realizowana przez rozkaz oznaczony skrótem literowym (mnemonikiem) MOV; rozkaz ten ma dwa argumenty: pierwszy argument określa cel, czyli "dokąd przesłać", drugi zaś określa źródło, czyli "skąd przesłać" lub "co przesłać":
w omawianym dalej fragmencie programu mnemonik operacji przesłania zapisywany jest małymi literami (mov), podczas w opisach używa się zwykle wielkich liter (MOV) — obie formy są równoważne;
rozkaz (instrukcja) przesłania MOV jest jednym z najprostszych w grupie rozkazów niesterujących — jego zadaniem jest skopiowanie zawartości podanej komórki pamięci lub rejestru do innego rejestru; w programach napisanych w asemblerze dla procesorów zgodnych z architekturą IA-32 rozkaz przesłania MOV ma dwa argumenty rozdzielone przecinkami;
w wielu rozkazach drugim argumentem może być liczba, która ma zostać przesłana do pierwszego argumentu — tego rodzaju rozkazy określa się jako przesłania z argumentami bezpośrednimi., np.
MOV CX, 7305
przypomnijmy, że rozkazy (instrukcje) niesterujące nie zmieniają naturalnego porządku wykonywania rozkazów, tzn. że po wykonaniu takiego rozkazu procesor rozpoczyna wykonywanie kolejnego rozkazu, przylegającego w pamięci do rozkazu właśnie zakończonego;
argumenty rozkazów wykonujących operacje dodawania ADD i odejmowania SUB zapisuje się podobnie jak argumenty rozkazu MOV
w identyczny sposób podaje się argumenty dla innych rozkazów wykonujących operacje dwuargumentowe, np. XOR; ogólnie rozkaz taki wykonuje operację na dwóch wartościach wskazanych przez pierwszy i drugi operand, a wynik wpisywany jest do pierwszego operandu; zatem rozkaz
„operacja” cel, źródło
wykonuje działanie
cel cel „operacja” źródło
operandy cel i źródło mogą wskazywać na rejestry lub lokacje pamięci, jednak tylko jeden operand może wskazywać lokację pamięci;
wyjątkowo spotyka się asemblery (np. asembler w wersji AT&T), w których wynik operacji wpisywany jest do drugiego operandu (przesłania zapisywane są w postaci skąd, dokąd);
powracając do przykładu sumowania, wymagane operacje możemy teraz zapisać w postaci równoważnej sekwencji rozkazów procesora
mov ax, ds:[72308H]
add ax, ds:[7230AH]
add ax, ds:[7230CH]
add ax, ds:[7230EH]
add ax, ds:[72310H]
występujący tutaj dodatkowy symbol ds: oznacza, że pobierane dane znajdują się w obszarze danych programu;
omawiane operacje w postaci zrozumiałej dla procesora wyglądają tak:
01100110 10100001 00001000 00100011 00000111
00000000 (mov ax, ds:[72308H])
01100110 00000011 00000101 00001010 00100011
00000111 00000000 (add ax, ds:[7230AH])
01100110 00000011 00000101 00001100 00100011
00000111 00000000 (add ax, ds:[7230CH])
01100110 00000011 00000101 00001110 00100011
00000111 00000000 (add ax, ds:[7230EH])
01100110 00000011 00000101 00010000 00100011
00000111 00000000 (add ax, ds:[72310H])
Modyfikacje adresowe
podany w poprzedniej części sposób sumowania elementów tablicy jest bardzo niewygodny, zwłaszcza jeśli ilość sumowanych liczb jest duża;
powtarzające się obliczenia wygodnie jest realizować w postaci pętli, ale wymaga to korekcji adresu instrukcji dodawania — w każdym obiegu pętli adres lokacji pamięci wskazujący dodawaną liczbę powinien być zwiększany o 2;
przedstawione problemy rozwiązuje się poprzez stosowanie modyfikacji adresowych — adres lokacji pamięci, na której wykonywane jest działanie określony jest nie tylko poprzez pole adresowe rozkazu, ale zależy również od zawartości jednego lub kilku wskazanych rejestrów;
w architekturze IA-32 dostępne są rozbudowane mechanizmy modyfikacji adresowych:
modyfikatorami mogą być dowolne 32-bitowe rejestry ogólnego przeznaczenia: EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP;
może również wystąpić drugi rejestr modyfikacji spośród ww. wymienionych, z wyłączeniem rejestru ESP;
drugi rejestr modyfikacji może być skojarzony z tzw. współczynnikiem skali, który podawany jest w postaci 1, 2, 4, 8 — podana liczba wskazuje przez ile zostanie pomnożona zawartość drugiego rejestru modyfikacji podczas obliczania adresu;
adres efektywny (wirtualny) w trybie 32-bitowym obliczany jest modulo 232, tzn. bierze się pod uwagę 32 najmłodsze bity uzyskanej sumy;
przykładowo, adres efektywny poniższego rozkazu
sub eax, ds:[123H][edx][ecx4]
zostanie jako obliczony jako suma:
liczby 123H,
zawartości rejestru EDX,
zawartości rejestru ECX pomnożonej przez 4;
w literaturze zawartość pierwszego rejestru modyfikacji nazywana jest adresem bazowym, a drugiego adresem indeksowym;
w aplikacjach 16-bitowych modyfikatorami mogą być niektóre rejestry 16-bitowe: BX, BP, SI, DI oraz ich pary: (BX, SI), (BX, DI), (BP, SI), (BP, DI);
obliczanie adresu efektywnego wg reguły modulo 232 pozwala uzyskiwać adresy efektywne mniejsze niż zawartość pola adresowego rozkazu — odpowiednio duże liczby umieszczone w polu adresowym działają tak liczby ujemne; ilustruje to poniższy przykład:
rozkaz mov ...., [ebx] + 000003A9H
|
||||||
. . . |
. . . |
A9 |
03 |
00 |
00 |
rejestr EBX |
FFFFFFFCH |
adres efektywny powyższego rozkazu MOV wynosi 000003A5H i jest mniejszy niż zawartość pola adresowego rozkazu;
niekiedy pole adresowe instrukcji jest całkowicie pominięte, a wartość adresu określona jest wyłącznie poprzez wskazane rejestry modyfikacji; takie rozwiązanie jest:
niezbędne, jeśli adres lokacji pamięci zostaje obliczony dopiero w trakcie wykonywania programu (nie jest znany ani w trakcie kodowania programu przez programistę ani też podczas translacji) — dotyczy to często kodu generowanego przez kompilatory języków wysokiego poziomu;
szczególnie korzystne w przypadku wielokrotnego odwoływania się do tej samej lokacji pamięci — ponieważ pole adresowe nie występuje, więc rozkaz może być zapisany na mniejszej liczbie bajtów (zwykle 2 bajty);
modyfikacja adresowa z użyciem rejestru EBP działa trochę inaczej; rejestry te zostały bowiem zaprojektowane do wspomagania operacji przekazywania parametrów do procedur za pośrednictwem stosu — z tego względu użycie ww. rejestrów jako modyfikatorów powoduje, że operacja zostanie wykonana na danych zawartych w obszarze stosu;
w programach asemblerowych, modyfikację adresową deklaruje się poprzez podanie w polu adresowym rozkazu nazwy rejestru w nawiasie kwadratowym; tego rodzaju operandy instrukcji nazywane są operandami modyfikacji adresowych; przykłady:
mov dh, [ebx]
mov [ecx], esi
zapisy w dwóch poniższych wierszach są równoważne
mov bx, [eax] [edx2]
mov bx, [eax + edx2]
nie dozwolone są wyrażenia modyfikacji adresowej, w których używane są rejestry 16- i 32-bitowe, np. mov edx, [eax + bx];
w operandach modyfikacji adresowych mogą także występować nazwy zmiennych i tablic, jak również pewne wartości stałe, np.
mov ax, tablica [ebx+ecx4+14]
posługując się modyfikacją adresową, omawiany wcześniej fragment programu obliczający sumę liczb można zakodować w formie pętli rozkazowej; w kolejnych obiegach pętli adres rozkazu dodawania ADD powinien zwiększać się o 2 — można to łatwo zrealizować poprzez uzależnienie adresu rozkazu od zawartości rejestru modyfikacji adresowej EBX;
ponieważ w kolejnych obiegach pętli rejestr EBX będzie zawierał liczby 0, 2, 4, ..., więc kolejne adresy efektywne rozkazu ADD, które stanowią sumę pola adresowego (tu: 7230AH) i zawartości rejestru EBX, będą wynosiły:
7230AH, 7230CH, 7230EH, 72310H
tak więc w każdym obiegu pętli do zawartości rejestru AX dodawane będą kolejne elementy tablicy liczb;
mov ecx, 4 ; licznik obiegów pętli
mov ax, ds:[72308H] ; początkowa wartość sumy
mov ebx, 0 ; początkowa zawartość rejestru
; modyfikacji adresowej
ptl_suma:
add ax, ds:[7230AH][ebx] ; dodanie kolejnego
; elementu tablicy
add ebx, 2 ; zwiększenie modyfikatora
loop ptl_suma ; sterowanie pętlą
rozkaz LOOP stanowi typowy sposób sterowania pętlą: powoduje on odjęcie 1 od zawartości rejestru ECX, i jeśli wynik odejmowania jest różny od zera, to sterowanie przenoszone do rozkazu poprzedzonego podaną etykietą, a przeciwnym razie następuje przejście do następnego rozkazu; ponieważ początkowa zawartość rejestru ECX wynosiła 4, więc rozkazy wchodzące w skład pętli zostaną wykonane 4 razy;
rozpatrując rozkaz LOOP jako rozkaz sterujący (skokowy) można powiedzieć, że warunek testowany przez rozkaz jest spełniony, jeśli po odjęciu 1 zawartość rejestru ECX jest różna od zera — wówczas następuje skok, który polega na dodaniu do rejestru EIP liczby umieszczonej w polu adresowym rozkazu LOOP i zwiększeniu EIP o 2 (liczba bajtów rozkazu LOOP); jeśli warunek nie jest spełniony, to EIP zostaje zwiększony o 2.
Zestawienie różnych typów modyfikacji adresowych
mechanizmy modyfikacji adresowych stosowane są od wielu lat w rozmaitych konstrukcjach procesorów; poniższa tablica zawiera zestawienie typowych trybów adresowania rozkazów; znaczna część podanych trybów jest implementowana w architekturze IA-32;
Adresowanie |
Przykład |
Znaczenie |
Opis |
Rejestro-we |
add R4,R3 |
Regs[R4] ←Regs[R4] + Regs[R3] |
dodawanie zawartości rejestrów |
Natych-miastowe |
add R4,#3 |
Regs[R4] ←Regs[R4] + 3 |
dodawanie liczby do rejestru |
Przemie-szczenie |
add R4,100(R1) |
Regs[R4] ←Regs[R4] + Mem[100 + Regs[R1]] |
dostęp do zmiennej lokalnej |
Rejestro-we pośrednie |
add R4, (R1) |
Regs[R4] ←Regs[R4]+Mem[Regs[R1]] |
dostęp przez wskaźnik |
Indeksowe |
add R3,(R1+R2) |
Regs[R3] ←Regs[R3]+Mem[Regs[R1] +Regs[R2]] |
w tablicach: R1 - adres tablicy, R2 - indeks w tablicy |
Bezpośrednie |
add R1,(1001) |
Regs[R1] ←Regs[R1] + Mem[1001] |
stosowane przy dostępie do danych statycznych |
Pośrednie |
add R1,@(R3) |
Regs[R1] ←Regs[R1] +Mem[Mem [Regs[R3]]] |
zob. przykład |
Autoinkrementacja |
add R1,(R2)+ |
Regs[R1] ←Regs[R1] +Mem[Regs[R2]]
Regs[R2] ←Regs[R2]+d |
stosowane w operacjach w tablicach |
Autodekrementacja |
add R1,− (R2) |
Regs[R2] ←Regs[R2]-d
Regs[R1] ←Regs[R1]+ Mem[Regs[R2]] |
stosowane w operacjach w tablicach |
Skalo-wanie |
add R1,100(R2)[R3] |
Regs[R1] ←Regs[R1] +Mem[100+ Regs[R2]+Regs[R3]*d] |
stosowane w operacjach w tablicach |
poniższy rysunek ilustruje modyfikację adresową pośrednią, która na wskazaniu komórki pamięci, w której znajduje potrzebny adres; w architekturze IA-32 adresowanie pośrednie stosowane jest w rozkazach sterujących (skokowych).
Zasady kodowania w asemblerze
wykonanie programu przez procesor wymaga uprzedniego załadowania do pamięci danych i rozkazów, zakodowanych w formie ciągów zerojedynkowych, zrozumiałych przez procesor; współczesne kompilatory języków programowania generują takie ciągi w sposób automatyczny na podstawie kodu źródłowego programu;
niekiedy jednak celowe jest precyzyjne zakodowanie programu lub fragmentu programu za pomocą pojedynczych rozkazów procesora; dokumentacja techniczna procesora zawiera zazwyczaj tablice ciągów zerojedynkowych przypisanych poszczególnym operacjom (rozkazom procesora); jednak kodowanie na poziomie zer i jedynek, czyli w języku maszynowym zrozumiałym dla procesora, aczkolwiek możliwe, byłoby bardzo żmudne i podatne na pomyłki;
istotną wadą kodowania w języku maszynowym jest także mała podatność na ewentualne zmiany i poprawki — nawet niewielka zmiana fragmentu kodu lub rozmiaru danych pociąga za sobą konieczność zmiany adresów wielu rozkazów programu;
z tego powodu opracowano programy, nazywane asemblerami, które na podstawie skrótu literowego (tzw. mnemonika) opisującego czynności rozkazu dokonują zamiany tego skrótu na odpowiedni ciąg zer i jedynek; asemblery udostępniają wiele innych udogodnień, jak np. możliwość zapisu liczb w systemach o podstawie 2, 8, 10, 16 czy też automatyczną zamianę tekstów znakowych na ciągi bajtów zawierające kody ASCII poszczególnych liter;
zatem kodowanie programu w asemblerze powinno pozwalać na zapisywanie algorytmów za pomocą pojedynczych rozkazów procesora; także definiowanie danych powinno być realizowane na poziomie pojedynczych bajtów lub słów;
od wielu lat we współczesnych asemblerach rysuje się tendencja wprowadzania coraz bardziej złożonych konstrukcji języka, które przyspieszają proces kodowania programu; jednak konstrukcje tego rodzaju, przejmując niektóre elementy języków wysokiego poziomu, odsuwają programowanie od procesora — są więc mało przydatne z punktu widzenia analizy architektury procesorów.
Asemblery: za i przeciw
od wielu lat trwają dyskusje na temat przydatności kodowania programów na poziomie asemblera; poniżej wymieniono najczęściej podawane argumenty;
za:
argumenty edukacyjne: można poznać działanie procesora i komputera na poziomie rozkazów, można wybrać efektywną technikę kodowania programu;
asembler stanowi pomost łączący sprzęt i oprogramowanie: można poznać kody generowane przez kompilatory języków wysokiego poziomu, można identyfikować błędy trudne do znalezienia (ang. hard-to-find-errors); znajomość techniki kodowania w asemblerze stanowi podstawę do budowy kompilatorów, debuggerów i innych narzędzi;
w systemach wbudowanych, ze względu na ich mniejsze zasoby sprzętowe w porównaniu z PC, stosowanie asemblera może być konieczne ze względu na ostre wymagania dotyczące rozmiaru programu czy prędkości wykonywania;
oprogramowanie sterowników urządzeń korzysta zazwyczaj z rejestrów sterujących, które nie są dostępne w językach wysokiego poziomu;
kodowanie w asemblerze pozwala na tworzenie kodu samomodyfikującego się (aczkolwiek nie jest to zalecane ze względu na interferencje z kodem przechowywanym w pamięci podręcznej);
możliwa jest optymalizacja kodu ze względu na rozmiar zajmowanej pamięci i prędkość wykonywania.
przeciw:
czas opracowywania programu jest zazwyczaj bardzo długi;
kodowanie w asemblerze jest podatne na błędy, np. rzadko wprowadza się kontrolę bilansowania stosu;
identyfikacja błędów w programie jest czasami bardzo trudna, mimo stosowania debuggerów;
kod w asemblerze jest w pełni zrozumiały dla autora, dla innych osób może być słabo czytelny — utrudnia to wprowadzanie aktualizacji, szczególnie gdy autor nie pracuje już w firmie;
kod w asemblerze jest dość trudno przenieść na inną platformę sprzętową;
wymienione przyczyny powodują, że kod asemblerowy stanowi zazwyczaj tylko fragment całego programu i często zawiera operacje krytyczne dla działania całej aplikacji.
Formaty wierszy źródłowych
uzup: |
mov |
cx, 78 |
; licznik obiegów |
nazwa wiersza (etykieta) |
akcja, którą należy wykonać |
operandy (określają obiekty, na których zostanie wykonana akcja) |
komentarz |
jeśli w zapisie instrukcji występują dwa operandy, to przyjmuje się, że wynik operacji zostanie wpisany do pierwszego operandu, np.
sub ecx, eax ; obliczenie ECX ECX EAX
var2 |
db |
23 |
; prędkość |
nazwa zmiennej |
dyrektywa |
operand |
komentarz |
num1 |
EQU |
18H |
; wartość max |
nazwa stałej |
dyrektywa |
operand |
komentarz |
Niektóre rodzaje operandów
operandy stałe — stanowią pewne wartości liczbowe, które określane są w trakcie translacji programu, np.:
78
65535/3+7
'xy'
operandy adresowania bezpośredniego — wskazują adres fizyczny lokacji pamięci, np.:
mov dx, es:[31H]
operandy rejestrowe — tworzone są przez nazwę rejestru, np.
mov ax, 560BH
add ah, dl
div esi
inc cl
jmp ebp
operandy modyfikacji adresowych — omawiane były wcześniej;
Etykiety i zmienne w programie asemblerowym
w pamięci głównej (operacyjnej) przechowywane są instrukcje (rozkazy) i dane programu; instrukcje (rozkazy) programu wykonują działania na danych zawartych w rejestrach i lokacjach pamięci; poszczególne instrukcje i dane zajmują jeden lub więcej bajtów;
w programie asemblerowym niektóre obszary pamięci opatrywane są nazwami:
jeśli nazwa odnosi się do obszaru zawierającego instrukcję (rozkaz) programu, to nazwa taka stanowi etykietę,
jeśli obszar zawiera zmienną (daną), to nazwa obszaru stanowi nazwę zmiennej;
nazwę w sensie asemblera tworzy ciąg liter, cyfr i znaków ? (znak zapytania), @ (symbol at), _ (znak podkreślenia), $ (znak dolara); nazwa nie może zaczynać się od cyfry;
etykietę, wraz ze znakiem : (dwukropka), umieszcza się przed instrukcją, np.
powtorz: mov dl, [ecx]
taka konstrukcja oznacza, że obszar kilku bajtów pamięci, w których przechowywany jest kod podanej instrukcji mov dl, [ecx] ma swoją unikatową nazwę, czyli etykietę; w trakcie asemblacji programu etykiecie przypisywana jest wartość równa odległości pierwszego bajtu obszaru (liczonej w bajtach) od początku segmentu kodu;
zmienne w programie deklaruje się za pomocą dyrektyw:
DB - definiowanie bajtu (8 bitów),
DW - definiowanie słowa (16 bitów),
DD - definiowanie słowa podwójnej długości (32 bity),
DF - definiowanie 6 bajtów (48 bitów);
DQ - definiowanie słowa poczwórnej długości (64 bity),
DT - definiowanie 10 bajtów (80 bitów).
po lewej stronie dyrektywy podaje się nazwę zmiennej, a po prawej wartość początkową; znak zapytania (?) oznacza, że wartość początkowa zmiennej jest nieokreślona; nazwę zmiennej można pominąć; przykłady:
kcal DB 169
linia7a DQ ?
DW 19331
elem_sygn DD 18B706H
alfa dw 4567H, 5678H, 6789H
nazwy zmiennych mogą występować w polu operandu instrukcji — operandy tego rodzaju nazywa się operandami relokowalnymi, ponieważ położenie zmiennych nie jest ustalone podczas kodowania programu, np.:
xor ebx, elem_sygn
add si, alfa [edi]+2
w asemblerze przyjęto, że nazwa zmiennej występująca w polu operandu rozkazu (podobnie jak nazwa rejestru) oznacza, że działanie ma być wykonane na zawartości tej zmiennej;
w trakcie asemblacji nazwie każdej zmiennej przypisywana jest wartość równa odległości pierwszego bajtu tej zmiennej (liczonej w bajtach) od początku segmentu danych; jeszcze raz podkreślamy: omawiana wartość nie jest wartością zmiennej, ale jest przypisywana nazwie zmiennej i określa położenie zmiennej w pamięci;
w polu operandu dyrektywy DB mogą występować także łańcuchy znaków, np.
DB 'Politechnika Gdańska', 0DH, 0AH
jeśli tekst nie mieści się w jednym wierszu (który może zawierać co najwyżej 128 znaków), to można kontynuować w następnym wierszach, za każdym razem poprzedzając go dyrektywą DB;
jeśli podane wartości są liczbami całkowitymi, to asembler tworzy ich reprezentacje binarne przyjmując, że najmniej znaczący bit ma wagę 20 ; liczby dziesiętne poprzedzone znakiem + i liczby bez znaku traktowane są jednakowo: zamiana na kod binarny wykonywana jest przy założeniu, że wszystkie bity w bajcie, w słowie, itd. są bitami znaczącymi (nie występuje bit znaku); dla liczb ujemnych stosuje się kod U2, przy czym przyjmuje się, że bit znaku zajmuje skrajną lewą pozycji bajtu, słowa, itd.; w szczególności oznacza to, że wiersze
DB 128
DB +128
generują takie same kody binarne 10000000;
należy zwracać uwagę na dopuszczalne zakresy zmienności argumentów podanych dyrektyw;
w polu operandu omawianych dyrektyw mogą występować zarówno pojedyncze liczby jak i ciągi liczb rozdzielonych przecinkami; jeśli ciąg składa się z jednakowych elementów, to można zastosować operator powtarzania DUP, np. wiersz:
sumy DW 5432H, 5432H, 5432H, 5432H, 5432H
można zapisać w krótszej postaci równoważnej:
sumy DW 5 DUP (5432H)
operator DUP generuje wskazaną ilość elementów podanych w nawiasie — stanowi więc wygodny sposób tworzenia tablic; w nawiasie można także umieścić znak zapytania (?) oznaczający element o nieokreślonej wartości początkowej, co pozwala na tworzenie tablic złożonych z elementów o nieokreślonej wartości początkowej, np.
tabl_wynikow dd 24 dup (?)
liczby z kropką oddzielającą część ułamkową mogą występować jako operandy dyrektyw DD, DQ, DT - liczby takie są przekształcane na postać zmiennoprzecinkową binarną, np.:
c_Eulera DT 0.577215 ; stała Eulera
— — — — — — — — — — — — — — —
fld c_Eulera ; załadowanie stałej Eulera ; na stos koprocesora
Rozmiary operandów
instrukcje procesorów IA-32 wykonują działania na bajtach (8 bitów), słowach (16-bitów), podwójnych słowach (32-bity), obiektach 48-, 64- i 80-bitowych; jeśli instrukcja wymaga podania dwóch operandów, to prawie zawsze muszą być one jednakowej długości, np.:
sub ecx, edi ; obliczenie ECX ECX EDI
add eax, dx ; błąd !!! — dodawanie
; zawartości rejestru 16-bitowego
; do 32-bitowego
wyjątkowo, jeśli jeden z operandów znajduje się w pamięci, to możliwa jest doraźna zmiana rozmiaru (typu) zmiennej za pomocą operatora PTR, np.
blok_sys dd ? ; zmienna 32-bitowa
— — — — — — — — —
mov cx, word PTR blok_sys
mov dx, word PTR blok_sys+2
w powyższym przykładzie do rejestru CX zostanie wpisana młodsza część 32-bitowej zmiennej blok_sys, a rejestru DX starsza część tej zmiennej; w przypadku pominięcia operatora PTR sygnalizowany byłby błąd asemblacji wynikający z niejednakowej długości obu operandów;
ogólnie: operator PTR używany jest w wyrażeniach adresowych do ścisłego określania atrybutów symbolu występującego w wyrażeniu adresowym; po prawej stronie operatora występuje wyrażenie adresowe, a po lewej atrybut spośród następujących: byte (8 bitów), word (16 bitów), dword (32 bity), fword (48 bitów), qword (64 bity), tword (80 bitów);
operator PTR stosuje się także w przypadkach, gdy wyrażenie adresowe nie pozwala na jednoznaczne przetłumaczenie rozkazu, np. poniższy rozkaz stanowi tzw. odwołanie anonimowe (nie zawiera nazwy zmiennej)
mov [ebx], 65
operandy rozkazu nie precyzują czy liczba 65 ma zostać zapisana w pamięci jako liczba 8-bitowa, 16-bitowa czy 32-bitowa — zamiast podanego wyżej rozkazu programista musi wyraźnie określić rozmiar liczby podając jeden z trzech poniższych rozkazów
mov byte PTR [ebx], 65
mov word PTR [ebx], 65
mov dword PTR [ebx], 65
operator PTR stosuje się także przy określaniu adresów rozkazów sterujących (skoków);
należy zwrócić uwagę, że operator PTR nie dokonuje konwersji typów; działa on tylko w trakcie asemblacji programu i nie może zastępować rozkazów wykonywanych w trakcie realizacji programu; z tego powodu poniższy rozkaz
mov ebx, dword PTR dh ; błąd !
jest błędny; konwersję taką można zrealizować za pomocą niżej opisanych rozkazów MOVSX lub MOVZX;
reguła dotycząca jednakowej długości operandów nie obowiązuje w odniesieniu do rozkazów MOVSX i MOVZX; rozkazy powodują przepisanie zawartości 8- lub 16-bitowego rejestru (lub zawartości lokacji pamięci) do rejestru 16- lub 32-bitowego; w przypadku rozkazu MOVZX brakujące bity w rejestrze docelowym uzupełniane są zerami, zaś w przypadku MOVSX bity te są wypełniane przez wielokrotnie powielony bit znaku kopiowanego rejestru; przykłady
liczba_proc db 145 ; zmienna 8-bitowa
— — — — — — — — —
movzx edx, liczba_proc
movsx edx, bh
rozkaz MOVSX stosuje się do kopiowania liczb ze znakiem — po zwiększeniu ilości bitów liczby, jej wartość pozostaje niezmieniona.
Operator OFFSET
w praktyce programowania posługujemy się zarówno zawartościami pewnych obszarów pamięci, jak też i ich adresami;
położenie zmiennej (lub rozkazu) we pamięci określa się poprzez podanie odległości, liczonej we bajtach, zmiennej od początku segmentu — odległość ta nazywana jest przesunięciem lub offsetem i można ją wyznaczyć za pomocą operatora OFFSET; przykładowo, adres zmiennej moc_czynna, która została zdefiniowana w segmencie danych
_DATA SEGMENT . . . . . . . . . . . .
moc_czynna dw 800
— — — — — — — — — — — —
można wpisać do rejestru EBX za pomocą rozkazu
mov ebx, OFFSET moc_czynna
położenie to można odszukać w sprawozdaniu z asemblacji zawartym w pliku .LST; adresy zawarte w pliku .LST określane są taki sposób, jak gdyby segment danych lub rozkazów miał być umieszczony w pamięci począwszy od adresu 0;
położenie pewnej lokacji pamięci względem początku segmentu można także wyznaczyć za pomocą rozkazu LEA, który wyznacza adres efektywny rozkazu, czyli położenie lokacji pamięci (względem początku segmentu), na której zostanie wykonana operacja; zatem podany wyżej przykład można przedstawić w innej postaci
lea ebx, moc_czynna
rozkaz LEA wyznacza adres efektywny w trakcie wykonywania programu, podczas gdy wartość operatora OFFSET obliczana jest w trakcie translacji (asemblacji) programu; należy zwrócić uwagę, że rozkaz LEA nigdy nie odczytuje zawartości lokacji pamięci;
rozkaz LEA używany jest także w obliczeniach nie zawsze związanych z wyznaczeniem adresu zmiennej, np. można go zastosować do obliczenia sumy zawartości rejestrów:
lea eax, [ebx + ecx]
zamiast tradycyjnego rozwiązania
mov eax, ebx
add eax, ecx
niekiedy rozkaz LEA używany do mnożenia:
lea eax, [eax + eax∗4]
zamiast tradycyjnego rozwiązania
mov ebx, 5
mul ebx
takie konstrukcje trzeba jednak stosować ostrożnie, ponieważ w przypadku rozkazu LEA ewentualny nadmiar nie jest sygnalizowany;
jak już wcześniej powiedzieliśmy, programy wykonywane w systemie Windows lokowane są w pamięci wirtualnej począwszy od adresu 400000H (4 MB); oznacza to, że w trakcie konsolidacji (linkowania) programu, a później bezpośrednio przed uruchomieniem programu, adresy obliczone podczas asemblacji muszą dostosowane do aktualnego położenia programu w pamięci;
tak więc adresy obliczone w czasie asemblacji należy traktować jako wartości wstępne, na podstawie których w kolejnych etapach zostaną wyznaczone właściwe adresy wirtualne; ogólnie, wyrażenia adresowe opisujące adresy zmiennych czy położenie rozkazów, noszą nazwę operandów relokowalnych jeśli adres przypisany danej lub rozkazowi może ulegać zmianie w kolejnych etapach translacji programu;
podstawową zaletą opisywania położenia danych (lub rozkazów) za pomocą operandów relokowalnych jest możliwość elastycznego rozmieszczania poszczególnych fragmentów programu w dostępnych obszarach pamięci wirtualnej.
Asemblacja programów
w procesie translacji programu zakodowanego w asemblerze wyróżnia się asemblację, konsolidację (linkowanie) i ładowanie; w językach wysokiego translacja przebiega podobnie, przy czym zamiast asemblacji stosowana jest kompilacja;
w istocie zarówno asemblacja jak i kompilacja mają na celu przekształcenie kodu źródłowego programu na ciąg instrukcji procesora, aczkolwiek uzyskany ciąg (zawarty w pliku z rozszerzeniem .OBJ) wymaga jeszcze dalszych przekształceń, które wykonywane są w trakcie konsolidacji i ładowania;
asemblacja realizowana jest dwuprzebiegowo: w każdym przebiegu czytany jest cały plik źródłowy (ściśle: moduł) od początku do końca;
w pierwszym przebiegu asembler stara się wyznaczyć ilości bajtów zajmowane przez poszczególne rozkazy i dane; jednocześnie asembler rejestruje w słowniku symboli wszystkie pojawiające się definicje symboli (zmiennych i etykiet);
w drugim przebiegu asembler tworzy kompletną wersję przetłumaczonego programu określając adresy wszystkich rozkazów w oparciu o informacje zawarte w słowniku symboli;
w procesie asemblacji programów istotną rolę odgrywa rejestr programowy (tj. definiowany przez asembler), zwany licznikiem lokacji;
licznik lokacji określa adres komórki pamięci operacyjnej, do której zostanie przesłany aktualnie tłumaczony rozkaz lub dana; po załadowaniu rozkazu lub danej, licznik lokacji zostaje zwiększony o ilość bajtów zajmowanych przez ten rozkaz lub daną; w trakcie tłumaczenia pierwszego wiersza segmentu licznik lokacji zawiera 0;
jeśli asembler napotka wiersz zawierający definicję symbolu, to rejestruje go w słowniku symboli, jednocześnie przypisując temu symbolowi wartość równą aktualnej zawartości licznika lokacji; ponadto zapisywane są także atrybuty symbolu, jak np. far, byte, itp;
w celu dokładniejszego wyjaśnienia omawianego postępowania rozpatrzmy przykład:
gamma dw ?
beta db ?
alfa dd 74567H, 885678H, 789H, 0A15FF3H, 89ABH
— — — — — — —
mov ebx, 12
sub eax, alfa ; odjęcie liczby 74567H od EAX
mov edx, alfa+4 ; wpisanie 885678H do EDX
add esi, alfa [ebx]+4 ; dodanie 89ABH do ESI
po wczytaniu segmentu danych (w którym zdefiniowano zmienne: gamma, beta, alfa) słownik symboli zawiera następujące pozycje
alfa Dword 0003 _DATA
beta Byte 0002 _DATA
gamma Word 0000 _DATA
tłumaczenie rozkazu mov ebx, 12 nie wymaga zaglądania do słownika symboli — wystarczy tylko zamienić mnemonik mov na odpowiedni kod binarny, następnie określić kod binarny rejestru ebx oraz liczbę 12 przedstawić w postaci 32-bitowej liczby binarnej, co prowadzi do podanego niżej rozkazu 5-bajtowego
BB |
0C |
00 |
00 |
00 |
tłumaczenie następnych rozkazów jest bardziej skomplikowane: w celu wyznaczenia pola adresowego asembler musi każdorazowo odszukiwać w słowniku symboli wartość przypisaną nazwie alfa — wartość licznika lokacji dla segmentu danych w chwili wystąpienia definicji zmiennej alfa wynosiła 3, zatem wartość przypisana nazwie alfa wynosi 3; należy zwrócić uwagę, że liczba 3 nie jest tu wartością zmiennej alfa, lecz jej adresem względem początku segmentu danych; w rezultacie trzy kolejne rozkazy zostaną przetłumaczone na poniższą postać binarną
sub eax, alfa |
|||||
2B |
05 |
03 |
00 |
00 |
00 |
mov edx, alfa + 4 |
|||||
8B |
15 |
07 |
00 |
00 |
00 |
add esi, alfa [ebx] + 4 |
|||||
03 |
B3 |
07 |
00 |
00 |
00 |
zauważmy, że dwa ostatnie rozkazy mają identyczne pole adresowe (cztery ostatnie bajty);
licznikiem lokacji można się także posługiwać w programie źródłowym - aktualna zawartość licznika lokacji jest reprezentowana przez symbol $; symbol $ może stanowić operand w wyrażeniach języka asembler, reprezentujący bieżącą lokację wewnątrz aktualnie tłumaczonego segmentu;
Przykład: podany niżej dwubajtowy rozkaz jmp powoduje przejście do następnego rozkazu. Obok podano postać rozkazu po asemblacji. Czasami tego rodzaju rozkazy umieszcza się w programie w celu wprowadzenia dodatkowego opóźnienia.
EBH |
00H |
jmp $+2
dyrektywa ORG umożliwia wpisanie do licznika lokacji potrzebnej wartości, np.:
ORG 100H
w polu operandu dyrektywy ORG można podać wyrażenie arytmetyczne czasu translacji, np. dyrektywa ORG $+7 powoduje zwiększenie aktualnej zawartości licznika lokacji o 7;
dyrektywa ORG może także zastępować dyrektywę DB — dyrektywy podane w lewej i w prawej kolumnie mają ten sam skutek:
ela LABEL byte ORG $+5 |
ela DB 5 DUP (?) |
Przykład
Wykorzystując symbol $ można łatwo wyznaczyć liczbę znaków łańcucha, np.:
blad_par db 'Podano błędne parametry', 0DH, 0AH
wiel = $ blad_par
- - - - - - - - - - - - - - - - - - - - - - - - - - -
mov cx, wiel
wartość wyrażenia $ blad_par obliczana jest w trakcie asemblacji programu, a nie w czasie jego wykonywania (co jest charakterystyczne dla języków wysokiego poziomu) — z tego powodu wyrażenia tego rodzaju nazywane są wyrażeniami arytmetycznymi czasu translacji; także zmienna wiel jest zmienną czasu translacji, co oznacza, że dla zmiennej tej nie jest rezerwowany żaden obszar w programie wynikowym; innymi słowy zmienna wiel funkcjonuje jedynie w czasie translacji (asemblacji) programu; zauważmy, że trakcie asemblacji instrukcji mov zmienna czasu translacji wiel traktowana jest jako liczba, a nie jako nazwa zmiennej zdefiniowanej za pomocą dyrektywy DW.
Sprawozdanie z przebiegu translacji programu
w trakcie asemblacji tworzony jest plik z rozszerzeniem .LST zawierający obszerne sprawozdanie z jej przebiegu (tzw. listing asemblacji); analogicznie w trakcie konsolidacji tworzony jest plik z rozszerzeniem .MAP;
w pliku .LST podawane są kolejne wiersze programu źródłowego oraz wygenerowany na ich podstawie kod półskompilowany (ang. object code); podane są także nazwy i wartości wszystkich etykiet, zmiennych i symboli stosowanych w programie źródłowym; sygnalizowane są też ewentualne błędy; poniżej podano przykładowy fragment tego pliku obejmujący trzy rozkazy programu
000000A6 8A 03 nowy: mov al, [ebx]
000000A8 43 inc ebx
000000A9 3C 0A cmp al,10
w końcowej części sprawozdania podany jest słownik symboli używanych w programie.
Kod asemblerowy w wersji AT&T
omawiany wcześniej asembler został zaprojektowany przez firmę Intel dla wytwarzanych przez nią procesorów; dostępne są także asemblery używające innych składni języka, spośród których najbardziej znana jest składnia AT&T stosowana w Linuksie;
zasadnicze różnice między tymi asemblerami są następujące:
nazwy rejestrów poprzedzone są znakiem %
przesłania zapisywane są w postaci skąd, dokąd
prawie każdy rozkaz posiada jawnie zdefiniowany rozmiar operandów, określony przez ostatnią literę instrukcji
movl (%ebx), %eax |
mov eax, dword ptr [ebx] |
wartości bezpośrednie są poprzedzone znakiem $, np.
movl $1, %ebx |
mov ebx, 1 |
liczby w zapisie szesnastkowym poprzedzone są znakami 0x (tak jak w C)
modyfikacje adresowe stosują dość specyficzną notację
movl (%ebx), %eax movl 3(%ebx), %eax mov (%ebx, %ecx), %eax mov (%ebx, %ecx, 2), %eax |
mov eax, [ebx] mov eax, [ebx+3] mov eax, [ebx + ecx] mov eax, [ebx + ecx*2] |
segmenty są deklarowane poprzez nazwy, np. .text, .bss
część rozkazów procesora posiada odmienne mnemoniki, np. cwb
komentarze poprzedzone są znakiem #
1
2
1
37
11
42
11
24
93