Wikipedysta:Doles/Brudnopis/Prototyp/Windows/Hello World

Hello World!

edytuj

Zacznijmy od dawki kodu, żeby mieć pojęcie jak w ogóle wygląda kod asemblera. Będzie to tradycyjny już program Hello World, który można napotkać w niemal każdym podręczniku do nauki programowania w dowolnym języku (za zadanie ma po prostu wyświetlenie napisu Hello World!).

.386
.model flat, stdcall

include \masm32\include\kernel32.inc
include \masm32\include\user32.inc
include \masm32\include\masm32.inc

includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
includelib \masm32\lib\masm32.lib

.data
Hello	db	"Hello World!",0

.data?
Buffer	db	?

.code
_start:

	mov	eax, offset Hello
	push	eax
	call	StdOut
	mov	eax, offset Buffer
	push	1
	push	eax
	call	StdIn
	push	0
	call	ExitProcess
end _start

Z pewnością kod źródłowy wygląda dla Ciebie wręcz tragicznie i przerażająco. Właściwie, to nie można się Tobie dziwić. Ciężko jest sobie wyobrazić, że kod złożony z kilkuliterowych wyrazów w słupku może być poprawnym programem. Właśnie na łamach tego podręcznika jest postaram się wyjaśnić, przybliżyć a nawet zaprzyjaźnić Ciebie z językiem Asemblera, na tyle abyś swobodnie się w nim poruszał, rozumiał go oraz potrafił w nim myśleć. Zatem zabierzmy się do analizowania programu, linia po linii.

Analiza

edytuj
 .386

To wyrażenie nie jest żadnym rozkazem procesora. Jest to tak zwana dyrektywa asemblera, która mówi mu dla jakiej klasy procesorów poniższy kod jest skierowany. Dzięki temu możesz zadecydować dla jakiego procesora ma być program, a co za tym idzie: jakiej optymalizacji ma użyć asembler, jakiego zestawu instrukcji etc.

 .model flat, stdcall

Kolejna dyrektywa asemblera, służąca do ustawienia tzw modelu pamięci. W (starożytnych) czasach systemu operacyjnego DOS używało się takich modeli jak "tiny, large, medium" co odpowiadało pewnym ustawieniom pamięci. Windows pracuje w trybie chronionym zatem możemy używać tylko płaskiego modelu pamięci. Co to jest płaski model pamięci ? Wystarczający opis znajdziesz w rozdziale o podstawach architektury.

STDCALL mówi asemblerowi jaki będzie styl wywoływania funkcji. Zapewne taka ilość dziwnych, nowych i przede wszystkim trudnych słów musi Ciebie przerastać, ale nie zważaj na to. Programowanie w Win32 w Asemblerze wymaga małego trudu nawet dla najprostszego programu. Wyobraź sobie, że Twój program musi wywołać pewne sekwencje rozkazów. Ta sekwencja to pewna lista instrukcji procesora o określonym punkcie wejścia oraz jednym lub kilku możliwych punktach wyjścia, czyli sposobów zakończenia. Sekwencja ta nazywa się funkcją. Funkcje mogą być zdefiniowane przez Ciebie i umieszczone w Twoim programie, mogą być zapisane w zupełnie osobnych bibliotekach albo nawet w jądrze (czytaj sercu) systemu operacyjnego. Jeśli mówimy o funkcjach jądra wówczas używamy terminu wywołanie systemowe. Nasz program korzysta z funkcji udostępnionych przez pakiet MASM32 a ten zaś z wywołań systemowych. Czymże w końcu jest ten styl wywoływania funkcji ? Jest to sposób przekazywania dla niej parametrów, stdcall mówi asemblerowi, że dajemy parametry (argumenty) przez stos. To czym jest stos wiesz zapewne z wcześniejszych rozdziałów. To czym są argumenty funkcji jest dla Ciebie obce, ale na razie powiedzmy sobie, że to takie liczby, ciągi znaków, adresy itd których użyje dana funkcja w swoich obliczeniach. Zatem po tej dyrektywie asembler zakłada, że argumenty dla pracy funkcji znajdą się na szczycie stosu zaś same funkcje odpowiednio je sobie zdejmą.

 include \masm32\include\kernel32.inc
 include \masm32\include\user32.inc
 include \masm32\include\masm32.inc

Te trzy linie to kolejne dyrektywy asemblera. W plikach nagłówkowych zawarte są pewne deklaracje niektórych stałych, albo struktur danych czy funkcji systemowych (wywołań).

 includelib \masm32\lib\kernel32.lib
 includelib \masm32\lib\user32.lib
 includelib \masm32\lib\masm32.lib

Te dyrektywy mówią asemblerowi gdzie znajdują się biblioteki importu. Biblioteki importu to taki pośrednik pomiędzy programem a potrzebną przez niego biblioteką DLL. Do szczegółów dojdziemy w swoim czasie.

 .data
 Hello	db	"Hello World!",0

Dyrektywa ".data" mówi Asemblerowi gdzie znajduje się sekcja (segment) danych. Każdy plik wykonywalny posiada taką sekcję, w niej właśnie znajdują się zmienne (właściwie zmienne globalne). Następnie tworzymy swoją zmienną, tak dokładnie to napis zwany również c-stringiem lub asciiz-stringiem. Nazwa wzięła się z połączenia standardu języka C oraz tablicy kodów ASCII gdzie poszczególne literki w naszym ciągu to właśnie kody ASCII, zaś sam ciąg MUSI być zakończony bajtem zerowym, czyli zwyczajnie zerem. Napis "Hello" jest to nazwa zmiennej pod jaką cały napis będzie funkcjonował w kodzie. Słowo kluczowe db mówi asemblerowi aby rezerwował bajty. Ile ? To zależy od samej wartości zmiennej. W naszym przypadku jest to zmienna o nazwie "Hello" która zajmuje 13 bajtów (tyle wynosi długość napisu z zerem oraz spacją w środku). Możemy sobie przetłumaczyć tę linię na nasz język w ten sposób: "Asemblerze stwórz dla mnie zmienną w segmencie danych o nazwie Hello, treści "Hello World!",0 oraz długości 13 bajtów". Oczywiście asembler sam sobie liczy ile wynosi ten napis, jego jednostką miary jest nasze słowo kluczowe db.

 .data?
 Buffer	db	?

Tworzymy w segmencie danych niezainicjalizowanych zmienną o nazwie "Buffer" wielkości 1 bajta. Skąd ten znak zapytania ? Mówi to asemblerowi, że zmienna którą chcemy utwórzyć nie ma jakieś specjalnej wartości na sam początek. To co bedzie w sobie zawierał nasz Buffer - ten jeden bajt - będzie najzwyczajniej losowe. Prawdopodobnie jakieś śmieci. Dlaczego więc pozwalamy aby coś takiego było treścią naszej zmiennej ? Dlatego, że to tylko bufor wejściowy w którym bedzie przechowywany znak wklepany z klawiatury. Nie interesuje nas co się w nim znajdzie przed wpisaniem, ma to być wyłacznie miejsce na kod klawisza.

 .code
 _start:

Dyrektywa .code mówi MASMowi gdzie zaczyna się sekcja kodu. Innymi słowy gdzie znajduje się kod wykonywalny naszego programu (jego instrukcje). Jak możesz się domyśleć po nazwie "_start:" jest to tzw etykieta czyli pewna lokacja w pamięci. W tym przypadku tą etykietą jest punkt startowy naszych rozkazów. Etykiety są niczym innym jak pewnym adresem, przedstawionym w ładnej formie.

 mov	eax, offset Hello
 push	eax
 call	StdOut
 mov	eax, offset Buffer
 push	1
 push	eax
 call	StdIn
 push	0
 call	ExitProcess

Tutaj jest troszkę do opowiedzenia. Na początek rozkazem mov przenosimy adres zmiennej Hello do rejestru eax. Zauważ, że gdybyśmy napisali:

 mov eax, [Hello]

to w rejestrze EAX zamiast adresu tej zmiennej byłaby literka "H" z naszego ciągu. Chcemy za chwilę wyświetlić napis. Aby to zrobić potrzebujemy adresu pod jakim się on zaczyna. Poniżej znajduje się schemat naszego ciągu w pamięci operacyjnej.

 

W związku z konwencją STDCALL odkładamy na stos zawartość rejestru EAX - czyli de facto adres napisu - jako argument dla funkcji StdOut. Rozkazem call wywołujemy daną funkcję. Teraz chcemy pobrac jeden znak z klawiatury. W tym celu zgodnie z STDCALL odkładamy argumenty dla funkcji StdIn. W przeciwieństwie do swojej poprzedniczki StdIn wymaga dwóch argumentów - pierwszy to bufor na klawisz, druga to ilość bajtów w naszym programie po prostu 1. Oczywiście okładamy argumenty w odwrotnej kolejności i rozkazem call wywołujemy funkcję. Na sam koniec należy zamknąć program, dlatego odkładamy na stos 0 co jest tzw kodem błędu. Wartość 0 wbrew pozorom nie oznacza błędu - wręcz przeciwnie, jego brak. Następnie wywołujemy funkcję ExitProcess i nasz program zostaje zakończony.

 end _start

