您的位置:首页 > 其它

程序语言的底层描述(4)——递归函数汇编栈帧实现

2014-10-13 13:33 225 查看
这一节我们来讨论递归函数栈帧实现。

一:mov和lea的含义和用法

在论述递归栈帧之前,回到第一节(1)中关于mov的阐述,说实话,就在写这些文字之前,我以为自己理解了mov的含义,但是当看到lea操作时,大脑却瞬间混乱,连之前认为熟练的mov也忽然打不到方向了。因此这里有必要再次详细阐述下mov和lea的实际含义,以免在后面的讨论中混淆。

先回忆下mov(数据传送指令),我提到过有两种用法,一种是值传递:mov $4, %eax和 mov %ebx,%eax。 都是把数值4或者寄存器ebx里的数值传送到寄存器eax;

第二种用法是间接引用,如mov 4(%ebx), %eax,就是用诸如"()"的方式把寄存器里的数值当成地址理解,并根据这个地址寻找到存储器的相应位置,并读出值,再传送给%eax。

关于间接引用,汇编和C语言在概念上完全一致,只是在实现方式上有所不同。我们熟悉的C语言在定义变量时,把存储地址的变量和存储数值的变量区分开来,于是才有无符整型变量和无符整型指针变量u_int a与u_int *a的区别,事实上在32位系统中他们都是占领4字节空间,并且存储的都是32位无符整数,只是在一些算数语句和数组运算以及“*”等操作时能体现不同的处理策略。而在汇编语言中,寄存器就是寄存器,不存在指针寄存器或整数寄存器的区别(之前的栈指针%esp和帧指针%ebp只是便于理解而约定俗成的名称)。寄存器里面可以存储数值,至于这个数值是地址还是其他,对CPU来说并不重要,只是当编译器觉得他存的是地址并希望找到相应的存储位置时,就可以利用“()”操作符,像C语言中的“*”那样,进行间接寻址罢了。C语言之所要定义出指针变量,一是为了使得间接寻址更直观好理解,二是特定类型按约定长度跳步更方便(和数组类似)。但即便这样,埋怨C指针和数组概念抽象的小伙伴仍是人山人海……

清楚了间接寻址,我们再来看lea(加载有效地址Load Effective Address),好高大上的名字,咋一看感觉这又是要把间接寻址玩翻天的操作,其实没那么麻烦。

看看上面所说的mov如何处理间接寻址实现数据传送的?分为三步:

1、计算地址值,要么直接是寄存器里的值,要么对寄存器里的值进行一系列运算得出的值;

2、根据得到的地址值引用其空间中的数值;

3、将得到的数值传送给接收方(寄存器)

上面说的是mov,那lea是想干什么伟大工程呢?其实它就是省略第2步操作,直接从1跳到3,解释完毕!举例如下:

假设%ebx的值是0x108,而0x108是某个存储器地址,里面存数值0xCD,那么如下两条语句:

movl (%ebx), %eax //1、从ebx中读出地址值0x108;2、根据地址0x108间接寻址到存储器,并将其中的0xCD挖出来;3、将0xCD传送给eax

leal (%ebx), %eax //1、从ebx中读出地址值0x108;2、对不起,作为lea我不干这一步嚯嚯呵呵哈哈………… ;3、将0x108传送给eax

简单吧?如果说mov可以间接寻址引用,那lea无非就是间接寻址,但不引用。其实另一种理解方式是,lea中的"()"操作毫无间接寻址的概念,它只是借用mov寻址时的计算规则,来加工寄存器里的值罢了。根据lea的这个性质,可以利用来做和地址毫不相干的数字运算

比如ebx里存的是3,但我想把5赋给eax,就可以leal 2(%ebx), %eax,而不需要像下面这样费事:

addl $2, %ebx

mov %ebx, %eax

subl $2, %ebx //为了保持ebx里面的3,还要把加的2减回来,这也忒麻烦了吧!!!

二:递归函数栈帧实现

这里引用教科书上的一则例子,fib_rec.c

int fib_rec(int n)

{

int prev_val, val;

if(n <= 2)

return 1;

prev_val = fib_rec(n-2);

val = fib_rec(n-1);

return prev_val + val;

}

这个递归,稍稍有点忽悠人,如果我输入的n是6,输出是多少?用二叉树比较不容易错:

(8) 6 括号里的数,就是该元素作为参数传进函数时得到的返回合

/ \

(3) 4 (5)5

/ \ / \

(1)2 (2)3 (2)3 (3)4

/ / \

1 1 2

/

1

接下来是递归函数的汇编实现,我把教科书的汇编摘抄下来(与我实际反汇编出来的略有出入)

1 fib_rec:

Setup code

2 pushl %ebp Save old %ebp

3 movl %esp,%ebp Set %ebp as frame pointer

4 subl $16,%esp Allocate 16 bytes on stack

5 pushl %esi Save %esi (offset -20)

6 pushl %ebx Save %ebx (offset -24)

Body code

7 movl 8(%ebp),%ebx Get n

8 cmpl $2,%ebx Compare n:2

9 jle .L24 if <=, goto terminate

10 addl $-12,%esp Allocate 12 bytes on stack

11 leal -2(%ebx),%eax Compute n-2

12 pushl %eax Push as argument

13 call fib_rec Call fib_rec(n-2)

14 movl %eax,%esi Store result in %esi

15 addl $-12,%esp Allocate 12 bytes to stack

16 leal -1(%ebx),%eax Compute n-1

17 pushl %eax Push as argument

18 call fib_rec Call fib_rec(n-1)

19 addl %esi,%eax Compute val+nval

20 jmp .L25 Go to done

Terminal condition

21 .L24: terminate:

22 movl $1,%eax Return value 1

Finishing code

23 .L25: done:

24 leal -24(%ebp),%esp Set stack to offset -24

25 popl %ebx Restore %ebx

26 popl %esi Restore %esi

27 movl %ebp,%esp Restore stack pointer

28 popl %ebp Restore %ebp

29 ret Return



现在我们先根据教科书上的汇编代码以及栈帧结构图来尝试着分析一下。书上将fib_rec函数体分成setup、body、terminal condition和finishing。

setup就是构建fib_rec函数的栈帧,2、3行是熟悉的通用函数栈指针和帧指针初始化的语句。运行到3行时,%ebp和%esp应该都在左图-4的那个位置上。接着是4行对栈指针作减16的操作,完事后%esp就应该指向-20的位置,这一减法的目的就是移动栈指针,产生16字节(四个栈单元)的空间。接下来5、6行是两条压栈语句,分别把%esi和ebx这两个“被调用者保存”的寄存器压入栈中进行保存,这步操作的视角是fib_rec作为子函数,保存调用它的函数可能会用到的%esi和%ebx寄存器的值。

问题来了,我们看栈指针从-20的位置开始,执行push %esi,咦?栈指针原本就在-20的位置,怎么做了压栈操作,新值%esi还呆在-20?仔细观察就会发现,既然“返回地址”是在+4,那么下一个栈帧单元就应该是0而不是-4,可以断定这个图上写错了,我们在心里把左右两图的-4位置用0来代替,再从第4行分析,%esp从0开始减去16,就会跳到-16的位置,然后再进行push %esi,栈指针就会顺利的指向-20,而%esi的值也会存入-20。

接下来看第6行,也是压栈操作,压栈对象%ebx,按理它应该接着被压入-24才对,可图上竟然印的是“保存的%ebp”!这分明是新的栈帧了?再看右图,n-2是准备好第二层调用的参数,那说明fib_rec的栈帧括号包含到了-40,既然是完整的栈帧,怎么可能有两个“保存的%ebp”?这里面一定有问题,从第6行对%ebx压栈开始就应该自信的怀疑图是否写错,为此我查看了教材的英文原版视图:



上图可以清楚的看到,除了刚开始我们找出的-4为0的那个错误外,左右两图的-24上分明印着“saved %ebx”也就是说,中文翻译教材将ebx误印成ebp了。我专门带大家走这一弯路,也是再次警醒自己和同道:尽信书不如无书

好了,我们用英文原版的视图来接着分析,setup code部分已经没有异议了。然后函数实现体body code,此时%ebp还在0的位置没变,第7行明显是对n的参数调用,%ebx暂时存储了n。第8、9行是条件判断,如果n<=2就直接跳转,返回1。第10行又是对栈指针的向下移动,开辟出12字节的空间,第11行对应本节开头所讲解的内容,这里明显是利用lea的性质,将%ebx的值减去2再传送给%eax,回忆下%eax是“调用者保存”寄存器,里面的旧值在fib_rec函数外肯定已完成了备份保护,因此子函数大胆的使用。好了,现在%eax里存了n-2这个值,接下来是要递归调用fib_rec(n-2)了,那么n-2作为参数,肯定要在call之前进行压栈,接下来就是第13行:具体的递归调用fib_rec了。

