函数调用时程序堆栈的变化

这个主要写一点关于在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字节的。





可变长参数总结:

可变长参数就是通过函数调用时,参数在函数的栈里面的情况来实现的。总的来说,这种函数,对于程序而言,非常危险。他只能通过程序员自己来定义取参数的停止条件,如果停止条件错误,则会造成缓存区溢出,会对调用可变长参数的那个函数里面的栈产生影响,很容易把存储在栈里面的内容破坏,

版权声明:本文为博主原创文章,未经博主允许不得转载。

 

posted @ 2015-06-14 21:25  fang92  阅读(1049)  评论(0)    收藏  举报