LEKCJA 16 - ASEMBLER TASM i BASM. ________________________________________________________________ W trakcie tej lekcji: * dowiesz się , jak łączyć C++ z assemblerem * poznasz wewnętrzne formaty danych ________________________________________________________________ WEWNĘTRZNY FORMAT DANYCH I WSPÓŁPRACA Z ASSEMBLEREM. W zależności od wybranej wersji kompilatora C++ zasady współpracy z asemblerem mogą się trochę różnić. Generalnie, kompilatory współpracują z tzw. asemblerami in-line (np. BASM), lub asemblerami zewnętrznymi (stand alone assembler np. MASM, TASM). Wstawki w programie napisane w assemblerze powinny zostać poprzedzone słowem asm (BORLAND/Turbo C++), bądź _asm (Microsoft C++). Przy kompilacji należy zatem stosownie do wybranego kompilatora przestrzegać specyficznych zasad współpracy. Np. dla BORLAND/Turbo C++ można stosować do kompilacji BCC.EXE/TCC.EXE przy zachowaniu warunku, że TASM.EXE jest dostępny na dysku w bieżącym katalogu. Typowymi sposobami wykorzystania assemblera z poziomu C++ są: * umieszczenie ciągu instrukcji assemblera bezpośrednio w źródłowym tekście programu napisanym w języku C/C++, * dołączeniu do programu zewnętrznych modułów (np. funkcji) napisanych w assemblerze. W C++ w tekście źródłowym programu blok napisany w asemblerze powinien zostać poprzedzony słowem kluczowym asm (lub _asm): # pragma inline void main() { asm mov dl, 81 asm mov ah, 2 asm int 33 } Program będzie drukował na ekranie literę "Q" (ASCII 81). JAK POSŁUGIWAĆ SIĘ DANYMI W ASEMBLERZE. Napiszemy w asemblerze program drukujący na ekranie napis "tekst - test". Rozpczynamy od zadeklarowania łańcucha znaków: void main() { char *NAPIS = "tekst - test$"; /* $ - ozn. koniec */ Umieściliśmy w pamięci łańcuch, będący w istocie tablicą składającą się z elementów typu char. Wskaźnik do łańcucha może zostać zastąpiony nazwą-identyfikatorem tablicy. Zwróć uwagę, że po łańcuchu znakowym dodaliśmy znak '$'. Dzięki temu możemy skorzystać z DOS'owskiej funkcji nr 9 (string-printing DOS service 9). Możemy utworzyć kod w asemblerze: asm mov dx, NAPIS asm mov ah, 9 asm int 33 Cały program będzie wyglądał tak: [P054.CPP] # pragma inline void main() { char *NAPIS = "\n tekst - test $"; asm { MOV DX, NAPIS MOV AH, 9 INT 33 } } Zmienna NAPIS jest pointerem i wskazuje adres w pamięci, od którego rozpoczyna się łańcuch znaków. Możemy przesłać zmienną NAPIS bezpośrednio do rejestru i przekazać wprost przerywaniu Int 33. Program assemblerowski (tu: TASM) mógłby wyglądać np. tak: [P055.ASM] .MODEL SMALL ;To zwylke robi TCC .STACK 100H ;TCC dodaje standardowo 4K .DATA NAPIS DB 'tekst - test','$' .CODE START: MOV AX, @DATA MOV DS, AX ;Ustawienie segmentu danych ASM: MOV DX, OFFSET NAPIS MOV AH, 9 INT 21H ;Drukowanie KONIEC: MOV AH, 4CH INT 21H ;Zakończenie programu END START Inne typy danych możemy stosować podobnie. Wygodną taktyką jest deklarowanie danych w tej części programu, która została napisana w C++, aby inne fragmenty programu mogły się do tych danych odwoływać. Możemy we wstawce asemblerowskiej odwoływać się do tych danych w taki sposób, jakgdyby zostały zadeklarowane przy użyciu dyrektyw DB, bądź DW. WEWNĘTRZNE FORMATY DANYCH W C++. LICZBY CAŁKOWITE typów char, short int i long int. Liczba całkowita typu short int stanowi 16-bitowe słowo i może zostać zastosowana np. w taki sposób: [P056.CPP] #pragma inline void main() { char *napis = "\nRazem warzyw: $"; int marchewki = 2, pietruszki = 5; asm { MOV DX, napis MOV AH, 9 INT 33 MOV DX, marchewki ADD DX, pietruszki ADD DX, '0' MOV AH, 2 INT 33 } } Zdefiniowaliśmy dwie liczby całkowite i łańcuch znaków - napis. Ponieważ obie zmienne (łańcuch znków jest stałą) mają długość jednego słowa maszynowego, to efekt jest taki sam, jakgdyby zmienne zostały zadeklarowane przy pomocy dyrektywy asemblera DW (define word). Możemy pobrać wartość zmiennej marchewki do rejestru instrukcją MOV DX, marchewki ;marchewki -> DX W rejestrze DX dokonujemy dodawania obu zmiennych i wyprowadzamy na ekran sumę, posługując się funkcją 2 przerywania DOS 33 (21H). W wyniku działania tego programu otrzymamy na ekranie napis: Razem warzyw: 7 Jeczsze jeden szczegół techniczny. Ponieważ stosowana funkcja DOS pracuje w trybie znakowym i wydrukuje nam znak o kodzie ASCII przechowywanym w rejestrze, potrzebna jest manipulacja: ADD DX, '0' ;Dodaj kod ASCII "zera" do rejestru Możesz sam sprawdzić, że po przekroczeniu wartości 9 przez sumę wszystko się trochę skomplikuje (kod ASCII zera - 48). Z równym skutkiem możnaby zastosować rozkaz ADD DX, 48 Jeśli prawidłowo dobierzemy format danych, fragment programu napisany w asemblerze może korzystać z danych dokładnie tak samo, jak każdy inny fragment programu napisany w C/C++. Możemy zastosować dane o jednobajtowej długości (jeśli drugi, pusty bajt nie jest nam potrzebny). Zwróć uwagę, że posługujemy się w tym przypadku tylko "połówką" rejestru DL (L - Low - młodszy). [P057.CPP] #pragma inline void main() { const char *napis = "\nRazem warzyw: $"; char marchewki = 2, pietruszki = 5; asm { MOV DX, napis MOV AH, 9 INT 33 MOV DL, marchewki ADD DL, pietruszki ADD DL, '0' MOV AH, 2 INT 33 } } W tej wersji zadeklarowaliśmy zmienne marchewki i pietruszki jako zmienne typu char, co jest równoznaczne zadeklarowaniu ich przy pomocy dyrektywy DB. Zajmijmy się teraz maszynową reprezentacją liczb typu unsigned long int (długie całkowite bez znaku). Ze względu na specyfikę zapisu danych do pamięci przez mikroprocesory rodziny Intel 80x86 długie liczby całkowite (podwójne słowo - double word) np. 12345678(hex) są przechowywane w pamięci w odwróconym szyku. Zamieniony miejscami zostaje starszy bajt z młodszym jak również starsze słowo z młodszym słowem. Liczba 12345678(hex) zostanie zapisana w pamięci komputera IBM PC jako 78 56 34 12. Gdy inicjujemy w programie zmienną long int x = 2; zostaje ona umieszczona w pamięci tak: 02 00 00 00 (hex). Młodsze słowo (02 00) jest umieszczone jako pierwsze. To właśnie słowo zawiera interesującą nas informację, możemy wczytać to słowo do rejestru rozkazem MOV DX, X Jeśli będzie nam potrzebna druga połówka zmiennej - starsze słowo (umieszczone w pamięci jako następne), możemy zastosować pointer (czyli podać adres następnego słowa pamięci). [P058.CPP] # pragma inline void main() { unsigned long marchewki = 2, pietruszki = 5; const char *napis = "\nRazem warzyw: $"; asm { MOV DX, napis MOV AH, 9 INT 33 MOV DX, marchewki ADD DX, pietruszki ADD DX, '0' MOV AH, 2 INT 33 } } W przypadku liczb całkowitych ujemnych C++ stosuje zapis w kodzie komplementarnym. Aby móc manipulować takimi danymi każdy szanujący się komputer powinien mieć możliwość stosowania liczb ujemnych. Najstarszy bit w słowie, bądź bajcie (pierwszy z lewej) może spełniać rolę bitu znakowego. O tym, czy liczba jest ze znakiem, czy też bez decyduje wyłącznie to, czy zwracamy uwagę na ten bit. W liczbach bez znaku, obojętnie, czy o długości słowa, czy bajtu, ten bit również jest (i był tam zawsze!), ale traktowaliśmy go, jako najstarszy bit nie przydając mu poza tym żadnego szczególnego znaczenia. Aby liczba stała się liczbą ze znakiem - to my musimy zacząć ją traktować jako liczbę ze znakiem, czyli zacząć zwracać uwagę na ten pierwszy bit. Pierwszy, najstarszy bit liczby ustawiony do stanu 1 będzie oznaczać, że liczba jest ujemna - jeśli zechcemy ją potraktować jako liczbę ze znakiem. Filozofia postępowania z liczbami ujemnymi opiera się na banalnym fakcie: (-1) + 1 = 0 Twój PC "rozumuje" tak: -1 to taka liczba, która po dodaniu 1 stanie się 0. Czy można jednakże wyobrazić sobie np. jednobajtową liczbę dwójkową, która po dodaniu 1 da nam w rezultacie 0 ? Wydawałoby się, że w dowolnym przypadku wynik powinien być conajmniej równy 1. A jednak. Jeśli ograniczymy swoje rozważania do ośmiu bitów jednego bajtu, może wystąpić taka, absurdalna tylko z pozoru sytuacja. Jeśli np. dodamy 255 + 1 (dwójkowo 255 = 11111111): 1111 1111 hex FF dec 255 + 1 + 1 + 1 ___________ _____ _____ 1 0000 0000 100 256 otrzymamy 1 0000 0000 (hex 100). Dla Twojego PC oznacza to, że w ośmiobitowym rejestrze pozostanie 0000 0000 , czyli po prostu 0. Nastąpi natomiast przeniesienie (carry) do dziewiątego (nie zawsze istniejącego sprzętowo bitu). Wystąpienie przeniesienia powoduje ustawienie flagi CARRY w rejestrze FLAGS. Jeśli zignorujemy flagę i będziemy brać pod uwagę tylko te osiem bitów w rejestrze, okaże się, że otrzymaliśmy wynik 0000 0000. Krótko mówiąc FF = (-1), ponieważ FF + 1 = 0. Aby odwrócić wszystkie bity bajtu, bądź słowa możemy w asemblerze zastosować instrukcję NOT. Jeśli zawartość rejestru AX wynosiła np. 0000 1111 0101 0101 (hex 0F55), to instrukcja NOT AX zmieni ją na 1111 0000 1010 1010 (hex F0AA). Dokładnie tak samo działa operator bitowy ~_AX w C/C++. W zestawie rozkazów mikroprocesorów rodziny Intel 80x86 jest także instrukcja NEG, powodująca zamianę znaku liczby (dokonując konwersji liczby na kod komplementarny). Instrukcja NEG robi to samo, co NOT, ale po odwróceniu bitów dodaje jeszcze jedynkę. Jeśli rejestr BX zawierał 0000 0000 0000 0001 (hex 0001), to po operacji NEG AX zawartość rejestru wyniesie 1111 1111 1111 1111 (hex FFFF). Zastosujmy praktycznie uzupełnienia dwójkowe przy współdziałaniu asemblera z C++: [P059.CPP] #pragma inline void main() { const char *napis = "\nRazem warzyw: $"; int marchewki = -2, pietruszki = 5; asm { MOV DX, napis MOV AH, 9 INT 33 MOV DX, marchewki NEG DX ADD DX, pietruszki ADD DX, '0' MOV AH, 2 INT 33 } } Dzięki zamianie (-2) na 2 przy pomocy instrukcji NEG DX otrzymamy wynik, jak poprzednio równy 7. Przypomnijmy prezentację działania operatorów bitowych C++. Wykorzystaj program przykładowy do przeglądu bitowej reprezentacji liczb typu int (ze znakiem i bez). [P060.CPP] /* Program prezentuje format liczb i operatory bitowe */ # include "iostream.h" # pragma inline void demo(int liczba) //Definicja funkcji { int n = 15; for (; n >= 0; n--) if ((liczba >> n) & 1) cout << "1"; else cout << "0"; } char odp; char *p = "\nLiczby rozdziel spacja $"; int main() { int x, y; cout ˙<< "\nPodaj dwie liczby calkowite od -32768 do +32767\n"; asm { mov dx, p mov ah, 9 int 33 } cout << "\nPo podaniu drugiej liczby nacisnij [Enter]"; cout << "\nLiczby ujemne sa w kodzie dopelniajacym"; cout << "\nSkrajny lewy bit oznacza znak 0-Plus, 1-Minus"; for(;;) { cout << "\n"; cin >> x >> y; cout << "\nX: "; demo(x); cout << "\t\tY: "; demo(y); cout << "\n~X: "; demo(~x); cout << "\t\t~Y: "; demo(~y); cout << "\nX & Y: "; demo(x & y); cout << "\nX | Y: "; demo(x | y); cout << "\nX ^ Y: "; demo(x ^ y); cout << "\n Y: "; demo(y); cout << "\nY >> 1: "; demo(y >> 1); cout << "\nY << 2: "; demo(y << 2); cout << "\n\n Jeszcze raz? T/N: "; cin >> odp; if (odp!='T'&& odp!='t') break; } } Wstawka asemblerowa nie jest w programie niezbędna, ale w tym miejscu wydaje się być "a propos". Przy pomocy programu przykładowego możesz zobaczyć "na własne oczy" jak wygląda reprezentacja bitowa liczb całkowitych i ich kody komplementarne. Praca bezpośrednio ze zmiennymi jest jednym ze sposobów komunikowania się z programem napisanym w C++. Mogą jednak wystąpić sytuacje bardziej skomplikowane, kiedy to nie będziemy znać nazwy zmiennej, przekazywanej do funkcji. Jeśli napiszemy w asemblerze funkcję w celu zastąpienia jakiejś funkcji bibliotecznej C++ , program wywołując funkcję przekaże jej parametry i będzie oczekiwał, iż funkcja pobierze sobie te parametry ze stosu. Rozważmy się to zagadnienie dokładniej. Typową sytuacją jest pisanie w asemblerze tylko kilku funkcji (zwykle takich, które powinny działać szczególnie szybko). Aby to zrobić, musimy nauczyć się odczytywać parametry, które program przekazuje do funkcji w momencie jej wywołania. Zaczynamy od trywialnej funkcji, która nie pobiera w momencie wywołania żadnych parametrów. W programie może to wyglądać np. tak: [P061.CPP] //*TEKST to znany funkcji zewnętrzny wskaźnik #pragma inline char *TEKST = "\ntekst - test$"; void drukuj(void); //Prototyp funkcji void main() { drukuj(); //Wywołanie funkcji drukuj() } void drukuj(void) //Definicja funkcji { asm MOV DX, TEKST asm MOV AH, 9 asm INT 33 } Funkcja może oczywiście nie tylko zgłosić się napisem, ale także zrobić dla nas coś pożytecznego. W kolejnym programie przykładowym czyścimy bufor klawiatury (flush), co czasami się przydaje, szczególnie na starcie programów. [P062.CPP] # pragma inline char *TEKST = "\nBufor klawiatury PUSTY. $"; void czysc_bufor(); //Też prototyp funkcji void main() { czysc_bufor(); //Czyszczenie bufora klawiatury } void czysc_bufor(void) //Definicja funkcji { START: asm MOV AH, 11 asm INT 33 asm OR AL, AL asm JZ KOMUNIKAT asm MOV AH, 7 asm INT 33 asm JMP START KOMUNIKAT: asm MOV DX, TEKST asm MOV AH, 9 asm INT 33 } Póki nie wystąpi problem przekazania parametrów, napisanie dla C++ funkcji w asemblerze jest banalnie proste. Zwróć uwagę, że zmienne wskazywane w programach przez pointer *TEKST zostały zadeklarowane poza funkcją main() - jako zmienne globalne. Dzięki temu nasze funkcje drukuj() i czysc_bufor() mają dostęp do tych zmiennych. Spróbujemy przekazać funkcji parametr. Nazwiemy naszą funkcję wyswietl() i będziemy ją wywoływać przekazując jej jako argument znak ASCII przeznaczony do wydrukowania na ekranie: wyswietl('A'); . Pojawia się zatem problem - gdzie program "pozostawia" argumenty przeznaczone dla funkcji przed jej wywołaniem? W Tabeli poniżej przedstawiono w skrócie "konwencję wywoływania funkcji" (ang. Function Calling Convention) języka C++. Konwencje wywołania funkcji. ________________________________________________________________ Język Argumenty na stos Postać Typ wart. zwrac. ________________________________________________________________ BASIC Kolejno offset adresu Return n C++ Odwrotnie wartości Return Pascal Kolejno wartości Return n ________________________________________________________________ Return n oznacza liczbę bajtów zajmowanych łącznie przez wszystkie odłożone na stos parametry. W C++ parametry są odkładane na stos w odwróconej kolejności. Jeśli chcemy, by parametry zostały odłożone na stos kolejno, powinniśmy zadeklarować funkcję jako "funkcję z Pascalowskimi manierami" - np.: pascal void nazwa_funkcji(void); Dodatkowo, w C++ argumenty są przekazywane poprzez swoją wartość, a nie przez wskazanie adresu parametru, jak ma to miejsce np. w BASICU. Istnieje tu kilka wyjątków przy przekazywaniu do funkcji struktur i tablic - bardziej szczegółowo zajmiemy się tym w dalszej części książki. Rozbudujemy nasz przykładowy program w taki sposób, by do funkcji były przekazywane dwa parametry - litery 'A' i 'B' przeznaczone do wydrukowania na ekranie przez funkcję: # pragma inline void wyswietl(char, char); //Prototyp funkcji void main() { wyswietl('A', 'B'); //Wywolanie funkcji } void wyswietl(char x, char y) //Definicja (implementacja) { .... Parametry zostaną odłożone na stos: PUSH 'B' PUSH 'A' Każdy parametr (mimo typu char) zajmie na stosie pełne słowo. C++ nie potrafi niestety układać na stosie bajt po bajcie. Funkcja wyswietl() musi uzyskać dostęp do przekazanych jej argumentówów. Odwołamy się do zmiennych C++ w taki sposób, jak robiłaby to każda inna funkcja w C++: [P063.CPP] # pragma inline void wyswietl(char, char); //Prototyp funkcji void main() { _AH = 2; //BEEEEE ! wyswietl('A', 'B'); //Wywolanie funkcji } void wyswietl(char x, char y) //Definicja (implementacja) { _DH = 0; // To C++ nie TASM, to samo, co asm MOV DH, 0 _DL = x; // asm MOV DL, x asm INT 33 _DH = 0; // asm MOV DH, 0 _DL = y; // asm MOV DL, y asm INT 33 } Aby pokazać jak dalece BORLAND C++ jest elastyczny wymieszaliśmy tu w jednaj funkcji instrukcje C++ (wykorzystując pseudozmienne) i instrukcje assemblera. Może tylko przesadziliśmy trochę ustawiając rejestr AH - numer funkcji DOS dla przerywania int 33 przed wywołaniem funkcji wyswietl() w programie głównym. To brzydka praktyka (ozn. //BEEEE), której autor nie zaleca. Jak widzisz, przekazanie parametrów jest proste.