Assembler (Jazyk symbolických adres)
(4. část)
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)
Př. Upravte uvedený příklad tak, aby se z Assembleru
volaly i funkce factorial a printf. Formátovací řetězec si uložte do proměnné.
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)
Otázka: Proč se ve výše uvedených adresách přičítá, resp. odečítá, právě 8, resp. 4? Vysvětlete. Jaké jsou adresy dalších parametrů nebo proměnných v pořadí?
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. Např. překladač M$ Visual C++ si tam ukládá ještě dalších
64 bytů (ví někdo proč?) a registry EBX, ESI a EDI.
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á!
Př. Imlementujte funkci factorial_iter v uvedeném
příkladu v Assembleru. V obou funkcích factorial a factorial_iter
přistupujte k parametrům a lokálním proměnným přes jejich adresy
vypočítané pomocí EBP.