您的位置:首页 > 移动开发

CSAPP读书笔记——程序的机器级表示之栈帧结构

2015-09-17 19:14 351 查看

引子

C语言的基本构成单位是函数,通过合理的组织、调用函数来完成一系列的目的。

我开始学习的时候就好奇调用函数(或者说调用过程)时到底发生了什么?

数据在内存中是如何组织的?

函数返回时如何准确到找到下一条将要执行的指令?

等等等一系列的疑问,了解了之后,豁然开朗,记录下来时常温习。

栈帧结构

IA32的程序使用堆栈支持过程的调用(函数的调用),在函数调用时会专门从堆栈中分出一块内存(称为)供函数使用。

传递给函数的参数由堆栈来保存,帧则负责存储寄存器的状态、局部变量的内存分配的相关任务。

如果说函数P调用函数Q,那么称P为调用者(caller),Q是被调用者(callee)。

根据上述规则, 堆栈会给Q分配帧,并且用两个指针(分别存储在%ebp\%ebp和%esp\%esp中)指示帧的开始和结束的位置。



[图示caller代表P,callee代表Q]

Caller’s Framer:P是一个函数,会被其他过程调用,所以也会给它分配帧,但是我们不关心它,因为这里考虑的是P调用Q。注意P的帧分为了三个部分。

(1)自身使用区:保存P函数中的局部变量,寄存器等状态,是在
Argument n
以上的蓝色部分。

(2)参数区:
Argument 1--Aegument n
,这些都是将要传递给Q的参数,记得吗?参数是存储在caller的帧中的。

(3)返回地址区:即标注为
Return address
的区域。它存储了当从Q返回后,P将要执行的下一条指令的地址。

Callee’s Framer : Callee’s Frame一定是当前帧(Current’s Framer)。我们注意到Q的帧也是大体分为了三个部分。

(1)帧开始段 : %ebp\%ebp指向帧开始段的地址。该地址的内存单元存储的是之前%ebp\%ebp指向的地址。在P调用Q之前,%ebp\%ebp指向一个地址假如为
0x88088808
(实际上这是P帧的开始地址),那么当P调用Q的时候,会将
0x88088808
放到
Saved %ebp
内存单元中。

(2)帧主体段:用于局部变量的创建、数据转移等

(3)帧结束段:%esp\%esp始终指向栈顶,也是当前活动帧的结束的地方。
Arugument bulid area
区是指的当Q调用其他的函数(例如Q1,Q2….),那么传递给Q1,Q2的参数是要保存在Q中的。

转移控制

了解了堆栈和栈帧结构之后,我们在看一下机器指令是如何支持过程的调用的。

指令描述
call Label直接过程调用,跳转到地址为Label的指令处执行
call *Operand间接过程调用,跳转到地址为 *Operand的指令处执行
leave“通知”堆栈做好函数返回的准备工作
ret函数返回
我们根据图示逐条解释。

call

在P调用Q之前,内存分布是这样的。



当执行
P = Q
时,假设生成了如下汇编代码:

//指令地址      //指令的二进制表示     //汇编指令
0x1234567 <Q>:
some instructions
0x80483dc      e8 b3 ff ff ff       call  0x1234567<Q>// call Q
0x80483e1      83 c4 14             add   $0x14.%esp


当调用Q,也就是
call 0x1234567
时,首先,将call指令的下一条指令的地址压入栈中,作为
Return address




之后(还是call的操作),将之前的%ebp\%ebp中的内容保存起来,作为Q的帧的开始段的内容,然后将%ebp\%ebp指向开始段的地址。



然后(仍然是call的操作),将%esp\%esp移动合适的位置(分配内存)



至此,call操作完成。

leave

leave
指令目的是让栈为
ret
做好准备,那么到底做好什么准备呢?

还记得吗?我们把之前的%ebp\%ebp的值保存了起来,在return后该值应该被重新赋值给%ebp\%ebp(实际上还有寄存器%ebx\%ebx,我们稍后再详细说明),还有,分配给Q的帧应当被回收(控制%esp\%esp)。

所以,leave指令相当于:

movl %ebp,%esp   //移动%esp到帧开始的地方,回收内存
popl %ebp        //将保存的旧的%ebp恢复(帧开始的地方存储,现在刚好被%esp指向,弹出后,%esp指向return address的地址,旧的%ebp也被重新赋值给了%ebp)


ret

leave
指令执行后,确保栈的内存被正确回收,状态正确恢复,可以放心大胆的
ret
了。

ret
后,将执行
return address
处的指令。

寄存器使用习惯

在讲解C实例的时候,我们要先了解一下寄存器的使用习惯(约定俗成的),方便理解汇编代码。

我们知道,寄存器是被所有的过程(或者说函数)共享的,只不过实际上一次只有一个过程(函数)可以使用它的资源。

这实际上就会引发一个问题,如果寄存器A存储了P的一些信息,当P调用Q时,如果Q也使用A那么就会覆盖掉A存储的P的信息(这样实际上P的信息就丢失了)。

