Teraz już wiesz, jak pisać wstawki assemblerowe w programach w C. Na pewno przyszło ci na myśl, że zamiast wstawek assemblerowych warto by było napisać program w assemblerze od A do Z. Myśl nie jest głupia. Wprawdzie dość trudno napisać bez użycia C, tylko przy pomocy assemblera program obiektowy albo obsługę stosu, ale zyskać można na prawdę sporo na wielkości.
Jeżeli jeszcze nie polubiłeś składni AT&T i przyzwyczaiłeś się do składni intelowskiej, nie pozostaje ci nic innego, jak ściągnąć nasma . W przeciwnym razie możesz używać programiku o nazwie GNU assembler, w skrócie gas, który jednak w większości dystrybucji (dokładnie mówiąc, w każdej jaką widziałem) występuje pod nazwą as. Jeśli masz gcc, to masz też i as-a, gdyż assembler ten służy przede wszystkim do assemblacji programów produkowanych przez gcc. Aby się upewnić wydaj polecenie which as i zobaczysz gdzie ten as jest. as --version poda ci jaką wersję as-a używasz.
Rozdział ten oparty jest na dokumentach Briana Reitera, dokładnie na Http://www.muppetlabs.com/~breadbox/software/tiny/teensy.php , oraz na dokumentach Konstantina Boldyshewa http://lightning.voshod.com/asm . Polecam gorąco lekturę obu tych stron, najpierw B.Reitera, potem Boldysheva. Pomysł na program testowany tutaj wzięty z ,,Assembler Programming Journal'' Nr 2. http://asmjournal.freeservers.com aczkolwiek po napisaniu się go okazało się, że niepotrzebnie się męczyłem, gdyż podobny programik, już zoptymalizowany na maxa, napisał też B.Reiter.
Napiszemy prosty programik typu "Hello World!" który w różnych odmianach (np. "Hello I'm Jan B.") występuje w podręcznikach programowania. Wyświetlimy napis na ekranie (niech będzie to napis "Yo!") i zakończymy. Nauczymy się korzystać z przerwań systemowych i pisać potworny, nieprzenośny kod, którego potem będziemy się wstydzić.
Program w C będzie prosty. Oto i on:
Kompilujemy toto za pomocą gcc first.c -o exC. Odpalamy ./exC. Pojawia się napis "Yo!". Nic specjalnego, nieprawdaż? Sprawdźmy rozmiar wygenerowanego pliku wykonywalnego : ls -l exC lub wc -c exC U mnie wyszło .. uwaga... 24141! Chyba to jest lekka przesada jak na programik wypisujący trzy znaki na ekranie? Zaraz, poprzednio zmniejszyliśmy rozmiar programu zmieniając tylko opcje kompilatora, może tym razem też? Kompilujemy jako gcc -O2 -o exC first.c i żadnej różnicy w rozmiarze nie ma... Ale po wgłębieniu się w info gcc znajdujemy bardzo interesującą opcję : -s . Opcja ta usuwa wiadomości na temat tablicy lokalnych symboli, tablicy przesunięć i innych rzeczy które zazwyczaj są potrzebne tylko przy debuggingu. Jeśli chcesz wiedzieć co to są za tablice symboli i tablice przesunięć poczytaj sobie info gcc lub opis standardu ELF. Podobny efekt ma komenda strip, czyli kompilacja gcc -s first.c -o exC jest równoważna gcc first.c -o exC;strip exC . Ile teraz wynosi rozmiar pliku ? 6224. Nieźle nie? Komendą gcc -S first.c możemy zobaczyć co właściwie gcc wygenerował za kod assemblerowy. Hm, wygląda trochę groźnie..
No tak, ale przecież my mieliśmy się uczyć assemblera a nie opcji kompilatora! Racja. Bierzemy ,,Assembler Programming Journal issue 2'' i znajdujemy tam ich wersję programu ,,Hello world!''. Z grubsza wygląda to tak:
Po kolei : .global main oznacza że deklarujemy symbol main jako ogólnie
dostępny, to znaczy że gdybyśmy mieli kilka części programu (gdyby składał
się z kilku plików osobno kompilowanych do .o i potem linkowanych) to
w każdym module ten symbol byłby widoczny (o ile sie nie mylę :)) ).
Program dzieli się na ,,sekcje''. Sekcja .text służy do przechowywania
kodu naszego programu. Dane umieszczane tam nie moga ulec zmianie, sekcja
ta jest read-only - tylko do odczytu. Sekcja .data służy do przechowywania
danych które będą mogły ulec zmianie. Sekcja .bss służy do przechowywania
danych niezainicjowanych, czyli takich, których wartość nie jest znana
na początku działania programu. Każdy program napisany za pomocą as-a będzie
musiał posiadać conajmniej te trzy sekcje. Nawet jeśli my ich nie zadeklarujemy,
zrobi to za nas sam assembler. Jeśli nie będziemy ich chcieli, musimy
skorzystać z objcopy . Widać tu przewagę nasma, o czym dalej.
Tak więc zadeklarowaliśmy sekcję .text. Następnie zdefiniowaliśmy symbol
message. Jego zawartość to łańuch znaków (deklaracja .ascii). Nasz kod polega
na włożeniu adresu wiadomości do wydrukowania na stos (pushl odkłada argument
na stos traktując go jako liczbę 32-bitową) i za pomocą instrukcji call
wywołania funkcji. Argumenty dla funkcji, jak zapewne wiesz, zawsze są
przekazywane za pomocą stosu. Funkcja puts jest zdefiniowana w libc, która
standardowo jest dołączana do każdego programu kompilowanego za pomocą
gcc (chyba że użyjemy przełącznika -nostdlib). Następnie zdejmujemy z stosu
wynik wywołania funkcji i instrukcją ret kończymy funkcję main. Czemu tam musi
być ret? Gdyz tak naprawdę program zaczął się od funkcji _start, którą dokłada
za nas gcc, która dopiero potem wywołuje main. Kompilujemy za pomocą
gcc -s first.s -o exS i porównujemy rozmiar. 6180! Wygląda na to, że
zaoszczędziliśmy tylko 44 bajty!
Wytłumaczenie jest proste. Żeby móc używać funkcji puts, musieliśmy
dołączyć standardowe biblioteki. Zrobiło to za nas gcc, tak samo
automatycznie dołączając funkcję _start. Nie wierzysz? napisz
objdump --disassemble exS . Zauważysz tam mnóstwo kodu którego na pewno
tam ty nie wkładałeś, jakąś sekcję .init, jakąś sekcję .fini... BTW, nasz
program ma błąd. żeby go poprawić wystarczy poczytać info gas na temat
dyrektyw i poprawić jedną literkę. Błąd polega na tym, że wyświetlamy
więcej niż trzeba.. Oprócz Yo! wyskakuje jeszcze w cholerę innych znaczków.
Poprawka zaś polega na zamianie dyrektywy .ascii na .asciz. Dlaczego?
Powieneś już to wiedzieć. Ta druga dyrektywa uzupełnia łańcuch znaków
znakiem pustym, a puts oczywiście wyświetla łańcuch znaków aż do napotkania
znaku pustego.
No tak, ale mieliśmy przecież nauczyć się pisać _małe_ programy! I nauczymy się oczywiście. Pierwsze, co będziemy musieli zrobić, to zrezygnować z symbolu _start. Jak? Każdy program zaczyna się standardowo od tego symbolu. Wystarczy więc jeśli nasz symbol main zastąpimy symbolem _start i skompilujemy z pomocą gcc -nostartfiles first.s -o exS co każe gcc by nie dołączało własnej definicji symbolu _start. Zauważmy, że tym razem nie mamy skąd wracać, więc instrukcja ret jest niepotrzebna; Ale czy ją zostawimy, czy wywalimy, program kończy się segmantation fault. No tak, przecież każdy proces w Unixie musi zakończyć się wołaniem funkcji exit(). Pisząc w C łatwo o tym zapomnieć, bo wszystko za nas robi gcc. Funkcja exit() należy do libc więc nic nie stracimy ją wołając. Dołączamy więc zamiast ret linijkę : call exit i wszystko kompilujemy jak poprzednio. Teraz wszystko działa i mamy tylko 1908 bajtów! No, to już całkiem nieźle, prawda?
Jak dotąd wszystko jest jasne i zgodne z tym, co zalecają w szkołach. Ale,
jak zauważa K.Boldyshev, pisanie w assemblerze i z wykorzystaniem standardowej
biblioteki C jest nieco nadmiarowe. Zamiast tego możemy bowiem bezpośrednio
odwoływać się do jądra Linuxa.
Wołania systemowego w Linuxie, czegoś takiego jak przerwania
systemowego w DOSie. używa się bardzo prosto. Należy wywołać przerwanie
int 0x80 - przy czym twórcy jądra ostrzegają by tego nie robić bo liczba
0x80 może bez ostrzeżenia ulec zmianie w przyszłości - do rejestru %eax
wstawić numer wywoływanej funkcji (__NR_exit np, czyli 1) a do rejestrów
ebx,ecx,edx,esi,edi wstawić argumenty dla wołania. Numery funkcji systemowych
zdefiniowane są w /usr/src/linux/arch/asm-i386/unistd.h. Tam też możesz
się domyślać jakie potrzebne będą argumenty dla jakiego wołania. Wynik funkcji
pamiętany jest w rejestrze %eax. Oczywiście
jeśli chcesz pisać oprogramowanie porządne i przenośne, nie powinieneś tego
robić, bo sposób wywoływania funkcji systemowych może się zmienić w każdej
chwili. Z drugiej jednak strony :)))
wykorzystamy więc dwa wołania systemowe: do funkcji exit (numer 1) oraz do funkcji write (numer 2). Funkcja write zapisuje bajty do pliku. Ponieważ każdym Unixie wszystko jest plikiem, ekran też, możemy ja wykorzystać do wypisywania tekstu na ekranie. Narzędzie prymitywne, ale zawsze. Tak więc do %eax pakujemy 4 (numer funkcji write), do %ebx 1 (deskryptor pliku, 1 zawsze zarezerwowany jest dla standardowego wyjścia czyli stdout) w %ecx musimy zapisać adres ciągu bajtów do zapisania, a w %edx ilość bajtów do zapisania, czyli po prostu rozmiar łańcuchu. Program więc wyglądać może tak:
Nie korzystamy tym razem z gcc; Jak więc toto skompilować? A jak poradziłoby
sobi gcc? gcc --verbose first.S -nostartfiles -nostdlib i widzimy
że najpierw wołany jest asembler a potem linker. My też tak zrobimy.
as first.S -o obj
ld obj -o exS
strip
. Program działa?
Działa! Jego rozmiar? 396 bajtów! Nieźle, nie?
Teraz zastanówmy się nad możliwymi poprawkami w programie. objdump
--disassemble exS pokazuje nam, że nasz program ma 34 bajty. Te 10 bajtów
na początku to zapis naszej wiadomości. Bajty to bajty - objdump znaki
zinterpretował tak, jakby to były kody rozkazów. Właściwy program rozpoczyna
się od piątej instrukcji. objdump -x exS mówi nam że sekcja .text liczy 22
bajty - i ma rację oczywiście :) gdyż 22
hexadecymalnie to 34.
Pierwsza poprawka: po co nam teraz .asciz? Nie używamy juz funkcji puts,
sami mówimy jaki jest rozmiar stringu czyli możemy użyć .ascii i zaoszczędzić
jeden bajt. Faktycznie objdump --disassemble exS mówi nam że mamy już tylko
33 bajty. Rozmiar pliku jednak się nie zmienił! Dalej 396 bajty! Dlaczego?
Hm, wygląda na to że as (lub linker) uparcie zawsze dodaje puste bajty by
rozmiar pliku był podzielny przez cztery. Jeśli obetniemy pięć bajtów,
rozmiar pliku zmniejszy się tylko o cztery. Można to ominąć, a jakże. Ale o
tym potem.
Co dalej możemy poprawić? Hm, może zamiast movl $1, %ebx lepiej będzie
użyc krótszych instrukcji? np. xorl %ebx, %ebx \n incl %ebx. Sprawdzamy :
faktycznie, jest lepiej! Co dalej? movl %ebx, %eax zamiast movl $1, %eax?
Jest lepiej! Co jeszcze? Pamiętamy że lepiej zawsze używać rejestrów niż
odwołań do pamięci itd, tak więc zamieńmy
movl $4, %edx
movl $4, %eax
na
movl %eax, %edx
Jest lepiej? Jest! Kolejne 3 bajty zaoszczędzone! Rozmiar pliku
388! Co dalej? Może zamiast movl używać movb? Juz tylko 22 bajty! Incl na incb
okazuje się nie opłaca się zamieniać. Trzy potrzebne jest xorl? Okazuje się, że
nie, gdyż Linux 2.2 standardowo zeruje wszystkie rejestry dla każdego
nowo powołanego procesu (za K.Boldyshevem). Kolejne dwa bajty! I rozmiar
programu 388 bajtów! Da się zmniejszyć? Hm...
Po przejrzeniu dokumentacji do as-a stwierdzamy, ze z pomocą samego
assemblera niestety nie.
Jak na razie as zawsze zapisuje, czy tego chcemy, czy
nie, sekcje .data i .bss. Po kiego grzyba, skoro akurat teraz ich nie
potrzebujemy? Nie wiadomo. Jednakże można zmniejszyć rozmiar naszego programu.
Mamy dwa sposoby - jeden, łatwiejszy, to przerzucamy sie na nasma.
Z jego pomocą można zdziałać cuda - jak to zrobić, patrz
http:/www.muppetlabs.com/~breadbox/tiny/teensy.php tekst B.Reitera, na
którym się do pewnego stopnia opierałem pisząc ten rozdział. Problem w tym,
że nasm ma własną składnię, intelowską na dodatek, która mi się na przykład
niezbyt podoba. Poza tym nie po to się uczyliśmy AT&T, żeby teraz
nagle przerzucać się na coś innego. Korzystając z wskazówek zawartych w tym
tekście możemy spróbować zrobić coś podobnego za pomocą as-a. Zaraz, mówiliśmy
przecież że się nie da? Co, mamy pracować z pomocą hexedita ? :)
Tak się składa że istnieje sporo narzędzi do pracy z plikami obiektowymi
ELF. Jedno z nich już poznaliśmy , jest to objdump. Drugie, o którym
dowiedziałem się niedawno dzięki listowi .. jest objcopy.
Za pomocą objcopy -R .bss ex
i objcopy -R .data ex możemy usunąć z pliku obiektowego sekcje .data i .bss
których nie używamy. Tak więc robimy po kolei
as first.S -o obj; ld obj -o ex; strip ex; objcopy -R .data ex;
objcopy -R .bss ex; ls -l ex; i widzimy że mamy już tylko 292 bajty!
Ale da się pójść dalej. Co zrobimy? Ręcznie stworzymy plik wykonywalny.
Wprawdzie as nie ma takiej opcji jak nasm, wyprodukowania tylko i wyłącznie
translacji programu bez nagłówków itd, ale za pomocą objcopy
-O binary możemy osiągnąć podobny efekt jak za pomocą nasm -f bin. Chociaż mniej
wygodne, jest to bliższe duchowi uniksów - nie wkładaj kilku funkcji w jeden
program, tylko zrób kilka prostych które w miarę potrzeby można połączyć.
Objcopy w ogólności służy do przekształcania i kopiowania plików obiektowych,
i format binary jest tylko jednym z wielu (man objcopy).
Każdy plik wykonywalny typu ELF w linuksie (obecnie standard, dawniej był
jeszcze format a.out) musi mieć nagłówek ELF i nagłówek programu. Po za tym
może mieć nagłówki sekcji, wiele sekcji itd. Dokładny opis znajduje się
w dodatku poświęconym formatowi ELF . Normalnie wszystko
to za nas robi linker i assembler. My to zrobimy ręcznie, korzystając obficie
z hexedita, ndisasma, as-a i objcopy. Hexedit jest wygodnym edytorem
plików binarnych, który standardowo jest dostępny w dystrybucji Debiana.
Ndisasm to dissasembler z pakietu nasma - objdump --dissasemble będzie
nieprzydatne bo głupieje przy tak zrobionych programach jakim będzie nasz.
Przykra wiadomość jest taka, że nie możemy korzystać z symboli : niemożliwe
będzie napisanie .int _entry w celu uzyskania adresu symbolu _entry,
ani mov $message,%ecx. Zamiast tego będziemy musieli podać adresy w hexie.
Po to właśnie będzie nam potrzebny hexedit - najpierw napiszey niedziałający
szkielet, za pomocą hexedita zobaczymy jakie powinniśmy powpisywać adresy i
potem poprawimy nasz program. Ndisasm będzie nam potrzebny w celu sprawdzenia
czy nas program został wygenerowany poprawnie.
Oto zaś nasz program :