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


10. Punkt dziesiąty: Programowanie w assemblerze bez pomocy gcc



do spisu treści

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:



first.c
void main()
{
puts("Yo!");
}

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:




first.s
.global main
.text
message:
.ascii "Yo!"

main:
pushl $message
call puts
popl %eax
ret

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:



first.S
.global _start
.text
message:
.asciz "Yo!\n"
// rozmiar : 4 bajty _start: movl $4, %eax // numer funkcji
movl $1, %ebx // deskryptor pliku (stdout)
movl $4, %edx // rozmiar stringu
movl $message, %ecx // adres stringu
int $0x80 //write
movl $1, %eax //numer funkcji exit
int $0x80 //exit

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 $4, %eax
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 :


first.S

.global _entry
.text
elf_header:
.byte 0x7f
.ascii "ELF"
.fill 0x3,1,1
.fill 0x9,1,0
.byte 2,0 //e_type
.byte 3,0 //e_machine
.long 0x1 //e_version e current version
.long 0x8048058 //e_entry
.long 52 //ph_off
.long 0
.long 0
.word 52 //rozmiar elf_header
.word 0x0020 // rozmiar w tablicy programu 2 bajty
.word 1 // jeden nagłówek programu
.word 0,0,0
program_header:
.long 1 //LOAD p_type
.long 0
.long 0x8048000 // _entry
.long 0x8048000
.long 0xdc //filesize
.long 0xdc //filesize
.long 5
.long 0x1000
.ascii "yo!\n"
_entry: //program zaczyna się tutaj
movb $4,%al
incl %ebx
movl $0x8048054,%ecx
movb $4,%dl
int $0x80
xor %eax,%eax
movb %bl,%al
int $0x80

Kompilacja : as first.S -o obj; ld obj --entry _entry -o ex; objcopy -O binary ex;
Symbol _start jest standardowym miejscem od którego zaczyna się program. Jeśli chcemy by zaczął się od innego miejsca, zaznaczamy to za pomocą opcji --entry linkera. Sprawdzamy czy program działa - działa! A .. jaki rozmiar? 106 bajtów! Pytanie teraz, czy było warto tyle się męczyć by zmniejszyć nasz program o 186 bajtów? :) Nauczyłeś się czegoś, nawet jeśli w przyszłości tego nie wykorzystasz, więc chyba warto :). Da się zmniejszyć jeszcze bardziej? Da. Wystarczy nałożyć na siebie nagłówek programu, program i nagłówek ELF, tak jak to zrobił B.Reiter. Odsyłam do jego testu jeśli chce Ci się obciąć program o kolejne mniej więcej 60 bajtów. Zwracam uwagę na linię movl $0x8048054,%ecx gdyż najdłużej na niej wysypywał się mój program. Nie działało mov $0x8055, %ecx ani mov $0x8048055, %ecx.