Laboratorium języków assemblerowych, ćwiczenie 1
Do wykonania ćwiczenia niezbędny jest komputer PC w postaci maszyny fizycznej lub wirtualnej z systemem Windows 7, 8, 8.1 lub 10, z zainstalowanym i skonfigurowanym środowiskiem programistycznym Microsoft Visual Studio 2019 w wrsji co najmniej Community Edition 1). Do pracy w laboratorium nada się również wersja VS Professional lub Enterprise. Proszę natomiast nie pomylić z Visual Studio Code, to zupełnie inny produkt, który nie nadaje się wprost do wykorzystania w naszym laboratorium do realizacji zadań, w szczególności kolejnych ćwiczeń.
W przypadku chęci skorzystania z gotowej maszyny, obraz można pobrać m.in. z poniższego linku:
Jako środowisko witualizacyjne można wykorzystać Oracle Virtual Box4), dostępne praktycznie na każdą platformę desktopową. W przypadku instalacji Linuxowych typu headless, można wykorzystać KVM5).
Proszę rownież pamiętać o zainstalowaniu dodatków specyficznych dla platformy wirutalizacyjnej (np, Oracle Virtual Box Add-ons), co może pociągać za sobą konieczności w zmianie konfiguracji maszyny wirtualnej (np. konieczność dołożenia napędu wirtualnego w celu zainstalowania ww dodatków). Sugeruję rownież zwiększenie pamięci RAM do co najmniej 8GB (o ile to możliwe), liczby dostępnych wątków procesora (przynajmniej 2) oraz zmianę wirtualnej karty graficznej na zgodną z akceleracją sprzętową DirectX/Direct 3D (np. w Oracle Vitual Box jest to karta VBoxSVGA).
Instalator Visual Studio Communicy Edition 2019 pozwala na zainstalowanie wielu różnych komponentów. Minimalny zestaw komponentów został przedstawiony na poniższych rysunkach 1, 2, 3:
Visual Studio natywnie nie posiada narzędzi wspomagających pracę dewelopera z kodem źródłowym w assemblerze. Jednak ze względu na istniejących mechanizm dodatków, można doinstalować zewnętrzne narzędzia. Nie jest to obowiązkowe, ale upraszcza pracę dewelopera, w szczególności powoduje, że kod w assemblerze zostanie pokolorowany i łatwiej będzie znaleźć ew. błędy, podczas debuggowania będą podpowiadane automatycznie wartości rejestrów, jak równnież będzie się pojawaiała (w ograniczonym zakresie) kontekstowa informacja o znaczeniu instrukcji, dyrektyw, zmiennych, etykiet, etc. Dodatek AsmDude można zainstalować za pomocą mechanizmów VS w automatyczny sposób. Opis dodatku znajduje się na stronie autora6).
Studenci muszą rozumieć sposób tworzenia i działania aplikacji typu “Command Line”, podstawy języka assembler, C++ i C#
W ramach tego ćwiczenia stworzysz w czystym assemblerze dla x64 (procesory Intel/AMD, 64 bity) aplikację konsolową, która będzie prowadziła interakcję z systemem operacyjnym Windows 10 (7, 8, 8.1) wywołując jego funkcje poprzez interfejs Windows API.
Jest to przykład technologiczny, ponieważ tworzenie w ten sposób skomplikowanych aplikacji i złożonych interfejsów użytkownika jest … bezsensowne , choć możliwe. Natomiast zdedycowanie przyda się przy robieniu prostych usług obliczeniowych, narzędzi, etc., które wymagają zwartości kodu i wykonywania zadań z maksymalną prędkością.
Zwróć uwagę, że podane przykłady nie ograniczają się do funkcji interfejsu użytkownika, a obejmują całe Windows API. A ze względu na rozmiar aplikacji, całość niejednokronie mieści się w pamięci L1 procesora, zatem wykonuje się niespotykanie szybko.
Ponieważ w Visual Studio nie ma gotowego modelu projektu na potrzeby aplikacji w czystym assemblerze, wykorzystamy model aplikacji konsolowej w języku C++ i przezbroimy go na potrzeby kompilacji zamiast kodu w języku C++, kodu w czystym assemblerze.
Takie podejście wymaga ręcznego przekonfigurowania kilku pramatetrów we właściwościach projekt, a w szczególności:
masm;main(), natomaist w przypadku assemblera, nie ma takiej reguły i trzeba podać samemu, o którą funkcję chodzi;asm, które natywnie nie są rozpoznawane przez Visual Studio jako pliki z kodem źródłowym, nie są one automatycznie zapisywane w momencie wywołania kompilacji, jak ma to miejsce w przypadku plików .cpp, .cs itp. Co prawda istnieje możliwość nauczenia VS 2019, aby plik był uznawany za źródłowy, niemniej jednak wykracza to poza niniejszą instrukcję. Proszę zatem pamiętać za każdym razem kliknąć zapis do pliku (Ctrl+s) przed kompilacją.
W przypadku pisania kodu w języku assembler, każdy bajt musi być przydzielony, zadeklarowany i użytkowany w odpowiedni sposób, szczególnie w kontekście wywoływania funkcji Windows API.
Ponieważ pracujemy na systemie 64.-bitowym, konieczne jest stosowane reguł wywołania funkcji zgodnie z kanonem.
Parametry wywołania funkcji przekazujemy poprzez rejestry, o ile ich liczba nie przekracza czterech.
W przeciwnym wypadu przekazujemy je przez stos (pozostałe). Natomiast obowiązkowo należy zaalokować na stosie przestrzeń na argumenty nawet … jeśli przekazujemy je przez rejestry 7).
W przypadku, gdy do funkcji przekazujemy argumenty numeryczne całkowite, w szczególności np. adresy i wskaźniki, należy użyć w kolejności następujących rejestrów:
Przykładowy kod w C++:
func1(int a, int b, int c, int d, int e, int f); // a w RCX, b w RDX, c w R8, d w R9, f, a następnie e, są odkładane na stosie
W przypadku przekazywania argumentów zmiennoprzecinkowych, kolejne wartości łądujemy do rejestrów XMM:
Przykładowy kod w C++:
func2(float a, double b, float c, double d, float e, float f); // a jest XMM0, b w XMM1, c w XMM2, d w XMM3, f a następnie e są odkładane na stosie
W przypadku mieszania argumentów zmiennoprzecinkowych z dziesiętnymi, stosuje się system pozycyjny w zależności od typu, a zatem jeśli przekazujemy np, int1, float1, float2, int2, to wartości należy umieścić w rejestrach (w kolejności):
Przykładowy kod w C++:
func3(int a, float b, float c, int d, int e, float f); // a w RCX, b w XMM1, c w XMM2, d w R9, f, a następnie e, poprzez stos
Wartość całkowita (w tym wskaźnik) zwracana jest w rejestrze RAX.
Wartości zmiennoprzecinkowa zwracana jest w rejestrze XMM0.
ret.
Rejestry, które można bezkarnie zmieniać wewnątrz kodu assemblerowej funkcji, bez konieczności przywracania oryginalnej wartości, to (x64 calling conventions):
Rejestry, których nie wolno zmieniać (albo raczej należy przywrócić ich oryginalne wartości przed opuszczeniem funkcji) to:
Stwórz nowy projekt: aplikacja typu command-line (konsolowa) dla języka C++, natywnego (nie C++ .NET) rysunek 4
Usuń wygenerowany plik .cpp (z poziomu VS) a następnie dodaj nowy plik .asm (można to zrobić klikając w projekt (nie w solucję!) i wybierając dodanie nowego elementu rysunek 5, a następnie wybrać plik typu tekstowego i zmienić jego nazwę oraz rozszerzenie rysunek 6:
W celu konwersji należy poinformować VS, że pliki źródłowe będą kompilowane za pomocą MASM a nie kompilatora C/C++. W tym celu klikamy prawym przyciskiem na projekt i wybieramy opcję dostosowania budowania projektu rysunek 7. W ustawieniach należy wybrać masm(.targets, .props) rysunek 8.
Następnie należy zmienić rodzaj budowanej aplikacji z x86 (aplikacja 32-bitowa) na x64 (aplikacja 64-bitowa): menu Build→Configuration Manager rysunek 9.
W tym kroku poinformujemy linker, jaki punkt startowy ma wpisać do pliku wykonywanego.
W językach wysokiego poziomu jest to domyślnie funkcja main(). W przypadku assemblera nie ma takiego założenia, a zatem kod źródłowy musi być zgodny z ustawioną tutaj wartością.
W podanym przykładzie, nazwa funkcji to mojaFunkcjaAsm. Aby dostać się do okna konfiguracyjnego należy w drzewie projektu wskazać projekt (nie solucję!), a następnie prawym przyciskiem myszy kilknąć w projekt, wybrać pozycję Properties, lub na skróty [Alt+Enter]. Pojawi się okienko jak na rysunku 10. Następnie w sekcji Linker należy wybrać opcję Advanced i w polu Entry Point wpisać nazwę funkcji.
W tej sekcji należy również dołożyć brakujące biblioteki, z których będziemy korzystać. Uwaga - w zależności od wersji Windows i bieżącego poziomu aktualizacji, ten krok może nie być potrzebny, ale nie zaszkodzi. W szczególności w tych bibliotekach znajdują się finkcje typu sprintf.
Uwaga - podane biblioteki należy dołożyć do tych, które standardowo już tam się znajdują. Usunięcie znajdujących się tam bibliotek (m.in. kernel32.lib,user32.lib) spowoduje brak możliwości użycia standardowych okienek dialogowych oraz interakcji z Windows API. Aby dołożyć biblioteki należy wskazać w menu Linker pozyję Input, a następnie wybrać Additional Dependencies i opcję <Edit..> po rozwinięciu listy wyboru. Następnie w górnym polu wpisać nazwy plików bibliotecznych, które mają być dostępne dodatkowo podczas kompilacji (jedna pozycja w linii).
Biblioteki do uzupełnienia to:
legacy_stdio_definitions.liblegacy_stdio_wide_specifiers.libSzczegóły na rysunku 11.
W niektórych przypadkach zdarza się, że plik .asm nie jest automatycznie rozpoznawany jako plik źródłowy z kodem w assemblerze. W związku z tym należy jawnie poinformować o tym środowiko. W tym celu należy kliknąć nazwę pliku w drzewie projektu prawym przyciskiem i wybrać opcję Properites, a następnie w sekcji General wskazać Item Type i ustawić jego wartośc na Microsoft Macro Assembler. Szczegóły na rysunku 12.
Na sieci jest dostępne rozszerzenie do Visual Studio o nazwie AsmDude, które (m.in.) automatycznie koloruje kod źródłowy w assemblerze, pomaga w pisaniu kodu (podaje nazwy etykiet, zmiennych, itp) oraz podczas debbugowania wyświetla okienka z wartościami rejestrów. Plugin ten może zostać zainstalowany poprzez managera rozszerzeń Visual Studio8).
Zaimplementuj odczyt daty (użyj funkcji GetSystemTime(sysinfoapi.h)9) odczytującej strukturę _SYSTEMTIME(minwinbase.h)10). Struktura składa się z 8 wartości szesnastobitowych.
Skonwertuj rok, miesiąc i dzień do postaci tekstowej ASCII i wyświetl na ekranie za pomocą:
WriteConsoleA;MessageBoxA;Możesz dodatkowo wyświetlić bieżącą godzinę, minuty i sekundy.
Pamiętaj o deklaracji segmentu kodu i danych za pomocą operatorów .data i .code. Na końcu kodu obowiązkowo umieść klauzulę END.
Każdy łańcuch tekstowy musi kończyć się znacznikem o binarnej wartości 0. Nie używamy polskich znaków, ponieważ korzystamy z zestawu znaków ASCII (8-bit), a nie Unicode.
Przykładowy segment danych, który był używany w prezentacji wygląda tak:
... .data STD_INPUT_HANDLE = -10 STD_OUTPUT_HANDLE = -11 STD_ERROR_HANDLE = -12 handler dq 0 stdHandle dq 0 datetimeStruct dw 16 dup (0) hello_msg db "Hello world", 0 info_msg db "Info", 0 title_msg db "Window Title JA",0 text_msg db "A to jest bardzo dlugi tekst do wyswietlenia w konsoli, ale bez polskich znakow!",13,10 dummy dq 0 ...
Aby móc skorzystać z zewnętrznych funkcji Windows API, trzeba poinformować assembler, jak one się nazywają, aby linker mógł potem dopasować nazwy z Twojego kodu do tych, które znajdują się w bibliotece.
W tym celu trzeba użyć dyrektywy EXTERN oraz PROC:
... includelib kernel32.lib EXTERN MessageBoxA: PROC EXTERN GetForegroundWindow: PROC EXTERN WriteConsoleA: PROC EXTERN SetConsoleTitleA: PROC EXTERN GetStdHandle: PROC EXTERN GetSystemTime: PROC ...
Zwyczajowo umieszczamy również informację o bibliotece.
Po uruchomieniu programu musimy zachować wartości pewnych rejestrów, w celu ich późniejszego przywrócenia. Na sam koniec należy również wywołać funkcję kończącą program i zwracającą tzw kod powrotu do systemu operacyjnego.
Minimalny zestaw instrukcji wygląda następująco:
... .code hello_world_asm PROC push rbp ; save frame pointer mov rbp, rsp ; fix stack pointer sub rsp, 8 * (4 + 2) .... ; tutaj kod Twojego programu mov rsp, rbp pop rbp ret hello_world_asm ENDP END
hello_world_asm i to ona została wskazana jako [Entry Point] (punkt wejścia) do programu, w konfiguracji opisanej powyżej. Dostosuj do Twojego nazewnictwa.
Jak widać, całość sprowadza się do zarezerwowania i odłożenia na stosie przestrzeni na tzw. shadow registry, czyli na zachowanie wartości rejestrów i przywrócenie ich po zakończeniu wykonywania programu.
W programie wykorzystujemy interakcję z elementami graficznymi takimi jak okienko główne (Foreground Window) oraz strumień danych (STD_OUT). Podczas wywoływania funkcji, należy jednoznacznie wskazać, względem którego obiektu graficznego chcemy wykonać interakcję lub stworzyć obiekt potomny (np. MessageBox).
W tym celu musimy pobrać dwa uchwyty (handlery):
,13,10. Jeśli tego nie zrobisz, konieczne będzie przejście do okienka i ręczne naciśnięcie ENTER w celu wyświetlenia przekierowanego tam ciągu znaków z Twojego programu. Inaczej okienko pozostanie puste i tekst nie pojawi się!
W celu odczytania uchwytów przydatne będą dwie funkcje z Windows API, które zwrócą nam wskaźniki (uchwyty) do odpowiednich obiektów. Wartości te należy zachować i można je wykorzystywać wielokrotnie.
Odczyt uchwytu do okienka głównego, ten uchwyt należy zastosować przy wywoływaniu okna dialogowego typu Message Box (i każdego innego):
... call GetForegroundWindow mov handler, rax ...
W powyższym przykładzie zwracana przez funkcję wartość (przez rejestr RAX) zapisywana jest w zmiennej handler, typu DQ (64 bity, czyli po prostu adres w pamięci).
Aby odczytać uchwyt do wnętrza terminala, należy skorzystać z innej funkcji:
... mov rcx, STD_OUTPUT_HANDLE call GetStdHandle mov stdHandle, rax ...
Przy czym ta funkcja wymaga jednego argumentu, o który strumień danych nam chodzi. Są trzy możliwości:
13,10!;
Przykładowy kod wyswietlający na konsoli tekst znajdujący się w zmiennej (pod adresem) text_msg znajduje się poniżej. Pierwszy parametr to odczytany wyżej uchwyt do konsoli. W trzecim parametrze należy podać, ile znaków ma zostać wyświetlonych. Czwarty parametr to wskaźnik do miejsca, gdzie funkcja zwróci informacje, ile tak na prawdę wyświetliła znaków (nie do końca wiadomo, czemu to służy).
... ; WINAPI WriteConsoleA( ; RCX => _In_ HANDLE hConsoleOutput, ; RDX => _In_ const VOID *lpBuffer, ; R8 => _In_ DWORD nNumberOfCharsToWrite, ; R9 => _Out_opt_ LPDWORD lpNumberOfCharsWritten, mov rcx, stdHandle lea rdx, text_msg mov r8, sizeof text_msg mov r9, dummy call WriteConsoleA ...
W celu wyświetlenia okienka dialogowego, należy użyć wskaźnika do okna głównego aplikacji. Jako parametry przekazywane są w kolejności: uchwyt, dwa wskaźniki, do tekstów (muszą kończyć się zerem, RDX to wskaźnik do treści, którą wyświetli dialog, a R8 wskaźnik do nagłówka okienka, który zotanie nadany okienku), które mają pojawić się w nagłówku oraz treści dialogu oraz jako ostatni, maska binarna określająca, jakie mają być zastosowane przyciski i ikony w okienku dialogowym11).
... ; WINUSERAPI int WINAPI MessageBoxA( ; RCX => _In_opt_ HWND hWnd, ; RDX => _In_opt_ LPCSTR lpText, ; R8 => _In_opt_ LPCSTR lpCaption, ; R9 => _In_ UINT uType); mov rcx, handler mov rdx, offset hello_msg mov r8, offset info_msg mov r9, 0 ; MB_OK and rsp, not 8 call MessageBoxA ...
and rsp, not 8). W większości przypadków nie jest to konieczne, ponieważ organizacja danych najczęściej jest 64-bitowa. Gdyby jednak okazało się, że gdzieś został odłożony na stosie pojedynczy bajt lub słowo, ulegnie to zmianie i program może rzucić wyjątkiem. W związku z tym w takim przypadku należy zadbać o wyrównanie wskaźnika stosu tak, aby był podzielny przez 16.
Uwagi praktycznie odnośnie debbugowania i podglądania pamięci:
LEA(Load Effective Address). Wartość z okienka podglądu stanu rejestrów należy przekopiować do okienka podglądu stanu pamięci.
LEA ponieważ następujący po niej CALL powoduje zmiany zawartości rejestrów i adres zostanie zamazany.
Na rysunku rysunek 13 przedstawiono kolejność działań w celu odczytania (odnalezienia) miejsca w pamięci, gdzie znajduje się zmienna. Breakpoint (pułapkę) należy ustawić bezpośrednio na następnej instrukcji znajdującej sie po LEA, chyba że jest gwarancja, iż instrukcje pomiędzy LEA i breakpointem nie modyfikują danego rejestru. Odczytaną wartość należy przenieść z okienka rejestrów do okienka pamięci.
Po wywołaniu funkcji, zawartość rejestru RCX zmienia się, natomiast dane zwracane są do pamięci, jak na rysunku rysunek 14. Zadanie dodatkowe: zamień widoczną na rysunku zawartość pamięci, a dowiesz się, kiedy powstał screenshot .
Teksty oraz okienka pojawiające się na ekranie zgadzają się z algorytmem. Zwróć szczególną uwagę na to, czy nie są generowane losowe ciągi znaków, co może sugerować, że zapomniałeś o kończącej łańcuch wartości 0.