- předchozí článek - následující článek - obsah - úvodní stránka -

Linuxové noviny Květen 1998

Jak jsem pěstoval trpaslíky

Pavel Kaňkovský, 7. května 1998

Slovo na úvod

V dubnovém čísle Linuxových novin byla vyhlášena soutěž o nejmenší soubor spustitelný pod Linuxem. Když jsem se dozvěděl, že program může dělat úplně cokoli, je-li to dobře definováno, zaujalo mne to a také jsem se zúčastnil.

Smršťování kódu

Nejdříve jsem si položil otázku: Jaký je nejmenší možný kód (bez hlaviček a podobného smetí), který ještě pod Linuxem dělá něco smysluplného? Je zřejmé, že takový program napsaný v jazyce C by vypadal například takto:

   int main()
   {
      return 0;
   }
to jest nedělá nic jiného, než že skončí.

Ovšem takový program je ve skutečnosti obalen kódem knihovny, která po návratu z funkce main() volá funkci exit(), která pak volá stejnojmené systémové volání, které ukončí život procesu. Což znamená, že program je možno "zjednodušit" takto (případné varování překladače, že nezná návratovou hodnotu, budeme ignorovat):

   #include <syscall.h>
   int main()
   {
      syscall(SYS_exit, 0);
   }

Nyní musíme volání funkce (resp. makra) syscall() "rozložit na prvočinitele". Nahlédneme do souboru /usr/include/asm/unistd.h a podíváme se, jak se provádí volání jádra na procesorech Intel (viz výpis Volání jádra na procesorech Intel).


   #define _syscall1(type,name,type1,arg1) \
      type name(type1 arg1) \
      { \
         long __res; \
         __asm__ volatile ("int $0x80" \
                         : "=a" (__res) \
                         : "0" (__NR_##name),"b" ((long)(arg1))); \
         if (__res >= 0) \
            return (type) __res; \
         errno = -__res; \
         return -1; \
      }

Výpis č. 1: Volání jádra na procesorech Intel

Poněkud kryptické, není-liž pravda? Nicméně cvičené oko odhalí, že je vhodné nastavit registr eax na číslo systémového volání (v našem případě je to číslo 1), registr ebx nastavit na hodnotu argumentu tohoto volání (tedy nulu) a vyvolat přerušení číslo 0x80.

Nyní již opusťme (relativně) pohostinné končiny jazyka C a přepišme celý program do assembleru (neboli "jazyka symbolických adres" pro přátele starých pořádků):

   .text
   .globl _start
   _start:
      mov     $1, %eax
      mov     $0, %ebx
      int     $0x80

Takový program se už obejde bez podpory podobných zbytečností, jako jsou knihovny, takže už nebudeme ani předstírat nějakou funkci main() a rovnou definujeme symbol _start (a ušetříme si jeden parametr při volání linkeru, neboť _start je implicitní název vstupního bodu programu, který se obyčejně nachází v /usr/lib/crt1.o).

Náš zdrojový text nazveme například program.s a přeložíme příkazem cc -c program.s. Výsledkem bude následujících 12 bajtů strojového kódu (objdump --disassemble --show-raw-insn program.o):

00000000 <_start>   b8 01 00 00 00  movl $0x1,%eax
00000005 <_start+5> bb 00 00 00 00  movl $0x0,%ebx
0000000a <_start+a> cd 80           int  $0x80

Je vidět, že značné rezervy jsou v nastavení obou registrů: 10 bajtů se zdá být značným plýtváním prostorem. Assemblerový expert by kód upravil například takto:

   .text
   .globl _start
   _start:
      xor     %eax, %eax
      mov     %eax, %ebx
      inc     %eax
      int     $0x80
což vytvoří následujících 7 bajtů kódu:

00000000 <_start>   31 c0  xorl %eax,%eax
00000002 <_start+2> 89 c3  movl %eax,%ebx
00000004 <_start+4> 40     incl %eax
00000005 <_start+5> cd 80  int  $0x80

Ale to ještě není konec! Ukazuje se (a lze to ověřit nahlédnutím do zdrojových textů jádra), že registr eax je již při vstupu do programu vynulován - jako by docházelo k úspěšnému návratu ze systémového volání execve(). Proto lze vynechat první instrukci. Nutným předpokladem ovšem je, že náš kód je skutečně to první, co bude vykonáno, nikoli dynamický linker nebo něco podobného, proto je žádoucí při linkování používat parametr -static.

Nyní lze přistoupit k zřejmě již definitivně poslední optimalizaci: nastavení registru ebx spotřebovává dva bajty. Tomu se jen těžko lze vyhnout, má-li mít nulovou (nebo jedničkovou) hodnotu, nicméně zadání vyžaduje, aby měl hodnotu "dobře definovanou". Iniciální hodnota při spuštění tuto podmínku bohužel - narozdíl od registru eax - nesplňuje. Naštěstí je jedna vhodná hodnota hned po ruce: jedná se o počet argumentů programu (známý pod přezdívkou argc), který je jádrem uložen na samotný vrchol zásobníku. Náš program je tedy možné zkrátit na tři instrukce zvíci pouhých čtyř bajtů:

   00000000 <_start>   40     incl   %eax
   00000001 <_start+1> 5b     popl   %ebx
   00000002 <_start+2> cd 80  int    $0x80
neboli ve zdrojové formě (která je teď pro změnu uvedena jako druhá):

   .text
   .globl _start
   _start:
      inc     %eax
      pop     %ebx
      int     $0x80
což je víceméně ekvivalent tohoto céčkového programu:

   int main(int argc)
   {
      return argc;
   }

Tento kód již vypadá celkem minimálně, takže z něj zkusíme vytvořit spustitelný soubor:

  $ cc -c program.s
  $ ld -static program.o
  $ ls -l a.out
  -rwxr-xr-x 1 peak users 675 May 3 22:00 a.out
Ó jé! Takový malý kód a tak velký soubor! S tím se musí něco udělat.

Smršťování souboru

Nyní je třeba řešit další zásadní problém: Jak vyrobit co nejmenší spustitelný soubor?

Linux zná několik druhů spustitelných souborů: základní jsou a.out, ELF a skripty (tj. soubory začínající znaky #!). Skripty vynecháme, protože nevyhovují původnímu zadání.

  • a.out je starší a jednodušší formát. Jeho kořeny sahají někam do šerého unixového dávnověku, ale v dnešní době se už prakticky nepoužívá. Má několik variant, které se liší způsobem načítání do paměti. Viz include/{linux,asm}/a.out.h a fs/binfmt_aout.c.
  • ELF je novější a složitější formát. Byl vyvinut pro potřeby System V. Ve své úplnosti je tak složitý, že pro jeho dekódování existuje zvláštní knihovna (libelf), ale nás zajímá jen malá část - přesně ta, co zajímá jádro. Viz include/{linux,asm}/elf.h a fs/binfmt_elf.c.

Věnujme se nejprve krátce formátu a.out. Jeho hlavička vypadá takto:

[ dword ]a_info"magické číslo"
[ dword ]a_textdélka kódu
[ dword ]a_datadélka inicializovaných dat
[ dword ]a_bssdélka neinicializovaných dat
[ dword ]a_symsvelikost tabulky symbolů v bajtech
[ dword ]a_entrystartovací adresa
[ dword ]a_trsizevelikost relokační tabulky pro kód
[ dword ]a_drsizevelikost relokační tabulky pro data

Spustitelný soubor je tvořen hlavičkou a za sebou následujícími úseky kódu, inicializovaných dat a tabulky symbolů (neinicializovaná data nejsou pochopitelně v souboru obsažena). Soubor nesmí obsahovat žádná relokační data a příslušné hodnoty v hlavičce musí být nulové.

"Magické číslo" popisuje variantu formátu. Nabývá jedné z následujících hodnot:

OMAGIC   tzv. "impure executable"; zbytek souboru je načten do paměti od adresy 0, zápis je povolen všude (pozn. tato varianta bývala používána i pro objektové soubory formátu a.out(*.o)
NMAGIC tzv. "pure executable"; zbytek souboru je načten do paměti od adresy 0, ale do oblasti kódu je zakázán zápis
ZMAGIC tzv. "demand-paged executable"; soubor počínaje pozicí 4096 je namapován do paměti od adresy 0x1000, v prostoru první stránky není nic namapováno
QMAGIC jiná varianta "demand-paged executable", celý soubor je namapován do paměti od adresy 0x1000, narozdíl od předchozí varianty není na začátku souboru jedna prakticky nevyužitá stránka

Zkusíme vytvořit soubor ve formátu a.out pomocí standardních nástrojů. Jediný problém je v tom, že musíme linkeru dát parametr -m i386linux nebo -b a.out-i386-linux, protože (pokud není provozovaná verze opravdu letitá) bez těchto parametrů by byl vytvořen soubor ve formátu ELF. Parametrem -N si vyžádáme vytvoření varianty OMAGIC (i když stejně dobře by posloužila varianta NMAGIC vyvolaná parametrem -n). Navíc (narozdíl od příkladu v předchozí kapitole) nezapomeneme odstranit ladící informace pomocí příkazu strip (což je kupodivu účinnější než použití parametru -s při linkování).

  $ ld -static -m i386linux -N program.o
  $ strip a.out
  $ ls -l a.out
  -rwxr-xr-x 1 peak users 40 May 3 22:05 a.out
Soubor obsahuje 32 bajtů hlavičky a 4 bajty kódu zvětšené o 4 bajty výplně. To je docela pěkný výsledek, ale, jak uvidíme záhy, není zdaleka nejlepší možný.

Přikročme nyní ke studiu formátu ELF. Ten má dvě zajímavé hlavičky. První je hlavička souboru známá pod názvem Elf32_Ehdr (existuje i 64-bitová varianta Elf64_Ehdr):

[ dword ]e_identidentifikace souboru
[ dword ]...
[ dword ]...
[ dword ]...
[ word ]e_typetyp souboru
[ word ]e_machineplatforma
[ dword ]e_versionverze formátu souboru
[ dword ]e_entrystartovací adresa
[ dword ]e_phoffzačátek tabulky segmentů (v souboru)
[ dword ]e_shoffzačátek tabulky sekcí (v souboru)
[ dword ]e_flagsrůzné příznaky
[ word ]e_ehsizevelikost hlavičky souboru v bajtech
[ word ]e_phentsizevelikost záznamu segmentu
[ word ]e_phnumpočet záznamů v tabulce segmentů
[ word ]e_shentsizevelikost záznamu sekce
[ word ]e_shnumpočet záznamů v tabulce sekcí
[ word ]e_shstrndxindex sekce obsahující jména

a druhá je hlavička segmentu (tedy položka v tabulce segmentů) čili Elf32_Phdr:

[ dword ]p_typedruh segmentu
[ dword ]p_offsetpočátek segmentu v souboru
[ dword ]p_vaddrvirtuální adresa v paměti
[ dword ]p_paddrfyzická adresa v paměti
[ dword ]p_fileszvelikost segmentu v souboru
[ dword ]p_memszvelikost segmentu v paměti
[ dword ]p_flagspříznaky segmentu
[ dword ]p_alignzarovnání adresy segmentu

Spustitelný soubor ve formátu ELF je tvořen hlavičkou souboru a "volným prostorem", ve kterém jsou víceméně libovolně rozloženy ostatní části souboru: tabulky segmentů a sekcí a samotné segmenty a sekce. Tyto části se mohou libovolně překrývat (nenaruší-li to integritu jejich obsahu).

První položkou v hlavičce souboru je identifikace: 16 bajtů, které určují, že se jedná o soubor ve formátu ELF a popisuje jeho základní vlastnosti (LSB/MSB, 32/64 bitů, verzi formátu hlavičky souboru). Celkem je obsazeno pouze 7 bajtů, zbylých 9 by mělo mít nulovou hodnotu, ale nutné to není. Později toho bude využito.

Položka e_type určuje typ souboru, spustitelné soubory zde mají hodnotu 2, symbolicky ET_EXEC. Položka e_machine určuje hardwarovou platformu, pro kterou je soubor určen - v našem případě to bude 3, symbolicky EM_I386. Položka e_version určuje verzi formátu, která zatím existuje pouze jediná s číslem 1.

Důležité položky hlavičky souboru jsou ještě e_entry, která určuje adresu, od které bude náš program vykonáván, e_phoff obsahující polohu tabulky segmentů v souboru, e_phnum udávající počet segmentů a e_phentsize, která musí obsahovat velikost hlavičky segmentu, v bajtech, tedy číslo 32.

Zbylé položky nejsou při načítání souboru do paměti jádrem nijak interpretovány, takže mohou mít prakticky libovolné hodnoty.

Segment je ta část souboru, která má být při spouštění programu jádrem načtena či namapována do paměti, tedy kód nebo data. Existují sice také některé druhy segmentů mající speciální význam, ale my budeme blíže zkoumat pouze právě popsaný typ PT_LOAD, číselně se jedná o typ 1.

V hlavičce segmentu je (narozdíl od hlavičky souboru) důležitá většina údajů s výjimkou p_paddr (neboť mapování na konkrétní fyzické adresy není možné) a p_align (segmenty jsou vždy zarovnány na celé stránky). Zvláštní pozornost zaslouží p_flags, který popisuje, jestli bude možno namapovaný segment číst (PF_R, 4), vykonávat (PF_X, 1), resp. zda do něj bude možno zapisovat (PF_W, 2).

Zkusme nyní vyrobit co nejmenší spustitelný soubor ve formátu ELF. Nejprve použijeme linker a strip, jako v případě formátu a.out:

  $ ld -static program.o
  $ strip a.out
  $ ls -l a.out
  -rwxr-xr-x 1 peak users 364 May 3 22:23 a.out

Výsledek je to rozhodně lepší než předtím, ale pořád to není ono. Přeložený soubor ve skutečnosti obsahuje velké množství smetí, jak nám ukáže program objdump:

 $ objdump -h a.out
 a.out:     file format elf32-i386
 Sections:
 Idx Name Size     VMA      LMA      File off Algn
  0 .text 00000004 08048074 08048074 00000074 2**2
    	CONTENTS, ALLOC, LOAD, READONLY, CODE 
  1 .data 00000000 08049078 08049078 00000078 2**2
    	CONTENTS, ALLOC, LOAD, DATA	      
  2 .bss  00000000 08049078 08049078 00000078 2**2
     	ALLOC

V souboru jsou obsaženy tři sekce, ale jediná potřebná z nich je sekce .text, která obsahuje spustitelný kód. Pokusíme se soubor zmenšit odstraněním ostatních sekcí. K tomu použijeme program objcopy:

  $ objcopy -R .data -R .bss a.out
  $ ls -l a.out
  -rwxr-xr-x 1 peak users  276 May 3 22:29 a.out

Soubor obsahuje 52 bajtů hlavičky souboru, jednu hlavičku segmentu o velikosti 32 bajtů, pak 32 bajtů nul a 4 bajty kód, dohromady jeden segment o velikosti 120 bajtů, po kterém pak následuje 156 bajtů s tabulkou sekcí a tabulkou jmen, což jsou ovšem věci pro vlastní chod programu celkem nepodstatné (je-li linkován staticky, u dynamicky linkovaných programů je to trochu jinak).

O mnoho více již s pomocí standardních nástrojů nelze dokázat. Nejjednodušší bude asi vyrobit celý soubor ručně...

Bitové inženýrství

Zkusme si tedy vyrobit nějaký spustitelný soubor sami - bajt po bajtu. Pro tento účel je vhodné použít nějaký "překladač", který nám umožní zapsat binární data v nějaké čitelné textové podobě. Já jsem si jednu takovou jednoduchou pomůcku pro tento účel vyrobil a nazval jsem ji hex. Jazyk překladače hex je opravdu jednoduchý:

  • vše počínaje znakem "#" je až do konce řádku ignorováno
  • obsah souboru je interpretován po slovech oddělených bílými znaky
  • slovo obsahující "L" je interpretováno jako 4-bajtová hodnota (long), slovo obsahující "W" jako 2-bajtová (word), vše ostatní je jednobajtová hodnota; vícebajtové hodnoty jsou ukládány od nejméně významného bajtu k nejvýznamnějšímu (little-endian)
  • slovo obsahující "x" je interpretováno v šestnáctkové soustavě, slovo obsahující "o" v osmičkové soustavě, ostatní v desítkové

Začneme opět s formátem a.out (viz výpis Zdrojový text pro a.out v jazyce hex), neboť je významně jednodušší.


   # a.out hlavicka
   Lx00640107 # a_info (OMAGIC)
   L4  # a_text
   L0  # a_data
   L0  # a_bss
   L0  # a_syms
   L0  # a_entry
   L0  # a_trsize
   L0  # a_drsize
   # kod
   x40 # inc %eax
   x5b # pop %ebx
   xcd # int
   x80 # $0x80

Výpis č. 2: Zdrojový text pro a.out v jazyce hex

Tento "zdrojový soubor" se po průchodu překladačem hex změní na 36 bajtový soubor, který je jádro Linuxu ochotno načíst a spustit. Uspořili jsme zatím čtyři bajty výplně. Někdo může namítnout, že vhodnější volbou parametrů překladu bychom mohli dosáhnout téhož. Ale ještě jsme neskončili!

Všimněme si nyní dvou věcí: za prvé, pokud změníme typ souboru na QMAGIC, bude do paměti načten - resp. namapován - celý soubor (i když jádro bude poněkud "nervózní" z toho, že délka souboru není násobek 4096 bajtů), za druhé, pokud vynecháme koncovou část hlavičky, domyslí si na tom místě jádro nuly (těžko říci, zda je to chyba nebo úmysl). Náš kód má pouze čtyři bajty, takže by ho bylo možno vecpat do některé položky v hlavičce a ještě ji na konci uříznout. Ač to zní překvapivě, možné to je: kód lze vložit do položky a_bss, ačkoli podle zdravého rozumu by něco takového nemělo jádro vůbec spustit (velikost neinicializovaných dat je větší než 2GB). Spustí...

Výsledný program vypadá tak, jak je zobrazeno na výpise Finální zdrojový text pro formát a.out.


   # a.out hlavicka
   Lx006400cc # a_info (QMAGIC)
   L20 # a_text
   L0  # a_data
   # L0  # a_bss
   # kod (na miste a_bss!)
   x40 # inc %eax
   x5b # pop %ebx
   xcd # int
   x80 # $0x80
   L0  # a_syms
   Lx100c  # a_entry
   # ty tady nemusi byt, protoze jsou nulove
   # (poznamenejme, ze nulove byt MUSI)
   # L0  # a_trsize
   # L0  # a_drsize

Výpis č. 3: Finální zdrojový text pro formát a.out

Hrůza! Ale na druhou stranu má výsledný spustitelný soubor velikost pouhých 24 bajtů (tedy o 8 méně než je regulérní velikost hlavičky). Menší spustitelný soubor už asi nikdo nevyrobí. Škoda jen, že při každém spuštění jádro vypíše executable not page aligned.

A co formát ELF? Začneme opět konzervativně (viz výpis Zdrojový text pro ELF v jazyce hex).


   # Elf32_Ehdr
   # e_ident
   x7f x45 x4c x46 # \177ELF
   x01 x01 x01 x00 # 32bit, LSB, current version
   L0
   L0
   # dalsi polozky
   W2      # e_type = ET_EXEC
   W3      # e_machine = EM_386
   L1      # e_version
   Lx1054  # e_entry
   Lx34    # e_phoff
   L0      # e_shoff
   L0      # e_flags
   Wx34    # e_ehsize
   Wx20    # e_phentsize
   W1      # e_phnum
   Wx28    # e_shentsize
   W0      # e_shnum
   W0      # e_shstrnum
   # Elf34_Phdr
   L1      # p_type = PT_LOAD
   L0      # p_offset
   Lx1000  # p_vaddr
   Lx1000  # p_paddr
   L88     # p_filesz
   L88     # p_memsz
   L5      # p_flags = PF_R | PF_X
   Lx1000  # p_align
   # kod
   x40 # inc %eax
   x5b # pop %ebx
   xcd # int
   x80 # $0x80

Výpis č. 4: Zdrojový text pro ELF v jazyce hex

Nejprve 52 bajtů hlavičky souboru, pak 32 bajtů hlavičky segmentu a pak čtyři bajty kódu. Dohromady 88 bajtů.

Zkusme nyní ušetřit nějaké místo. Prvním krokem bude asi využití prázdného místa v e_ident: nejjednodušší bude do něj přesunout kód - tím soubor zkrátíme o čtyři bajty. Lepší by bylo někam přesunout hlavičku segmentu, ale ta je moc velká, do e_ident se rozhodně nevejde, ani kdybychom odtud odstranili kód, který jsme tam zrovna umístili... Ovšem pozor! V hlavičce souboru je spousta zbytečného místa: umístíme-li začátek hlavičky segmentu na stejné místo jako e_shoff, dojde k jediné významné kolizi: druhý bajt p_vaddr bude na stejném místě jako e_phentsize, což je jednoduché vyřešit - stačí vhodně zvolit adresu, na kterou program v paměti umístíme. Tím ušetříme 20 bajtů, další čtyři ještě můžeme získat useknutím položky e_align, kterou jádro postrádat nebude. Program pak bude vypadat tak, jak je na výpisu Finální zdrojový text pro formát ELF.


  # Elf32_Ehdr
  # e_ident
  x7f x45 x4c x46 # \177ELF
  x01 x01 x01 x00 # 32bit, LSB, current version
  # kod (pres zbytek e_ident)
  x40 # inc %eax
  x5b # pop %ebx
  xcd # int
  x80 # $0x80
  L0      # tady je jeste mista....
  # dalsi polozky
  W2      # e_type = ET_EXEC
  W3      # e_machine = EM_386
  L1      # e_version
  Lx200008# e_entry
  Lx20    # e_phoff
  #                         Elf34_Phdr
  L1      # e_shoff         p_type = PT_LOAD
  L0      # e_flags         p_offset
  W0      # e_ehsize        p_vaddr = 0x00200000
  Wx20    # e_phentsize (!)
  W1      # e_phnum         p_paddr = 0x00280001
  Wx28    # e_shentsize
  Lx20    # e_shnum         p_filesz
          # e_shstrnum
  Lx20    #                 p_memsz
  L5      #                 p_flags = PF_R | PF_X
          #                 p_align (vynechano)

Výpis č. 5: Finální zdrojový text pro formát ELF

Celková velikost je neuvěřitelných 60 bajtů! Navíc takový program funguje, jádro si na nic nestěžuje a program file ho správně identifikuje:

  $ ls -l elf_tiny
  -rwxr-xr-x 1 peak users  60 May 3 22:47 elf_tiny
  $ file elf_tiny
  elf_tiny: ELF 32-bit LSB executable, \
  Intel 80386, version 1,
  statically linked, stripped
  $ ./elf_tiny 1 2 3; echo $?
  4

A to je vše, přátelé! Menší programy jsem už opravdu vyrobit nedokázal. *


- předchozí článek - následující článek - obsah - úvodní stránka -