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寄存器的状态。
相关文章推荐
- Android推荐书目文章(实时更新....)
- 【转】android fragment 博客 学习
- android 开发环境
- iOS简单的数据持久化
- iOS的沙盒机制
- OC-KVO的应用
- iOS - 邮件 短信 通讯录
- Android0917<二十一>(自定义的View、绘制简单图形、Bitmap)(二)
- Arcgis For Android实现比例尺
- iOS提示框,为什么你应该使用 MBProgressHUD?
- Android-ListActivity单击事件的响应
- Android 自定义View——Path的使用
- iOS提示框,为什么你应该使用 MBProgressHUD?
- iOS - 数据持久化 - 文件的写入(简单对象和复杂对象)
- iOS上的ReactiveCocoa响应式编程
- hdu 5119 Happy Matt Friends(dp)
- iOS中objecive-c语言和android中java语言的区别
- Android自由选择TextView的文字
- html5实现微信摇一摇功能
- 深入理解Android的startservice和bindservice