A.D.Danilecki, Poznan, Polska
Politechnika Poznanska, Wydzial Informatyki i Zarzadzania
W tej chwili adanilecki _malpa_ cs.put.poznan.pl
Z wykorzystaniem wielu listow z uwagami od wielu autorow
Krótki wstęp do programowania z wykorzystaniem inline assemblera x86


4. Punkt Trzeci : Moje pierwsze programiki


Przykład pierwszy
Przykład drugi
Przykład trzeci
Przykład czwarty
Przykład piąty
Przykład szósty
Przykład siódmy
Przykład ósmy
Przykład dziewiąty

Teraz napiszemy kilka programików które krok po kroku nauczą nas jak pisać w assemblerze pod Linuxem za pomocą gcc.

Przykład pierwszy :

pierwszy.c

void main()
{
asm (
" nop "
);
}


Wszystko pomiędzy '(' i ')' jest traktowane jako instrukcje assemblerowe. w tym wypadku instrukcja ta to 'nop' ktora jest odpowiednikiem 'instrukcji pustej' z C, tzn. nie wykonująca żadnej akcji. Tak więc sam program oczywiście również nic nie robi.

Program kompilujemy w następujący sposób:

gcc pierwszy.c -o a.out

i wywołujemy następująco:

./a.out

Efekt programu? Oczywiście żaden :-) Obejrzyjmy sobie tylko jego przekład na assembler przez gcc.
gcc -S pierwszy.c -o pierwszy.s
i potem oczywiście vi pierwszy.s. Podobnie najlepiej postąpisz z każdym programem, który będziesz pisał, dzięki temu szybko będziesz w stanie poprawiać błędy i uczyć się asma.

Przykład drugi:

Pokażemy teraz w jaki sposób z poziomu assemblera odwoływać się do zmiennych lokalnych. Do zmiennych globalnych, jak wiemy, możemy się odwoływać prosto, za pomocą nazwy. Jeżeli chcemy odwoływać się do zmiennych lokalnych, czynimy to w następujący sposób:
asm("...." : : "jakaś litera" (nazwa zmiennej) , ...)
Na przykład chcąc mieć dostęp do zmiennej a, mogę napisać
asm("..":: "q" (a) );
. Do zmiennej tej mogę się odwoływać za pomocą %(numer). Tutaj napisalibyśmy %0. Gdybyśmy napisali tak:
asm(".."::"q" (a) "g" (b) );
to do zmiennej a odwoływalibyśmy się za pomocą %0 a do zmiennej b za pomocą %1. Proste nieprawdaż? Wyjaśnienia tylko wyjaśniają tylko te literki których użyliśmy w cudzysłowiach. Za ich pomocą mówimy gcc gdzie ma przechować nasze zmienne. I tak :
"a"
oznacza rejestr eax
"b"
oznacza rejestr ebx
"c"
oznacza rejestr ecx
"d"
oznacza rejestr edx
"S"
oznacza esi
"D"
oznacza edi
"I"
oznacza stałą
"q" oraz "r"
pozwalają gcc zadecydować do którego rejestru załadować zmienną
"g"
oznacza że zmienna będzie albo w rejestrach, albo w pamięci
"A"
oznacza załadowanie zmiennej typu long long (64 bity) do rejstrów eax i edx.
"m"
oznacza miejsce w pamięci
"0", "1","2"
oznacza że chcesz ponownie wykorzystać poprzednie przypisanie rejestrów (np drugi raz chcesz wykorzystać rejestr przypisany "q").


Poniższe oznaczenia znajdziesz w info na temat gcc. Napisz info gcc, najedź na napis 'C Extensions', kliknij enter, najedź na napis 'Extended Asm' i tam znajdziesz nieco po angielsku na temat rozszerzonego asma. Napisz info gcc ->Machine Desc -> Constraints -> Machine Constraints i poszukaj 'Intel 386' tam znajdziesz opis (po angielsku) tych oznaczeń.

Przypominam, że jeżeli używamy któregoś z trzech pól (zmienne wyjściowe, wejściowe, zmodyfikowane rejestry), to mamy do czynienia z assemblerem rozszerzonym, i nazwy rejestrów musimy poprzedzać '%%' a nie '%', czyli piszemy %%eax a nie %eax.
W naszym przykładzie nie używamy pola zmienne wyjściowe, więc zostawiamy je puste, nie używamy pola zmodyfikowane rejestry więc je pomijamy.
drugi.c

#include <stdio.h>

void main()
{
int a=0;
printf("Wartość a: %d przed wykonaniem \n",a);
asm (

" movw $1,%0"
:
: "r" (a)
);
printf("I wartość tej samej zmiennej po wykonaniu %d\n",a);
}

Efektem wykonania będzie napis :
Wartość a: 0 przed wykonaniem
I wartość tej samej zmiennej po wykonaniu 0

Dlaczego ? Zmienna 'a' jest traktowana jako 'wejściowa', tzn z poziomu inline-assemblera można się do niej odwoływać, ale nie można jej zmieniać. Jeżeli zależy nam na tym, by zobaczyć efekt, napiszemy kolejny program, z wykorzystaniem wartości "wyjściowych"

Przykład trzeci

Jeżeli chcemy z poziomu assemblera zmieniać zawartość zmiennych, musimy zadeklarować te zmienne w polu "Zmienne wyjściowe". Robimy to tak :
asm("..." :"=q" (a) );
Jak widać wszystko podobnie jak poprzednio , z tym że używamy znaku = zawsze przed określaniem gdzie ma być załadowana zmienna. Odwołujmey się znowu za pomocą %0, %1 itd. Zawsze pamiętamy, że %0 odnosi się do pierwszej zadeklarowanej w ten sposób zmiennej niezależnie od tego w którym polu się znalazła. Format jest identyczny jak dla zmiennych wejściowych .

trzeci.c

#include <stdio.h>
void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
asm (
" movw $1,%0\n"
" movw %0,%1\n"
: "=r" (b)
: "r" (a)
);
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}


Tym razem otrzymamy następujący wynik:
Wartość a: 0  b: 0 przed wykonaniem
I wartość tych samych zmiennych po wykonaniu a: 0 b: 1

Co się stało ? Zmienna 'a' została zadeklarowana jako wejściowa. Ponieważ jest druga na liście więc odwołujemy się do niej przez %1. Zmienna 'b' jest jako wyjściowa więc efekty jej zmian będzie widać na zewnątrz instrukcji assemblerowej. Odwołujemy się do niej za pomocą %0, bo została zadeklarowana na pierwszym miejscu.
Załadowaliśmy liczbę '1' do zmiennej a (zmiany tej na zewnątrz nie widać, bo a jest tylko wejściowa) a potem zmiennej 'b' przypisaliśmy tymczasową wartość 'a'. Kazaliśmy zarówno 'a' jak i 'b' przechować w rejestrach, ale nie obchodzi nas w jakich. Jeżeli nas interesuje gdzie dokładnie, to wykonajmy instrukcję gcc -S trzeci.c i obejrzyjmy plik trzeci.s , między #APP a #NOAPP będzie nasz wstawiony inline assembler. Slowko wyjasnienia: jest mozliwe oczywiscie, by zamiast tego napisac:
#include <stdio.h>
void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
asm (

" movw $1,%0\n"
" movw $1,%1\n"
: "=r" (b)
: "r" (a)
);
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}


Nie robie tego dlatego, zeby po pierwsze, dokonac dokladnego przekladu a=b=1, a po drugie, zeby zademonstrowac, ze i tak mozna :). Ma to wplyw jednakze na szybkosc, o czym nizej.

Przykład czwarty


To samo co poprzednio, tyle tylko że a zadeklarujemy jako zmienną wejściową. Wydawałoby się, że powinniśmy wyniki identyczne, prawda? Mi też na początku tak się zdawało i dziesiątki razy popełniałem ten sam bład, przed którym teraz was przestrzegę.
czwarty.c

#include <stdio.h>

void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
asm (
" movw $1,%0\n"
" movw %0,%1\n"
: "=r" (b), "=r" (a)
:
);
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}

i efekt :

Wartość a: 0  b: 0 przed wykonaniem
I wartość tych samych zmiennych po wykonaniu a: 134479873 b: 1
Dziwny nie ? Dlaczego tak się stało? To bardzo proste. Zmienna b została załadowana do któregoś z rejestrów 32 bitowych. My, instrukcją movw $1,%0 (która rozwinęła się do np movw $1,%eax) wprowadziliśmy tam 16 bitową liczbę. Czyli, jak łatwo się domyśleć, zawartość ax będzie równa 1, a pozostała część eax będzie miało wartość nieokreśloną. Można ten błąd naprawić na dwa sposoby:
pierwszy : piaty.c


#include <stdio.h>

void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
asm (
" xor %1,%1\n"
"movw $1,%0\n"
" movw %0,%1\n"
: "=r" (b), "=r" (a)
:
);
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}


i drugi
szosty.c

#include <stdio.h>

void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
asm (
" movl $1,%0\n"
" movl %0,%1\n"
: "=r" (b), "=r" (a)
);
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}

Wartość a: 0  b: 0 przed wykonaniem
I wartość tych samych zmiennych po wykonaniu a: 1 b: 1

W sposobie pierwszym po prostu wyczyściliśmy rejestr, który akurat przechowywał zmienną 'a', w związku z czym rejestr ten nie zawierał żadnych przypadkowych wartości, w sposobie drugim (lepszym) powiedzieliśmy gcc, że ma traktować argumenty jako liczby 32-bitowe (movl zamiast movw).
Przy okazji : skompiluj ten sam plik szosty.c nastepująco :
gcc szosty.c -o a.out
A potem :
gcc -O2 szosty.c -o a1.out
i porównaj pliki wynikowe komendą ls -al a.out .
Widać małą różnicę rozmiaru, nieprawdaż? Warto więc znać opcje kompilatora.

I jeszcze przy okazji :

siodmy.c #include <stdio.h>

void main()
{
int a=0;
int b=0;
printf("Wartość a: %d b: %d przed wykonaniem \n",a,b);
a=b=1;
printf("I wartość tych samych zmiennych po wykonaniu a: %d b: %d\n",a,b);
}

Zazwyczaj ten kod będzie albo takiego samego rozmiaru jako poprzedni, albo nawet mniejszy. Dlaczego? Gdyz w naszym przykladzie pojawia sie zaleznosc miedzy dwoma rejestrami (wyjasnienie juz wkrotce w rozdziale o optymalizacji), podczas gdy kompilator automatycznie generuje kod ktory zaleznosci tej unika. Pytanie więc znowu po co używać assemblera? Spokojnie. Ten przyklad pokazuje, ze programiki pisane na chybcika i bez zastanowienia moga byc gorsze od C. Chyba nie zdziwiony? :-) Naprawdę zastanów się nim zaczniesz pisać coś w assemblerze, i zawsze próbuj napisać równoważny kod w C. Potem porównaj szybkość działania, wielkość pliku i dopiero wtedy wybierz lepszą wersję. A najlepiej obejrzyj kod wypluty przez gcc i popraw go by uzyskac lepszy wynik.

Przykład ósmy

Teraz napiszemy sobie mały programik, który posłuży nam do pokazania jednego błędu i paru innych rzeczy. Programik będzie wpisywał elementy jednej tablicy w odwrotnej kolejności do drugiej, tzn. przed jego wykonaniem będziemy mieli tablicę a={11,12} a po wykonaniu tablicę c={12,11}.
Kilka słów na temat użytych instrukcji. Instrukcja leal oblicza adres pierwszego argumentu i umieszcza go w drugim. Np leal a,%%eax znaczy to samo mniej więcej co movl $a,%%eax. .Instrukcja mull mnoży swój argument przez zawartość rejstru %%eax i wynik zwraca w parze rejestrów %%eax:%%edx (%%eax młodszy bajt, %%edx starszy bajt). Instrukcja loop powoduje skok do etykiety podanej jako argument, jeżeli %%ecx jest różne od zera, i zmniejsza %%ecx o 1. Instrukcja subl powoduje odjęcie od pierwszego argumentu durgiego i umieszcza wynik w drugim argumencie. Instrukcja push umieszcza argument na stosie, a pop zdejmuje go stamtąd. Wreszcie instrukcja dec podowuje zmniejszenie argumentu o jeden. Dokładniejszego opisu tych instrukcji poszukaj na końcu tego tekstu .
osmy.c (wersja z błędem)
#include <stdio.h>

void main()
{
int a[2]={11,12};
int c[2];
printf("Przed wykonaniem instrukcji a[0]=%d, a[1]=%d",a[0],a[1]);
asm volatile(

"leal %0,%%edx\n\t"
"leal %1,%%ebx\n\t"
" movl $1,%%eax\n\t"
" movl $2,%%ecx\n\t"
" petla1:n\t"
" pushl %%eax\n\t"
"pushl %%ecx\n\t"
" movl $2,%%ecx\n\t"
" mull %%ecx\n\t"
" movl (%%edx,%%eax,2),%%ecx\n\t"
"pushl %%ebx\n\t"
" movl $2,%%ebx\n\t"
" subl %%eax,%%ebx\n\t"
" movl %%ebx,%%eax\n\t"
" popl %%ebx\n\t"
" movl %%ecx,(%%ebx,%%eax,2)\n\t"
"popl %%ecx\n\t"
"popl %%eax\n\t"
"decl %%eax\n\t"
" loop petla1\n\t"
: "=g" (a) , "=g" (c)
:: "ax", "cx", "dx" , "memory"

);
printf("Po wykonaniu instrukcji c[0]=%d, c[1]=%d\n",c[0],c[1]);
}


Niestety, ten program ma błąd. Spróbuj znaleźć go sam. Jeżeli uda Ci się go znaleźć, to już umiesz w miarę dobrze assemblera, a przynajmniej jesteś na dobrej drodze do nauczenia się go.

Jeżeli nie wiesz gdzie jest błąd, doczytaj najpierw ten paragraf do końca. Po kolei wyjaśnię co robią które komendy.

Najpierw obliczamy adres początku tablicy a ( która jest podana jako pierwszy argument, stąd %0, i gcc ma samo zadecydować gdzie ten argument ma być, stąd "=g") i adres ten ładujemy do rejestru %%edx. Tak samo obliczamy adres tablicy c, w której maja się znaleźć odwrócone elementy tablicy a. Adres tej drugiej tablicy, która została podana jako drugi argument (stąd %1) zostaje złożona do rejestru %ebx. Ponieważ używam rozszerzonego assemblera, nazwy wszsytkich rejestrów poprzedzam dodatkowym '%'. Ładuję jedynkę do %eax i dwójkę do %ecx. Ta dwójka to liczba pętli które wykona program. Kolejna instrukcja to etykieta o nazwie 'petla1'. Stare wartości %eax i %ecx odkładam na stos. Do %ecx ładuję dwójkę, i mnożę %eax przez %ecx. Wynik ma być w rejestrze %eax:%edx (już widzisz, gdzie jest błąd, prawda?).

Teraz przykład odwoływania się do adresów zmienej. Gdybym chciał się odwołać do pierwszego elementu tablicy 'a', napisałbym (%%edx) (bo do tego właśnie rejstru wcześniej załadowałem adres komendą leal %0,%%edx). Gdybym chciał odwoływać się do elementu drugiego tablicy, napisałbym 4(%%edx). Dlaczego? Dokonaj translacji osmy.c komenda gcc -S osmy.c na plik w asmie osmy.c i obejrzyj sobie go. Widzisz? Pierwszy element to -8(%%ebp), drugi to -4(%%ebp). Skoro %edx wskazuje na pierwszy element, wieć żeby dostać się do drugiego, muszę do niego dodać cztery bajty. Stąd to 4(%%edx). Dlaczego akurat cztery? Ponieważ typ int ma właśnie długość czterech bajtów. Gdyby to była tablica typu char, to dodałbym 1, czyli jeden bajt, bo długość typu char to jeden bajt. Proste prawda? (%%edx,%%eax,4) oznacza natomiast: element o adresie %edx+ %eax*4. Zamiast czwórki mógłbym wpisać jakąś inną potęgę dwójki, np: 1,2,4,8. W moim przypadku %eax zawiera w pierwszej pętli 2, a w drugiej zero, więc najpierw odwołam się do %edx+2*2 czyli tak samo jak 4(%edx), czyli drugiego elementu tablicy, a potem do %edx+0*2, czyli do pierwszego elementu tablicy. Reszty chyba już nie muszę tłumaczyć?

Teraz wyjaśnienie gdzie był błąd : sam się zresztą już chyba domyśliłeś. W rejestrze %edx miałem przechowywać adres tablicy a, ale komenda mul, gdy jej argument jest 32-bitowy, umieszcza wynik w parze rejestrów %eax:%edx. W naszym przypadku do %edx powędrowało zero, więc więc zamiast odczytywać elementy z tablicy 'a', czytałem je gdzieś z kosmosu. Jak go poprawić? Bardzo prosto. Dopisz przed komendą "mull %%ecx\n\t" komendą "pushl %%edx\n\t" a bezpośrednio po niej komendę "popl %%edx\n\t".

Ten przykład, zauważ, był potwornie rozbudowany, po to by pokazać Ci działanie kilku komend i paru mechanizmów. A oto podobny program, który odrwaca elementy jednej tablicy :


#include

void main()
{
int a[2]={11,12};
// gdybym chciał dwie tablice, tu zadeklarowałbym int b[2]={0,0};
printf("Przed wykonaniem instrukcji a[0]=%d, a[1]=%d\n",a[0],a[1]);
asm volatile(

"lea %0,%%edx\n\t"
// tutaj dodałbym leal %1,%%ebx
"pushl (%%edx)\n\t"
"pushl 4(%%edx)\n\t"
//i wtedy dwie następne linijki zamieniłbym %%edx na %%ebx
"popl (%%edx)\n\t"
"popl 4(%%edx)\n\t"
: "=g" (a) // tu dodałbym "=g" (b)
::"dx" // a tu dodałby, "bx"
);
printf("Po wykonaniu instrukcji a[0]=%d, a[1]=%d\n",a[0],a[1]);
}

Aha, zapomniałem jeszcze wspomnieć po co jest lista zmodyfikowanych rejestrów. W liście tej informujemy gcc, że dany rejestr jest używany przez nas, i kompilator nie powinien już zakładać, że znajdujące się tam dane są takie, jak przed wykonaniem instrukcji asm. Jeżeli sami zachowujemy wartość odpoweidnich rejestrów, to wtedy ostatnie pola możemy pominąć. W ostatnim przykładzie modyfikowałem rejestr %edx, więc poinformowałem kompilator o tym.