所以必须有一个原则,callee不得覆盖caller之后还会用到的寄存器的信息。(实际上,限制了callee的访问权限)

为了解决这个问题,IA32机器对寄存器加入了一些“限制”,规定了哪些寄存器的状态被caller或者callee保存。

%eax,%edx,%ecx\%eax,\%edx,\%ecx:caller-saved寄存器。当P调用Q时,Q可以使用这些寄存器而不用担心破坏P的信息。

%ebx,%esi,%edi\%ebx,\%esi,\%edi:callee-saved寄存器。当Q需要覆盖这些寄存器的信息的时候,必须先将其copy到内存中,因为调用Q的caller可能会在今后的计算中用到这些数据。

%esp,%ebp\%esp,\%ebp:必须要保持状态,改变时要copy出一个副本以便恢复。

举一个汇编的例子:

subl $12,  %esp
movl %ebx, (%esp) //
movl %esi, 4(%esp)//
movl %edi, 8(%esp)//想对这三个寄存器写入,那么再写入之前必须将其以前的数据备份到内存中


C实例

考虑CSAPP书上的一个例子:

int swap_add(int *xp,int *yp)
{
int x = *xp;
int y = *yp;

*xp = y;
*yp = x;
return x + y;
}

int caller()
{
int arg1 = 534;
int arg2 = 1057;
int sum = swap_add(&arg1,&arg2);
int diff = arg1 - arg2;

return sum * diff;
}


我们画出它的栈帧结构,是这样的:



注意这里arg1,arg2和&arg1,&arg2的相对顺序:

一会看汇编我们会发现,arg1,arg2是按照参数表的声明顺序的相反顺序添加到栈中的。

执行call汇编指令时:



我们会发现,寄存器%ebx\%ebx中的值也被save了,因为这属于callee-saved寄存器,而且稍后会用到。

我们看生成的汇编代码:

caller:
pushl  %ebp          //保存%ebp的值
movl   %esp,%ebp     //将%ebp设置为帧的开始地址
subl   $24,%esp      //给caller分配24个字节的内存
movl   $534,-4(%ebp) //arg1 = 534
movl   $1057,-8(%ebp)//arg2 = 1057
leal   -8(%ebp),%eax //计算&arg2----先计算arg2的地址而不是arg1的
movl   %eax,4(%esp)  //放到栈里
leal   -4(%ebp),%eax //计算arg1
movl   %eax,(%esp)   //放到栈里
call   swap_add      //调用swap_add


swap_add:

pushl    %ebp
movl     %esp,%ebp
pushl    %ebx     //和上述caller的开始三行一模一样
movl     8(%ebp),%edx//get xp
movl     12(%ebp),%ecx//get yp
movl     (%edx),%ebx// get x
movl     (%ecx),%eax//get y
movl     %eax,(%edx)// *xp = y
movl     %ebx,(%ecx)// *yp = x
addl     %ebx,%eax  // value = x + y,%eax始终作为返回值的寄存器


递归

int rFact(int n)//递归求阶乘
{
int result;

if(n <= 1)
result = 1;
else
result = n * rFact(n-1);
return result;
}


递归是很重要的编程思想,它的本质就是函数自己调用自己,它的汇编代码类似循环的汇编(条件跳转+标签标记),我们一起看一下。

//argument : n at %ebp+8
registers: n in %ebx.result in %eax
rfact:
pushl %ebp     //保存%ebp以前的状态
movl  %esp,%ebp//移动栈指针,指向帧开始的地方
pushl %ebx     //保存%ebx以前的状态
subl  $4,%esp  //分配帧空间
//以上4行都是帧创建的set-up操作,
movl  8(%ebp),%ebx  //get n
movl  $1,%eax       //result = 1,%eax是用来保存返回数据的寄存器
cmpl  $1,%ebx       //比较n和1
jle   .L53          //如果<= goto done,while循环的判断
leal  -(%ebx),%eax  //计算n-1
movl  %eax,(%esp)   //存到栈顶,%eax在返回后还要使用其中的值,所以copy一份
call  rfact         //递归
imull %ebx,%eax     //可以理解现在%eax中存储的是递归返回后的值,也就是rFact(n-1),因为该值稍后要返回,一直保存在%eax
.L3:
//done,实现栈内存的回收,%ebx,%ebp,%esp的状态返回
addl  $4,%esp      //回收栈内存
popl  %ebx         //恢复%ebx的内容
popl  %ebp         //恢复%ebp的内容
ret                //所有准备操作都做好了,return


如此来看,其实递归没有那么神秘,和普通的函数调用实际上一致的。

set-up:保存%ebp的旧状态,分配栈空间,保存callee-saved registers的状态(例如%ebx,如果用到的话)

body:函数主体部分,完成相应操作(注意%eax总是优先存取return的变量)。

end :回收栈分配的内存,恢复%ebp寄存器的状态。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: