Programowanie niskopoziomowe 1) Ogólna budowa komputera 2) Budowa procesora i386 Głównym celem tego opisu jest lepsze zrozumienie jak są wykonywane programy napisane w C, dlatego poczynimy w opisie różne uproczczenia. Mimo uproszczeń, opis powinien wystarczyć do tego by samodzielnie zacząć programować w asemblerze. Głównym uproszczeniem jest pominięcie większości instrukcji procesora i386 --- podajemy tylko często używane istrukcje. Pominiemy dwie części procesora: jednostkę zmiennopozycyjną i układy zarządzania pamięcią. Pominiemy także różne udoskonalenia dostępne w nowszych procesorach Intela, zmieniają one 'logiczny' opis procesora w minimalnym stopniu, zaś główny wpływ mają na wydajność. - część sterująca - jednostka arytmetyczno-logiczna (ALU) - rejestr stanu (znaczników) - licznik rozkazów - rejestry ogólnego przeznaczenia - interfejs pamięci Program dla procesora i386 składa się instrukcji, dla odróżniania od np. C nazywa się je często instrukcjami maszynowymi. Instrukcje maszynowe procesora i386 mają zmienną długość, od 1 do 15 bajtów. Poszczególne bajty instrukcji są umieszczone po kolei w pamięci. Instrukcje mogą się zaczynać od dowolnego adresu. Gdy procesor zaczyna wykonanie instrukcji licznik rozkazów zawiera jej adres. Część sterująca procesora odczytuje kolejne bajty instrukcji i ustala jej znaczenie (w szczególności także długość). Dekodowanie (ustalanie znaczenia) instrukcji jest dość skomplikowane, ale można je wykonać jednoznacznie tzn. dwu różnym istrukcjom odpowiadają dwa różne ciągi bajtów w pamięci, dodatkowo żadna instrukcja nie jest prefiksem (początkowym fragmentem) innej instrukcji. Po zdekodowaniu instrukcji następuje jej wykonanie, robi się to co ta instrukcja każe (patrz następny punkt). Dodatkowo wylicza się nową wartość licznika rozkazów. Zwykłe instrukcje wykonuje się po kolei, tak że do licznika rozkazów po prostu dodaje się długość właśnie wykonanej instrukcji. Niektóre instrukcje (skoki) zmieniają wartość licznika rozkazów, pozwalając na realizaję np. pętli czy procedur. Istotną częścią procesora są rejestry, tzn. mała pamięć składająca się z kilku (do kilkudziesięciu, zależnie od konkretnego procesora) komórek. Procesor i386 ma osiem rejestrów ogólnego przeznaczenia: eax, ebx, ecx, edx, edi, esi, ebp i esp. Wszystkie one mają nazwy zaczynające się od litery `e', aby podkreślić że chodzi nam o dostęp 32-bitowy. Można też wykonywać operacje na częsciach (fragmentach) tych rejestrów. Część instrukcji korzysta z rejestru znaczników. Ten rejestr to kilka bitów pozwalających zapamiętać np. wynik porównia. Wiele zwykłych instrukcji modyfikuje rejestr znaczników np. zaznaczając że wynik operacji jest zerem lub ujemny. Instrukcje skoku warunkowego zmieniają (lub nie) wartość licznika rozkazów w zależności od znaczników. Pierwszą fazą wykonania instrukcji jest pobranie argumentów -- odczytuje się je z rejestrów albo pobiera z pamięci. Po pobraniu argumentów następuje własciwe wykonanie -- typowo robi to jednostka arytmetyczno- logiczna (umie ona wykonać operacje arytmetyczne i manipulacje bitowe, dodatkowo sprawdza warunki ustawiając znaczniki). Ostatnią fazą wykonania jest zapisanie wyniku (do rejestru albo do pamięci). Typowa instrukcja pobiera dane z rejestrów i zapisuje wynik z powrotem do rejestru, np.: addl %eax,%ebx -- dodaje zawartość rejestru `eax' do rejestru `ebx' (niszcząc w ten sposób starą zawartość `ebx') (w instrukcjach asemblerowych nazwę rejestru poprzedzamy znakiem `%' aby odróżnić ją od nazw zmiennych). Instrukcje procesora i386 mogą też pobierać argumenty z pamięci i umieszczać wynik w pamięci. Adres (numer) komórki pamięci można podać na kilka sposobów. W najprostszym przypadku adres może być zawarty w (być częścią) instrukcji. Mówimy wtedy o adresowaniu bezpośrednim. Adres może też być pobrany z rejestru -- jest to tzw. adresowanie rejestrowe pośrednie. Procesor i386 pozwala na dość złożone tworzenie adresów. Mianowicie adres może być sumą trzech części: -- stałej zawartej w instrukcji, tzw. przesunięcia -- rejestru tzw. bazy -- innego rejestru (tzw. indeksu) razy czynnik skalujący który może wynosić 1, 2, 4 albo 8. Np: movl (%edx),%ebx -- przesyła słowo (cztery bajty) spod adresu zawartego w `edx' do `ebx'. movl 128(%edx,%eax,4),%ebx -- przesyła słowo (cztery bajty) spod adresu `128+edx+4*eax' do rejestru `ebx'. 3) Język maszynowy a język asemblera Program w pamięci komputera jest ciągiem bitów (lub bajtów). Czyli program można by zapisać jako ciąg liczb. Asembler pozwala stosować znacznie czytelniejszą notację. Instrukcje zapisujemy podając ich nazwy (kilkuliterowe sktóty tzw. mnemoniki). Również do rejestrów odwołujemy się podając ich nazwy. Liczby możemy podawać w notacji dziesiętnej. Istotnym udogodnieniem jest możliwość nazwania komórek pamięci i lokacji programu (służą do tego tzw. etykiety) i odwoływania się do nich przy pomocy nazw. Niektóre asemblery mają rozbudowane mechanizmy do obliczania stałych, makrodefinicje itp. `gas' którego składnię tu używam, oferuje niewiele udogodnień (głównym jego przeznaczeniem jest tłumaczenie wyjścia z kompilatora). 4) Wybrane instrukcje procesora i386. Te instrukcje powinny wystarczyć do napisania dowolnego programu, choć niekoniecznie w najprostszy czy najwydajniejszy sposób. movl arg1,arg2 -- przesyła arg1 do arg2 jeden z arg1, arg2 może oznaczać komórkę pamięci arg1 może być stałą np: movl %eax,%ebx -- przesyła zawartość rejestru `%eax' do rejestru `%ebx' addl arg1,arg2 -- dodaje arg1 do arg2 jeden z arg1, arg2 może oznaczać komórkę pamięci arg1 może być stałą np: addl %eax,%ebx -- dodaje zawartość rejestru `%eax' do rejestru `%ebx' inne operacje to: adc - dodawanie z uwzględnieniem przeniesienia sub - odejmowanie sbb - odejmowanie z uwzględnieniem przeniesienia and - iloczyn bitowy or - suma bitowa xor - suma bitowa modulo 2 Mnożenie i dzielenie: mull arg,%eax -- mnoży `eax' i `arg' jako liczby bez znaku, wynik jest 64-bitowy, dolne 32 bity wyniku umieszcza się w `eax', górne 32 bity umieszcza się w `edx' imull arg,%eax -- podobnie jak `mull', ale argumenty traktuje się jako liczby ze znakiem (`signed' w C) divl arg -- dzieli 64-bitową liczbę bez znaku (`unsigned' w C) której dolne 32 bity są w `eax' zaś górne 32 bity są w `edx' przez argument. Iloraz umieszcza się w `eax' zaś resztę w `edx' idivl arg -- podobnie jak `divl', ale argumenty traktuje się jako liczby ze znakiem (`signed' w C) Operacje na stosie: push arg -- umieszcza argument na stosie pop arg -- pobiera argument ze stosu np: push %eax -- umieszcza zawartość `eax' na stosie pop %edx -- pobiera wartość ze szczytu stosu i umieszcza w `edz' (łącznie te dwie instrukcje powyżej kopiują zawartość `eax' do `edx'). Do zmiany kolejności wykonania instrukcji służą skoki: jmp adres -- bezwarunkowo skacze pod adres jz adres -- skok jeśli wynik ostatniej operacji był zerem lub ostatnio porównane liczby były równe (sprawdza rejestr znaczników) jnz adres -- skok jeśli wynik ostatniej operacji nie był zerem ja adres -- skocz przy większym, tzn. ostatnie porównanie liczb bez znaku (`unsigned' w C) dało wynik "wiekszy" jna adres -- skocz jeśli nie większy jb adres -- skocz przy mniejszym tzn. ostatnie porównanie liczb bez znaku (`unsigned' w C) dało wynik "mniejszy" jg adres -- skocz przy większym, dla liczb ze znakiem, tzn. ostatnie porównanie liczb ze znakiem (`signed' w C) dało wynik "wiekszy" jng adres -- skocz jeśli nie większy (warunków które można sprawdzać jest więcej ale te powinny wystarczyć do pisania programów). Podprogramy: call adres -- wywołuje podprogram pod danym adresem, tzn. umieszcza adres kolejnej instrukcji (tej po `call') na stosie i wykonuje skok pod adres ret -- wraca z podprogramu, tzn. pobiera adres ze stosu i skocze do niego