LEKCJA 17: TROCHĘ SZCZEGÓLÓW TECHNICZNYCH. ________________________________________________________________ W trakcie tej lekcji dowiesz się więcej o szczegółach działania komputera widzianych z poziomu assemblera. ________________________________________________________________ LICZBY ZMIENNOPRZECINKOWE TYPU float. To, że C++ przy wywołaniu funkcji jest "przyzwyczajony" do odkładania argumentów na stos zawsze po dwa bajty może nam sprawić trochę kłopotów, gdy zechcemy zastosować argument typu float, double, bądź long - znacznie przekraczający długością dwubajtowe słowo maszynowe. # include <.... .... # pragma inline void main() { float liczba = 3.5; .... Jeżeli zajrzymy do pamięci naszego PC, okaże się, że liczba 3.5 została tam "zaszyfrowana" jako 00 00 60 40. Dlaczego? Format liczb zmiennoprzecinkowych jest znacznie bardziej skomplikowany. Liczba dziesiętna w rodzaju 123.4 to 1*102 + 2*101 + 3*100 + 4*10-1 {* !UWAGA SKLAD tu cyfry potegi wyzej *} Ale PC może posługiwać się wyłącznie zerami i jedynkami, i liczyć wyłącznie w systemie dwójkowym. Liczbę dziesiętną 3.5 możnaby przedstawić dwójkowo np. tak: 1*21 + 1*20 + 1*2-1 = 2 + 1 + 1/2 {* !UWAGA SKLAD: potegi *} czyli 0000 0000 0000 0011.1000 0000 0000 0000 Kropka oznacza przecinek oddzielający część całkowitą od części ułamkowaj - "przecinek dwójkowy" (a nie dziesiętny!). Każdą liczbę dziesiętna można zamienić na liczbę dwójkową. Przykładowodzieiętne 7.75 można zamienić na 4 + 2 + 1 + 1/2 + 1/4 = 0000 0000 0000 0111.1100 (dwójkowo) Pozostaje jednak pewien problem. Komputer nie ma możliwości zaznaczenia przecinka, dlatego też przecinek musi być ustawiany zawsze w tej samej pozycji - blisko początku liczby. Liczby zmiennoprzecinkowe są poddawane "normalizacji" (ang. noralized). Nasza liczba 0000 0000 0000 0011.1000 po normalizacji będzie wyglądać tak: 1.110 0000 0000... * 2^1. Odbywa się to zupełnie tak samo, jak normalizacja liczb dziesiętnych. Przesunięcie przecinka powoduje, że 12345.67 = 1.234567 * 10^4. Aby wróciła do swojej starej "zwykłej" postaci (jest to tzw. "rozwinięcie" liczby - ang. expand) należy przesunąć przecinek o jedno miejsce w prawo - otrzymamy znowu 11.1 . W liczbach dziesiętnych pierwsza cyfra może być różna (tylko nie zero), a w dowolnej poddanej normalizacji zmiennoprzecinkowej liczbie dwójkowej pierwszą cyfrą jest zawsze 1. Skoro w formacie liczb zmiennoprzecinkowych pierwsza jedynka jest przyjmowana "z definicji" (ang. implicit), więc można ją pominąć. Zostanie nam zatem zamiast 1.11 tylko 11 i ta przechowywana część liczby jest nazywana jej częścią znaczącą (ang. significant). To jeszcze nie wszystko - powinien tam być wykładnik potęgi. Wystarczy zapamiętać wykładnik, bo podstawa jest zawsze ta sama - 2. Niestety wykładniki są przechowywane nie w sposób naturalny, a po dodaniu do nich tzw. przesunięcia (ang. offset lub bias). Pozwala to uniknąć kłopotów z określaniem znaku wykładnika potęgi. Dla liczb typu float offset wykładnika wynosi +127 a dla liczb double float +1023. Wrócmy do naszej przykładowej liczby. Jeśli nasza liczba 3.5 = 11.1(B) ma być zapisana w postaci zmiennoprzecinkowej - float, zapisany w pamięci wykładnik potęgi wyniesie: 1 + 127 = 128 = 80 (hex) A teraz znak liczby. Pierwszy bit każdej liczby zmiennoprzecinkowej określa znak liczby (ang. sign bit). Liczby zmiennoprzecinkowe nie są przechowywane w postaci dwójkowych uzupełnień. Jeśli pierwszy bit - bit znaku równy jest 1 - liczba jest ujemna. natomiast jeżeli 0, liczba jest dodatnia. Jest to jedyna różnica pomiędzy dodatnimi a ujemnymi liczbami zmiennoprzecinkowymi. Nasza liczba 3.5 = 11.1 zostanie zakodowana jako: znak liczby - 0 wykładnik potęgi - 1000 0000 cyfry znaczące liczby - 110000000.... Ponieważ wiemy, że mamy do dyspozycji dla liczb float 4 bajty (możesz to sprawdzić sizeof(float x=3.5)), uzupełnijmy brakujące do 32 bity zerami: 3.5 = 0100 0000 0110 0000 0000 0000 0000 0000 = 40 60 00 00 zapis 40600000 to oczywiście szesnastkowa postać naszej liczby. Jeśli teraz weźmiemy pod uwagę, że nasz PC zamieni miejscami starsze słowo z młodszym 00 00 40 60 a następnie w obrębie każdego słowa dodatkowo starszy bit z młodszym, to zrozumiemy, dlaczego nasza liczba "siedziała" w pamięci w zaszyfrowanej postaci 00 00 60 40. Rozpatrzmy szkielet programu wykorzystującego funkcję z "długim" argumentem. Aby zapanować nad zapisem liczby zmiennoprzecinkowej do pamięci naszego PC możemy na poziomie assemblera postąpić np. tak: # include <..... # pragma inline void funkcja(long int) //Prototyp funkcji main() { long liczba = 0xABCDCDEF; //Deklaracja argumentu ..... funkcja(liczba); //Wywołanie w programie .... } void funkcja(long int x) //Implementacja funkcji { ..... } // x - argument formalny Argument przekazywany funkcji() jest zmienną 4 - bajtową typu long int. Możemy ją zamienić na dwa słowa, zanim przekażemy ją do wykorzystania w asemblerowskiej części programu. funkcja(long int x) { int x1starsze, x2mlodsze; //Wewnętrzne zmienne pomocnicze x2mlodsze = (int) x; x >> 16; x1starsze = (int) x; _DX = x1starsze; _BX = x2mlodsze; asm { ...... //Tu funkcja już może działać Forsując konwersję typu na (int), spowodujemy, że młodsze słowo zostanie przypisane zwyczajnej krótkiej zmiennej x2mlodsze. Następnie zawartość długiej zmiennej zostanie przesunięta o 16 bitów w prawo (starsze słowo zostanie przesunięte na miejsce młodszego). Powtórzenie operacji przypisania spowoduje przypisanie zmiennej x1starsze starszej połówki słowa. Od tej chwili możemy odwołać się do tych zmiennych w naszym fragmencie napisanym w asemblerze. Postępujemy tak, by to C++ martwił się o szczegóły techniczne i sam manipulował stosem i jednocześnie pilnował poprawności konwersji danych. ZWROT WARTOŚCI PRZEZ FUNKCJĘ. A teraz kilka słów o tym, co się dzieje, gdy funkcja zapragnie zwrócić jakąś wartość do programu. Wykorzystanie przez funkcje rejestrów do zwrotu wartości. ________________________________________________________________ Typ wartości Funkcja używa rejestru (lub pary) ________________________________________________________________ signed char / unsigned char AL short AX int AX enum AX long para DX:AX (starsze słowo DX, młodsze AX) float AX = Adres (jeśli far to DX:AX) double AX = Adres (jeśli far to DX:AX) struct AX = Adres (jeśli far to DX:AX) near pointer AX far pointer DX:AX ________________________________________________________________ Zależnie od typu wartości zwracanej przez funkcję (określonej w prototypie funkcji), C++ odczytuje zawartość odpowiedniego rejestru: AL, AX lub DX:AX. Jeśli funkcja ma np. zwrócić wartość o długości jednego bajtu, to przed wyjściem z funkcji należy ją "zostwić" w rejestrze AL. Jeśli wywołując funkcję C++ oczekuje zwrotu wartości jednobajtowej, to po powrocie z funkcji automatycznie pobierze bajt z rejestru AL. Krótkie wartości (typu short int) są "pozostawiane" przez funkcję w AX, a długie w parze rejestrów: DX - starsze, AX - młodsze słowo. Zastosujmy to w programie. Funkcja będzie odejmować dwie liczby całkowite. Pobierze dwa argumenty typu int, wykona odejmowanie i zwróci wynik typu int (return (_AX)). Dla modelu pamięci small będzie to wyglądać tak: [P064.CPP] # include # pragma inline int funkcja(int, int); //Prototyp funkcji void main() { cout << "\nWynik 7 - 8 = " << funkcja(7, 8); } int funkcja(int x, int y) //Implementacja funkcji { asm { MOV AX, x SUB AX, y } return (_AX); //Zwróć zawartość rejestru AX } Zwróć uwagę, że po return(_AX); stawiamy średnik, natomiast po instrukcjach assemblera nie: asm MOV AX, DX chyba, że chcemy umieścić kilka instrukcji assemblera w jednej linii (patrz niżej). C++ i assembler są równoprawnymi partnerami. C++ może odwoływać się do zmiennych i funkcji assemblera, jeśli zostały zadeklarowane, jako publiczne (public) oraz zewnętrzne (EXTeRNal) i vice versa. C++ oczekuje, że zewnętrzne identyfikatory będą się rozpoczynać od znaku podkreślenia "_". Jeśli w programie pisanym w BORLAND C++ zastosujemy zewnętrzne zmienne i funkcje, C++ sam automatycznie doda do identyfikatorów znak podkreślenia. Turbo Assembler nie robi tego automatycznie i musimy zadbać o to "ręcznie". Przykładowo, współpraca pomiędzy programem P .CPP i modułem MODUL.ASM będzie przebiegać poprawnie: [P065.CPP] extern int UstawFlage(void); //Prototyp funkcji int Flaga; void main() { UstawFlage(); } [MODUL.ASM] .MODEL SMALL .DATA EXTRN _Flaga:WORD .CODE PUBLIC _UstawFlage _UstawFlage PROC CMP [_Flaga], 0 JNZ SKASUJ_Flage MOV [_Flaga], 1 JMP SHORT KONIEC SKASUJ_Flage: MOV [_Flaga], 0 KONIEC: RET _UstawFlage ENDP END Kompilacja może przebiegać oddzielnie wg schematu: PROGRAM.CPP --> PROGRAM.OBJ MODUL.ASM --> MODUL.OBJ TLINK PROGRAM.OBJ MODUL.OBJ --> PROGRAM.EXE Lub możemy powierzyć tę pracę kompilatorowi, który sam wywoła TASM i TLINK: TCC PROGRAM.CPP MODUL.ASM W BORLAND C++ 3.1 mamy do dyspozycji zintegrowany assembler (ang. in-line) - BASM. Ma on jednak w stosunku do "wolnostojącego" Turbo Assemblera pewne ograniczenia: * ma zawężony w stosunku do TASM zestaw dyrektyw (tylko DB, DD, DW, EXTRN); * nie pozwala na stosowanie składni typowej dla trybu "Ideal mode"; * nie pozwala na zastosowanie makra; * nie pozwala stosować instrukcji charakterystycznych dla 386 ani 486. Możesz stosować kilka rozkazów assemblera w jednej linii, ale powinieneś rozdzielać je wewnątrz linii średnikami: asm { POP AX; POP DX; POP DS IRET } Komentarz we wstawce assemblerowskiej musi zostać poprzedzony typowym dla C - /* (sam średnik, jak w TASM jest niedopuszczalny): asm { MOV DX, 1 ;TAK NIE MOŻNA W BASM ! ... asm { ADD AX, BX; /* Taki komentarz może być */ [???] KŁOPOTY Z REJESTRAMI ? ________________________________________________________________ Jeśli zastosujesz rejestry DI i SI we wstawce assemblerowaj, kompilator C++ nie będzie miał gdzie umieścić zmiennych klasy register z programu głónego. Zastanów się - co się bardziej opłaca. ________________________________________________________________ O WEKTORACH PRZERYWAŃ DOS Mikroprocesory Intel 80X86 rezerwują w pamięci naszych PC początkowe 1024 Bajty (adresy fizyczne 00000...00400 hex) na 256 wektorów przerywań (każdy wektor składa się z dwu słów i może być traktowany jako DW, bądź far pointer). Następne 256 bajtów (00400...00500 hex) zajmuje BIOS, a kolejne 256 (00500...00600 hex) wykorzystuje DOS i Basic. Wektor to w samej rzeczy pełny adres początku procedury obsługującej przerywanie o danym numerze UWAGA: Wektor zapisywany jest w pamięci w odwrotnej kolejności: Adres pamięci: 0000:0000 [OFFSET Wekt. int 0] 0000:0002 [SEGMENT int 0] 0000:0004 [OFFSET Wekt. int 1] 0000:0006 [SEGMENT int 1] 0000:0008 [OFFSET int 2] .... .... Procesory 80X86 zamieniają jeszcze dodatkowo starszy bajt z młodszym. Posługując się systemowym debuggerem DEBUG możesz łatwo przejrzeć tablicę wektorów przerywań własnego komputera. Jeśli wydasz rozkaz: C:\DOS\DEBUG -D 0:0 zobaczysz zawartość pierwszych 32 wektorów int #0...int#31, czyli pierwsze 128 bajtów pamięci: -d 0:0 0000:0000 FB 91 32 00 F4 06 70 00-78 F8 00 F0 F4 06 70 00 0000:0010 F4 06 70 00 54 FF 00 F0-53 FF 00 F0 53 FF 00 F0 0000:0020 A5 FE 00 F0 87 E9 00 F0-23 FF 00 F0 23 FF 00 F0 0000:0030 23 FF 00 F0 CE 02 00 C8-57 EF 00 F0 F4 06 70 00 0000:0040 D1 0C BD 1B 4D F8 00 F0-41 F8 00 F0 74 07 70 00 0000:0050 39 E7 00 F0 4A 08 70 00-2E E8 00 F0 D2 EF 00 F0 0000:0060 00 00 FF FF FB 07 70 00-5D 0C 00 CA 9F 01 BD 1B 0000:0070 53 FF 00 F0 A0 7C 00 C0-22 05 00 00 2F 58 00 C0 Po zdeszyfrowaniu okaże się, że pierwszy wektor (przerywanie 0) wskazuje na adres startowy: 0032:91FB (adres absolutny 0951B). Generalnie możliwe są cztery sytuacje. Wektor może wskazywać: * adres startowy procedur ROM-BIOS: blok F - Fxxx:xxxx, * adres funkcji DOS, * adres funkcji działającego właśnie debuggera (DEBUG przejmuje obsługę niektórych przerywań), lub innego programu rezydującego w pamięci - np. NC.EXE, * wektor może być pusty - 00 00:00 00 jeśli dane przerywanie nie jest obsługiwane. Jeśli zechcesz sprawdzić, jak obsługiwane jest dane przerywanie możesz znów zastosować debugger, wydając mu rozkaz zdezasamblowania zawartości pamięci począwszy od wskazanego adresu: -u 32:91FB 0032:91FB BE6B47 MOV SI,476B 0032:91FE 2E CS: 0032:91FF 8B1E7E47 MOV BX,[477E] 0032:9203 2E CS: 0032:9204 8E16D73D MOV SS,[3DD7] 0032:9208 BCA007 MOV SP,07A0 0032:920B ˙˙˙˙˙˙˙˙˙˙˙E80200 ˙˙˙˙˙˙˙˙˙˙CALL ˙˙˙˙˙˙˙˙˙˙9210 0032:920E EBDA JMP 91EA 0032:9210 ˙˙˙˙˙˙˙˙˙˙˙16 ˙˙˙˙˙˙˙˙˙˙˙PUSH ˙˙˙˙˙˙˙˙˙˙˙SS 0032:9211 07 POP ES 0032:9212 ˙˙˙˙˙˙˙˙˙˙˙16 ˙˙˙˙˙˙˙˙˙˙˙PUSH ˙˙˙˙˙˙˙˙˙˙˙SS 0032:9213 1F POP DS 0032:9214 C606940308 MOV BYTE PTR [0394],08 0032:9219 C606920316 MOV BYTE PTR [0392],16 Z poziomu assemblera do wektora i odpowiednio do funkcji obsługującej przerywanie możesz odwołać się instrukcją INT numer. Zmienna numer może tu przyjmować wartości od 00 do FF. Jeśli wydasz taki rozkaz, komputer zapisze na stos (żeby sobie nie zapomnieć) zawartość rejestrów CS - bież. segment rozkazu, IP - bieżący offset rozkazu i FLAGS. Następnie wykona daleki (far jump) skok do adresu wskazanego przez wektor. Jeśli jednak część przerywań jest "niewykorzystana", lub w Twoim programie trzeba je obsługiwać inaczej - niestandardowo ? W BORLAND C++ masz do dyspozycji specjalny typ funkcji: interrupt. Aby Twoja funkcja mogła stać się "handlerem" przerywania, możesz zadeklarować ją tak: void interrupt MojaFunkcja(bp, di, si, ds .....) Do funkcji klasy interrupt przekazywane są jako argumenty rejestry, nie musisz zatem stosować pseudozmiennych _AX, _FLAGS itp.. Jeśli zadeklarujesz funkcję jako handler przy pomocy słowa "interrupt", funkcja automatycznie zapamiętuje stan rejestrów: AX, BX, CX, DX, SI, DI, BP, ES i DS. Po powrocie z funkcji rejestry zostaną automatycznie odtworzone. Przykładem funkcji obsługującej przerywanie może być piszczek() posługujący się wbudowanym głośniczkiem i portem: # define us unsigned # include # include void InstalujWektor(void interrupt (*adres)(), int numer_wekt); void interrupt Piszczek(us bp, us di, us si, us ds, us es, us ax, us bx, us cx, us dx); void main() { ..... } .... Po zadeklarowaniu prototypów dwu funkcji: Piszczek() - nasz handler przerywania; InstalujWektor() - funkcja instalująca nasz handler; możemy przystąpić do zdefiniowania oby funkcji. Posłużymy się zmiennymi nowe_bity, stare_bity. Wydawanie dźwięku polega na włączaniu i wyłączaniu głośniczka. Pusta pętla posłuży nam do zwłoki w czasie. void interrupt Piszczek(us bp, us di, us si, us ds, us es, us ax, us bx, us cx, us dx) { char nowe_bity, stare_bity, i; int n; unsigned char licznik = ax >> 8; stare_bity = inportb(0x61); for(nowe_bity = stare_bity, n = 0; n <= licznik; n++) { outportb(0x61, 0xFC & nowe_bity); //Wylacz for(i = 1; i < 255; i++) ; //Czekaj outportb(0x61, nowe_bity / 2); //WLACZ for(i = 1; i < 255; i++) ; //Czekaj } outportb(0x61, stare_bity); //Stan poczatkowy } Funkcja instalująca handler korzysta z bibliotecznej funkcji C++ setvect() (ustaw wektor przerywania) i potrzebuje dwu argumentów: * numeru wektora przerywania (numer * 4 = adres), * adresu funkcji - handlera - *faddr. void InstalujWektor(void interrupt (*adres)(), int numer_wektora) { cout << "\nInstaluje wektor" << numer_wektora << "\n"; setvect(numer_wektora, adres); } Pozostało nam wygenerować przerywanie. Załatwimy to funkcją Start(): void Start(unsigned char licznik, int numer_wektora) { _AH = licznik; geninterrupt(numer_wektora); //generuj przerywanie } Nasz główny program będzie zatem wyglądać tak: # include <... ... void main() { Instaluj(Piszczek, 10); Start(5, 10); } Należy do dobrych manier odtworzyć po wykorzystaniu oryginalną zawartość wektora przerywania, który "unowocześniliśmy". W bibliotece BORLAND C++ masz do dyspozycji m. in. funkcje getvect() - pobierz wektor (ten stary) i setvect() - ustaw wektor (ten nasz - nowocześniejszy). Jeśli zechcemy korzystać z rejestrów 386/486? Jeśli mamy komputer z 32 bitowymi rejestrami, to wypadałoby z tego korzystać. Na poziomie assemblera masz do dyspozycji dyrektywy: .386, .386P i .386C (P oznacza pełny zestaw instrukcji wraz z trybem uprzywilejowanym - 386 privileged instruction set). Mikroprocesor Intel 80386 może obsługiwać pamięć zgodnie z tradycyjnym podziałem na 64 kilobajtowe segmenty (tryb USE16), lub podzieloną na ciągłe segmenty po 4 GB (tryb USE32). Rejestry ogólnego przeznaczenia rozrosły się z 16 do 32 bitów i zyskały w nazwie dodatkową literę E (Extended - rozszerzony). "Stare" rejestry stały się młodszą połówką nowych. I tak: EAX = 0...15 to stary AX, 16...31 to rozbudowa do EAX (dokładniej: 0..7 = AL, 8..15 = AH, 0...15 = AX, 0...31 = EAX) BX -> 0...31 EBX: 0...7 BL, 8...15 BH, 0...15 BX CX -> 0...31 ECX DX -> 0...31 EDX wszystkie z dodatkowym podziałem na połówki H i L (np. DX = DH:DL). SI -> 0...31 ESI w tym (SI = 0..15) DI -> 0...31 EDI w tym (DI = 0..15) BP -> 0...31 EBP w tym (BP = 0..15) SP -> 0...31 ESP w tym (SP = 0..15) IP -> 0...31 EIP w tym (IP = 0..15) FLAGS -> 0...31 EFLAGS w tym (FLAGS = 0..15) Wszystkie "stare" połówki dostępne pod starą nazwą. Rejestry segmentowe pozostały 16 bitowe, ale jest ich o dwa więcej: CS, DS, ES, SS oraz nowe FS i GS. Nowe 32 bitowe rejestry działają według tych samych zasad: .386 ... MOV EAX, 1 ;zapisz 1 do rejestru EAX SUB EBX, EBX ;wyzeruj rejestr EBX ADD EBX, EAX ;dodaj (EAX)+(EBX) --> EBX Dostęp do starszej połowy rejestru można uzyskać np. poprzez przesuwanie (rotation): .386 ... MOV AX, Liczba_16_bitowa ROR EDX, 16 MOV AX, DX ROR EDX, 16 ... itp. W assemblerze możesz stosować wobec procesora 386 nowe instrukcje (testowania nie istniejących wcześniej bitów, przenoszenia krótkich liczb do 32 bitowych rejestrów z uwzględnieniem zaku i uzupełnieniem zerami itp.): BSF, BSR, BTR, BTS, LFS, LGS, MOVZX, SETxx, BT, BTC, CDQ, CWDE, LSS, MOVSX, SHLD i SHRD. Przy pomocji instrukcji MOV w trybie uprzywilejowanym (tzw. most-privileged level 0 - tylko w trybie .386P) możesz dodatkowo uzyskać dostęp do specjalnych rejestrów mikroprocesora 80386. CR0, CR2, CR3, DR0, DR1, DR2, DR3, DR6, DR7 TR6, TR7 Występuje tu typ danych - FWORD - 48 bitów (6 bajtów). Obok znanych dyrektyw DB i DW pojawia się zatem nowa DF, a oprócz znajomych wskaźników BYTE PTR, WORD PTR pojawia się nowy FWORD PTR. Przy pomocy dyrektywy .387 możesz skorzystać z koprocesora. Jak wynika z zestawu dodatkowych insrukcji: FCOS, FSINCOS, FUCOMP, FPREM1, FUCOM, FUCOMPP, FSIN warto dysponować koprocesorem, jeśli często korzystasz z grafiki, animacji i funkcji trygonometrycznych (kompilacji nie przyspieszy to niestety ani o 10% - tam odbywają się operacje stałoprzecinkowe). Zwróć uwagę, że procesory 386 i wcześniejsze wymagały instalacji dodatkowego układu 387 zawierającego koprocesor zmiennoprzecinkowy. Procesory 486 jeśli mają rozszerzenie DX - zawierają już koprocesor wewnątrz układu scalonego. ________________________________________________________________