Kiedy i jak korzystać z asemblera. Podstawy programowania w asemblerze

Części silnika

Podstawy programowania w asemblerze, budowa procesora, rejestry, pamięć, instrukcje, wykorzystanie asemblera w C++ oraz Delphi.

1. Wprowadzenie do asemblera

Asembler, czyli język programowania niskopoziomowego, umożliwiający wykorzystanie wszystkich możliwości procesora jest dziś już nieco zapomniany przez „nowożytnych” programistów.

Główną przyczyną takiego stanu jest fakt, iż pisanie w asemblerze nie należy do najprostszych czynności i jest bardzo czasochłonne (testowanie kodu, wyszukiwanie bug-ów etc.).

Jednak asembler w niektórych sytuacjach może okazać się idealnym rozwiązaniem, przykładem mogą być wszelkiego rodzaju algorytmy wymagające szybkości działania, do których przykładowo można zaliczyć algorytmy kryptograficzne (szyfrowanie danych).

Mimo niesamowitego rozwoju kompilatorów w ostatnich latach, algorytmy takie jak np. Blowfish, Rijndeal, Idea napisane w asemblerze i „ręcznie” zoptymalizowane wykazują znaczną przewagę prędkości nad ich odpowiednikami napisanymi np. w C++ i skompilowanymi z opcjami maksymalnej optymalizacji.

Oprócz kryptografii asembler często wykorzystywany jest także przez twórców gier, czego najlepszym przykładem może być gra QUAKE 2, po opublikowaniu jej źródeł, okazało się, że wszelkie algorytmy wymagające szybkości działania, zostały napisane właśnie w asemblerze. Więc zaczynamy, jeszcze dla ścisłości powiem, że w tym artykule skupię się na omówieniu asemblera dla procesorów x86 i jego wykorzystaniu w środowisku Windows.

2. Podstawy asemblera

Jeśli nigdy nie pisałeś w asemblerze, aby w ogóle można było zacząć coś zrobić, najpierw należy zapoznać się choćby z podstawowymi informacjami takimi jak rejestry procesora, stos, instrukcje.

Domyślny procesor, dla przykładu użyje procesora Intel Pentium MMX (jedynie taki posiadam :-), z punktu widzenia programisty, do wykorzystania mamy cały zestaw instrukcji począwszy od 8, 16 i 32 bitowych x86, poprzez instrukcje zmiennoprzecinkowe i MMX.

2.1. Rejestry procesora

Procesor posiada osiem 32 bitowych rejestrów ogólnego przeznaczenia + rejestr flag, dodatkowo osiem 80 bitowych rejestrów kooprocesora (st0 - st7) i tyle samo 64 bitowych rejestrów MMX (mm0 - mm7), oprócz tego procesor posiada także masę rejestrów kontrolnych, których raczej nie będziemy używać.

Teraz wyjaśnienie, co to jest ten rejestr, otóż rejestr, to jakby komórka pamięci, do której możemy tymczasowo zapisywać dane, możemy wymieniać dane między rejestrami, wykonywać operacje logiczne i arytmetyczne. Procesor Pentium jest procesorem 32 bitowym, co oznacza, że każdy z rejestrów ogólnego przeznaczenia ma 32 bitów szerokości (typ unsigned int pod C). Wszystkie rejestry 32 bitowe posiadają 16 bitową połówkę (pamiątka po procesorze 286), z kolei 16 bitowe połówki w rejestrach EAX, EBX, ECX i EDX dzielą się na kolejne dwie 8 bitowe połówki:

Nazwa rejestru 16 bitowa połówka 8 bitowe połówki Opis
EAX AX AH i AL Akumulator
EBX BX BH i BL Baza
ECX CX CH i CL Licznik dla operacji wykonywanych na ciągach oraz dla pętli
EDX DX DH i DL Dane
ESI SI n/a Rejestr źródłowy dla instrukcji operujących na ciągach
EDI DI n/a Rejestr docelowy dla instrukcji operujących na ciągach
EBP BP n/a Wskaźnik danych na stosie, wykorzystywany w funkcjach jako wskaźnik parametrów zapamiętanych na stosie
ESP SP n/a Wskaźnik stosu

2.2. Rejestry ogólnego przeznaczenia

Pisząc program, czy też wstawkę w asemblerze pod Windows, możemy korzystać z wszystkich rejestrów ogólnego przeznaczenia, ale często grozi to destabilizacją pracy programu, gdy użyje się specjalnych rejestrów, konkretnie ESP i EBP, np. wyzerowanie rejestru ESP w kodzie funkcji, najprawdopodobniej spowoduje zawieszenie się programu w jego dalszej części (jeśli program np. będzie chciał powrócić z funkcji do kodu programu).

2.3. Stos

Stos to mówiąc potocznie obszar pamięci zarezerwowany na potrzeby programu, jest wykorzystywany m.in. do przekazywania parametrów funkcjom (jako 32 bitowe wartości), służy też do tymczasowego przechowywania danych, wszystkie zmienne lokalne również „tworzone” są na stosie. Po uruchomieniu programu rejestr ESP (Stack Pointer - wskaźnik stosu) wskazuje na koniec stosu, zapamiętując jakieś dane na stosie, rejestr ESP jest dekrementowany, i w obszar pamięci wskazywany przez ESP zapisywana jest wartość. Do zapisywania danych na stosie wykorzystuje się instrukcję push, np:

__asm {

    push    5                // zapisz na stosie liczbę 5 (32bit)
    push    eax              // zapamiętuje na stosie zawartość rejestru EAX
    push    dword ptr[edx]   // zapamiętuje wartość wskazywaną przez wskaźnik zapisany
                             // w rejestrze EDX

    sub    esp,4             // odpowiednik instrukcji 'push 5'
    mov    dword ptr[esp],5

    sub    esp,4             // odpowiednik instrukcji 'push eax'
    mov    dword ptr[esp],eax
}

Aby zdjąć wartość ze stosu należy skorzystać z instrukcji pop, która działa w odwrotny sposób do push, najpierw odczytywana jest wartość spod adresu wskazywanego przez rejestr ESP, następnie rejestr ESP jest inkrementowany:

__asm {

    push    5                // zapamiętaj na stosie 4 32bitowe wartości
    push    eax
    push    dword ptr[edx]
    push    13B0C032h

    pop    eax               // zdejmij ze stosu ostatnią zapamiętaną wartość,
                             // która w tym wypadku jest liczba 13B0C032h
    pop    dword ptr[edx]    // operacja ta nic nie zmienia, ponieważ zapamiętana
                             // była wartość wskazywana przez rejestr EDX i została
                             // ona tylko przywrócona

    pop    edx               // do rejestru EDX odczytaj wartość jaką miał rejestr EAX
    pop    ecx               // do rejestru ECX zostanie wpisana liczba 5

    push    5                // zapamiętaj na stosie liczbę 5
                             // instrukcje symulujące 'pop eax'
    mov    eax,dword ptr[esp]
    add    esp,4
}

2.4. Ograniczenia w systemie Windows

Jak pisałem wcześniej, z naszego punktu widzenia mamy dostęp do wszystkich instrukcji procesora, dla którego piszemy kod, ale ogranicza nas system, w naszym wypadku Windows. Oznacza to, że możemy np. używać instrukcji odwołujących się do portów, jak to miało miejsce w systemie MS-DOS, ale najpewniej skończy się to zawieszeniem programu.

Do instrukcji, których używanie kończy się potocznie zwanym zwisem, można zaliczyć ww. instrukcje operujące na portach, oprócz tego instrukcje odwołujące się do przerwań, modyfikujące rejestry kontrolne i segmentowe.

Apropos rejestrów segmentowych, Windows używa modelu pamięci FLAT, co oznacza, że teoretycznie mamy dostęp do całej pamięci od adresu 0 po 0xFFFFFFFF (nie polecam jednak modyfikować obszarów zarezerwowanych przez system) i żeby uzyskać dostęp do danych wcale nie trzeba odwoływać się np. do rejestru segmentowego DS:.

3. Użycie asemblera

Aby skorzystać z dobrodziejstw asemblera należy najpierw sprawdzić czy nasze narzędzie pracy umożliwia jego wykorzystanie. Produkty takie jak Borland Delphi, Builder, Watcom C++, czy Microsoft Visual C++ umożliwiają użycie (kompilowanie) kodu asemblera, z popularnych pakietów RAD jedynie Visual Basic nie daje możliwości pisania kodu w asemblerze. Wyżej wspomniane produkty umożliwiają wykorzystanie kodu asemblera na dwa sposoby, pierwszym z nich są tzw. wstawki, czyli mówiąc prościej, kod asemblera dodawany jest pomiędzy regularnym kodem napisanym np. w C++. Drugim sposobem umożliwiającym wykorzystanie asm-a jest linkowanie (łączenie) modułów napisanych w asemblerze z modułami napisanymi np. w Delphi.

3.1. Wstawki asemblerowe

Zanim przystąpimy do pisania kodu asemblera, warto sprawdzić jak mamy go pisać, istnieją bowiem dwa typy składni, w której zapisuje się kod asemblera. Pierwszym typem jest tzw. składnia „intel” wykorzystywana w produktach takich jak Delphi, Builder, MSVC, Borland TASM, Microsoft MASM (kompilatory asemblera), składnia ta obecnie stanowi standard i 90% źródeł zapisanych jest zgodnie z nią. Drugim typem zapisu kodu asemblera jest składnia „at&t” wykorzystywana np. w kompilatorach C takich jak GCC (platforma Linux), DJGPP i LCC.

Wstawki stanowią najprostszy sposób na pisanie kodu asm, aby zapisać kod asemblera w Delphi lub Builderze należy go ująć pomiędzy znacznikami asm co oznacza początek kodu asemblera, po zakończeniu kodu należy zamknąć znacznik end;, przykład:

// nasze pierwsze 'hello world' w asemblerze, wersja dla Delphi
asm                          // początek kodu asemblera

    mov    eax,1             // przenieś wartość 0x00000001 do rejestru EAX
                             // w C++ odpowiednikiem tej instrukcji jest operator
                             // przypisania '=' np.
                             // x = 1;
                             // w Delphi odpowiednikiem także jest operator
                             // przypisania ':=' np.
                             // y := 1;

    mov    ecx,eax           // przenieś do rejestru ECX zawartość rejestru EAX,
                             // czyli w ECX znajdzie się wartość 0x00000001

    shl    ecx,2             // Shift Left, przesuwanie bitów rejestru ECX o 2 bity
                             // w lewo, przesuwanie bitów jak wiadomo (lub nie :),
                             // służy do mnożenia wartości przez kolejne potęgi
                             // liczby 2, przesuwając zawartość 0x00000001 o 2 bity
                             // w lewo w ECX znajdzie się wartość 0x00000001 * 4 = 0x00000004
                             // w C++ przesuwanie bitów zapisujemy operatorem '<<'
                             // np. x = y << 2;
                             // w Delphi przesuwanie bitów zapisuje się podobnie
                             // jak w kodzie asemblera operatorem 'shl' lub 'shr'
                             // np. x := y shl 2;

    shr    eax,1             // Shift Right, przesuwanie bitów rejestru EAX o 1 bit
                             // w prawo

    and    eax,0             // And, logiczne mnożenie bitów, tabelka funkcji And:
                             // 0 * 0 = 0
                             // 1 * 0 = 0
                             // 1 * 1 = 1
                             // dowolna wartość przemnożona przez 0 da 0, czyli w
                             // tym wypadku rejestr EAX zostanie wyzerowany
                             // w C++ odpowiednikiem tej instrukcji jest operator '&'
                             // np. x = y & 0;
                             // w Delphi
                             // np. x = y and 0;

    or    eax,0FFFFFFFFh     // Or, logiczne dodawanie bitów, tabelka funkcji Or:
                             // 0 + 0 = 0
                             // 1 + 0 = 1
                             // 1 + 1 = 1
                             // w tym przypadku do EAX dodawana jest wartość
                             // 0xFFFFFFFF, pisząc w Delphi 32bitowe liczby hex
                             // zaczynające się od litery, należy poprzedzić je
                             // jednym zerem na początku i literą 'h' na końcu, co
                             // oznacza, że liczba zapisana jest w postaci
                             // heksadecymalnej
                             // w C++ odpowiednikiem funkcji Or jest operator '|'
                             // np. x = y | 0xFFFFFFFF;
                             // w Delphi
                             // np. x := y or $FFFFFFFF;

    sub    edx,edx           // Subtract, odejmowanie, wyzeruj zawartość rejestru EDX
                             // w C++ odpowiednikiem tej instrukcji jest np.
                             // x = x - x;

    xor    eax,eax           // eXclusive Or tabela dla funkcji XOR
                             // 0 ^ 0 = 0
                             // 1 ^ 0 = 1
                             // 1 ^ 1 = 0
                             // funkcja daje 1 dla 2 rożnych bitów, jeśli bity sa
                             // takie same to zostaną wyzerowane, więc instrukcja
                             // xor eax,eax wyzeruje zawartość rejestru eax
                             // odpowiednikiem w C++ funkcji XOR jest operator '^'
                             // np. x = x ^ y
                             // w Delphi
                             // np. x := x xor y;

end;                         // koniec kodu asemblera

Sposób wstawiania kodu asemblera do kodu MSVC rożni się praktycznie tylko sposobem otwarcia znaczników informujących kompilator, że jest to kod asemblera:

// nasze drugie 'hello world' w asemblerze
__asm {                      // początek kodu asemblera

    push    5                // zapamiętaj na stosie wartość 0x00000005
    pop    eax               // zdejmij ze stosu wartość 0x00000005 i zapisz ją do
                             // rejestru eax

    push    eax              // zapamiętaj na stosie zawartość rejestru EAX (czyli
                             // w tym przypadku 5)
    pop    edx               // zdejmij ze stosu wartość 5 i zapisz ja do rejestru
                             // EDX

    mov    ax,0FFFFh         // zapisz do 16bitowej połówki rejestru EAX wartość 0FFFFh
    mov    dx,ax             // zapisz do 16bitowej połówki rejestru EDX wartość z
                             // rejestru AX
    mov    al,11             // zapisz do dolnej (LO) 8 bitowej połówki
                             // rejestru AX wartość 11 (decymalnie)
    mov    ah,11h            // zapisz do górnej (HI) 8 bitowej połówki rejestru AX
                             // wartość 11h (hex) co decymalnie równa się 17
}                            // koniec kodu asemblera

3.2. Korzystanie ze zmiennych w asemblerze

Pisząc w asemblerze ma się dostęp do wszystkich zmiennych globalnych oraz jeśli kod znajduje się w procedurze, to także dostęp do zmiennych lokalnych oraz parametrów procedury/funkcji, czyli praktycznie ma się takie same możliwości jak zwykły kod. Przykład wykorzystania zmiennych globalnych i lokalnych:

// zmienne globalne
var
    ByteVar: Byte;           // bajt, 8 bitów
    WordVar: Word;           // słowo, 16 bitów
    IntVar: Integer;         // podwójne słowo, 32 bity
  ...

procedure noop;

// zmienne lokalne funkcji noop
var
    LocalByte: Byte;
    LocalWord: Word;
    LocalInt: Integer;

begin

    // inicjalizuj zmienne globalne
    ByteVar := $FF;          // 8 bitowa wartość
    WordVar := $FFFF;        // 16 bitowa
    IntVar  := $FFFFFFFF;    // 32 bitowa

    asm
        mov    al,ByteVar    // do 8 bitowego rejestru wpisz 8 bitową wartość
        mov    LocalByte,al  // zapisz do zmiennej lokalnej 8 bitową wartość

        mov    ax,WordVar    // do 16 bitowego 16 bitową
        mov    LocalWord,ax

        mov    eax,IntVar    // do 32 bitowego 32 bitową
        mov    LocalInt,eax
    end;

end;

Przykład dla MSVC niewiele różni się od tego z Delphi:

// zmienne globalne
char ByteVar;
short WordVar;
int IntVar;
...

void noop()
{
    // zmienne lokalne
    char LocalByte;
    short LocalWord;
    int LocalInt;

    // inicjalizuj zmienne globalne
    ByteVar = 0xFF;          // 8 bitowa wartość
    WordVar = 0xFFFF;        // 16 bitowa
    IntVar  = 0xFFFFFFFF;    // 32 bitowa

    __asm {

        mov    al,ByteVar    // do 8 bitowego rejestru wpisz 8 bitową wartość
        mov    LocalByte,al  // zapisz do zmiennej lokalnej 8 bitową wartość

        mov    ax,WordVar    // do 16 bitowego 16 bitową
        mov    LocalWord,ax

        mov    eax,IntVar    // do 32 bitowego 32 bitową
        mov    LocalInt,eax
    }

}

Oprócz samych wstawek, całe funkcje mogą być zapisane w asemblerze, tutaj kilka ważnych uwag, funkcje z założenia zwracają wartości, jeśli piszemy funkcję, musimy zatroszczyć się żeby zwracana wartość przed wyjściem z funkcji była zapisana w rejestrze EAX, najpierw prosty przykład:

// wersja dla Delphi
function dodaj(x, y:integer):integer;
asm
    mov    edx,x             // wpisz do rejestru EDX pierwszy parametr funkcji
    mov    ecx,y             // do ECX wpisz drugi parametr funkcji
    add    edx,ecx           // dodaj do siebie x i y
    mov    eax,edx           // wynik dodawania zapisz w rejestrze EAX
                             // taką wartość zwraca funkcja
end;
// wersja dla C++
int mnoz(int x,int y)
{
  __asm {

    mov    edx,x             // wpisz do rejestru EDX pierwszy parametr funkcji
    mov    ecx,y             // do ECX wpisz drugi parametr funkcji
    imul   edx,ecx           // wymnóż x * y
    mov    eax,edx           // wynik mnożenia zapisz w rejestrze EAX
                             // taką wartość zwraca funkcja
}

}

Wiemy już, że funkcje pisane w asemblerze muszą zwracać wartości w rejestrze EAX, a co z innymi rejestrami?

Mówiąc krótko, rejestry EAX, EDX i ECX po wyjściu z funkcji mogą ulec zmianie, rejestry EDI, ESI, EBX i EBP domyślnie nie mają prawa być zmieniane (ich stan ma być taki sam jak przed wywołaniem funkcji). Spytacie, dlaczego tak jest? Otóż w kodzie „wyprodukowanym” przez kompilatory HLL (High Level Language - język wysokiego poziomu) ww. rejestry używane są w głównych pętlach programu do trzymania np. adresów jakichś funkcji, stałych wartości itp. i ich zmiana w kodzie funkcji może spowodować nieprawidłową lub niestabilną pracę całej aplikacji. Jak ustrzec się przed takimi błędami, otóż bardzo prosto:

// wersja dla Delphi
function licz(w,x,y,z:integer):integer;
asm

    push    edi              // zapamiętaj na stosie kolejno wartości rejestrów
    push    esi              // EDI, ESI i EBX
    push    ebx

    mov    edi,w             // odczytaj parametry funkcji do kolejnych rejestrów
    mov    esi,x
    mov    edx,y
    mov    ebx,z

    add    edi,esi           // w + x
    add    edx,ebx           // y + z

    imul    edi,edx          // (w+x) * (y+z)

    xchg    eax,edi          // eXCHanGe, zamień wartość rejestru EAX z wartością
                             // rejestru EDI, mówiąc prościej wartość z rejestru
                             // EAX zostanie przepisana do rejestru EDI, a wartość
                             // rejestru EDI zostanie przepisana do rejestru EAX, w
                             // którym zwracamy wynik funkcji

    pop    ebx               // zdejmij ze stosu wartości zapamiętanych rejestrów
    pop    esi               // wartości zdejmujemy od końca (patrząc na kod
    pop    edi               // można powiedzieć, że symetrycznie), czyli
                             // jeżeli rejestry były zapisane w kolejności
                             // EDI, ESI, EBX to zdejmujemy je ze stosu w kolejności
                             // EBX, ESI, EDI
end;

Oprócz tego, że rejestry EDI, ESI, EBX i EBP nie mogą być zmieniane, dodatkowo flaga kierunku DF (Direction Flag) przed wywołaniem funkcji domyślnie jest wyzerowana (takie jest założenie) i po wyjściu z funkcji oczekiwane jest, że nadal będzie wyzerowana. Wystarczy użyć instrukcji CLD jeśli jej stan jest zmieniany wewnątrz funkcji.

Pisząc kod w asemblerze wykorzystujący stos, należy szczególną uwagę zwrócić także na to, żeby wskaźnik stosu ESP był za każdym razem korygowany, np. jeśli w procedurze czy funkcji zapamiętamy coś na stosie, to przed wyjściem z funkcji należy tą wartość zdjąć ze stosu, tym razem przykład dla MSVC:

// przykład funkcji szyfrującej
void crypt(unsigned char *string)
{
__asm {

    push    edx              // zapamiętaj na stosie rejestr EDX
    mov    edx,string        // pobierz parametr ze stosu, czyli w tym wypadku
                             // wskaźnik do stringa, który ma by zaszyfrowany

    cmp    edx,0            // sprawdź czy parametr podany funkcji jest poprawny
    je    _exit_encrypt     // jeśli parametr jest niepoprawny wyjdź z funkcji

_encrypt_loop:

    mov    al,byte ptr[edx] // pobierz kolejny bajt ze stringa podanego jako parametr
    cmp    al,0             // sprawdź czy to koniec stringa, stringi są zapisane
                            // jako asciiz (bajt 00h oznacza koniec stringa)
    je    _exit_encrypt

    xor    al,7             // zaszyfruj bajt prostym xor-em
    mov    byte ptr[edx],al // zapisz zaszyfrowany bajt
    inc    edx              // ustaw wskaźnik stringa na kolejny bajt

    jmp    _encrypt_loop    // wykonuj pętle szyfrowania aż do momentu napotkania
                            // bajtu 00h
_exit_encrypt:

    pop    edx              // ważne, koryguj stos, przywróć oryginalną wartość
                            // rejestru EDX
}
}

3.3. Wywoływanie funkcji z poziomu asemblera

Czasami w kodzie asemblera trzeba będzie wywołać jakąś funkcję, jak to zrobić? Bardzo prosto, funkcje wywołuje się instrukcją call nazwa_funkcji, warto zauważyć, że istnieje kilka sposobów wywoływania i „sprzątania” po funkcjach:

Nazwa W kodzie C Parametry Zwracane wartości Modyfikowane rejestry Info
cdecl cdecl zapisywane na stosie, stos nie jest korygowany przez funkcję eax, 8 bajtów: eax:edx eax, ecx, edx, st(0), st(7), mm0, mm7, xmm0, xmm7 Sposób wywoływania funkcji z bibliotek C, wprowadzony przez Microsoft, wszystkie funkcje systemowe na platformie Linux również używają tego standardu
fastcall __fastcall ecx,edx, reszta parametrów przekazywana przez stos eax, 8 bajtów: eax:edx eax, ecx, edx, st(0), st(7), mm0, mm7, xmm0, xmm7 Microsoft wprowadził ten standard, ale potem w swoich produktach zmienił go na standard cdecl
watcom __declspec (wcall) eax, ebx, ecx, edx eax, 8 bajtów: eax:edx eax Standard wywoływania funkcji wprowadzony przez firmę Watcom w ich kompilatorze C++
stdcall __stdcall zapisywane na stosie, stos korygowany przez samą funkcję eax, 8 bajtów: eax:edx eax, ecx, edx, st(0), st(7), mm0, mm7, xmm0, xmm7 Domyślny typ wywoływania funkcji API w Windows, w bibliotekach DLL
register n/a eax, edx, ecx, reszta na stosie eax eax, ecx, edx, st(0), st(7), mm0, mm7, xmm0, xmm7 Metoda wywoływania funkcji w Delphi firmy Borland

Sposób wywoływania funkcji w naszych programach (nie mówię o WinApi) często zależy od opcji, z jakimi program został skompilowany, dla Delphi standardem jest typ „register”, dla większości programów napisanych w C standardem jest „cdecl”.

Funkcje WinApi (systemowe Windows) korzystają z mechanizmu stdcall, czyli parametry funkcji najpierw zapamiętywane są na stosie, po czym następuje wywołanie funkcji, po wywołaniu funkcji, nie trzeba korygować stosu (zdejmować wcześniej zapamiętanych parametrów), ponieważ funkcja zrobi to za nas, ciekawostką jest, że kilka funkcji WinApi nie korzysta ze sposobu wywoływania stdcall, ale z cdecl, czyli parametry zapamiętywane są na stosie, po czym wywoływana jest funkcja, ale samo korygowanie stosu musi być wykonane ręcznie. Przykładem takiej funkcji jest wsprintfA z biblioteki systemowej Windows user32.dll (jej odpowiednikiem w bibliotekach C jest sprintf), ten sposób został najprawdopodobniej wprowadzony, ponieważ ww. funkcje nie posiadają stałej ilości parametrów:

// string globalny
unsigned char tytul[] = "Liczby x i y";
...

// funkcja zamienia liczby x i y na ciąg asciiz, po czym wyświetlane
// jest okienko informacyjne pokazujące liczby x i y zapisane już
// w formie stringa
unsigned int int2str(unsigned char *bufor, unsigned int x, unsigned int y)
{
    // string lokalny, dostępny tylko dla funkcji int2str
    unsigned char format[] = "x = %lu\ny = 0x%X\n";

    __asm {

        // zwróć uwagę, w jaki sposób zapamiętywane są parametry funkcji,
        // w C++ wywołanie tej funkcji wyglądałoby następująco:
        // wsprintf(bufor, "x = %lu\ny = 0x%X\n", x, y);
        // w asemblerze parametry przed wywołaniem funkcji zapamiętywane
        // są na stosie w odwrotnej kolejności

        push    y            // liczba y
        push    x            // zapamiętaj na stosie liczbę x
        lea     eax,format   // do EAX załaduj adres lokalnego stringa
        push    eax          // zapisz wskaźnik do stringa formatującego
        push    bufor        // zapamiętaj wskaźnik bufora wyjściowego, gdzie znajdzie
                             // się sformatowany tekst
        call    wsprintfA    // wywołaj funkcję WinApi
        add    esp,4*4       // koryguj stos, 4*4 = 16 bajtów, tyle zajmują zapamiętane
                             // na stosie parametry przed wywołaniem funkcji, pisząc kod
                             // np. w C++ kompilator sam troszczy się o to żeby wskaźnik
                             // stosu był korygowany, ale pisząc w asemblerze trzeba
                             // samemu zatroszczyć się o wszystko

        push    MB_ICONINFORMATION // typ ikony jaka pojawi się obok tekstu w oknie
        push    offset tytul // tytuł okienka (zmienna globalna, używamy słowa
                             // kluczowego 'offset' ponieważ zapisujemy na stosie adres
                             // stringa)
        push    bufor        // tekst jaki pojawi się w okienku
        push    0            // uchwyt okna 'matki'
        call    MessageBoxA  // pokaż okienko informacyjne

    }
}

4. Jednostka MMX

MMX to nazwa rozszerzeń, jakie wprowadziła firma Intel do serii procesorów Pentium, skrót podobno pochodzi od „MultiMedia eXtensions”, ale sam Intel temu zaprzecza, a także nigdy nie wyjaśnił tej kwestii. Rozszerzenia MMX w procesorach Pentium obejmują nową listę rozkazów (konkretnie 57) oraz 8 nowych, 64 bitowych rejestrów.

Rejestry MMX współdzielone są z rejestrami FPU, oznacza to, że nie mogą być jednocześnie wykonywane instrukcje operujące na jednostce zmiennoprzecinkowej FPU (Floating Point Unit) oraz na jednostce MMX. Instrukcje MMX potrafią operować na danych w trybie SIMD (Single Instruction Multiple Data), tryb ten oznacza, że jedna instrukcja potrafi przetwarzać jednocześnie wiele danych, co nie jest możliwe korzystając ze standardowych instrukcji x86.

Instrukcje MMX znakomicie nadają się do przetwarzania multimedialnych danych, jak np. wideo, grafika, dźwięk, czego żywym przykładem mogą być takie programy jak DivX czy Winamp, intensywnie wykorzystujące kod MMX. Obecnie większość procesorów począwszy od tych firmy Intel poprzez AMD i Cyrix wspiera MMX.

Mimo, że MMX od ładnych paru lat stanowi praktycznie standard, kompilatory HLL domyślnie nie generują kodu MMX (oprócz specjalizowanych takich jak np. VectorC), tutaj przychodzi z odsieczą idea programowania MMX w asemblerze.

Pisząc procedury w MMX można niejednokrotnie uzyskać 100% przyrost prędkości w stosunku do oryginalnego kodu, wpływ na to, ma fakt wykorzystania ww. trybu SIMD. Wyobraźmy sobie np. taką sytuację, mamy dwie tablice 8 bajtowe i chcemy dodać kolejne bajty z obu tablic do siebie, przykładowo w C++ robimy to tak:


unsigned char tablica1[] = { 0x0A,0x1A,0x2A,0x3A,0x4A,0x5A,0x6A,0x7A };
unsigned char tablica2[] = { 0xA7,0xA6,0xA5,0xA4,0xA3,0xA2,0xA1,0xA0 };
...

for (int i = 0; i < 8; i++)
{
    tablica1[i] += tablica2[i];
}

Wszystko jest ok, ale tak czy tak operacja dodawania bajtów zostanie powtórzona 8 razy, a teraz spójrzmy jak można to zrobić o wiele wydajniej z wykorzystaniem MMX:

__asm {

    movq    mm0,qword ptr[tablica1]  // załaduj 8 bajtów z pierwszej tablicy
                                     // do rejestru MM0

    movq    mm1,qword ptr[tablica2]  // 8 bajtów z drugiej tablicy do rejestru MM1
    paddb   mm0,mm1                  // dodaj do siebie bajty z rejestrów MM0 i MM1
    movq    qword ptr[tablica1],mm0  // zapisz wynik
}

W sumie jedna instrukcja zamiast 8 powtórzeń dodawania, czyż nie jest to piękne, ale przede wszystkim o ile wydajniejsze. Kilka przykładowych funkcji operujących na grafice:

#define IMG_WIDTH 640
#define IMG_HEIGHT 320

...

//
// funkcja inicjalizuje jednostkę MMX, należy ją wywoływać
// przed operacjami MMX oraz po tym jak operowaliśmy na FPU
// i ponownie chcemy używać MMX, oraz jeśli chcemy operować na
// FPU po operacjach na MMX
//
void InitMMX()
{
    __asm emms;                      // Empty MultiMedia State, inicjalizuję
}                                    // jednostkę MMX

//
// efekt zanikania ekranu (tzw. fadeout), fullscreen
//
void fadeout(DWORD *lpScreen,DWORD iRounds)
{
    __asm {

        mov          edx,iRounds     // pobierz liczbę powtórzeń

        mov          eax,03030303h   // maska dla kolejnych składowych pikseli,
                                     // zmniejszając wartość każdej składowej (RGB)
                                     // kolejnych pikseli uzyskujemy efekt zanikania
                                     // obrazu

        movd         mm0,eax         // przenieś maskę do młodszej (lo) połówki
                                     // rejestru MM0

        punpckldq    mm0,mm0         // kopiuj maskę na pozycje starszej połówki rejestru
                                     // MMX tak, że cały rejestr, będzie zawierał wartość
                                     // 0x0303030303030303

        pxor         mm1,mm1         // wyzeruj rejestr MM1

    _fadeout_max:

        paddb        mm1,mm0         // pomnóż maskę, która będzie odejmowana od składowych
                                     // pikseli, razy ilość powtórzeń (parametr iRounds)
        dec          edx             //
        jne          _fadeout_max

        mov          eax,lpScreen    // wskaźnik mapy obrazu załaduj do rejestru EAX

                                     // liczba pikseli obrazu /2 (piksel na mapie obrazu
                                     // ma 4 bajty), liczba pikseli podzielona jest przez
                                     // 2 ponieważ z użyciem MMX przetwarzać będziemy 2
                                     // piksele jednocześnie
        mov          ecx,(IMG_WIDTH*IMG_HEIGHT) / 2

    _clear_screen_2_mmx:
                                     // odczytaj do rejestru MM0 2 piksele z mapy obrazu
        movq         mm0,qword ptr[eax]
        psubusb      mm0,mm1         // odejmuj od składowych 2 pikseli maskę, składowe
                                     // i maska traktowane są, jako tablica 8 osobnych
                                     // bajtów (SIMD)

                                     // zapisz zmodyfikowane 2 piksele do mapy obrazu
        movq         qword ptr[eax],mm0

        add          eax,8           // koryguj wskaźnik mapy obrazu, ustaw go na
                                     // następne 2 piksele

        dec          ecx             // liczba powtórzeń pętli (ilość pikseli w buforze
                                     // ekranu / 2)
        jne          _clear_screen_2_mmx

    }
}

//
// negatyw obrazu
//
void negatyw(DWORD *lpScreen)
{
    __asm {

        mov          eax,lpScreen    // do EAX wpisz wskaźnik mapy obrazu

                                     // ECX liczba pikseli / 4, ponieważ przetwarzamy
                                     // 4 piksele jednocześnie
        mov          ecx,(IMG_WIDTH*IMG_HEIGHT) / 4

        pcmpeqb      mm7,mm7         // ustaw rejestr MM7 na 0xFFFFFFFFFFFFFFFF

    _neg_mmx:
                                     // odczytaj 2 piksele obrazu do MM0
        movq         mm0,qword ptr[eax]
        pxor         mm0,mm7         // XOR -1 działa jak logiczna funkcja NOT
        movq         qword ptr[eax],mm0

        movq         mm0,qword ptr[eax+8]
        pxor         mm0,mm7
        movq         qword ptr[eax+8],mm0

        add          eax,16          // ustaw wskaźnik mapy obrazu na kolejne 4 piksele
        dec          ecx
        jne          _neg_mmx

    }
}

//
// rozmazywanie obrazu (tzw. blur)
//
void blur(DWORD *lpScreen)
{
    __asm {

        push         esi             // zapamiętaj rejestry ESI i EDI
        push         edi

        mov          esi,lpScreen    // wskaźnik mapy obrazu załaduj do ESI

        mov          ecx,( (IMG_WIDTH*IMG_HEIGHT) - (IMG_WIDTH*8) + 4 )
        mov          eax,IMG_WIDTH*4 // szerokość wiersza w mapie obrazu
        mov          edx,IMG_WIDTH*8 // szerokość dwóch wierszy

        lea          esi,[esi+eax+4] // ustaw mapę obrazu na pierwszy piksel
                                     // drugiego wiersza ekranu

        pxor         mm7,mm7         // ustaw mm7 na 0
        movd         mm0,[esi-4]     // czytaj 1 piksel

    _blur_more:

        movd         mm1,[esi+4]

        mov          edx,esi
        sub          edx,eax
        movd         mm2,[edx]

        movd         mm3,[esi+eax]
        punpcklbw    mm0,mm7         // rozpakuj składowe kolejnych 4 pikseli
        punpcklbw    mm1,mm7         // do WORDów
        punpcklbw    mm2,mm7
        punpcklbw    mm3,mm7
        paddusw      mm0,mm1         // dodaj kolejno do siebie wartości składowe
        paddusw      mm0,mm2         // kolorów 4 pikseli
        paddusw      mm0,mm3
        psrlw        mm0,2           // suma składowych kolorów / 4, w ten sposób
                                     // obliczamy
        packuswb     mm0,mm7         // spakuj składowe piksela zapisane jako WORDy
                                     // w rejestrze MM0 do DWORDa
        movd         [esi],mm0       // zapisz piksel
        add          esi,4

        dec          ecx
        jne          _blur_more

        pop          edi
        pop          esi

    }
}

5. Kiedy korzystać z asemblera

Jak wspomniałem na początku artykułu, asembler wykorzystywany jest głownie tam gdzie liczy się prędkość i tam znajduje zastosowanie. Pisząc jakieś algorytmy warto się czasami zatrzymać i pomyśleć czy czasem nie szybciej będzie, jeśli w jakichś krytycznych punktach programu (jak pętle itp.) użyjemy np. MMX.

Wyobraźcie sobie, że napisaliście właśnie encoder mp3, konkurencja zrobiła to samo, ale wasz korzysta z ręcznie napisanego kodu MMX, który jest trzykrotnie szybszy od tego konkurencji. Teraz kogo wybierze użytkownik, który zamiast czekać 30 minut na wykonanie zadania, będzie musiał jedynie poczekać 10min? Odpowiedź nasuwa się sama.

Asembler, oprócz tego, że idealnie nadaje się do pisania algorytmów wymagających prędkości, także jest wykorzystywany do pisania specyficznych programów jak np. exe-kompresory. Założę się, że większość osób kojarzy programy takie jak UPX czy Aspack, programy te służą do kompresji plików wykonywalnych, mówiąc prościej, jeśli napiszemy jakiś program i będzie zajmował dajmy na to 700 kB, to po skompresowaniu go UPXem jego rozmiar zmniejszy się do ok. 300 kB, ale program w formie pliku EXE będzie tak samo funkcjonalny jak przed kompresją. W tym wypadku asembler jest wykorzystany do napisania kodu tzw. loadera, czyli fragmentu kodu, który zapisany jest w pliku EXE (prawie jak wirus) i po uruchomieniu takiego programu, kod loadera dekompresuje dane pliku EXE i powoduje jego uruchomienie. Napisanie kodu loadera w języku HLL, obojętnie czy jest to C++, Delphi czy nawet Power Basic, jest praktycznie niemożliwe.

Można powiedzieć, że asembler ma specyficzne przeznaczenie, prędkość i nietypowe aplikacje, ale nie do końca, pisanie w asemblerze to nie tylko wstawki i pojedyncze procedury. Całe programy mogą być pisane w asemblerze, czasami słyszę, jak ludzie mówią, że to niemożliwe, że nie można napisać dużej aplikacji w asemblerze od podstaw. Najczęściej mówią tak ci, którzy obcowali z asemblerem pięć minut, jeśli już nabierze się wprawy w kodowaniu, to nic nie stoi na przeszkodzie żeby budować profesjonalne aplikacje. Pisząc cały program w asemblerze mamy nad nim totalną kontrolę, wszystko zależy od nas, program jest wykonywany zgodnie z naszą wolą, nie jesteśmy zdani na „łaskę” kompilatora.

Ostatnimi czasy pisanie w asemblerze jest bardzo proste i wygodne, dużo ludzi na całym świecie zaczyna widzieć w tym języku prawdziwą magię, powstaje dużo projektów, można znaleźć całą masę przykładowych tutoriali i kodów źródłowych, dzięki którym każdy problem przestaje być problemem. Pisanie całych aplikacji w asemblerze ma też tą zaletę, iż nawet pięciomegabajtowy kod źródłowy, zostanie skompilowany do ok. 90kb pliku wykonywalnego. Przykładowo, aplikacja napisana w Delphi 6, zawierająca 1 okno, po skompilowaniu zajmuje ok. 300kb, a program napisany w asemblerze, który robi dokładnie to samo i bez problemów uruchamia się na całej serii systemów operacyjnych Windows począwszy od 95 po XP, zajmuje 4 kB. Dlaczego tak jest, przyczyna jest prosta, kompilator sam dodaje wszystko to, co nie jest potrzebne, ale mogłoby być użyte, a dlaczego tak to jest zrobione, to wypadałoby już zapytać firmy produkujące kompilatory.

Pomimo tego, że asembler może być wykorzystywany do wielu pożytecznych rzeczy, wykorzystywany jest też do pisania destrukcyjnych programów, jakimi niezaprzeczalnie są wirusy albo exploity, ale jak mawiał Kubuś Puchatek, to jest już całkiem inna historia...

6. Podsumowanie

Powyższe przykłady prezentują tylko mały zakres tego, co umożliwia asembler, jeszcze dużo jest do odkrycia, zarówno przed Wami jak i przede mną, bo asembler wbrew temu, co mówią, nie jest martwy, ciągle się zmienia, ewoluuje, dając nam możliwości, jakich nie da nam żaden język wysokiego poziomu. SSE, SSE2, 3DNow, terminy wymieniane w prasie, to nie fikcja, to wszystko stoi otworem, wystarczy tylko po to sięgnąć.

Z mojej strony mogę powiedzieć, że pisanie w asemblerze daje mi uczucie wolności, jakiego nie zaznałem pisząc w żadnym innym języku, i życzę Wam, aby podróż z asemblerem nie skończyła się na tym artykule.

7. Odnośniki

www.win32asm.cjb.netstrona dla programistów ASM, źródła, tutoriale, forum
www.int80h.orgprogramowanie w asemblerze pod FreeBSD
www.rbthomas.freeserve.co.ukgrafika w Windows, algorytmy, fraktale
www.chrisdragan.orgstrona Chrisa Dragana, wiele przykładowych programów w asemblerze (MMX)
www.azillionmonkeys.com/qed/index.htmlznakomite artykuły nt. optymalizacji kodu (MMX, Pentium)
asmjournal.freeservers.comAssembly Programming Journal, magazyn traktujący o różnych aspektach programowania w asm, optymalizacja kodu z bibliotek C, pisanie w asm pod graficznymi powłokami systemów Unix, programowanie gier z wykorzystaniem DirectX, oraz wiele ciekawostek
www.nasm.usoficjalna strona darmowego asemblera NASM (Windows, Unix)
www.borland.com/Products/Software-Testing/Automated-Testing/Devpartner-StudioSoftIce, debugger pozwalający dokładnie analizować kod dowolnego programu, zarówno na poziomie HLL oraz na poziomie kodu asemblera, niezastąpiony w wykrywaniu bugów

O autorze

Bartosz Wójcik — autor dla programistów, wykorzystującego szyfrowania pliku aplikacji w połączeniu z systemem kluczy licencyjnych.