5. cvičení

Volání podprogramů

Využití vkládaného Assembleru je hlavně v implementaci podprogramů. Předem si ale musíme ukázat, jak se podprogramy volají.

Volání podprogramu spočívá v uložení parametrů do zásobníku a změně adresy v registru EIP (čítač instrukcí) na adresu podprogramu s tím, že je uschována adresa odkud provádíme volání (to aby procesor věděl kam se má vrátit). Parametry do zásobníku ukládáme my, zbytek zařídí instrukce CALL.

Ukládání parametrů do zásobníku

V hlavičce procedury (nebo funkce) najdeme téměř vždy definici parametrů volaných:
  • hodnotou - podprogram jejich hodnoty pouze využívá
  • odkazem - podprogram je může číst a může do nich i zapsat
Například void soucet (short a, short b, short *c); je deklarace procedury s názvem soucet s parametry a, b volanými hodnotou a c volaným odkazem. Při volání této procedury z některé části programu psaném v C na místa a, b zapíšeme konkrétní hodnoty (nebo proměnné, ty ale podprogram nezmění) a na místo c zapíšeme adresu (nebo proměnnou typu ukazatel), na které pak najdeme hodnotu po provedení procedury (např. soucet (1, 3, &prom);). Z místa volání předáváme parametry do podprogramů vždy přes zásobník a v opačném pořadí než jsou v deklaraci podprogramu (takže na vrcholu zásobníku je první parametr). Do zásobníku před voláním procedury ukládáme odlišně u parametrů volaných hodnotou a odkazem.

Při volání hodnotou uložíme konkrétní hodnoty (přečtené třeba i z paměti). Vzhledem k organizaci zásobníku jsou parametry volané hodnotou uloženy po dvojslovech následovně:

  • parametry o délce jedné slabiky (char, unsigned char) - obsadí celé dvojslovo, nevyužité bity budou nulové
  • parametry o délce jednoho slova (short, unsigned short) - obsadí celé dvojslovo, nevyužité bity budou nulové
  • parametry o délce dvojslova (long, unsigned long, float, ukazatel) - obsadí právě dvojslovo
  • parametry delší (řetězce, pole, struktury) - ukládá se jejich adresa (ukazatel)
Při volání odkazem uložíme adresu místa odkud se má hodnota číst nebo kam se má zapsat (což je vlastně obsah ukazatele na paměťové místo).

Samotné volání podprogramu

Při volání se mění registr EIP. Skok do podprogramu zajistí instrukce:
  • CALL adresa - na vrchol zásobníku ulož obsah EIP a naplň tento registr adresou uvedenou v parametru (v jazyce C se na adresu vyhodnocuje přímo název podprogramu)

Volání podprogramů je tedy jednoduché. Jednoduše napíšeme instrukci CALL se jménem podprogramu (procedury nebo funkce). Ostatní zařídí překladač, tj. dosadí adresu. Před vstupem do podprogramu jsme do zásobníku uložili parametry podprogramu, proto po ukončení podprogramu nesmíme zapomenout tyto parametry ze zásobníku odstranit (za parametry podprogramu odpovídá volající)!

Příklad:
#include <stdio.h>

void factorial_iter(unsigned char a, unsigned long *b)
{
	if (a <= 1) return;
	*b *= a;
	factorial_iter(a - 1, b);
}

unsigned long factorial(unsigned char a)
{
	unsigned long ret = 1, *pret = &ret;

	_asm {
		push dword ptr pret
		push dword ptr a
		call factorial_iter
		add esp, 8
	}
	return ret;
}

int main()
{
	printf("%u! = %lu\n", 10, factorial(10));
	return 0;
}
    
Stejnou posloupnost instrukcí jako blok Assembleru v tomto programu provede jeden řádek v C: factorial_iter(a, pret);

Návrat hodnoty z funkce

Funkce je podprogram, který vrací jednu hodnotu typu uvedeného v deklaraci. Vrácenou hodnotu zjistíme po návratu z funkce vždy v registrech:
  • AL - funkční hodnota o velikosti slabiky
  • AX - funkční hodnota o velikosti slova
  • EAX - funkční hodnota o velikosti dvojslova, tedy i adresa (např. řetězce)

Tvorba podprogramů

S parametry pracujeme v podprogramech v souladu s tím, jak jsme je přes zásobník předávali. To znamená, že k parametrům volaným hodnotou přistupujeme jako ke klasickým proměnným (z pohledu podprogramu přímou, popř. nepřímou adresací), k parametrům volaným odkazem přistupujeme jako k ukazatelům (nepřímou adresací). Je potřeba si ale uvědomit, že parametry patří volajícímu (podprogramu), ne volanému podprogramu!

Lokální proměnné

V okamžiku vstupu do podprogramu se na vrcholu zásobníku automaticky vytvoří místo pro lokální proměnné definované kdekoliv v podprogramu. Velikost alokovaného místa (počet bytů) pro každou proměnnou je vždy rovna nejmenšímu většímu násobku 4 skutečné velikosti proměnné (alokační jednotka zásobníku je dvojslovo). Tzn. např. pro lokální proměnné typu char, short int i long int je vytvořeno místo stejné velikosti dvojslova (4B), pro pole 10-ti prvků char je zabráno místo velikosti 3 dvojslova (12B), apod. Proměnné (místa pro ně) jsou na zásobníku alokovány v pořadí, v jakém jsou deklarovány, a samozřejmě jsou také případně inicializovány na hodnoty uvedené v deklaraci.

Vstup do podprogramu

Všechny podprogramy musí mít stejné podmínky pro vykonávání svého kódu a pro přístup k proměnným lokálním v rámci podprogramu, tzn. musí "mít svůj zásobník". Zásobník programu je ale jen jeden, proto je podprogramu vyhrazena jeho část odpovídající lokálním proměnným podprogramu. Umístění zásobníku v paměti je určeno registry EBP a ESP, registr EBP je tedy nasměrován na vrchol zásobníku v okamžiku vstupu do podprogramu (nové dno zásobníku) a registr ESP je nastaven na adresu hned za lokální proměnné (nový vrchol zásobníku), tj. těsně po vstupu (CALL) do podprogramu se automaticky provádí:
      PUSH EBP
      MOV EBP, ESP
      SUB ESP, velikost_lokalnich_promennych
    

Tuto sekvenci instrukcí provádí také jedna instrukce ENTER velikost_lokalnich_promennych, 0. Druhý parametr se používá při vnořených podprogramech (např. v Pascalu, v C ne).

Nyní s pomocí registru EBP můžeme přistupovat k:
  • parametrům - přičítáním k hodnotě v EBP, protože jsou "před zásobníkem" (např. [EBP + 8] je adresa prvního parametru)
  • lokálním proměnným - odečítáním od hodnoty v EBP, protože jsou "v zásobníku" (např. [EBP - 4] je adresa první lokální proměnné typu long int)

Vzhledem k tomu, že se o tyto přepočty adres může postarat překladač, je jednodušší používat pro přístupy k lokálním proměnným a parametrům jen jejich jména uvedená v deklaraci podprogramu.

Pozor! K lokálním proměnným (a parametrům) nelze přistupovat pomocí přičítání k registru ESP (ani na začátku podprogramu), protože překladače si do zásobníku po lokálních proměnných ukládají ještě něco dalšího.

Ukončení podprogramu

Před ukončením podprogramu se musí "obnovit" původní zásobník volajícího. Registry EBP a ESP se tedy automaticky obnoví na hodnoty těsně po vstupu do podprogramu (před úpravu zásobníku, původní hodnota registru EBP se ukládala, pamatujete?):
      MOV ESP, EBP
      POP EBP
    

nebo pomocí instrukce LEAVE.

Samotné ukončení podprogramu (návrat do volající části kódu) zajišťuje automaticky instrukce:
  • RET - z vrcholu zásobníku vezmi adresu a dosaď ji do EIP, velmi důležitá instrukce, při jejím neuvedení na konci podprogramu (překladačem) by procesor pokračoval další instrukcí za instrukcemi podprogramu (což téměř nikdy nejsou instrukce volající procedury za místem volání) a prováděl by se tak libovolný kód (nebo dokonce data!) programu!!

Pozor! O akce, které provádějí instrukce ENTER, LEAVE a RET se v inline Assembleru (v C) ale nemusíme vůbec starat, potřebné instrukce si překladač automaticky do každé funkce doplní sám. Proto tyto akce ani provádět nesmíme! Na druhou stranu je ale přece dobré vědět, co všechno za nás překladač udělá!

Používání registrů

Za normálních okolností všechny funkce sdílí stejnou sadu registrů. To nemusí být vždy žádoucí, např. pokud chceme zachovat hodnotu v registru i po zavolání funkce, která tento registr používá. Proto se na procesorech x86 používá následující konvence:

  • Za registry EAX, ECX, EDX je zodpovědná volající funkce. Tzn. pokud chceme zachovat hodnotu v některém z těchto registrů, musíme ji někam uložit před zavoláním funkce. Po návratu z funkce může být v těchto registrech libovolná hodnota.
  • Za registry EBX, ESI, EDI je zodpovědná volaná funkce. Tzn. pokud chceme použít některý z těchto registrů, musíme jeho hodnotu na začátku funkce uložit na zásobník a při návratu musíme hodnotu nastavit zpět. Po zavolání funkce by v těchto registrech měla být stejná hodnota jako před zavoláním.

Nastavení Visual Studia

Visual Studio se ve svém implicitním nastavení nemusí chovat intuitivně. Proto doporučuji nastavení projektu (Solution Explorer -- Properties) provést následující změny:

  • C/C++ / Code Generation / Run Time Library -> Multi-threaded Debug
  • C/C++ / Code Generation / Basic Runtime Checks -> default
  • C/C++ / Code Generation / Buffer Security Check -> No
  • C/C++ / Linker / General / Enable Incremental Linking -> No

Úkoly

  1. Přepište výše uvedenou funkci factorial_iter do assembleru.
  2. Napište funkci char *my_strdup(char *s), která vytvoří kopii řetezce s. Použijte volání funkcí malloc a strlen.
  3. Napište funkci char *abcs(unsigned char n), která vytvoří řetězec s s prvními n písmeny abecedy. Pokud by výčet měl obsahovat víc než písmeno Z, vratí řetězec "Oops." Použijte volání funkcí malloc a strcpy.
  4. Napište funkci unsigned int fib(unsigned short n), která vypočítá rekurzivně hodnotu n-tého fibonacciho čísla.
  5. Napište funkci void print_fact(unsigned char n), která vypíše hodnotu ve tvaru "fact(n) = X". Pro výpočet a výpis hodnoty zavolejte funkce factorial a printf.
  6. Napište funkci void print_facts(unsigned char n), která vypíše prvních n hodnot faktoriálu s pomocí volání print_fact.
  7. Napište funkci void read_and_print_fib(), která načte ze vstupu hodnotu n, vypočte a výpíše hodnotu "fib(n) = X". Použijte funkci scanf. V případě, že je na vstupu neplatná hodnota, program vypíše chybovou hlášku.
  8. Napište funkci unsigned int ack(unsigned short n, unsigned short m), která vypočítá hodnotu ackermannovy funkce.

Pro získání bodového hodnocení je potřeba splnit úkoly 3, 5 a 6.


Last update on 14. 3. 2017 18:30
Powered by Schemik.

© Petr Krajča, 2010, 2012
petr.krajca (at) upol.cz