Programi brez špeha
Pred dvajsetimi leti je bil povsem spodoben urejevalnik besedil dolg nekaj deset kilobajtov, danes pa se pod nekaj megabajtov, raje nekaj deset megabajtov, skoraj ne pogovarjamo več. Večine to morda ne moti, vsi, ki se ukvarjajo z vgradnimi sistemi, pa so živo zainteresirani za čim manjše programe. Oglejmo si torej razloge za nabuhlost programov v sistemu GNU/Linux in pokažimo obvoz.
Uporabniki sistemov GNU/Linux se pogosto radi ustimo, da so programi, pisani za ta sistem, napisani toliko bolj učinkovito, da nam z nadgradnjo železja ni treba hiteti enako kakor uporabnikom drugih sistemov. Vse podobne kategorične trditve je seveda vedno treba jemati z zrnom soli - eni programi so res napisani precej kompaktno, drugi pač ne. Vseeno pa je tema učinkovitosti dovolj zanimiva, da se jo splača podrobneje ogledati, še posebej, ker za povrh ponuja še nekoliko vpogleda v notranji ustroj izvedljivih datotek.
Zglede bomo ponazorili s prevedenimi programi v zapisu ELF. Taki so vsi sodobni sistemi GNU/Linux - le uporabniki z najdaljšim stažem se bodo morda spomnili, da so pred desetimi leti uporabljali zapis a.out. Pomagali si bomo še s prevajalnikom GCC, zbirnikom NASM in programi iz paketa GNU Binutils.
Iz malega zraste veliko
Začnimo s kar se da preprostim programčkom. "Kar se da preprost" tokrat ne pomeni znanega zgleda "Hello, world" - slednji je namreč preveč zapleten. Poskusimo torej s čim lažjim, recimo s programčkom, ki - po Douglasu Adamsu in njegovem Štoparskem vodniku po galaksiji - vrne odgovor na vprašanje o življenju, vesolju in sploh vsem:
int main()
{
return(42);
}
Program - poimenujmo ga test0.c - ne bere in ne piše nič, temveč le vrne izhodno vrednost. Slednjo lahko ujamemo v ukazni lupini kot spremenljivko $?:
$ gcc -o test0 test0.c
$ ./test0; echo $?
42
Deluje, ne? Programček ni nujno povsem neuporaben. Sistemi Unix in GNU/Linux so namreč standardno opremljeni z dvema programčkoma, ki se od opisanega pravzaprav razlikujeta le po vrednosti, ki jo vrneta - to sta programčka true in false (v sistemu GNU/Linux sta del paketa "GNU coreutils").
Kaj pa bi rekli, koliko je tak program dolg?
$ wc -c test0
11480 test0
Odvisno od sistema je lahko številka pri vas malo drugačna, a kljub temu je - ne toliko v primerjavi s povprečno velikostjo programov danes, temveč oziraje na to, kar program pravzaprav počne - kar nekaj, kajne? Programerjem, plačanim po bajtu prevedene kode, se mora dandanes lepo goditi.
Kaj lahko napravimo? Prva misel je, da z ukazom strip odstranimo sistemske tabele. S tem se sicer odrečemo podatkom, ki so lahko pomembni pri odpravljanju napak v programu, program je pa res precej manjši:
$ strip test0
$ wc -c test0
2704 test0
"Precej manjši" je seveda relativen pojem - dolžino datoteke smo sicer oklestili na četrtino prvotne velikosti, kljub temu pa 2704 bajtov ni malo za program, ki ne naredi nič, temveč le vrne izhodno vrednost.
Zviti, kot smo, se spomnimo optimizacije programa. Prevajalnik gcc poženimo z dvema dodatnima izbirama - z izbiro -O3 vklopimo optimizacijo, izbira -s pa na prevedeni datoteki požene strip:
$ gcc -s -O3 -o test0 test0.c
$ wc -c test0
2696 test0
Prihranek osmih bajtov ni nekaj, s čimer bi se bahali. Je pa res, da pri tako preprostem programčku ni kaj dosti optimizirati. Je torej vzrok v sami izbiri programskega jezika? Morda C sam po sebi pridela toliko dodatne administracije?
Hja, pa prepišimo zgled v zbirni jezik. To mora pomagati.
V sistemih GNU/Linux na Intelovih in z njimi združljivimi procesorji lahko izbiramo med dvema prevajalnikoma za zbirni jezik: GNU as in NASM. Prvi je del kolekcije "GNU binutils" in zna prevajati v strojno kodo kakih 20 različnih družin procesorjev, tudi zgodovinskih, kot je PDP11, in neobstoječih, kot je MMIX, hipotetični mikroprocesor, ki ga je "definiral" Donald Knuth v svoji zbirki "The Art of Computer Programming". NASM, "Netwide Assembler", obvlada samo Intelovo družino procesorjev. Njegova poglavitna prednost v primerjavi z GNU as je ob majhnosti predvsem ta, da uporablja Intelovo skladnjo zbirnega jezika, medtem ko uporablja GNU as skladnjo AT&T. Prva je neprimerno bolj domača vsem, ki so se z zbirnim jezikom srečali, preden so se srečali s prevajalnikom GNU as. Eden takih je tudi pisec teh vrstic, zato bodo zgledi v skladnji, združljivi z NASM. Slednji je del vseh standardnih distribucij sistema GNU/Linux, lahko pa ga snamemo tudi iz spleta: http://nasm.sourceforge.net/.
V prvem zgledu smo vrnili vrednost 42 iz podprograma main(). Ustreznica v zbirnem jeziku je, da naložimo vrednost 42 v akumulator EAX in zapustimo podprogram:
; test1.s
BITS 32
GLOBAL main
SECTION .text
main:
mov eax, 42
ret
Pa poglejmo. Program prevedemo z zbirnikom NASM do predmetne kode, to pa povežemo v izvedljiv program kar s prevajalnikom gcc:
$ nasm -f elf test1.s
$ gcc -s -o test1 test1.o
$ ./test1; echo $?
42
Nestrpno že pričakujemo novice o tem, koliko smo prihranili. Pa poglejmo:
$ wc -c test1
2680 test1
Z besedo: šestnajst bajtov. To bo le težko prepričalo koga, da se splača naučiti zbirnega jezika. Neučinkovitosti programov, pisanih v C, smo delali veliko krivico.
Težava ni v izbiri jezika, temveč v mehanizmu, ki se izvede, preden se začne izvajati funkcija main(). To je vmesnik med operacijskim sistemom in našim programom, ki ga doda povezovalnik ob povezovanju programa. Ko poženemo program, se najprej izvede vmesnik, šele ta pa nato izvede funkcijo main(). Ali lahko to kako obvozimo?
Simbol _start
Pravzaprav lahko. Vstopna točka programa, ki jo privzeto uporablja povezovalnik, je določena s simbolom _start. Ko program povežemo z GCC, ta samodejno doda podprogram _start. Slednji med drugim poskrbi za definicijo spremenljivk argc in argv, na koncu pa pokliče funkcijo main(). Morda lahko vse to zaobidemo in sami definiramo svojo funkcijo _start? Poskusimo:
; test2.s
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 42
ret
Bo GCC znal uganiti, kaj bi radi?
$ nasm -f elf test2.s
$ gcc -s -o test2 test2.o
test2.o(.text+0x0): In function `_start':
: multiple definition of `_start'
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../../crt1.o(.text+0x0):../sysdeps/i386/elf/start.S:47: first defined here
/usr/lib/gcc-lib/i386-redhat-linux/3.2.2/../../../crt1.o(.text+0x18): In function `_start':
../sysdeps/i386/elf/start.S:77: undefined reference to `main'
collect2: ld returned 1 exit status
Ne. GCC je dodal še svojo funkcijo _start, zato ob povezovanju dobimo napako, da je funkcija _start definirana dvakrat, funkcija main() pa manjka.
Rešitev se skriva med stotnijo različnih izbir, ki jih prepozna GCC. Ena od njih je -nostartfiles. Če jo podamo ob prevajanju, GCC ne bo dodal začetnega programskega vmesnika med operacijskim sistemom in našim programom. Program bo zato res nekaj krajši, za vmesnik pa moramo poskrbeti sami.
To bi moralo biti natanko to, kar potrebujemo. Poskusimo:
$ nasm -f elf test2.s
$ gcc -s -nostartfiles -o test2 test2.o
$ ./test2; echo $?
Segmentation fault
139
GCC je s tem zadovoljen, program pa ne deluje. Kaj je šlo narobe?
Napaka je bila v našem nerazumevanju podprograma _start. Obravnavali smo ga, kot da gre za funkcijo v C, in jo na koncu zapustili z ukazom ret. V resnici _start seveda ni funkcija, je le simbol v tabeli predmetne kode, ki povezovalniku pove, kje je vstopna točka v naš program. Pri našem programu kaže _start neposredno na začetek našega programa, ki poskuša ob izhodu s sklada pobrati naslov, na katerega naj se vrne. A na skladu ni nič takega. Na vrhu sklada je vrednost 1, to pa je, priznali boste, precej nenavaden naslov. Seveda sploh ne gre za naslov, to je v resnici vrednost argc, ki čaka na skladu, da jo bo pobral pravilno napisan programski vmesnik. Pod njo so elementi polja argv, skupaj z zaključnim elementom NULL, pod temi pa elementi envp. Nikjer nobenega naslova.
Kako pa potem _start sploh ve, kam naj se vrne? Hja, pokliče funkcijo exit(), saj za to jo imamo, kajne?
Funkcija _exit()
Malo smo prehitevali. Funkcija, ki jo _start zares pokliče, je funkcija _exit(). Funkcija exit() (brez podčrtaja) postori še nekaj opravil, preden konča tekoči proces. V našem primeru to ni potrebno, saj se stvari, ki naj bi jih exit() zaključeval, niso nikdar začele - poženemo jih namreč v standardnem vstopnem vmesniku. Zato moramo obiti klic za izhod iz standardne knjižnice in neposredno poklicati funkcijo operacijskega sistema za zaključek procesa.
Pa poskusimo znova. Tokrat bomo klicali funkcijo _exit, ki s sklada vzame en sam celoštevilčni argument - izhodno kodo programa. Vse, kar moramo storiti, je, da na sklad položimo želeno vrednost in pokličemo funkcijo _exit. No ja, dodatno moramo definirati še funkcijo _exit kot zunanjo funkcijo:
; test3.s
BITS 32
GLOBAL _start
EXTERN _exit
SECTION .text
_start:
push dword 42
call _exit
Program zgradimo enako kakor prej:
$ nasm -f elf test3.s
$ gcc -s -nostartfiles -o test3 test3.o
$ ./test3; echo $?
42
Deluje! In prihranek pri velikosti?
$ wc -c test3
1332 test3
Na okroglo pol manj kakor prej. Ni slabo, ni slabo. Kljub temu, ali morda lahko še kje kaj prihranimo? Morda skriva GCC še kakšne uporabne izbire?
Izbira, ki nam pade v oči, je -nostdlib, ki pri povezovanju programa ne poveže s funkcijami v standardni knjižnici, temveč poveže le predmetne datoteke, ki jih podamo. To bi moralo biti natanko tisto, kar potrebujemo. Poskusimo:
$ gcc -s -nostdlib -o test3 test3.o
test3.o(.text+0x6): In function `_start':
: undefined reference to `_exit'
collect2: ld returned 1 exit status
Opla. Povezovalnik ima prav - _exit() pravzaprav je funkcija v standardni knjižnici; če te nismo marali, je nima od kod vzeti.
Po drugi strani pa menda ne potrebujemo standardne knjižnice libc samo zato, da bi zapustili program? Res ne nujno. Če se odrečemo že tako sumljivim ambicijam po prenosljivosti, lahko izvedemo izhod iz programa brez klica standardne knjižnice, le z nekaj znanja o tem, kako so pravzaprav izvedeni sistemski klici.
Sistemski klici
Kot vsi operacijski sistemi tudi GNU/Linux ponuja s sistemskimi klici nekaj osnovne infrastrukture uporabnim programom. To so stvari, kot odpiranje datoteke, branje in pisanje ter seveda tudi izhod iz programa.
Vmesnik do sistemskih klicev je v sistemu GNU/Linux en sam ukaz: int 0x80. Vsi sistemski klici so izvedeni s to prekinitvijo. Pred klicem mora akumulator EAX vsebovati vrednost, ki ustreza številki sistemskega klica, drugi registri pa morebitne argumente funkcije, ki jo kličemo. Če funkcija potrebuje le en argument, ga shranimo v EBX. Če dva, shranimo prvega v EBX, drugega pa v ECX. Registri EDX, ESI in EDI se uporabijo, če funkcija bere tri, štiri ali pet argumentov. Po vrnitvi je v akumulatorju EAX shranjena izhodna vrednost sistemskega klica. Če je prišlo do napake, je vrednost ob vrnitvi negativna, njena absolutna vrednost pa pove vrsto napake.
Številke, ki ustrezajo različnim sistemskim klicem, so navedene v datoteki /usr/include/asm/unistd.h. Če si jo ogledamo, bomo iskani klic našli čisto na začetku - klicu funkcije exit() ustreza številka 1. Enako kot istoimenska funkcija v standardni knjižnici sprejema en argument - vrednost, ki jo vrne starševskemu programu. To torej shranimo v register EBX.
Programček je zdaj tak:
; test4.s
BITS 32
GLOBAL _start
SECTION .text
_start:
mov eax, 1
mov ebx, 42
int 0x80
Prevedimo in poženimo ga:
$ nasm -f elf test4.s
$ gcc -s -nostdlib -o test4 test4.o
$ ./test4; echo $?
42
Pa velikost?
$ wc -c test4
404 test4
Oho! Slaba tretjina prejšnje velikosti! Če štejemo, da smo začeli z 11.480 bajti, smo skrčili program na 3,5 % začetne dolžine. Se da še manjše?
Seveda se. Stari mački, ki zbirni jezik za Pentiume poznajo še iz časov, ko se mu je reklo zbirni jezik za procesor 8086, so dobršen del svojih najboljših let prebili ob preštevanju bajtov, ki jih posamezen ukaz zasede v pomnilniku, ter procesorskih taktov, ki jih porabi za svoje izvajanje. Vsi ukazi namreč niso enakovredni - operacije nad registri so, denimo, dosti hitrejše kakor tiste, ki zahtevajo posege v zunanji pomnilnik.
Zbirnik NASM ima izbiro -l, s katero lahko izpišemo prevedeno kodo. Za naš program vidimo naslednje:
00000000 B801000000 mov eax, 1
00000005 BB2A000000 mov ebx, 42
0000000A CD80 int 0x80
Ukaza mov, s katerima priredimo vrednosti akumulatorjema eax in ebx, sta dolga po pet bajtov vsak. Krajša niti ne moreta biti - en bajt za kodo procesorskega ukaza in štirje bajti za 32-bitno število, ki sledi.
Pa seveda ni treba, da uporabimo prav te ukaze. Spodnji program napravi natanko isto. Najprej izvedemo operacijo XOR nad vrednostjo akumulatorja eax in še enkrat isto vrednostjo, to pa da po definiciji natanko nič. To vrednost nato povečamo za 1 z ukazom inc. Obe operaciji se izvajata le nad registri in sta zato hitrejši, pa tudi krajši, saj ni treba navajati 32-bitnih vrednosti. Pri drugem ukazu pa uporabimo drugo zvijačo: ukaz inc prebere samo spodnjih osem bitov registra ebx. Nobene potrebe ni, da bi preostale sploh nastavljali. Spodnjih osem bitov tega registra pa lahko nastavljamo kot register bl.
00000000 31C0 xor eax, eax
00000002 40 inc eax
00000003 B32A mov bl, 42
00000005 CD80 int 0x80
In prihranek?
$ wc -c test5
400 test5
Pičli štirje bajti. Časi, ko se je splačalo uganjati take zvijače, so, kot kaže, dokončno minili, še posebej, če na drugo stran tehtnice postavimo berljivost programa. (Pozorni bralci so morda opazili, da smo v resnici oklestili dolžino programa z dvanajstih na sedem bajtov, torej smo prihranili pet bajtov. Naš trud je delno spodkopala zahteva po poravnanosti v datoteki ELF, ki je zato le štiri bajte krajša od prejšnje različice.)
S programom objdump lahko izpišemo vsebino prevedenega programa:
$ objdump -x test5
test5: file format elf32-i386
test5
architecture: i386, flags 0x00000102:
EXEC_P, D_PAGED
start address 0x08048080
Program Header:
LOAD off 0x00000000 vaddr 0x08048000 paddr 0x08048000 align 2**12
filesz 0x00000087 memsz 0x00000087 flags r-x
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000007 08048080 08048080 00000080 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .bss 00000000 08049088 08049088 00000087 2**0
CONTENTS
2 .comment 0000001f 00000000 00000000 00000087 2**0
CONTENTS, READONLY
Celoten program - razdelek .text - je res dolg sedem bajtov, kot smo napovedali. Nad programskim delom datoteke ELF imamo zdaj očitno popoln nadzor. Opazimo pa lahko, da se je v njej zaredil še razdelek .comment. Celih 31 bajtov ga je, nekajkrat več kakor našega programa. S programom hexdump lahko pogledamo, kaj je v njem:
$ hexdump -C test5
...
00000080 31 c0 40 b3 2a cd 80 00 54 68 65 20 4e 65 74 77 |1.@.*...The Netw|
00000090 69 64 65 20 41 73 73 65 6d 62 6c 65 72 20 30 2e |ide Assembler 0.|
000000a0 39 38 2e 33 34 00 00 2e 73 68 73 74 72 74 61 62 |98.34...shstrtab|
000000b0 00 2e 74 65 78 74 00 2e 62 73 73 00 2e 63 6f 6d |..text..bss..com|
000000c0 6d 65 6e 74 00 00 00 00 00 00 00 00 00 00 00 00 |ment............|
000000d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
Naš zbirnik, NASM, takole sabotira naša prizadevanja za čim krajši program. Sram ga bodi! Morda je čas za spremembo koalicije - je zbirnik GNU tudi tako vsiljiv s svojim podpisom? Morda je čas, da se ga lotimo, kljub skladnji AT&T:
/* test6.s */
.globl _start
.text
_start:
xorl %eax, %eax
incl %eax
movb $42, %bl
int $0x80
Ker so orodja GNU zasnovana kot usklajena celota, lahko ta progam prevedemo kar s prevajalnikom GCC:
$ gcc -s -nostdlib -o test6 test6.s
$ ./tests; echo $?
42
In velikost?
$ wc -c test6
352 test6
No, nekaj prihranka je. Še vedno pa v nas gloda črv dvoma, ali je tudi teh 352 bajtov res nujno potrebnih. Naš program je dolg sedem bajtov - preostalih 345 bajtov pa je očitno administracija zapisa ELM.
Namesto sklepa
Izvedljivo datoteko smo s prvotnih 11.480 bajtov ob neoskrunjeni funkcionalnosti skrčili na vsega 352 bajtov. Se da še kaj manj? Verjetno ste uganili - da. Vendar pa je treba za nadaljnje krčenje poznati nekaj notranjega ustroja datotek ELF, to pa si bomo ogledali v nadaljevanju prihodnji mesec.