Kolejna dyrektywa. Mówi MASMowi gdzie kończy się segment kodu. To wszystko z analizy naszego programu. Teraz przejdziemy do przykładu troszkę bardziej zaawansowanego, gdzie użyjemy wyłącznie procedur (funkcji) z jądra Windowsa aby uzyskać ten sam efekt.

Hello World! (wersja druga)

edytuj

Tak jak obiecałem napiszemy teraz program wyświetlający ten sam napis, ale korzystając wyłącznie z funkcji, które udostpęnia nam jądro Windowsa. StdIn oraz StdOut pochodzą z pakietu MASM32, tworząc raczej drogę na skróty gdyż wersja czysto-winapi jest nieco bardziej skomplikowana. Zanim pokażę kod, przedstawię listę kroków które trzeba podjąć. Znowu zobaczysz listę trudnych pojęć a ja ponownie postaram się Tobie wytłumaczyć co się za nimi kryje:

  1. Pobierz uchwyt standardowego wejścia i przechowaj go w danej zmiennej.
  2. Pobierz uchwyt standardowego wyjścia i przechowaj go w danej zmiennej.
  3. Użyj funkcji WriteFile aby zapisać do pliku treść zmiennej Hello.
  4. Użyj funkcji ReadFile aby wczytać dane z pliku do zmiennej
  5. Zamknij program

Teraz słowa wyjaśnienia:

  1. Pobierz uchwyt standardowego wejścia i przechowaj go w danej zmiennej.. Uchwyt to jest po prostu pewna liczba, a właściwie numer identyfikacyjny dla danego pliku albo urządzenia, zawsze unikatowy. Po pobraniu takie numeru dla standardowego wyjścia (ekran) musimy go sobie przechować w zmiennej, w celu późniejszego użycia.
  2. Pobierz uchwyt standardowego wyjścia i przechowaj go w danej zmiennej. Komentarz ten sam co powyżej, jednak standardowe wejście to zwyczajnie klawiatura.
  3. Użyj funkcji WriteFile aby zapisać do pliku treść zmiennej Hello. Standardowe wejście lub wyjście mogą być traktowane jak zwykle pliki. Można do nich pisać, można z nich czytać, otwierać oraz zamykać. Zapisanie do standardowego wyjścia jest tym samym co wyświetleniem napisu w konsoli (wierszu poleceń).
  4. Użyj funkcji ReadFile aby wczytać dane z pliku do zmiennej. Traktujemy klawiaturę jak pewien specjalny plik i czytamy z niego bajty. W naszym przypadku wczytamy dokładnie jeden bajt, który zaś odpowiada dokładnie jednemu klawiszowi klawiatury. "Po co ?" możesz zapytać. Tylko dlatego aby program po wyświetleniu "Hello World!" zatrzymał się na chwilkę. W ten sposób czeka na wciśnięcie klawisza i nie zamyka się od razu po wyświetleniu komunikatu.
  5. Zamknij program. Zwyczajnie wywołujemy funkcję ExitProcess aby zakończyć program. Oto i kod źródłowy:
.386
.model flat, stdcall

include \masm32\include\windows.inc
include \masm32\include\kernel32.inc
include \masm32\include\user32.inc
include \masm32\include\masm32.inc

includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
includelib \masm32\lib\masm32.lib

.data
hello       db "Hello World!",13,10

.data?
hStdIn      HANDLE  ?
hStdOut     HANDLE  ?
nIOBytes    DWORD   ?
biff        DWORD   ?

.code
_start:

    push STD_INPUT_HANDLE
    call GetStdHandle     ;krok 1
    mov hStdIn,eax        

    push STD_OUTPUT_HANDLE
    call GetStdHandle     ;krok 2
    mov hStdOut,eax

    push 0
    mov  eax, offset nIOBytes
    push eax
    mov  eax, sizeof Hello
    push eax
    mov  eax, offset Hello
    push eax
    push hStdOut
    call WriteFile        ;krok 3

    push 0
    mov  eax, offset nIOBytes
    push eax
    push 1
    mov  eax, offset biff
    push eax
    push hStdIn
    call ReadFile         ;krok 4

    push 0
    call ExitProcess      ;krok 5