好了,我们先接着看第一层fib_rec,13行执行完后一定有返回值保存在%eax中,于是将其传送给%esi,此时%esi就存储了prev_val的值;接着第15行又往下开辟12字节空间,16行计算出n-1(注意%ebx从来没被覆盖过,一直代表fib_rec所在该递归层传入的参数值n),17、18类似12、13,等fib_rec(n-1)调用返回后,将返回值%eax(就是val值)与%esi相加,得到合prev_val
+ val;(%esi接收了fib_rec(n-2)的返回值prev_val),并作为返回值,传送给%eax。

最后是结束代码L25,第24行将栈指针%esp移回到-24处,也就是保存%ebx的位置,接着25、26行就顺序弹出两个寄存器,最后27、28实际上就是leave的分解代码,fib_rec第一层函数执行结束。

截止目前我们讨论的都是fib_rec第一层调用,关于fib_rec(n-2)和fib_rec(n-1)内部做了啥似乎没有涉及,越想越觉得难,其实非也,你琢磨下,既然fib_rec(n)你都能分析得头头是道,那fib_rec(n-2)和fib_rec(n-1)还会远么?用宏观的思维一想应该是没有区别的,但没有对其一一跟进,总让人有种心里没底的错觉,那下面我们就试着再往后面推几步。

假设现在我们在第一层fib_rec(n)中,13行和18行两个递归调用,之前的push %eax已经把n-2参数准备好了,此时%ebp还在0,%esp在-40。那么当启动13行时,call指令会将第14行代码的地址作为”返回地址“,压入栈-44的位置,然后CPU重新跳转到setup code,此时执行的第2行,就把栈0地址压入-48的位置,-48处就作为fib_rec(n-2)的栈帧首,里面存储着“保存的%ebp”,此值恰恰就是第一层fib_rec(n)的帧首地址。接下来执行第3行,移动%ebp(之前都还指着0位置),将新栈帧首地址-48传送给%ebp,使得%ebp指向fib_rec(n-2)的栈帧首,此时第二层fib_rec(n-2)的栈帧以及栈指针和帧指针已经初始化完毕。

接着执行第4、5、6行,又扩展16字节后,%esi和%ebx分别被压入-68和-72。回忆下,由于第二层fib_rec(n-2)还未调用结束,因此第一层fib_rec(n)并未获得prev_val,因此此时%esi里存的还不是第一层需要备份的值(白备份,无奈浪费CPU生命),而%ebx存储了第一层fib_rec(n)的参数n的值!上层的参数信息在下层的栈帧中被有效备份保护起来,接下来第7行,读取-48+8位置的值,也就是参数n-2,并把他作为第二层fib_rec(n-2)的n,赋给%ebx。然后当执行到11行时,%eax就存储了(n-2)-2,并作为参数,在12行被压栈,13行调用第三层fib_rec(n-2-2)……后面如何调用第三层fib_rec(n-2-1)的逻辑完全一样。

我们直接往后看第19行,此时%eax里被保存了fib_rec(n-2-2)和fib_rec(n-2-1)返回值的合(也就是fib_rec(n-2)的返回值),然后是第24行,通过第二层的%ebp找到第一层%esi和%ebx旧值的保存位置,并顺序弹出栈,%esi和%ebx即恢复到第一层的旧值。然后27行使得栈指针指回到第二层“保存的%ebp”(里面存了第一层%ebp的地址0),第28行通过出栈,让%ebp指回到第一层%ebp的0位置,此时%ebp回到栈0的位置,而%esp回到了-40的位置。第29行根据返回地址跳转到第一层fib_rec(n)的第14行,通过%eax的传送,第一层的%esi就存储了第二层fib_rec(n-2)的返回值,第15行将%esp从-40往下走到-52,第16行又将%eax存储成新参数n-1,并通过17行压栈入-56,第18行执行第二层fib_rec(n-1),在里面将分解出第三层fib_rec(n-1-2)和第三层fib_rec(n-1-1)……

很明显栈帧结构将会像左图和右图那样循环构建下去,8存参数n,-40存n-2,-56存n-1,以此类推……,没有晕吧?呵呵,我觉得写到这里已经足够了,有兴趣你可以继续往下推,画出各层栈帧,第三层第四层第n-3层都是完全一样的逻辑,只要每层在寄存器里存储的值被本层或子层有效的备份在各自的栈中,并在合适的地点恢复,递归得再深也是不会出错的。如果您还是不能理解,我仍抱有相同的建议,是不是考虑转行O(∩_∩)O。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: