函数调用时程序堆栈的变化
这个主要写一点关于在C里面,堆栈是怎么保存数据的,以及调用函数时,堆栈指针的变化。
编译环境:32位ubuntu系统,gcc编译,gdb调试
首先说明两个寄存器
1.rbp:栈帧指针,具体应该是指向当前函数栈的栈底,是不动的。实际的作用应该就是类似于一个基址,通过这个基址上栈中变量的寻址。
2.rsp:栈顶指针。
3.rip:指令寄存器。存储cpu读取指令的地址
首先,写了一个比较简单的C程序:
#include <stdio.h>
#include <stdlib.h>
int add(int a, int b)
{
int c=a+b;
return c;
}
int main()
{
int a=0x1;
int b=0x10;
int c=0x100;
int sum=add(a,b);
sum=add(sum,c);
return 0;
}
经过反汇编之后,得到的main的汇编程序如下:
push %ebp %esp,%ebp sub $0x18,%esp movl $0x1,-0x10(%ebp) movl $0x10,-0xc(%ebp) movl $0x100,-0x8(%ebp) mov -0xc(%ebp),%eax mov %eax,0x4(%esp) mov -0x10(%ebp),%eax mov %eax,(%esp) call 80483b4 <add> mov %eax,-0x4(%ebp) mov -0x8(%ebp),%eax mov %eax,0x4(%esp) mov -0x4(%ebp),%eax mov %eax,(%esp) call 80483b4 <add> mov %eax,-0x4(%ebp) mov $0x0,%eax leave ret
通过这个,可以画出main函数的栈:
通过看main的栈的结构,有一点需要注意,而传入函数add的参数a和b,都在main栈的最后两个位置里面。在mian函数里面调用的函数,其参数的拷贝是在main函数的栈里面的,而不是拷贝在add函数的栈里面。
所以函数add(a,b),通过在main的栈里面添加一个a,b的副本,来传递给add函数,所以传递的是参数的一个拷贝,而不是直接把参数传递进去。
接下里看add的汇编程序:
080483b4 <add>: push %ebp mov %esp,%ebp sub $0x10,%esp mov 0xc(%ebp),%eax mov 0x8(%ebp),%edx add %edx,%eax mov %eax,-0x4(%ebp) mov -0x4(%ebp),%eax leave ret
通过汇编,可以得到程序第一次调用add的栈信息:
这里可以看到,程序通过add函数的ebp的值,来相对寻址输入的a和b参数。有一点需要注意,在add函数的栈里面有12字节的空栈,这应该主要是为了数据对齐的方面来考虑的,整个程序,应该是需要16字节对齐。
还有比较重要的一点,需要注意的。栈内是4字节对齐的,也就是说,即使函数foo(char c1,char c2)传递进来两个单字节的char变量,在foo的栈内依旧是需要1个char存在在4个字节的栈中,两个char总共需要8个字节的栈。
函数最后返回的时候,有两条语句:
leave ret
这两条指令的功能相当于下面的指令:
mov %ebp,%esp
pop %ebp
pop %rip
即在操作上面两条指令的时候,首先把esp赋值,它的值是存储调用函数ebp的值的地址,所以可以通过出栈操作,来给ebp赋值,来找回调用函数的ebp。通过栈的结构,可以知道,ebp上面就是调用涵数调用被调用函数的下一条指令的执行地址,所以需要赋值给rip,来找回调用函数里的指令执行地址。
所以整个函数跳转回main的时候,他的esp,ebp都会变回原来的main函数的栈指针,C语言程序就是用这种方式来确保函数的调用之后,还能继续执行原来的程序。
接下来看一下,C程序函数参数是指针的情况下的参数传递情况。
改变add函数,使其传入两个指针参数。
#include <stdio.h>
#include <stdlib.h>
int add(int*pa, int *pb)
{
int c=*pa+*pb;
return c;
}
int main()
{
int a=0x1;
int b=0x10;
int c=add(&a,&b);
printf("%d",c);
return 0;
}
通过反汇编,得到main的汇编代码为:
80483fe: 55 push %ebp 80483ff: 89 e5 mov %esp,%ebp 8048401: 83 e4 f0 and $0xfffffff0,%esp 8048404: 83 ec 20 sub $0x20,%esp 8048407: c7 44 24 14 01 00 00 movl $0x1,0x14(%esp) 804840e: 00 804840f: c7 44 24 18 10 00 00 movl $0x10,0x18(%esp) 8048416: 00 8048417: 8d 44 24 18 lea 0x18(%esp),%eax 804841b: 89 44 24 04 mov %eax,0x4(%esp) 804841f: 8d 44 24 14 lea 0x14(%esp),%eax 8048423: 89 04 24 mov %eax,(%esp) 8048426: e8 b9 ff ff ff call 80483e4 <add>
通过上述汇编代码,可以画出main的堆栈情况。
这里有一句不是太明白:
这里在给main函数设定堆栈时,先进行了与操作,把esp的低位置0.
看了下esp的值,是esp=0xbffff2e8。取与之后,比原来直接减0x20多了8个字节的空间,不知道为什么要这个操作,可能是有数据对齐这方面的考虑。
接下来看一下add的函数的反汇编:
080483e4 <add>: 80483e4: 55 push %ebp 80483e5: 89 e5 mov %esp,%ebp 80483e7: 83 ec 10 sub $0x10,%esp 80483ea: 8b 45 08 mov 0x8(%ebp),%eax 80483ed: 8b 10 mov (%eax),%edx 80483ef: 8b 45 0c mov 0xc(%ebp),%eax 80483f2: 8b 00 mov (%eax),%eax 80483f4: 01 d0 add %edx,%eax 80483f6: 89 45 fc mov %eax,-0x4(%ebp) 80483f9: 8b 45 fc mov -0x4(%ebp),%eax 80483fc: c9 leave 80483fd: c3 ret
在add函数里面,通过寄存器来进行间接取值操作,通过地址,得到值。而传进来的参数是保存在main函数的栈里面,把参数传进寄存器,然后通过计算得到。
总结:
总的来说,C语言函数传值的两种方法:值传递和指针的地址传递,说到底都是值传递的。只不过地址传递通过解引用的方法,可以改变指针里面地址所指向的值。对于这些堆栈,还有一个要注意的,就是每次函数在开始分配堆栈的时候,都是有一定的空余的,这个应该是和编译器和系统的数据对齐都有关,看了上面的程序,在实验环境下,应该是16字节对齐的。
函数的栈的变化,应该已经满清楚了,下面来看看一种特殊的函数——可变长参数函数。
一些基本的简单介绍可以看看以前写的东西:http://blog.csdn.net/fang92/article/details/45696733,在以前,只是有一个模糊的认识,感觉可变长参数的实现应该是和函数的栈空间有关的,也用了一定的方法来实现可变长参数。现在对函数的栈认识比较清楚,所以再次来验证一下。
这里,来看看这个可变长参数的具体的实现到底是怎么样的。是不是和以前的猜想一样。
首先,举个用可变长参数的例子,求和函数,函数的第一个是参数的个数。
int Add(int varNum, ...)
{
va_list ap;
va_start(ap, varNum);
int sum = 0;
int temp;
for (int i = 0; i < varNum; i++)
{
temp = va_arg(ap, int);
sum += temp;
}
va_end(ap);
return sum;
}
这里先查看上面几个函数和变量是怎么定义的:
#define va_start _crt_va_start #define va_arg _crt_va_arg #define va_end _crt_va_end // vadefs.h typedef char * va_list; #define _crt_va_start(ap,v) ( ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v) ) #define _crt_va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) ) #define _crt_va_end(ap) ( ap = (va_list)0 ) #define _ADDRESSOF(v) ( &(v) ) #define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
可以比较清楚看到,
1.va_start(ap,v)这个函数,首先初始化ap,初始化过程:v是函数的第一个参数,ap的值就是第一个参数的地址加上v在栈里面所占的字节数。根据上面的栈的一些知识,可以得到栈图:
根据上面讲的,由于C的函数的参数入栈的顺序是由右向左的,所以函数第一个参数是最后入栈的,所以位置是最靠下面的,然后向上是第一个参数,第二个参数……一直到最后一个参数。
而va_start(ap,v),就是得到函数的第二个参数:v0的地址。
2.va_arg(ap,T),其实就是一个解引用,而且使ap指向下一个参数地址:
( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
ap先加上t类型在栈里面的字节数,得到新的ap,指向下一个参数的地址,然后再解引用上一个参数地址,得到上一个参数。
这样,一直调用va_arg(ap,T),直到终止条件。
3.va_end(ap),这个函数比较清楚,ap=0,释放ap。
这里面可能就是intsizeof(n)不是一下能看出来干嘛的:
_INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
~(sizeof(int) - 1)这个表示取sizeof(int)的高位,这个不好描述,直接举例子。我是用32位的ubuntu系统来进行试验的,在这个系统上,sizeof(int)=4,所以~(sizeof(int) - 1)=0xFFFFFFF4,和他按位与,就是取高位,把左边的数据的低2位置0,。左边的数据+ sizeof(int) - 1,是把小于int的字节数的数据类型升到4字节以上,然后再和右边数据位与,舍弃低位。
所以现在来看_INTSIZEOF(n)也就比较清楚,他的功能就是进行数据对齐的,在我的系统里面,他保证了每次加的偏移都是4字节对齐的。这个和栈的性质是相关的。在函数的栈里面,即使是char型,他在栈里面也是占了4字节的。
可变长参数总结:
可变长参数就是通过函数调用时,参数在函数的栈里面的情况来实现的。总的来说,这种函数,对于程序而言,非常危险。他只能通过程序员自己来定义取参数的停止条件,如果停止条件错误,则会造成缓存区溢出,会对调用可变长参数的那个函数里面的栈产生影响,很容易把存储在栈里面的内容破坏,
版权声明:本文为博主原创文章,未经博主允许不得转载。

浙公网安备 33010602011771号