Rekurzió
Az alábbiakban egy konkrét példán mutatjuk be, hogy egy önmagát rekurzívan hívó függvény hogyan
használja a vermet.
Példaként tekintsük azt a programot, amely a rekurzív definíciója szerint számítja ki a Fibonacci sorozat n-dik elemét.
F(0) := 0
F(1) := 1
F(n) := F(n-1) + F(n-2), ha n>1
Vagyis:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...
fibon.asm:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
global _Fibon
section .text
_Fibon:
push ebp
mov ebp, esp
push ecx
mov eax, [ ebp + 8 ] ;N-t kivesszuk a verembol
cmp eax, 1
jbe nincs_rekurzio
dec eax
push eax
dec eax
push eax
call _Fibon
add esp,4
mov ecx, eax
call _Fibon
add esp,4
add eax, ecx
nincs_rekurzio:
pop ecx
pop ebp
ret
|
fibon.c:
1
2
3
4
5
6
7
8
9
|
#include "stdio.h"
int main( void ) {
unsigned int n;
printf( "Meddig szamoljuk ki a fibonacci szamot? " );
scanf( "%d", &n );
printf( "Az eredmeny: %u", Fibon( n ) );
}
|
|
Vizsgáljuk meg, hogy ha a C főprogramból meghívjuk a Fibon függvényt
n-3-re, akkor hogyan alakul a verem tartalma!
A C főprogram a verembe helyezi a függvény paraméterét, majd meghívja a szubrutint, végül a visszatérés
után kitakarítja a verembe tett paramétert. Ezt a következő utasítássorozat végzi:
push ...
call _Fibon
add esp,4
Mire az assembly szubrutinhoz kerül a vezérlés, a veremtartalom így fog kinézni:
esp => |
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Mire a vezérlés eljut a 15. sorban lévő call utasításig, a verem így alakul:
esp => |
1 |
3-1-1 |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
ebp => |
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Ezek után a call a verembe teszi a 16. sorba való visszatéréshez
szükséges VTC-t, majd elugrik a _Fibon cimkéhez. Ott megint a verembe kerül az ebp majd az ecx regiszter
tartalma, és az eax-be kiolvasásra kerül a call előtt a verembe push-olt érték
az ebp+8 címről, vagyis a 1. Ekkor a verem tartalma így néz ki:
esp => |
ecx |
ez még mindig az az ecx érték, ami még a C főprogramban volt |
ebp => |
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
|
1 |
3-1-1 |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Mivel eax=1, ezért a jbe feltétel már teljesül, vagyis a vezérlés a
nincs_rekurzio cimkéhez kerül. Itt a pop ecx kiveszi a verem
tetején lévő adatot, és azt az ecx-be másolja, majd a pop ebp
a következő adatot, és azt az ebp regiszterbe másolja. Ezután a ret
a verem következő adatát visszatérési címként értelmezi, és oda
ugrik, ahova ez a cím mutat. Jegyezzük meg, hogy a ret végrehajtásakor
eax=1, vagyis a szubrutin által visszaadott érték 1 (a legutoljára átadott n-re,
vagyis n=1-ra _Fibon(1)=1).
Ha jól írtuk meg a programot, akkor az ecx-be az az érték kerül vissza, amely
korábban éppen az ecx-ből került a verembe, és az ebp-be is az, amely az ebp-ből került a verembe, továbbá
olyan címet vesz ki a ret és értelmez visszatérési címkként, amely egy call utasítás által került a verembe.
Ha nem így lenne, akkor valószínűleg valami hibát vétettünk a program tervezésekor, és a program nem fog
működni.
Most nem hibás a programunk, ezért a két pop utáni
ret a 16. sorba vezeti a programot. Eddigre a verem tartalma
a következőképpen néz ki. (Ne felejtsük el, hogy a veremből kivétel (pop és ret) csak kimásolja az adatokat
a megfelelő regiszterekbe, és az esp értékét aktualizálja, de a memóriából az adatok ténylegesen nem törlődnek,
ezért a veremmutató által jelzett "verem teteje" feletti adatok bizonytalan ideig még elérhetőek.
Ezt halványszürke betűkkel jeleztük)
|
ecx |
ez még mindig az az ecx érték, ami még a C főprogramban volt |
|
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
esp=> |
1 |
3-1-1 |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most az add esp,4 kitakarítja a verembe utoljára berakott paramétert,
vagyis felszabadítja az általa elfoglalt területet (vagyis az 1-et),
majd a jelenleg eax-ben lévő 1-et (a Fibon(1) értékét) ecx-be másolja átmeneti megőrzés céljából. Ekkor a verem így
néz ki:
|
ecx |
ez még mindig az az ecx érték, ami még a C főprogramban volt |
|
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
|
1 |
3-1-1 |
esp=> |
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most a 18. sorban lévő call hajtódik végre, vagyis a verembe belekerül az a cím, ahova majd vissza kell térni
(ez a 19. sor címe lesz), majd a vezérlés újra a _Fibon cimkéhez kerül. Itt megint a verembe másolódik az ebp
és az ecx értéke. Az ecx ekkor az 1-et tartalmazza!
|
ecx |
ez még mindig az az ecx érték, ami még a C főprogramban volt |
esp=> |
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
ebp=> |
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most az eax-be beolvasódik a legutolsó VTC előtt a verembe tett érték, vagyis a 2. Most tehát a _Fibon(2)
értékét számoljuk. Erre az eax értékre még mindig nem teljesül a jbe feltétel, ezért a dec-ek irányába
fut tovább a program. A két push a következő verem tartalmaz alakítja ki:
esp=> |
0 |
2-1-1 |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
ebp=> |
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most a call a _Fibon cimkéhez ugrunk, ahol megint a verembe kerül az ebp majd az ecx értéke:
esp=> |
ecx |
|
ebp=> |
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
|
0 |
2-1-1 |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Az eax-be kiolvasódik az ebp+8 címről a legutóbbi VTC előtt verembe tett érték, vagyis eax=0 lesz. Erre a jbe feltétele teljesül, vagyis a nincs_rekurzio cimkéhez ugrunk.
Most a verem tetején lévő érték ecx-be másolódik, és a verem mutató egy sorral lejjebbi értéket fog mutatni (=néggyel nő). A pop ebp hatására a verem aktuális teteje az ebp-be másolódik, és az esp újabb 4-gyel nő. Végül a ret a verem jelenlegi tetején lévő adatot címként értelmezi, és erre a címre ugrik.
Végeredményként a ret után ez a helyzet áll elő (és ne felejtsük el, hogy közben eax=0, vagyis ezt a 0 értéket adjuk vissza _Fibon(0)-ra):
|
ecx |
|
|
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
esp=> |
0 |
2-1-1 |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most az add esp,4 kitakarítja a verembe utoljára berakott paramétert,
vagyis a 0-t, majd a jelenleg eax-ben lévő 0-t ecx-be másolja átmeneti megőrzés céljából. Ez egy fontos pillanat,
hiszen az ecx egészen eddig azt az értéket tartalmazta, amit a főprogram benne "hagyott". Az egész
push ecx-pop ecx tortúra a 7. és 23.
sorban azért kell, hogy ezt az ecx-módosulást a meghívó főprogram elől elrejtsük. Jelenleg a verem így
néz ki:
|
ecx |
|
|
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
|
0 |
2-1-1 |
esp=> |
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most végrehajtódik a 18. sorban lévő call, amely a 19. sorban lévő utasítás
címét helyezi VTC-ként a verembe. Ezután a verem a következőképpen alakul:
|
ecx |
|
|
ebp |
|
|
VTC |
(a 16. sorba visszatéréshez) |
esp=> |
VTC |
(a 19. sorba visszatéréshez) |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most a verem tetejére kerül először az ebp aktuális értéke, majd az ecx-ben lévő 0.
|
ecx |
|
esp=> |
0 |
a Fibon(0) részeredmény tárolása ecx-ből |
ebp=> |
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Ekkor az eax-be kiolvasódik az ebp+8 címről a kapott paraméter, az 1. A jbe
feltétele erre is teljesül, vagyis a nincs_rekurzio cimkétől folytatódik a végrehajtás. Ezzel a verem tetejéről
kiolvasunk egy sort és azt tároljuk az ecx rgiszterben (ez éppen az a 0, amit legutóbb a verembe mentettünk megőrzés céljából!), a következőt pedig az ebp-ben, és a
ret hatására visszatérünk a 19. sorhoz. Ekkor az eax értéke 1, vagyis
kiszámoltuk, hogy Fibon(1)=1. A verem ezután így néz ki:
|
ecx |
|
esp=> |
0 |
a Fibon(0) részeredmény tárolása ecx-ből |
ebp=> |
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
esp=> |
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most a 19. sorban lévő add esp, 4 felszabadítja az utoljára verembe tett
paraméter által elfoglalt helyet. Ezután a 20. sorban összeadjuk a most eax-be előállított Fibon(1) értékét
az ecx-ben lévő, korábban kiszámolt Fibon(0) értékkel. Most eax-ben előállt a Fibon(2) értéke (=1), már csak
a veremből kivesszük az ecx, majd az ebp értékét, és visszatérünk a 19. sorba. Ekkor a verem így fest:
|
ecx |
|
|
0 |
a Fibon(0) részeredmény tárolása ecx-ből |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
esp=> |
2 |
3-1 |
|
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
A 19. sorban járva tehát az eax-ben a Fibon(2) értéke (=1) található, és az ecx-be az imént rekonstruáltuk
a Fibon(1)=1 eredményt. Most a veremből kitakarítjuk a kapott paramétert (a 2-t). Ezután a 20. sorban
képezzük a Fibon(2) és Fibon(1) összegét, és az eredményt eax-ben tároljuk, vagyis a
Fibon(3)=Fibon(2)+Fibon(1) képlet alapján járunk el. Az eax-ben tehát előállt a Fibon(3) eredménye (=2).
|
ecx |
|
|
0 |
a Fibon(0) részeredmény tárolása ecx-ből |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
1 |
2-1 |
|
1 |
ecx-ből, a Fibon(1)=1 részeredmény |
|
ebp |
|
|
VTC |
(a 19. sorba visszatéréshez) |
|
2 |
3-1 |
esp=> |
ecx |
az az ecx érték, ami még a C főprogramban volt |
|
ebp |
az az ebp érték, ami még a C főprogramban volt |
|
VTC |
(a főprogramba visszatéréshez) |
|
3 |
Fibon(3) paramétere |
|
... |
Most a veremből kiszedjük a két felső értéket az ecx és az ebp regiszterbe, majd visszatérünk. Ezúttal
azonban a vissaztérés a főprogramba történik, és az ecx és ebp regiszterek pedig azokat at értékeket
nyerik vissza, amelyekkel annak idején a vézérlés átkerült a C nyelvú főprogramból a mi assemblyben megírt
függvényünkhöz.
A C főprogram tehát a regiszterekből csak annyit lát, hogy az eax-ben előállt a Fibon(3) értéke, de minden
más használt regiszter (az ebp és az ecx is) ugyanazt az értéket tartalmazza, mint kezdetben. Az assembly
függvényünk tehát maradéktalanul teljesíti feladatát: csak azt a regisztert módosítja, amelyben az
eredményt elő kell állítania, minden más értéket változatlanul hagy.
Tanulság
Amint az látható, még viszonylag kis rekurziós mélységnél is elég bonyolult az egyes függvényhívások
paraméterlistáját követni a veremben. Sokkal célszerűbb, ha az ember nem gondol bele ebbe a mélységbe,
hanem a következőket tartja szem ellőtt:
- Az önmagát rekurzívan hívó függvény előbb-utóbb lépjen ki a
rekurzióból (vagyis ne legyen végtelen rekurzió).
- A függvény minden körülmények között (=minden lehetséges paraméterkombináció esetén) garantálja azt,
hogy csak a kötelezően módosuló
regiszterek módosulnak (vagyis ne legyenek mellékhatások). Ezt úgy lehet elérni, hogy
az átmenetileg szükségből mégiscsak módosítandó regiszterek értékét az esetleges
módosítás előtt verembe mentjük, majd utána visszatöltjük.
- A C főprogram által meghívott függvényt mi is ugyanúgy paraméterezzük fel, mint ahogy a C teszi.
Ezeket szem előtt tartva sokkal könnyebben megérthető a fenti assembly program:
- A Fibonacci sorozat definíciója garantálja, hogy n>=0-ra nem kerül végtelen rekurzióba a programunk.
Mivel a C főprogramban a változó unsigned int-nek van deklarálva, ezért nem is lehet olyan n, amelyre
az n>=0 nem teljesül. Ezek után ha a program pontosan a definíció szerint számol, akkor nem kerülhetünk
végtelen rekurzióba.
- Az ebp és ecx regisztereket minden esetben verembe mentjük a többi művelet előtt, és
visszaállítjuk őket azok után. Ezeken kívül csak az eax-et használjuk, amely minden körülmények
között a függvény lefutás végére a visszaadandó értéket tartalmazza.
- A két call előtt két paraméterlistányi push hajtódik végre. A szigorúan C-szerű paraméterkezelés
push-call-add-push-call-add sorrendben történt volna. Az is működne, de így,
a push-push-call-add-call-add sorrenddel is működik. A lényeg csak az, hogy amit egyszer a
verembe push-olunk, azt a call-lal meghívott függvény használja fel, és az add takarítsa ki.
Ha a rekurzív függvényre ezzel a szemlélettel tekintünk, akkor sokkal nagyobb eséllyel sikerülhet
működőképes rekurziót megvalósítani.
Megjegyzés: Ez a leírás 2007.12.02. 1:32-kor készült. Igyekeztem hibátlanra írni, de ha mégis van benne hiba,
akkor kérem, hogy azt emailben jelezzétek nekem. Köszönöm!