end _start

Analiza

edytuj

Wiele części tego programu jest taka sama jak jego poprzednik, zatem nie ma sensu mówić dwa razy o tym samym. Przejdę do "nowości" które tutaj się pojawiają.

    push STD_INPUT_HANDLE
    call GetStdHandle
    mov hStdIn,eax

Kładziemy na stosie jeden, jedyny argument dla funkcji GetStdHandle - nazwę standardowego urządzenia, którym jesteśmy zainteresowani. STD_INPUT_HANDLE jest to tak zwana stała zadeklarowana w pliku nagłówkowym windows.inc. Stała to pewien rodzaj zmiennej, której raz ustalonej wartości nie można już zmieniać. Jest to pewna liczba, której wartość jest ważna dla Windowsa nie dla nas. Jak można się domyśleć z nazwy stałej "prosimy" Windows aby podał nam uchwyt standardowego wejścia (klawiatury). Rozkazem mov (wyjaśnie go w następnej lekcji) przenosimy zawartość rejestru EAX to komórki pamięci, która przechowuje zmienną hStdIn. MASM pozwala na pewne domysły i uproszczenia, bowiem wg niego zapis:

 mov hStdIn, eax

jest równoważny temu:

 mov [hStdIn], eax

To jakiego będziesz używał stylu zależy wyłącznie od Twoich upodobań. Wybierz sobie taki, który Twoim zdaniem zwiększa czytelność kodu.

   push STD_OUTPUT_HANDLE
   call GetStdHandle     
   mov hStdOut,eax

Wykonujemy dokładnie to samo co poprzednio, jednak interesuje nas uchwyt standardowego wyjścia (ekran). Jego wartość przechowujemy w osobnej zmiennej. Brnijmy w kodzie dalej...

    push 0
    mov  eax, offset nIOBytes
    push eax
    mov  eax, sizeof Hello
    push eax
    mov  eax, offset Hello
    push eax
    push hStdOut
    call WriteFile

To jest dość spory kawałek programu. Całość polega na odłożeniu na stosie odpowiednich argumentów, w odpowiedniej kolejności dla funkcji WriteFile. Jej prototyp (czyli to jak została ona zadeklarowana) wygląda w ten sposób:

 BOOL WINAPI WriteFile(
  __in         HANDLE hFile,
  __in         LPCVOID lpBuffer,
  __in         DWORD nNumberOfBytesToWrite,
  __out_opt    LPDWORD lpNumberOfBytesWritten,
  __inout_opt  LPOVERLAPPED lpOverlapped
);

Mimo, że jest to deklaracja w języku C, to można łatwo ją wykorzystać dla naszych celów. Jak wspomniałem o standardzie STDCALL argumenty odkładamy w odwrotnej kolejności, zatem lpOverlapped w deklaracji jest naszym pierwszym agrumentem odłożonym na stosie. Innymi słowy:

  __inout_opt  LPOVERLAPPED lpOverlapped

odpowiada:

    push 0

Ostatni argument może przyjąć różne wartości my jednak podajemy funkcji WriteFile 0, gdyż nie chcemy korzystać z jej zaawansowanych właściwości ani specjalnych struktur danych dla plików. Kolejny argument to:

   __out_opt    LPDWORD lpNumberOfBytesWritten,

Ten argument jest wskaźnikiem na zmienną (czyli adresem zmiennej), w której WriteFile umieści ilość zapisanych bajtów. Nie jest on konieczny, ale mimo wszystko skorzystamy z niego. Skoro WriteFile chce wskaźnika do zmiennej to dajmy jej to:

     mov  eax, offset nIOBytes
     push eax

dyrektywą offset obliczamy adres zmiennej nIOBytes, w której umieścimy ilość zapisanych bajtów do pliku. Po załadowaniu rejestru EAX naszym adresem odkładamy zawartość EAX na stos, jako drugi argument. Następnie:

   __in         DWORD nNumberOfBytesToWrite,

WriteFile chce aby tym argumentem była liczba bajtów do zapisania. Obliczamy sobie długość naszego napisu ( pamiętasz oczywiście, że jeden znak to jeden bajt, zatem jeśli nasz napis ma długość 14 znaków to musimy zapisać 14 bajtów) i odkładamy go na stos serią rozkazów:

 mov  eax, sizeof Hello
 push eax

Teraz przedostatni argument funkcji WriteFile:

   __in         LPCVOID lpBuffer,

