基于栈的运行时环境

函数堆栈

程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中,还要有其动态环境,其中最重要的就是堆栈。下图是linux下进程的地址空间布局:
p1

TIPS:  
代码段就是存储程序文本的,所以也叫文本段,指令指针中的指令就是从这里取得,对应图中的text。这个段是可以被共享的,比如在Linux下开了两个vim编辑文本,那么一般来说这两个vim是共享一个代码段的,但是数据段不同(这有点类似c++中类的不同对象共享相同的成员函数)。代码段的特点是:可读可执行不可写。

数据段是存储数据用的,包括初始化的数据和未初始化的数据(BSS段)两部分,对应图中的data和bss。data一般存放静态非零数据和全局非零数据;bss(Block Started by Symbol)段主要存放未初始化的静态数据和全局数据。数据段的特点是:可读可写不可执行。

堆是给动态分配内存使用的,malloc等函数分配的内存就是在这个区域里的。堆的特点是:可读可写可执行。


首先,execve(2)负责为进程的代码段和数据段建立映射,而真正的将代码段和数据段的内容读进内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将bss段清零,这就是为什么未赋初值的全局变量和静态变量的初值为零的原因。
进程的用户空间的最高位置是用来存放程序运行时的命令行参数和环境变量的,在这段地址的下方和bss段的上方还留有一个很大的“空洞”,而作为进程动态运行环境的堆栈(stack)和堆(heap)就栖身其中,其中堆栈向下生长,堆向上生长。
知道了堆栈在进程地址空间中的位置,下面来看一看堆栈中存放了什么。实际上堆栈中存放的就是与每个函数(调用)对应的堆栈桢(frame,也叫活动记录)。当函数调用发生时,新的堆栈桢被压入堆栈;当函数返回时,相应的堆栈桢从堆栈中弹出。典型的堆栈桢如下图所示:
p2 堆栈桢的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈桢的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈桢通常有两个指针,其中一个称为堆栈桢指针,另外一个成为栈顶指针。前者所指向的位置是固定的,而后者所指向的位置在函数运行过程中可变。因此,在函数中访问实参和局部变量时,都是以堆栈桢指针为基址,再加上一个偏移。从上图可以看出,实参的偏移为正,局部变量的偏移为负。


Intel i386体系结构上,堆栈桢的实现

下面是一个简单的C程序及其编译后生成的汇编程序:

int function(int a, int b, int c)  
{
        char buffer[14];
        int     sum;
        sum = a + b + c;
        return sum;
}
void main()  
{
        int     i;
        i = function(1,2,3);
}
1   .file   "example1.c"  
2     .version    "01.01"  
3 gcc2_compiled.:  
4 .text  
5     .align 4  
6 .globl function  
7     .type    function,@function  
8 function:  
9     pushl %ebp  
10     movl %esp,%ebp  
11     subl $20,%esp  
12     movl 8(%ebp),%eax  
13     addl 12(%ebp),%eax  
14     movl 16(%ebp),%edx  
15     addl %eax,%edx  
16     movl %edx,-20(%ebp)  
17     movl -20(%ebp),%eax  
18     jmp .L1  
19     .align 4  
20 .L1:  
21     leave  
22     ret  
23 .Lfe1:  
24     .size    function,.Lfe1-function  
25     .align 4  
26 .globl main  
27     .type    main,@function  
28 main:  
29     pushl %ebp  
30    movl %esp,%ebp  
31     subl $4,%esp  
32     pushl $3  
33     pushl $2  
34     pushl $1  
35     call function  
36     addl $12,%esp  
37     movl %eax,%eax  
38     movl %eax,-4(%ebp)  
39 .L2:  
40     leave  
41     ret  
42 .Lfe2:  
43     .size    main,.Lfe2-main  
44     .ident  "GCC: (GNU) 2.7.2.3"  
  • 首先看堆栈桢的形成:
    function是在main中被调用的,三个实参的值分别为1、2、3。由于C语言中函数传参遵循反向压栈顺序,所以在汇编代码的第32-34行,三个实参从右向左依次被压入堆栈。接下来35行的call指令,除了将控制转移到function之外,还要将call的下一条指令addl的地址,也就是function函数的返回地址压入堆栈。下面就进入到function函数了,首先在第9行将main函数的堆栈桢指针ebp保存在堆栈中,并在第10行将当前的栈顶指针esp保存在堆栈桢指针ebp中,最后在第11行为function函数的局部变量buffer[14]和sum在堆栈中分配空间。至此,function函数的堆栈桢就构建完成了,其结构如下图所示:
    p5
  • 接下来看在函数function中是如何将a、b、c的和赋值给sum的:
    前面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,上图中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下汇编代码中第12至16行的计算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分别是实参a、b、c和局部变量sum的地址,几个简单的add指令和mov指令执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在汇编代码中第17行将sum的值拷贝到eax中。
  • 最后看函数function执行完成之后,与其对应的堆栈桢是如何弹出堆栈的:
    汇编代码中第21行的leave指令将堆栈桢指针ebp拷贝到栈顶指针esp中,于是在堆栈桢中为局部变量buffer[14]、sum分配的空间就被释放了。除此之外,leave指令还有一个功能,就是从堆栈中弹出一个机器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹出一个机器字并将其存放到指令指针eip中。这样控制就返回到了第36行main函数中的addl指令处。addl指令将栈顶指针esp加上12,于是当初调用函数function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇编程序中通过eax传递函数的返回结果,因此汇编代码中第38行将函数function的返回结果保存在了main函数的局部变量i中。

参考文档

感谢浏览tim chow的作品!

如果您喜欢,可以分享到: 更多

如果您有任何疑问或想要与tim chow进行交流

可点此给tim chow发信

如有问题,也可在下面留言: