V šesté části seriálu o použití assembleru v Linuxu se seznámíme se základními způsoby manipulace se zásobníkem na mikroprocesorech s architekturami i386 a x86_64. Popíšeme si především instrukce CALL, RET, PUSH a POP i použití relativního adresování při přístupu k parametrům volaných funkcí, což je základ pro vytváření takzvaných zásobníkových rámců – stack frames.
Obsah
1. Použití assembleru v Linuxu: volání podprogramů a použití zásobníku
4. Vytvoření podprogramů (subrutin) v assembleru
5. První demonstrační příklad: otestování chování instrukcí CALL a RET
6. Použití zásobníku pro předávání parametrů volané funkci
7. Meziuložení návratové adresy podprogramu v pracovním registru
8. Druhý demonstrační příklad: ukládání parametrů na zásobník
9. Relativní adresování hodnot uložených na zásobníku
10. Třetí demonstrační příklad: volací sekvence, v níž parametry odstraňuje volající kód
11. Repositář s demonstračními příklady
1. Použití assembleru v Linuxu: volání podprogramů, použití zásobníku
V předchozích částech tohoto seriálu jsme se seznámili se základními instrukcemi, které nalezneme u většiny mikroprocesorových architektur. Připomeňme si, že se jednalo především o instrukce určené pro přenosy dat mezi pracovními registry, dále pak o instrukce používané pro načítání a ukládání dat z/do operační paměti, základní aritmetické instrukce (součet, rozdíl), instrukci pro porovnání dvou operandů (v podstatě rozdíl bez uložení výsledku) a v neposlední řadě jsme se zmínili i o podmíněných a nepodmíněných skocích i o jejich použití při implementaci programových smyček a rozhodovacích struktur.
To však samozřejmě není vše, protože nesmíme zapomenout na to, že programy, a to i mnohé programy psané v assembleru, jsou rozděleny do relativně samostatných subrutin (podprogramů) a procesory tedy musí obsahovat nějaké instrukce určené pro volání těchto subrutin, pro návrat ze subrutin a pro případné předání parametrů volaným subrutinám. Při volání subrutiny je nutné si nějakým způsobem zapamatovat adresu, na níž se řízení programu vrátí po provedení celé subrutiny. V závislosti na architektuře procesoru se návratové adresy ukládají buď na zásobník (i386, x86_64) či do takzvaného link registru (ARM a některé další RISCové architektury).
2. Strojová instrukce CALL
Pro implementaci podprogramů, které je možné v případě potřeby volat i rekurzivně, obsahuje většina mikroprocesorových architektur speciální instrukce určené pro skok do podprogramu a taktéž instrukci či instrukce starající se o návrat z podprogramu. Dnes si řekneme, jak jsou tyto instrukce implementovány na mikroprocesorech s architekturou i386 (a odvozeně i s architekturou x86_64). Instrukce pro skok do podprogramu se jmenuje CALL (význam tohoto jména je pravděpodobně zřejmý) a o návrat z podprogramu se stará instrukce RET (odvozeno od slova return).
Skok do podprogramu je realizován prakticky stejným způsobem jako běžný skok, což konkrétně znamená, že adresa zapsaná za operačním kódem instrukce je vložena do registru PC, čímž dojde k přemístění řízení na uvedenou adresu. Ovšem původní hodnota registru PC je ještě před provedením skoku uložena na zásobník, takže si mikroprocesor vlastně zapamatuje, na které adrese se před skokem do podprogramu nacházel (ukazatel na vrchol zásobníku je uschován v registru SP, s nímž se dnes ještě setkáme).
3. Strojová instrukce RET
Instrukce RET vyjme hodnotu uloženou na vrcholu zásobníku a přenese ji do pracovního registru PC (ve skutečnosti se ovšem během operací CALL/RET do tohoto registru přenese hodnota zvýšená o konstantu 5, protože samotná instrukce CALL má v 32bitovém režimu mikroprocesorů Intel délku přesně pěti bajtů). To znamená, že po provedení instrukce RET se řízení programu vrátí na adresu, která leží těsně za původní instrukcí CALL.
Pokud se volaný podprogram postará o automatickou obnovu obsahu všech pracovních registrů, je možné takový podprogram (subrutinu) zavolat prakticky kdekoli, a to bez strachu z toho, že se nějakým způsobem „pokazí“ běh hlavního programu (ostatně podobně by se měly v ideálním případě chovat i funkce či metody deklarované ve vyšších programovacích jazycích). Samozřejmě zbývá vyřešit problematiku předávání parametrů volným podprogramům a taktéž předávání návratové hodnoty (hodnot) zpět do volajícího kódu. Uvidíme, že se tento problém dá řešit hned několika různými způsoby.
Poznámka: instrukci RET je možné v případě potřeby použít ve funkci „aritmetického GOTO“. Jedná se o příkaz známý například z některých interpretů programovacího jazyka Basic, ve kterých bylo možné provádět skok na řádek, jehož číslo bylo vypočtené pomocí aritmetického výrazu (podobně se však používá i přímý skok realizovaný instrukcí JMP s pracovním registrem použitým namísto konstantní adresy). V dobách masivního používání ručně optimalizovaného assembleru se tato technika využívala poměrně často, například při implementaci rozhodovacích tabulek (decision tables), popřípadě jako náhrada za strukturovaný příkaz typu switch-case.
Tento způsob využití (či spíše přesněji řečeno většinou zneužití) zásobníku je v některých případech použit například i při pokusu o napadení nativních aplikací, kdy se útočník snaží změnit vstupní data takovým způsobem, aby se poškodila návratová adresa uložená na zásobníku a následně se provedl skok na nějakou pro útočníka „zajímavou“ funkci (login atd.).
4. Vytvoření podprogramů (subrutin) v assembleru
Strojové instrukce CALL a RET jsou použity i v dnešním prvním demonstračním příkladu. Samotné tělo tohoto demonstračního příkladu je tvořeno pouhými třemi instrukcemi, které postupně zavolají podprogramy určené pro vytištění první zprávy na standardní výstup, následně pro vytištění druhé zprávy (opět na standardní výstup) a nakonec se zavolá podprogram, který celý proces ukončí (samozřejmě s využitím příslušného syscallu). Ve skutečnosti je zajištěno, že se z tohoto posledního podprogramu již řízení zpět nikdy nevrátí, takže by zde mohla být namísto instrukce CALL použita instrukce JMP pro provedení nepodmíněného skoku.
Z úryvku zdrojového kódu je zřejmé, jak se instrukce CALL zapisuje společně s návěštím podprogramu (konkrétní adresa, na níž je podprogram uložen, se vypočte automaticky assemblerem a uvidíme ji při pohledu do vygenerovaného objektového kódu):
call writeFirstMessage # zavolani podprogramu pro vytisteni prvni zpravy
call writeSecondMessage # zavolani podprogramu pro vytisteni druhe zpravy
call exit # zavolani podprogramu pro ukonceni procesu
Jednotlivé podprogramy vždy musí končit instrukcí RET, která zajistí přečtení návratové adresy ze zásobníku a skok na tuto adresu. Příkladem je podprogram určený pro vytištění první zprávy, který naplní pracovní registry ECX a EDX a nakonec zavolá další podprogram:
writeFirstMessage:
mov ecx, offset message1 # adresa retezce, ktery se ma vytisknout
mov edx, message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
Podprogram začínající na návěští writeMessage očekává, že v pracovních registrech ECX a EDX bude předána adresa zprávy a její délka. Následně se naplní obsahy dalších dvou pracovních registrů EAX a EBX a provede se syscall (zavolání služby jádra operačního systému):
writeMessage:
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
ret # navrat z podprogramu
5. První demonstrační příklad: otestování chování instrukcí CALL a RET
Výše uvedené fragmenty podprogramů jsou použity v dnešním prvním demonstračním příkladu, jehož úplný zdrojový kód je vypsán pod tímto odstavcem:
# asmsyntax=as
# Program pro otestovani chovani instrukci CALL a RET
# - pouzita je "Intel" syntaxe.
#
# Autor: Pavel Tisnovsky
.intel_syntax noprefix
# Linux kernel system call table
sys_exit = 1
sys_write = 4
# Dalsi konstanty pouzite v programu - standardni streamy
std_input = 0
std_output = 1
#-----------------------------------------------------------------------------
.section .data
message1: # adresa prvni zpravy
.string "Hello World\n"
message1len = $ - message1 - 1 # delka prvni zpravy
message2: # adresa druhe zpravy
.string "Assembler je fajn\n"
message2len = $ - message2 - 1 # delka druhe zpravy
#-----------------------------------------------------------------------------
.section .bss
#-----------------------------------------------------------------------------
.section .text
.global _start # tento symbol ma byt dostupny i linkeru
_start:
call writeFirstMessage # zavolani podprogramu pro vytisteni prvni zpravy
call writeSecondMessage # zavolani podprogramu pro vytisteni druhe zpravy
call exit # zavolani podprogramu pro ukonceni procesu
# Podprogram pro vytisteni prvni zpravy
writeFirstMessage:
mov ecx, offset message1 # adresa retezce, ktery se ma vytisknout
mov edx, message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
# Podprogram pro vytisteni druhe zpravy
writeSecondMessage:
mov ecx, offset message2 # adresa retezce, ktery se ma vytisknout
mov edx, message2len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
# Podprogram pro vytisteni zpravy na standardni vystup
# Ocekava se, ze v ecx bude adresa zpravy a v edx jeji delka
writeMessage:
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
ret # navrat z podprogramu
# Podprogram pro ukonceni procesu zavolanim syscallu
exit:
mov eax, sys_exit # cislo sycallu pro funkci "exit"
mov ebx, 0 # exit code = 0
int 0x80 # volani Linuxoveho kernelu
# finito
Při překladu a linkování si musíme dát pozor na to, aby se generoval 32bitový kód. Je tomu tak z toho důvodu, že v 64bitovém režimu není možné na zásobník ukládat 32bitové konstanty. Příklad by samozřejmě bylo možné pro plně 64bitový systém upravit, ovšem této relativně obsáhlé problematice se budeme věnovat později (navíc bude 32bitový kód na 64bitovém systému fungovat). Povšimněte si, že v prvním kroku (spuštění assembleru) používáme přepínač --32 a ve druhém kroku (spuštění linkeru) naopak přepínač -m elf_i386:
as -g --32 subroutines-1.s -o subroutines-1.o
ld -m elf_i386 -s subroutines-1.o
Zajímavé bude se podívat na obsah vygenerovaného objektového kódu, zejména na tu část, v níž jsou použity instrukce CALL a RET:
<strong>objdump -M intel-mnemonic -f -d -t -h subroutines-1.o</strong>
subroutines-1.o: file format elf32-i386
architecture: i386, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x00000000
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000048 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000020 00000000 00000000 0000007c 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 0000009c 2**0
ALLOC
SYMBOL TABLE:
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000001 l *ABS* 00000000 sys_exit
00000004 l *ABS* 00000000 sys_write
00000000 l *ABS* 00000000 std_input
00000001 l *ABS* 00000000 std_output
00000000 l .data 00000000 message1
0000000c l *ABS* 00000000 message1len
0000000d l .data 00000000 message2
00000012 l *ABS* 00000000 message2len
0000000f l .text 00000000 writeFirstMessage
0000001f l .text 00000000 writeSecondMessage
0000003c l .text 00000000 exit
0000002f l .text 00000000 writeMessage
00000000 g .text 00000000 _start
Disassembly of section .text:
00000000 <_start>:
0: e8 0a 00 00 00 call f <writeFirstMessage>
5: e8 15 00 00 00 call 1f <writeSecondMessage>
a: e8 2d 00 00 00 call 3c <exit>
0000000f <writeFirstMessage>:
f: b9 00 00 00 00 mov ecx,0x0
14: ba 0c 00 00 00 mov edx,0xc
19: e8 11 00 00 00 call 2f <writeMessage>
1e: c3 ret
0000001f <writeSecondMessage>:
1f: b9 0d 00 00 00 mov ecx,0xd
24: ba 12 00 00 00 mov edx,0x12
29: e8 01 00 00 00 call 2f <writeMessage>
2e: c3 ret
0000002f <writeMessage>:
2f: b8 04 00 00 00 mov eax,0x4
34: bb 01 00 00 00 mov ebx,0x1
39: cd 80 int 0x80
3b: c3 ret
0000003c <exit>:
3c: b8 01 00 00 00 mov eax,0x1
41: bb 00 00 00 00 mov ebx,0x0
46: cd 80 int 0x80
Povšimněte si rozdílného kódování instrukcí CALL pro volání subrutiny nazvané writeMessage. První volání nalezneme na adrese 0x00000019, druhé volání pak na adrese 0x00000029:
19: e8 11 00 00 00 call 2f <writeMessage>
29: e8 01 00 00 00 call 2f <writeMessage>
0000002f <writeMessage>:
Samotná subrutina writeMessage je uložena na adrese 0x0000002f, ovšem v prvním instrukčním slovu vidíme adresu 0x00000011 (zakódovanou v sekvenci bajtů 0x11 0x00 0x00 0x00, první bajt s hodnotou 0xe8 je kód instrukce CALL) a ve druhém instrukčním slovu vidíme adresu 0x00000001. Je tomu tak z toho důvodu, že se ve skutečnosti jedná o relativní adresu: v prvním případě 0x00000019+0x11=0x0000002a, což se od skutečné absolutní adresy 0x0000002f liší přesně o hodnotu 5, tedy o délku instrukce CALL. Podobně je tomu samozřejmě i ve druhém případě. Mimochodem – díky tomu, že se pro cíle skoků nepoužívají absolutní adresy, ale adresy relativní, je možné takové programy nebo jejich části přesouvat v operační paměti. Takovému kódu se říká pozičně nezávislý kód (position-independent code – PIC), i když je samozřejmě nutné dodržet i některé další požadavky.
Instrukce RET je naproti tomu až absurdně jednoduchá – je zakódována v jediném bajtu.
Poznámka: ve skutečnosti existuje hned několik adresovacích režimů podporovaných instrukcí CALL, viz též jejich popis na stránce https://pdos.csail.mit.edu/6.828/2007/readings/i386/CALL.htm.
6. Použití zásobníku pro předávání parametrů volané funkci
Problematika předávání parametrů podprogramům je většinou řešena buď předáváním parametrů na zásobníku či alternativně předáváním parametrů v pracovních registrech. Druhý způsob – použití pracovních registrů – jsme si již vlastně ukázali v předchozím demonstračním příkladu, takže se nyní podívejme, jak lze pro tento účel využít zásobník. Ve skutečnosti to není nic těžkého, protože instrukční soubor mikroprocesorů s architekturou i386 i x86_64 pro tento účel obsahuje dvojici instrukcí PUSH a POP. Instrukce PUSH slouží pro uložení konstanty či obsahu pracovního registru na vrchol zásobníku (ukazatel uložený v pracovním registru SP se automaticky změní).
Opakem instrukce PUSH je instrukce nazvaná POP, která naopak přenese data ze zásobníku do zvoleného pracovního registru. Předpokládejme například, že podprogram writeMessage očekává, že při jeho zavolání budou na zásobníku uloženy tři hodnoty: návratová adresa (vložená instrukcí CALL), počet znaků, které se mají vytisknout (tj. délka zprávy) a adresa zprávy. Připomeňme si, že zásobník je struktura typu LIFO (Last In, First Out), tj. naposledy vložená hodnota je ze zásobníku načtena jako první.
Volání podprogramu nazvaného writeMessage je tedy ve skutečnosti velmi jednoduché, pouze si musíme dát pozor na pořadí hodnot ukládaných na zásobník:
# Podprogram pro vytisteni prvni zpravy
writeFirstMessage:
push offset message1 # adresa retezce, ktery se ma vytisknout
push message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
7. Meziuložení návratové adresy podprogramu v pracovním registru
Poněkud složitější je situace uvnitř podprogramu, neboť zde potřebujeme ze zásobníku získat délku zprávy a její adresu. Při použití instrukce POP nám na vrcholu zásobníku „přebývá“ návratová adresa, kterou nesmíme ztratit, protože bude na konci podprogramu použita instrukcí RET. Jedno z možných řešení je následující – návratovou adresu si na chvíli uložíme do nějakého nepoužívaného pracovního registru (například EBP) a těsně před provedením instrukce RET obsah tohoto registru opět vložíme na zásobník. Výsledek sice není ideální, ale jeho předností je, že ho lze použít i na procesorech s omezenými adresovacími schopnostmi:
writeMessage:
pop ebp # ulozime na chvili navratovou adresu do EBP
pop edx # ziskame v poradi druhy ulozeny parametr
pop ecx # ziskame v poradi prvni ulozeny parametr
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
push ebp # obnovime navratovou adresu
ret # navrat z podprogramu
Povšimněte si, že si při operaci se zásobníkem stále vystačíme pouze s instrukcemi PUSH a POP (nepřímo též CALL a RET). Zásobník je „vyvážený“ v tom smyslu, že hodnoty uložené do něj volajícím kódem jsou v podprogramu nazvaném writeMessage automaticky odstraněny, takže po návratu z tohoto podprogramu má zásobník opět takovou kapacitu, jako před uložením parametrů tohoto podprogramu.
8. Druhý demonstrační příklad: ukládání parametrů na zásobník
Podívejme se nyní na úplný zdrojový kód dnešního druhého demonstračního příkladu, v němž se používá výše popsané předávání parametrů volaným subrutinám přes zásobník:
# asmsyntax=as
# Program pro otestovani chovani instrukci CALL a RET spolecne
# s ukladanim parametru na zasobnik
# - pouzita je "Intel" syntaxe.
#
# Autor: Pavel Tisnovsky
.intel_syntax noprefix
# Linux kernel system call table
sys_exit = 1
sys_write = 4
# Dalsi konstanty pouzite v programu - standardni streamy
std_input = 0
std_output = 1
#-----------------------------------------------------------------------------
.section .data
message1: # adresa prvni zpravy
.string "Hello World\n"
message1len = $ - message1 - 1 # delka prvni zpravy
message2: # adresa druhe zpravy
.string "Assembler je fajn\n"
message2len = $ - message2 - 1 # delka druhe zpravy
#-----------------------------------------------------------------------------
.section .bss
#-----------------------------------------------------------------------------
.section .text
.global _start # tento symbol ma byt dostupny i linkeru
_start:
call writeFirstMessage # zavolani podprogramu pro vytisteni prvni zpravy
call writeSecondMessage # zavolani podprogramu pro vytisteni druhe zpravy
call exit # zavolani podprogramu pro ukonceni procesu
# Podprogram pro vytisteni prvni zpravy
writeFirstMessage:
push offset message1 # adresa retezce, ktery se ma vytisknout
push message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
# Podprogram pro vytisteni druhe zpravy
writeSecondMessage:
push offset message2 # adresa retezce, ktery se ma vytisknout
push message2len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
ret # navrat z podprogramu
# Podprogram pro vytisteni zpravy na standardni vystup
# Ocekava se, ze v ecx bude adresa zpravy a v edx jeji delka
writeMessage:
pop ebp # ulozime na chvili navratovou adresu do EBP
pop edx # ziskame v poradi druhy ulozeny parametr
pop ecx # ziskame v poradi prvni ulozeny parametr
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
push ebp # obnovime navratovou adresu
ret # navrat z podprogramu
# Podprogram pro ukonceni procesu zavolanim syscallu
exit:
mov eax, sys_exit # cislo sycallu pro funkci "exit"
mov ebx, 0 # exit code = 0
int 0x80 # volani Linuxoveho kernelu
# finito
Překlad se opět provede v režimu 32bitových procesorů Intel:
as -g --32 subroutines-2.s -o subroutines-2.o
ld -m elf_i386 -s subroutines-2.o
Na disassemblovaném výpisu se můžeme podívat na způsob kódování instrukčních slov CALL, RET, PUSH, POP atd.:
<strong>objdump -M intel-mnemonic -f -d -t -h subroutines-2.o</strong>
subroutines-2.o: file format elf32-i386
architecture: i386, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x00000000
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000046 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000020 00000000 00000000 0000007a 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 0000009a 2**0
ALLOC
SYMBOL TABLE:
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000001 l *ABS* 00000000 sys_exit
00000004 l *ABS* 00000000 sys_write
00000000 l *ABS* 00000000 std_input
00000001 l *ABS* 00000000 std_output
00000000 l .data 00000000 message1
0000000c l *ABS* 00000000 message1len
0000000d l .data 00000000 message2
00000012 l *ABS* 00000000 message2len
0000000f l .text 00000000 writeFirstMessage
0000001c l .text 00000000 writeSecondMessage
0000003a l .text 00000000 exit
00000029 l .text 00000000 writeMessage
00000000 g .text 00000000 _start
Disassembly of section .text:
00000000 <_start>:
0: e8 0a 00 00 00 call f <writeFirstMessage>
5: e8 12 00 00 00 call 1c <writeSecondMessage>
a: e8 2b 00 00 00 call 3a <exit>
0000000f <writeFirstMessage>:
f: 68 00 00 00 00 push 0x0
14: 6a 0c push 0xc
16: e8 0e 00 00 00 call 29 <writeMessage>
1b: c3 ret
0000001c <writeSecondMessage>:
1c: 68 0d 00 00 00 push 0xd
21: 6a 12 push 0x12
23: e8 01 00 00 00 call 29 <writeMessage>
28: c3 ret
00000029 <writeMessage>:
29: 5d pop ebp
2a: 5a pop edx
2b: 59 pop ecx
2c: b8 04 00 00 00 mov eax,0x4
31: bb 01 00 00 00 mov ebx,0x1
36: cd 80 int 0x80
38: 55 push ebp
39: c3 ret
0000003a <exit>:
3a: b8 01 00 00 00 mov eax,0x1
3f: bb 00 00 00 00 mov ebx,0x0
44: cd 80 int 0x80
Za zmínku stojí fakt, že instrukce PUSH a POP pracující s registrem mají délku pouhý jeden bajt. Ještě zajímavější je kódování instrukce PUSH s konstantou:
Instrukce | Význam | Kódování |
---|---|---|
push offset message1 | uložení 32bitové adresy/konstanty | 0x6a + jeden bajt |
push message1len | uložení 8bitové konstanty | 0x68 + čtyři bajty |
9. Relativní adresování hodnot uložených na zásobníku
Způsobů, jak předávat parametry volaným subrutinám, existuje větší množství. Jeden ze způsobů, který je (po dalších úpravách) používán, spočívá v tom, že se o obnovu původního obsahu zásobníku stará volající kód. Ten tedy nejprve uloží na zásobník všechny parametry volané subrutiny, následně subrutinu skutečně zavolá (uloží na ni tedy ještě návratovou adresu) a po návratu zásobník obnoví. V našem případě to znamená uvolnění celkem osmi bajtů, což zajistí buď dvě operace POP nebo jednodušeji operace snižující ukazatel na vrchol zásobníku o hodnotu osm (ADD ESP, 8):
writeFirstMessage:
push offset message1 # adresa retezce, ktery se ma vytisknout
push message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
add esp, 8 # obnoveni puvodni adresy vrcholu zasobniku
ret # navrat z podprogramu
V subrutině taktéž dojde ke změně – parametry se ze zásobníku nemusí (a nesmí) odstraňovat, takže je můžeme adresovat relativně přes registr ESP. Na adrese [ESP] je uložena návratová adresa subrutiny, na adrese [ESP+4] poslední (zde druhý) parametr, na adrese [ESP+8] předposlední (zde první) parametr atd.:
# Podprogram pro vytisteni zpravy na standardni vystup
# Ocekava se, ze v ecx bude adresa zpravy a v edx jeji delka
writeMessage:
mov edx, [esp+4] # precteni v poradi druheho parametru ze zasobniku
mov ecx, [esp+8] # precteni v poradi prvniho parametru ze zasobniku
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
ret # navrat z podprogramu
Po mírných úpravách se tento způsob používá pro vytváření zásobníkových rámců, které si popíšeme příště.
10. Třetí demonstrační příklad: volací sekvence, v níž parametry odstraňuje volající kód
Opět se podívejme na implementaci výše popsaného předávání a čtení parametrů v demonstračním příkladu:
# asmsyntax=as
# Program pro otestovani chovani instrukci CALL a RET spolecne
# s ukladanim parametru na zasobnik
# - pouzita je "Intel" syntaxe.
#
# Autor: Pavel Tisnovsky
.intel_syntax noprefix
# Linux kernel system call table
sys_exit = 1
sys_write = 4
# Dalsi konstanty pouzite v programu - standardni streamy
std_input = 0
std_output = 1
#-----------------------------------------------------------------------------
.section .data
message1: # adresa prvni zpravy
.string "Hello World\n"
message1len = $ - message1 - 1 # delka prvni zpravy
message2: # adresa druhe zpravy
.string "Assembler je fajn\n"
message2len = $ - message2 - 1 # delka druhe zpravy
#-----------------------------------------------------------------------------
.section .bss
#-----------------------------------------------------------------------------
.section .text
.global _start # tento symbol ma byt dostupny i linkeru
_start:
call writeFirstMessage # zavolani podprogramu pro vytisteni prvni zpravy
call writeSecondMessage # zavolani podprogramu pro vytisteni druhe zpravy
call exit # zavolani podprogramu pro ukonceni procesu
# Podprogram pro vytisteni prvni zpravy
writeFirstMessage:
push offset message1 # adresa retezce, ktery se ma vytisknout
push message1len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
add esp, 8 # obnoveni puvodni adresy vrcholu zasobniku
ret # navrat z podprogramu
# Podprogram pro vytisteni druhe zpravy
writeSecondMessage:
push offset message2 # adresa retezce, ktery se ma vytisknout
push message2len # pocet znaku, ktere se maji vytisknout
call writeMessage # zavolani podprogramu pro vytisteni zpravy
add esp, 8 # obnoveni puvodni adresy vrcholu zasobniku
ret # navrat z podprogramu
# Podprogram pro vytisteni zpravy na standardni vystup
# Ocekava se, ze v ecx bude adresa zpravy a v edx jeji delka
writeMessage:
mov edx, [esp+4] # precteni v poradi druheho parametru ze zasobniku
mov ecx, [esp+8] # precteni v poradi prvniho parametru ze zasobniku
mov eax, sys_write # cislo syscallu pro funkci "write"
mov ebx, std_output # standardni vystup
int 0x80 # volani Linuxoveho kernelu
ret # navrat z podprogramu
# Podprogram pro ukonceni procesu zavolanim syscallu
exit:
mov eax, sys_exit # cislo sycallu pro funkci "exit"
mov ebx, 0 # exit code = 0
int 0x80 # volani Linuxoveho kernelu
# finito
Překlad tohoto demonstračního příkladu se opět provede v režimu 32bitových procesorů Intel:
as -g --32 subroutines-3.s -o subroutines-3.o
ld -m elf_i386 -s subroutines-3.o
Na disassemblovaném výpisu se podíváme na to, jak vlastně vypadá adresování parametrů uložených na zásobníku:
<strong>objdump -M intel-mnemonic -f -d -t -h subroutines-3.o</strong>
subroutines-3.o: file format elf32-i386
architecture: i386, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x00000000
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000050 00000000 00000000 00000034 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000020 00000000 00000000 00000084 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 000000a4 2**0
ALLOC
SYMBOL TABLE:
00000000 l d .text 00000000 .text
00000000 l d .data 00000000 .data
00000000 l d .bss 00000000 .bss
00000001 l *ABS* 00000000 sys_exit
00000004 l *ABS* 00000000 sys_write
00000000 l *ABS* 00000000 std_input
00000001 l *ABS* 00000000 std_output
00000000 l .data 00000000 message1
0000000c l *ABS* 00000000 message1len
0000000d l .data 00000000 message2
00000012 l *ABS* 00000000 message2len
0000000f l .text 00000000 writeFirstMessage
0000001f l .text 00000000 writeSecondMessage
00000044 l .text 00000000 exit
0000002f l .text 00000000 writeMessage
00000000 g .text 00000000 _start
Disassembly of section .text:
00000000 <_start>:
0: e8 0a 00 00 00 call f <writeFirstMessage>
5: e8 15 00 00 00 call 1f <writeSecondMessage>
a: e8 35 00 00 00 call 44 <exit>
0000000f <writeFirstMessage>:
f: 68 00 00 00 00 push 0x0
14: 6a 0c push 0xc
16: e8 14 00 00 00 call 2f <writeMessage>
1b: 83 c4 08 add esp,0x8
1e: c3 ret
0000001f <writeSecondMessage>:
1f: 68 0d 00 00 00 push 0xd
24: 6a 12 push 0x12
26: e8 04 00 00 00 call 2f <writeMessage>
2b: 83 c4 08 add esp,0x8
2e: c3 ret
0000002f <writeMessage>:
2f: 8b 54 24 04 mov edx,DWORD PTR [esp+0x4]
33: 8b 4c 24 08 mov ecx,DWORD PTR [esp+0x8]
37: b8 04 00 00 00 mov eax,0x4
3c: bb 01 00 00 00 mov ebx,0x1
41: cd 80 int 0x80
43: c3 ret
00000044 <exit>:
44: b8 01 00 00 00 mov eax,0x1
49: bb 00 00 00 00 mov ebx,0x0
4e: cd 80 int 0x80
Instrukce mov edx, [esp+0x04] je zakódována do čtyř bajtů, což je vlastně relativně malá velikost, zvláště když si uvědomíme, že v instrukčním slovu musí být zakódován cílový registr, zdrojový registr a relativní adresa (tedy konstanta):
2f: 8b 54 24 04 mov edx,DWORD PTR [esp+0x4]
Z referenční příručky můžeme zjistit, že kód 0x8B znamená:
MOV r16/32 r/m16/32
Další bajt 0x54 znamená registr EDX a současně způsob zápisu druhé adresy REG+offset. Třetí bajt 0x24 specifikuje druhý registr, kterým je ESP a čtvrtý bajt 0x04 je kýžený offset (kódování instrukcí u procesorů ARM je značně jednodušší).
11. Repositář s demonstračními příklady
Všechny tři dnes popisované demonstrační příklady byly společně s podpůrnými skripty určenými pro jejich překlad či naopak pro disassembling uloženy do GIT repositáře dostupného na adrese https://github.com/tisnik/presentations/. Všechny dnešní příklady jsou určené pro GNU Assembler a využívají přitom Intel syntaxi. Následuje tabulka s odkazy na zdrojové kódy příkladů i na již zmíněné podpůrné skripty:
První demonstrační příklad: otestování chování instrukcí CALL a RET
# | Soubor | Popis | Odkaz do repositáře |
---|---|---|---|
1 | subroutines-1.s | program pro GNU Assembler | https://github.com/tisnik/presentations/blob/master/assembler/15_subroutines-1/subroutines-1.s |
2 | assemble | skript pro překlad na procesorech i386 | https://github.com/tisnik/presentations/blob/master/assembler/15_subroutines-1/assemble |
3 | disassemble | skript pro disassembling | https://github.com/tisnik/presentations/blob/master/assembler/15_subroutines-1/assemble |
Druhý demonstrační příklad: ukládání parametrů na zásobník
# | Soubor | Popis | Odkaz do repositáře |
---|---|---|---|
1 | subroutines-2.s | program pro GNU Assembler | https://github.com/tisnik/presentations/blob/master/assembler/16_subroutines-2/subroutines-2.s |
2 | assemble | skript pro překlad na procesorech i386 | https://github.com/tisnik/presentations/blob/master/assembler/16_subroutines-2/assemble |
3 | disassemble | skript pro disassembling | https://github.com/tisnik/presentations/blob/master/assembler/16_subroutines-2/disassemble |
Třetí demonstrační příklad: volací sekvence, v níž parametry odstraňuje volající kód
# | Soubor | Popis | Odkaz do repositáře |
---|---|---|---|
1 | subroutines-3.s | program pro GNU Assembler | https://github.com/tisnik/presentations/blob/master/assembler/17_subroutines-3/subroutines-3.s |
2 | assemble | skript pro překlad na procesorech i386 | https://github.com/tisnik/presentations/blob/master/assembler/17_subroutines-3/assemble |
3 | disassemble | skript pro disassembling | https://github.com/tisnik/presentations/blob/master/assembler/17_subroutines-3/disassemble |
12. Odkazy na Internetu
- ASM Flags
http://www.cavestory.org/guides/csasm/guide/asm_flags.html - Status Register
https://en.wikipedia.org/wiki/Status_register - Intel x86 JUMP quick reference
http://unixwiz.net/techtips/x86-jumps.html - Linux assemblers: A comparison of GAS and NASM
http://www.ibm.com/developerworks/library/l-gas-nasm/index.html - Programovani v assembleru na OS Linux
http://www.cs.vsb.cz/grygarek/asm/asmlinux.html - Is it worthwhile to learn x86 assembly language today?
https://www.quora.com/Is-it-worthwhile-to-learn-x86-assembly-language-today?share=1 - Why Learn Assembly Language?
http://www.codeproject.com/Articles/89460/Why-Learn-Assembly-Language - Is Assembly still relevant?
http://programmers.stackexchange.com/questions/95836/is-assembly-still-relevant - Why Learning Assembly Language Is Still a Good Idea
http://www.onlamp.com/pub/a/onlamp/2004/05/06/writegreatcode.html - Assembly language today
http://beust.com/weblog/2004/06/23/assembly-language-today/ - Assembler: Význam assembleru dnes
http://www.builder.cz/rubriky/assembler/vyznam-assembleru-dnes-155960cz - Assembler pod Linuxem
http://phoenix.inf.upol.cz/linux/prog/asm.html - AT&T Syntax versus Intel Syntax
https://www.sourceware.org/binutils/docs-2.12/as.info/i386-Syntax.html - Linux Assembly website
http://asm.sourceforge.net/ - Using Assembly Language in Linux
http://asm.sourceforge.net/articles/linasm.html - vasm
http://sun.hasenbraten.de/vasm/ - vasm – dokumentace
http://sun.hasenbraten.de/vasm/release/vasm.html - The Yasm Modular Assembler Project
http://yasm.tortall.net/ - 680x0:AsmOne
http://www.amigacoding.com/index.php/680x0:AsmOne - ASM-One Macro Assembler
http://en.wikipedia.org/wiki/ASM-One_Macro_Assembler - ASM-One pages
http://www.theflamearrows.info/documents/asmone.html - Základní informace o ASM-One
http://www.theflamearrows.info/documents/asminfo.html - Linux Syscall Reference
http://syscalls.kernelgrok.com/ - Programming from the Ground Up Book - Summary
http://savannah.nongnu.org/projects/pgubook/ - IBM System 360/370 Compiler and Historical Documentation
http://www.edelweb.fr/Simula/ - IBM 700/7000 series
http://en.wikipedia.org/wiki/IBM_700/7000_series - IBM System/360
http://en.wikipedia.org/wiki/IBM_System/360 - IBM System/370
http://en.wikipedia.org/wiki/IBM_System/370 - Mainframe family tree and chronology
http://www-03.ibm.com/ibm/history/exhibits/mainframe/mainframe_FT1.html - 704 Data Processing System
http://www-03.ibm.com/ibm/history/exhibits/mainframe/mainframe_PP704.html - 705 Data Processing System
http://www-03.ibm.com/ibm/history/exhibits/mainframe/mainframe_PP705.html - The IBM 704
http://www.columbia.edu/acis/history/704.html - IBM Mainframe album
http://www-03.ibm.com/ibm/history/exhibits/mainframe/mainframe_album.html - Osmibitové muzeum
http://osmi.tarbik.com/ - Tesla PMI-80
http://osmi.tarbik.com/cssr/pmi80.html - PMI-80
http://en.wikipedia.org/wiki/PMI-80 - PMI-80
http://www.old-computers.com/museum/computer.asp?st=1&c=1016 - The 6502 overflow flag explained mathematically
http://www.righto.com/2012/12/the-6502-overflow-flag-explained.html - X86 Opcode and Instruction Reference
http://ref.x86asm.net/coder32.html