Jest to adres buforu danych, który chcemy zapisać. Mówiąc prościej - WriteFile tutaj właśnie chce adresu naszego napisu który wyświetli na ekranie. Adres napisu pod którym zaczyna się cały napis podajemy jej w ten sposób:

  mov  eax, offset Hello
  push eax

Myślę, że potrafisz sobie poradzić z rozszyfrowaniem tej konstrukcji. Czas na finał funkcji WriteFile. Powiedzieliśmy jej ile chcemy zapisać bajtów, powiedzieliśmy jej co chcemy zapisać, oraz gdzie umieścić liczbę zapisanych bajtów. Brakuje tylko jednego: miejsca do którego chcemy pisać. Należy poinformować WriteFile do którego pliku chcemy zapisać nasz napis. Oto i argument:

   __in         HANDLE hFile,

Jest to uchwyt pliku. Każdego pliku, zarówno naszego tekstowego, który tworzymy sobie edytorem jak i pliku specjalnego - klawiatury czy ekranu. My podajemy uchwyt ekranu, zatem:

  push hStdOut

Odkładamy na stos uchwyt standardowego wyjścia. Wszystko jest gotowe, więc rozkazem call wywołujemy funkcję systemową WriteFile. Zanalizowaliśmy sobie dwa kroki naszego programu, czas na wczytanie znaku z klawiatury oraz zamknięcie programu.

Do wczytania klawisza z klawiatury posłużymy się ogólną funkcją ReadFile. Jak wspomnieliśmy sobie wcześniej możemy traktować urządzenie standardowego wejścia jako pewien specjalny plik, co więcej odczytamy sobie z niego 1 bajt. Oto deklaracja funkcji ReadFile zaczerpnięta z MSDN:

 BOOL WINAPI ReadFile(
  __in         HANDLE hFile,
  __out        LPVOID lpBuffer,
  __in         DWORD nNumberOfBytesToRead,
  __out_opt    LPDWORD lpNumberOfBytesRead,
  __inout_opt  LPOVERLAPPED lpOverlapped
);

Nie przejmuj się dziwnymi z pozoru znakami podkreślenia, dziwną składnią czy nawiasami. Dla nas istotne są wyłącznie nazwy argumentów podane w kolumnie oraz ich przeznaczenie.

 __inout_opt  LPOVERLAPPED lpOverlapped

Ten argument ma takie samo znaczenie jak w funkcji WriteFile. Możemy tutaj podać adres pewnej struktury dla zaawansowanych działań na pliku, co nas obecnie w ogóle nie interesuje. Powiemy to asemblerowi w ten sposób:

 push 0

ReadFile gdy zobaczy 0 w miejscu tego argumentu, będzie znała nasze zamiary. Następnym argumentem jest:

 __out_opt    LPDWORD lpNumberOfBytesRead,

Jak sugeruje nazwa tutaj umieszczona jest liczba wczytanych bajtów. Możesz sobie pomyśleć: "Dlaczego jest w ogóle ten argument ? Przecież podałem funkcji ile bajtów chcę przeczytać więc to powinna zrobić !". No i tutaj się mylisz. Wyobraź sobie, że plik ma 300 bajtów. Zażądamy od ReadFile aby wczytała 600. Oczywiście to jest dwa razy więcej niż w ogóle ten plik posiada zatem w lpNumberOfBytesRead zwrócona zostanie liczba 300, mimo że żądaliśmy 600. Jest to zmienna służąca do kontroli czy pobrano tyle bajtów ile zamierzano. W ten sposób sprawdza się czy osiągnięto koniec pliku etc. My wykorzystamy tę zmienną w znajomy Ci już sposób:

 mov  eax, offset nIOBytes
 push eax

Następnie musimy podać ReadFile ile bajtów chcemy wczytać z klawiatury. Interesuje nas tylko jeden znak zatem używamy poniższego kodu aby zapisać na stosie kolejny argument dla funkcji:

 push 1

Dwa ostatnie parametry (ostatnie licząc oczywiście od końca, bowiem wg deklaracji w C są to dwa pierwsze - musisz być czujny) są takie same jak w WriteFile, małą tylko różnicą. O ile w WriteFile podawało się bufor z którego zostaną zapisane dane do pliku, w naszym przypadku bufor będzie miejscem składowym na kod klawisza. Następnie rozkazem call wywołujemy funkcję, potem przy pomocy ExitProcess kończymy działanie programu.

Zademonstrowany sposób, prezentuje jak bardzo biblioteki MASM32 upraszczają życie programiście. Ta sama czynność wykonana w "surowym" WinAPI jest relatywnie pracochłonna. Musimy wykonać serię działań co mogą zastąpić dwie funkcje biblioteczne. W dalszej części tej książki będziemy korzystali z StdOut oraz StdIn aby nie zaciemniać kodu i oddać meritum sprawy.

Jeśli jeszcze nie spanikowałeś, tudzież nie jesteś przygniecionymi trudnościami programowania w Asemblerze, zamieszczam dodatkowy programik, będący nieco odskocznią od dwóch pozostałych. Wykorzystamy tutaj pewien rozkaz procesora o nazwie CPUID. Służy on do udzielania programiście pewnych informacji o procesorze, między innymi nazwę jego producenta. Poniższy program pobierze właśnie tę nazwę i wyświetli ją na ekranie. Zobaczysz również jak wyglądają nieco bardziej ciekawsze tryby adresowania "w akcji". Spokojnie ! Wszystko wytłumaczę, wszystko opatrzę w komentarze i zamieszczę odpowiednie obrazki abyś nie czuł się zagubiony. W interesie nas - autorów podręcznika - leży abyś nauczył się Asemblera możliwie najłatwiej i bezstresowo. Zabierzmy się jednak do pracy ( to będzie ostatni program w tej lekcji).

Rozkaz CPUID jest bardzo rozbudowaną instrukcją wprowadzoną od procesorów i586. Potrafi udzielić programiście wielu ciekawych informacji jak np nazwę rodziny procesorów. Nasz nowy program wydrukuje na ekranie konsoli nazwę kodową producenta procesora. W tym celu wykonamy w Asemblerze poniższą listę kroków:

  1. Załaduj EAX na 0 (wyzeruj).
  2. Ustaw rejestr EDI na adres początku napisu
  3. Wykonaj rozkaz cpuid.
  4. Pobierz 4 bajty z rejestru EBX i wstaw je do napisu.
  5. Pobierz 4 bajty z rejestru EDX i wstaw je do napisu.
  6. Pobierz 4 bajty z rejestru ECX i wstaw je do napisu.
  7. Wyświetl napis.
  8. Zatrzymaj program, aby użytkownik wcisnął dowolny klawisz.
  9. Zakończ program.

I słowa wyjaśnienia:

  1. Załaduj EAX na 0 (wyzeruj) - rejestr EAX przechowuje pewną liczbę będącą "argumentem" dla rozkazu cpuid. W ten sposób mówimy procesorowi jaki rodzaj informacji o nim chcemy otrzymać po wykonaniu rozkazu cpuid. Jeśli załadujemy rejestr EAX na 0, wówczas cpuid "będzie wiedział", że chcemy dowiedzieć się nazwy kodowej producenta oraz zwróci nam ją w postaci 12 bajtowego ciągu rozbitego na 3 czterobajtowe, kolejne rejestry: 4 znaki w EBX, dalsze 4 w EDX oraz ostatnie 4 ECX (właśnie w tej kolejności!).
  2. Ustaw rejestr EDI na adres początku napisu - będziemy modyfikować nasz napis, czyli podstawiać bajty w miejsce znaków zapytania. Jedynym sposobem aby "dostać się" do tych znaków jest użycie jakiegoś rejestru (ale nie EBX, ECX i EDX gdyż potrzebuje je cpuid i zamaże ich wartość) np EDI lub ESI jako tzw wskaźnika. Czym jest wskaźnik ? Wiele książek definiuje go jako "Zmienną na wskazującą na inną zmienną". Bardzo krótkie i enigmatyczne sformułowanie dla początkującego programisty. Wskaźnik jest to zmienn, która przechowuje w sobie adres danej komórki pamięci, nie zaś konkretną wartość liczbową czy znakową, którą użylibyśmy jako operand wyrażenia matematycznego itd. Nasz wskaźnik będzie ustawiony na odpowiednie adresy znaków "?" tak aby w te miejsce wstawić znaki z rejestrów ogólnego przeznaczenia posiadające nazwę producenta. Ja wybrałem na wskaźnik rejestr EDI gdyż semantycznie lepiej się do tego nadaje (ang Extended Destination Index - Rozszerzony Wskaźnik Docelowy czyli jest niejako stworzony do wskazywania na coś docelowego). Bardziej szczegółowo omówimy to sobie przy analizie kodu źródłowego programu.
  3. Wykonaj rozkaz cpuid - w tym miejscu prosimy procesor o dane.
  4. Pobierz 4 bajty z rejestru EBX (EDX, ECX) i wstaw je do napisu. - Tak jak wspomniałem, znaki są w kolejnych rejestrach. Naszym zadaniem będzie je pobrać oraz wstawić w miejsce znaków zapytania. Podmieniamy 4 bajty a następnie przesuwamy wskaźnik o 4, aby pokazywał adres następnej czwórki komórek pamięci do podmiany znaków.
  5. Wyświetl napis - użyjemy już znanej Ci funkcji StdOut, aby wyświetlić nasz napis wraz z nazwą kodową producenta.
  6. Zatrzymaj program, aby użytkownik wcisnął dowolny klawisz" - gdy uruchomisz cały program w wierszu poleceń, oczekiwanie na wciśnięcie klawisza nie jest istotne, bowiem po zakończeniu programu jego wynik będzie ciagle na ekranie konsoli. Jeśli uruchomisz program poprzez dwukrotne kliknięcie na niego, zobaczysz okno w wierszem poleceń, które zatrzyma się w momencie oczekiwania na klawisz klawiatury. W ten sposóby zobaczysz wszystkie komunikaty, inaczej program po prostu by Ci "mignął" na ekranie i nie ujrzałbyś wyników jego pracy. Aby pobrać klawisz użyjemy funkcji StdIn (możesz usunąć ją i jej argumenty aby samemu się przekonać co się stanie).
  7. Zakończ program - funkcją ExitProcess terminujemy (kończymy) działanie programu. Teraz czas na kod źródłowy.
.586
.model flat, stdcall

include \masm32\include\kernel32.inc
include \masm32\include\user32.inc
include \masm32\include\masm32.inc
 
includelib \masm32\lib\kernel32.lib
includelib \masm32\lib\user32.lib
includelib \masm32\lib\masm32.lib


.data
napis	db	"VendorID to: ????????????",0Ah, 0

.data?
klawisz	db	?

.code
_start:
	
	mov edi, offset napis
	mov eax, 0
	cpuid
	mov [edi + 13], ebx
	mov [edi + 17], edx
	mov [edi + 21], ecx
	
	push offset napis
	call StdOut
	
	push 1
	push offset klawisz
	call StdIn
	
	push 0
	call ExitProcess
end _start

Analiza

edytuj

Najpierw zaczniemy od wyjaśnienia tej linii kodu:

napis	db	"VendorID to: ????????????",0Ah, 0

Dlaczego są te znaki zapytania ? To jest dość "siłowy" sposób na wypełnienie napisu tymczasowymi znkami. Robimy rezerwację 12 bajtów pod nazwę producenta i wypełniamy to dowolnymi znakami, na przykład "x" albo "?" (z wyłączeniem znaków białych jak "0", który kończy napis albo 0Ah - dziesiętnie 10 - w tablicy kodów ASCII jest to znak nowej linii). Następnie mamy:

 mov edi, offset napis
 mov eax, 0

Jest to realizacja kroku 1. oraz 2. z naszej listy. Nasz wskaźnik - EDI - jest ustawiony na początek zmiennej "napis". Na początek, czyli EDI zawiera w sobie adres pierwszego znaku z ciągu. Zatem "w środku" EDI nie ma literki "V" tylko jej adres. Teraz czas na "gwóźdź programu":

 cpuid

Wywołujemy długo oczekiwany rozkaz cpuid. Nie posiada on żadnych argumentów. Najlepsze dopiero przed nami, bowiem trzy poniższe rozkazy są najciekawsze w całym programie:

 mov [edi + 13], ebx
 mov [edi + 17], edx
 mov [edi + 21], ecx

Zacznijmy od początku. EDI jest ustawiony na adres pierwszej litery w ciągu, jednak teraz należy przestawić go aby wskazywał na pierwszy znak "?". W tym celu dodajemy do EDI 13 aby uzyskać odpowiedni adres. Gdy EDI zawiera adres naszego ciągu znaków zapytania, kopiujemy 4 bajty z EBX do lokacji docelowej. Skąd MASM wie, że ma przekopiować 4 bajty ? Domniema sobie, że ma skopiować 4 bajty (dokładnie tyle i ile mieści się w EBX). Robi to na ślepo, to znaczy nie wie czy tam gdzie wskazuje EDI jest w ogóle miejsce na nowe dane. Oczywiście my zadbaliśmy o to aby było dokładnie 12 bajtów zarezerwowanych. Asembler wcale tego nie wie. Całość oczywiście przedstawia poniższy obrazek:  

Z matematycznego punktu widzenia, aby literka "V" posiada pewien adres. Aby przesunąć wskaźnik na adres znaku zapytania należy dodać 13 do adresu "V". Jednak aby Ci było łatwiej, możesz sobie tłumaczyć przesunięcie wskaźnika o 13 znaków dalej ( Pamiętaj, że spacje mimo iż są na ekranie puste to jednak to zawsze znaki. Ich kod w ASCII wynosi 20). Taki tok rozumowania działa dla danych, które zajmują w pamięci 1 bajt. Jeśli miałbyś ciąg dużych liczb które zajmują np 4 bajty, wówczas aby przesunąć wskaźnik z jednej zmiennej na drugą należy dodać 4 bajty do wskaźnika. Druga linijka kodu robi dokładnie to samo. W rejestrze EDX są środkowe 4 znaki nazwy kodowej. Tymi znakami zastąpimy kolejne 4 "?". W tym celu przesuwamy znowu wskaźnik o 4 względem początku. Zauważ, że EDI ciągle wskazuje na początek, wewnątrz ciągle posiada adres początkowy napisu tak jak ustawiliśmy wcześniej. Dodawanie adresu jest tylko "na niby", oblicza to sobie Asembler oraz procesor na czas danej instrukcji. Na tym właśnie polega tryb adresowania indeksowego z przemieszczeniem. Indeksem jest tutaj rejestr EDI zaś przemieszczeniem liczba którą do niego dodajemy. W ostatniej, trzeciej linijce przenosimy dane z rejestru ECX to ostatnich czterech bajtów w napisie. W ten sposób nazwa kodowa jest już kompletna, należy ją tylko wyświetlić. Posłużymy się znaną nam funkcją:

 push offset napis
 call StdOut

Zauważyłeś może małą różnicę w porównaniu do poprzednich programów ? Tutaj nieco skróciłem przygotowywanie argumentu dla StdOut. Zamiast przenoszenia, adresu do rejestru EAX a potem odkładania zawartości EAX na stos, zrobiłem mały skrót. Od razu odkładam na stos adres zmiennej napis. Nie potrzebujemy tutaj "pośredników" w postaci rejestru EAX czy innego. Wywołujemy StdOut i mamy już wyświetlony pełny napis. Teraz tylko musimy ...

 push 1
 push offset klawisz
 call StdIn

... pobrać jeden znak z klawiatury. Dlaczego musimy to zrobić wytłumaczyłem wcześniej. Nasz program jest prawie skończony teraz tylko go zamknąć:

 push 0
 call ExitProcess

I koniec. Oto cały kod źródłowy. Czas najwyższy go zasemblować i zlinkować. Zapisz kod jako plik cpuid.asm a następnie w wierszu poleceń, w katalogu Dysk:\ścieżka do MASM32\bin ( na przykład c:\masm32\bin) wydaj te dwa polecenia:

ml.exe /c /coff /Cp cpuid.asm
link.exe /subsystem:console cpuid.obj

Teraz już posiadasz program cpuid.exe. Uruchom go i sprawdź jaki otrzymałeś wynik. To już wszystko. Jak na jedną lekcję poznałeś bardzo dużo nowych, ciekawych rzeczy (I zapewne stwierdziłeś, że jeszcze więcej nie wiesz...). Abyś pomógł sobie usystematyzować nową wiedzę, poniżej znajduje się podsumowanie najważniejszych partii materiału.

Podsumowanie

edytuj

W tej lekcji napisaliśmy pierwsze 3 programy używając języka Asembler oraz korzystając z funkcji bazy programistycznej Win32, na platformie Microsoft Windows. Poznaliśmy jak prosi się system operacyjny o dane operacje jak wyświetlenie napisu czy pobranie znaku z klawiatury. Poznaliśmy (skromnie) co to są wywołania systemowe, ich parametry oraz sposób przekazywania. Nieobca stała nam się konwerncja STDCALL, na której bazuje całe WinAPI. W pierwszym programie wyświetliliśmy napis korzystając z gotowych funkcji pakietu MASM32, w drugim wyłącznie z wywołań systemowych. Ostatni program jest pewną ciekawostką obrazującą sposoby adresowania danych w pamięci "w akcji". Użyliśmy nietypowego rozkazu "cpuid", aby pobrać nazwę kodową producenta procesora.

Nauczycielem wszystkiego jest praktyka.
Juliusz Cezar