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

来源:互联网 发布:无主之地2 mac汉化 编辑:程序博客网 时间:2024/05/22 12:48

引子


C语言的基本构成单位是函数,通过合理的组织、调用函数来完成一系列的目的。
我开始学习的时候就好奇调用函数(或者说调用过程)时到底发生了什么?
数据在内存中是如何组织的?
函数返回时如何准确到找到下一条将要执行的指令?
等等等一系列的疑问,了解了之后,豁然开朗,记录下来时常温习。


栈帧结构


IA32的程序使用堆栈支持过程的调用(函数的调用),在函数调用时会专门从堆栈中分出一块内存(称为)供函数使用。
传递给函数的参数由堆栈来保存,帧则负责存储寄存器的状态、局部变量的内存分配的相关任务。
如果说函数P调用函数Q,那么称P为调用者(caller),Q是被调用者(callee)。
根据上述规则, 堆栈会给Q分配帧,并且用两个指针(分别存储在%ebp%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指向的地址。在P调用Q之前,%ebp指向一个地址假如为0x88088808(实际上这是P帧的开始地址),那么当P调用Q的时候,会将0x88088808放到Saved %ebp内存单元中。
    (2)帧主体段:用于局部变量的创建、数据转移等
    (3)帧结束段:%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 instructions0x80483dc      e8 b3 ff ff ff       call  0x1234567<Q>// call Q0x80483e1      83 c4 14             add   $0x14.%esp
  1. 当调用Q,也就是call 0x1234567时,首先,将call指令的下一条指令的地址压入栈中,作为Return address
    这里写图片描述

  2. 之后(还是call的操作),将之前的%ebp中的内容保存起来,作为Q的帧的开始段的内容,然后将%ebp指向开始段的地址。
    这里写图片描述

  3. 然后(仍然是call的操作),将%esp移动合适的位置(分配内存)
    这里写图片描述

至此,call操作完成。


leave


leave指令目的是让栈为ret做好准备,那么到底做好什么准备呢?
还记得吗?我们把之前的%ebp的值保存了起来,在return后该值应该被重新赋值给%ebp(实际上还有寄存器%ebx,我们稍后再详细说明),还有,分配给Q的帧应当被回收(控制%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:caller-saved寄存器。当P调用Q时,Q可以使用这些寄存器而不用担心破坏P的信息。
  • %ebx,%esi,%edi:callee-saved寄存器。当Q需要覆盖这些寄存器的信息的时候,必须先将其copy到内存中,因为调用Q的caller可能会在今后的计算中用到这些数据。
  • %esp,%ebp:必须要保持状态,改变时要copy出一个副本以便恢复。

举一个汇编的例子:

subl $12,  %espmovl %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中的值也被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 %eaxrfact:    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寄存器的状态。
1 0
原创粉丝点击