栈由栈帧组成,每个栈帧对应于一个(未执行完的)函数。接下来我们通过讲解栈帧的布局、形成和消亡来理解栈帧在函数调用时是如何起作用的。
栈帧的布局图10.7所示是一个简单的测试程序,用于帮助我们了解栈帧。
embedded/code/application/stackframe/main.c
00001: #include <stdio.h>
00002:
00003: //lint -e530 -e123
00004:
00005: void tail (int _param)
00006: {
00007: int local = 0;
00008: int reg_esp, reg_ebp;
00009:
00010: asm volatile(
00011: // get EBP
00012: "movl %%ebp, %0 \n"
00013: // get ESP
00014: "movl %%esp, %1 \n"
00015: : "=r" (reg_ebp), "=r" (reg_esp)
00016: );
00017: printf ("tail (): EBP = %x\n", reg_ebp);
00018: printf ("tail (): ESP = %x\n", reg_esp);
00019: printf ("tail (): (EBP) = %x\n", *(int *)reg_ebp);
00020: printf ("tail (): return address = %x\n", *(((int *)reg_ebp + 1)));
00021: printf ("tail (): &local = %p\n", &local);
00022: printf ("tail (): ®_esp = %p\n", ®_esp);
00023: printf ("tail (): ®_ebp = %p\n", ®_ebp);
00024: printf ("tail (): &_param = %p\n", &_param);
00025: }
00026:
00027: int middle (int _p0, int _p1, int _p2)
00028: {
00029: int reg_esp, reg_ebp;
00030:
00031: asm volatile(
00032: // get EBP
00033: "movl %%ebp, %0 \n"
00034: // get ESP
00035: "movl %%esp, %1 \n"
00036: : "=r" (reg_ebp), "=r" (reg_esp)
00037: );
00038: tail (_p0);
00039: printf ("middle (): EBP = %x\n", reg_ebp);
00040: printf ("middle (): ESP = %x\n", reg_esp);
00041: printf ("middle (): (EBP) = %x\n", *(int *)reg_ebp);
00042: printf ("middle (): return address = %x\n", *(((int *)reg_ebp + 1)));
00043: printf ("middle (): ®_esp = %p\n", ®_esp);
00044: printf ("middle (): ®_ebp = %p\n", ®_ebp);
00045: printf ("middle (): &_p0 = %p\n", &_p0);
00046: printf ("middle (): &_p1 = %p\n", &_p1);
00047: printf ("middle (): &_p2 = %p\n", &_p2);
00048: return 1;
00049: }
00050:
00051: int main ()
00052: {
00053: int reg_esp, reg_ebp;
00054: int local = middle (1, 2, 3);
00055:
00056: asm volatile(
00057: // get EBP
00058: "movl %%ebp, %0 \n"
00059: // get ESP
00060: "movl %%esp, %1 \n"
00061: : "=r" (reg_ebp), "=r" (reg_esp)
00062: );
00063: printf ("main (): EBP = %x\n", reg_ebp);
00064: printf ("main (): ESP = %x\n", reg_esp);
00065: printf ("main (): (EBP) = %x\n", *(int *)reg_ebp);
00066: printf ("main (): return address = %x\n", *(((int *)reg_ebp + 1)));
00067: printf ("main (): ®_esp = %p\n", ®_esp);
00068: printf ("main (): ®_ebp = %p\n", ®_ebp);
00069: printf ("main (): &local = %p\n", &local);
00070: return 0;
00071: }
图10.7
这个小程序的每个函数中都嵌入了汇编代码,以便获得各函数运行时刻ESP和EBP寄存器的值。另外,每一个函数中都打印出了EBP寄存器所指向内存地址处的值,以及位于其后的函数返回地址,这样做的原因后面还会细讲。图10.8显示了这一程序的编译和运行结果。
yunli.blog.51CTO.com /embedded/build
$ make
yunli.blog.51CTO.com /embedded/build
$ ./release/stackframe.exe
tail (): EBP = 22cd08
tail (): ESP = 22ccf0
tail (): (EBP) = 22cd28
tail (): return address = 40120b
tail (): &local = 0x22cd04
tail (): ®_esp = 0x22cd00
tail (): ®_ebp = 0x22ccfc
tail (): &_param = 0x22cd10
middle (): EBP = 22cd28
middle (): ESP = 22cd10
middle (): (EBP) = 22cd58
middle (): return address = 401302
middle (): ®_esp = 0x22cd24
middle (): ®_ebp = 0x22cd20
middle (): &_p0 = 0x22cd30
middle (): &_p1 = 0x22cd34
middle (): &_p2 = 0x22cd38
main (): EBP = 22cd58
main (): ESP = 22cd30
main (): (EBP) = 22cd98
main (): return address = 61006e73
main (): ®_esp = 0x22cd50
main (): ®_ebp = 0x22cd4c
main (): &local = 0x22cd48
图10.8
为 了更好地理解输出结果中各数据间的关系,我们将其转化为图,如图10.9所示。图的左边还示例说明了栈的增长方向和栈的内存地址。黑色的箭头和寄存器名表 示当前栈帧,否则用灰色表示。图中表示的是站在tail()函数内所看到的栈布局,其中完整地示例说明了tail()和middle()两个函数的栈帧结 构,以及main()函数的一部分。
在通常情形下,每个函数都有自己的栈帧。各栈帧中存在一个域用于存放前一个调用函数的栈帧基址,通过这个 域将所有调用与被调用函数的栈帧以链表的形式连在一起。栈帧的这种组织结构说明了为什么函数调用级数越多,所占用的栈空间也越大,也解释了为什么在嵌入式 软件开发中我们需要小心使用递归函数。
栈帧的形成为了方便讲解,我们还得获取图10.7所示的示例程序所对应的汇编代码片段,如图10.10所示。图中删除了tail()函数汇编代码的中间部分,而只保留了头和尾用于创建和删除栈帧的内容。在汇编代码中,最左边列出了指令在内存中的地址,在接下来讲解栈帧中的返回地址(return address)信息时,其所指的内容就是指这一地址。
yunli.blog.51CTO.com /embedded/build
$ objdump -d ./release/stackframe.exe > stackframe.txt
yunli.blog.51CTO.com /embedded/build
$ vi stackframe.txt
00401130 <_tail>:
401130: 55 push %ebp
401131: 89 e5 mov %esp,%ebp
401133: 83 ec 18 sub $0x18,%esp
401136: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%ebp)
40113d: 89 ea mov %ebp,%edx
40113f: 89 e0 mov %esp,%eax
401141: 89 55 f4 mov %edx,-0xc(%ebp)
401144: 89 54 24 04 mov %edx,0x4(%esp)
401148: 89 45 f8 mov %eax,-0x8(%ebp)
40114b: c7 04 24 a0 20 40 00 movl $0x4020a0,(%esp)
…… 显示结果有删减 ……
4011e1: c9 leave
4011e2: c3 ret
004011f0 <_middle>:
4011f0: 55 push %ebp
4011f1: 89 e5 mov %esp,%ebp
4011f3: 83 ec 18 sub $0x18,%esp
4011f6: 89 e8 mov %ebp,%eax
4011f8: 89 e2 mov %esp,%edx
4011fa: 89 45 f8 mov %eax,-0x8(%ebp)
4011fd: 8b 45 08 mov 0x8(%ebp),%eax
401200: 89 55 fc mov %edx,-0x4(%ebp)
401203: 89 04 24 mov %eax,(%esp)
401206: e8 25 ff ff ff call 401130 <_tail>
40120b: 8b 45 f8 mov -0x8(%ebp),%eax
40120e: c7 04 24 44 21 40 00 movl $0x402144,(%esp)
401215: 89 44 24 04 mov %eax,0x4(%esp)
401219: e8 da 01 00 00 call 4013f8 <_printf>
…… 显示结果有删减 ……
图10.10
现在假设程序运行在main()刚调用middle()函数的时刻,让我们看一看栈布局是如何发生变化的。程序一进入middle()函数所运行的第一条指令位于内存地址4011f0处,在运行这一指令之前的栈结构如图10.11所示。此时的EBP还是指向main()函数栈帧的头部,而ESP所指向的内存中所存放的是程序返回到main()函数的指令位置,后面分析middle()函数对tail()函数的调用时还将涉及这一点。
内存地址4011f0~4011f3的指令的作用就是形成middle()函数的栈帧。第一条指令(位于内存地址4011f0处)是将调用函数(即main()函数,middle()是被调用函数)的栈帧基址保存到栈上,这条指令是一个压栈操作。正是各函数内的这一操作,使得所有的栈帧连在了一起成为一条链。
第二条指令(位于内存地址4011f1处)将ESP寄存器的值赋值给EBP寄存器,也就是说,此时的ESP寄存器中保存的是middle()函数的栈帧基址。请注意,基址并没有将用于保存返回地址的空间包含在内。
第三条指令(位于内存地址4011f3处)对ESP进行一个减操作,即将ESP向低地址处移动24个字节(对应于十六进制的0x18),移动24个字节的目地是为了在栈上腾出空间来存放局部变量和本函数需调用函数的传入参数。显然,函数内局部变量越大,则所减的数值就越大。
运行完了上面的三条指令以后,middle()函数的栈帧就形成了,如图10.12所示。图中还示例说明了middle()函数内局部变量reg_esp和reg_ebp在栈帧中的位置。
位于内存地址4011f6和4011f8处的指令是我们在middle()函数中所嵌入的汇编代码即用于获取此时EBP和ESP寄存器的值。4011fa处的指令将EBP寄存器的值放入局部变量reg_ebp中,401200处的指令将ESP寄存器的值放入局部变量reg_esp中。4011fd和401203处的指令将main()函数中传递过来的第一个变量_p0的值拷贝到ESP寄存器所指向的内存中,为调用tail()函数准备参数。此刻的栈空间如图10.13所示。
位于内存地址401206处的指令是调用tail()函数的指令,这个调用会造成返回地址被压入到栈中,调用完了这条指令后的栈空间如图10.14所示。
所压入栈的返回地址是40120b,从图10.10中可以看出这一地址指向的是middle()函数内调用tail()函数的后一条指令,也就是说,当tail()函数返回时将从这一地址处继续运行程序。这条指令的调用也意味着进入了tail()函数的栈帧,tail()函数也像middle()函数那样采用相同的“手法”建立自己的栈帧。前面图10.9所示的内存布局,正是tail()函数建立了栈帧时的。
栈帧的消亡下面让我们看一看在tail()函数内进行函数返回时栈空间又是如何发生变化的。内存地址4011e1处的leave指令,其功能是将ESP寄存器的值设置为EBP寄存器的并做一次退栈操作,将退栈操作的内容放入EBP寄存器中。这条指令的功能等价于“mov %ebp, %esp; pop %ebp”,就是将tail()函数所建立的栈帧去掉。这条指令执行完了后的栈布局与图10.14完全一样。tail()函数的最后是一条返回指令(位于内存地址4011e2处),用于将栈上(即ESP寄存器所指的位置)的内容弹出到PC寄存器中,其效果就是程序返回到了middle()函数的40120b地址处。执行完这条指令后的栈结构与图10.13是一样的。
至此,我们完全了解了栈帧的形成与消亡。实际上,对于每一个C函数,编译器都会生成汇编代码在进入函数时创建其栈帧,以及从函数返回时将栈帧删除。在x86的ABI规范中,分别称这两部分为“前言”和“后序”,其大致代码分别如图10.15和图10.16所示。
prologue:
pushl %ebp // 保存上一函数的栈帧指针
movel %esp, %ebp // 设置本函数的栈帧指针
subl $80, %ebp // 分配函数的栈帧空间(会因各函数的局变量大小不同而不同)
pushl %edi // 保存局部变量寄存器
pushl %esi // 保存局部变量寄存器
pushl %ebx // 保存局部变量寄存器
图10.15
epilogue:
popl %ebx // 恢复局部变量寄存器
popl %esi // 恢复局部变量寄存器
popl %edi // 恢复局部变量寄存器
leave // 恢复调用这一函数的栈帧指针
ret // 返回到调用这一函数的函数内
图10.16
在每一个函数的“前言”部分存在为栈帧分配大小的指令(比如图10.15中的“subl $80, %ebp”),C编译器会根据函数中所存在的局部变量大小和所调用函数最多参数的个数来决定栈帧的大小。
另外在这两个图中分别存在对EDI、ESI和EBX的压栈及退栈操作。在10.3节中提到,EDI、ESI和EBX是用做局部变量寄存器的。也就是说,如果这三个寄存器器在某函数(称之为函数A)中使用了,而在其调用的函数(称之为函数B)中也要用到它的话,那么函数B就必须在使用它们之前将它们保存起来,以便返回到函数A之前能恢复。但如果这两个函数都没有使用到这些寄存器,“聪明的”编译器会做出无须在“前言”中对其压栈的决定,以便提高程序的执行效率。
由于函数一旦返回其栈帧就不存在了,正因如此,我们不能将局部变量的指针作为函数的返回值。
如果读者现在回头看一看图10.6中的表,相信能更好地理解其含义。