前情提要
函数是C语言的基本组成单位,相信平常使用函数也是觉得很easy的,但是有没有深入了解一下其具体实现过程呢?
这里将对函数调用堆栈的具体过程做一个疏通,虽然不能保证很深入,很有知识,但能保证对函数调用堆栈的过程有一个大概的了解。因为这本身就是对初学者适用的。
我们要了解函数堆栈,就必须得先知道栈是个什么东西。先给出理论:
栈
栈在经典计算机中被定义为一个特殊的容器,用户可以将数据压入栈(PHSU),也可以将数据从 栈顶弹出(POP)。栈的生长方式是从高地址往低地址增长的。栈的基本形式:
堆栈帧(活动记录)作用:
1)函数的返回地址和参数
2)临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
3)保存的上下文:包括在函数调用前后需要保持不变的寄存器。函数堆栈顺序
1)压入形参:把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递(fastcall调用)
2)把当前指令的下一条指令地址压栈,即记录调用完毕后继续执行的指令
3)栈桢开辟,跳转到函数体执行
了解完什么是栈之后,就可以开始进入函数堆栈的流程:
函数调用具体实现
实验代码
#include <stdio.h>
#include <stdlib.h>
int sum(int a, int b) //被调函数
{
int tmp = a + b;
return tmp;
}
int main() //主调函数
{
int a = 10;
int b = 20;
int ret = sum(a, b);
printf("%d\n",ret); //结果为30
return 0;
}
在开始之前还得了解一个东西:栈桢
- 要想分析函数堆栈,必须知道两个寄存器——ebp,esp
ebp:栈底指针(基地址)
esp:栈顶指针 - 假想栈
我们先假想一个栈,栈底地址为0x93f92c
先对主调函数的汇编代码进行分析,要问为啥是汇编代码,是因为汇编代码可以很清楚的将堆栈过程描述出来。 - 压参
int a = 10;
mov dword ptr [ebp-4],0Ah //a
int b = 20;
mov dword ptr [ebp-8],14h//b
这两行代码对主调函数的参数a,b进行赋值,ebp为栈底指针,栈从高地址到低地址生长,所以mov dword ptr [ebp-4],0Ah意为对ebp-4这个地址里面放入4个字节的值OAh。我们知道函数参数的压栈是在主调函数里面进行的,但main也是一个函数,也算是一个被调函数,所以在main函数里面看不到push a 或者 push b这种压栈代码,只能看到赋值代码。
所以栈中有元素了:
- 函数调用
下来是int ret = sum(a, b);
这一句便是本篇博客的核心,函数的调用。
首先我们得知道函数调用大概干了啥,在上面的函数堆栈顺序已经提到过了,分为三步,压形参,压下一行指令地址,跳转到函数。
int ret = sum(a, b);
/*
*这一步将sum函数的形参压入栈中,进一步验证了刚才的说法,
*压参动作是在主调函数进行的,被调函数只需要对其赋值。
*/
mov eax,dword ptr [ebp-8]
push eax
mov ecx,dword ptr [ebp-4]
push ecx
/*
call为调用函数指令
*/
call sum (0F1105Fh)
/*
*这两句是在函数调用完毕之后的栈桢回退动作,
*即在调用完毕之后将esp+8即将栈的空间减小8至于为啥是8.后面会解释。
*/
add esp,8
mov dword ptr [ret],eax
call为函数调用指令,它主要执行了上述函数调用的第二,三步
先将当前指令的下一行指令地址压栈,再跳转函数
sum (0F1105Fh) 这个括号里的地址就是sum函数的地址,该句执行完后就会跳转到sum函数。
那么在上面代码执行完后栈为:
这里形参的压栈顺序不同是因为在C语言等支持可变参函数的语言中,压参顺序是从右至左的。
- sum函数
接下来就跳转到sum函数了,在sum函数的汇编代码中,有一段跟main函数几乎一样的代码,而且这段代码是在可见的高级代码之前执行的,来看一看:
int sum(int a, int b)
{
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
来看一下解释:
int sum(int a, int b)
{
这头两句将ebp栈底指针压入栈中,即将原主调函数的栈中的栈底指针压入了栈顶,并且使其等于esp栈顶指针。
push ebp
mov ebp,esp
那栈变为了:
1,将esp栈顶指针减去0cch即增大栈0cch
sub esp,0CCh
2,压入三个寄存器,这个现在不需要关心
push ebx
push esi
push edi
3,lea与mov作用差不多,只不过是将地址移动,即将ebp-0cch的地址放入edi寄存器
lea edi,[ebp-0CCh]
4,接下来的语句相当于一个for循环,ecx中的值33h为循环次数,eax中的值0cccccccch为要替换的值。
5,rep stos dword ptr es:[edi]意为从[ ]里面地址开始,将eax中的值,循环压入ecx次。
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
则上述语句可以这样用伪代码表示:
for (int ecx = 33h; ecx > 0; ecx--)
{
mov dword ptr[edi], eax;
}
所以上述代码加在一起的意思就是:
1)将主调函数的ebp栈底指针移动到主调函数栈顶,以便于在主调函数上方重新开辟大小为33h的栈。
2)用一个循环将这个新栈内的数据全置为0cccccccch(注:两个0cch连在一起就是汉字 烫,两个0dch连在一起就是汉字 屯)
所以这一步下来,栈为:
接下来的这三句:
int tmp = a + b;
mov eax,dword ptr [ebp-8]
add eax,dword ptr [ebp-0ch]
mov dword ptr [tmp],eax
就是对tmp进行了一个加法。主要看下面的return:
return tmp;
mov eax,dword ptr [tmp]
}
pop edi
pop esi
pop ebx
//主要看下面部分:(栈桢回退)
mov esp,ebp
pop ebp //pop将栈顶元素出到ebp里
ret
我们结合图来看:
然后再执行00F113F0 ret:
把出栈的顺序放到CPU的PC寄存器里面(下一次要执行指令的地址),即将主调函数栈顶的下一行地址放入PC寄存器,使跳转到下一行代码。
- 栈桢回退
esp回退到0x00981440这个地方,这就是刚才记录的主调函数中下
一行代码的地址即:
add esp,8
mov dword ptr [ebp-0ch],eax
对于esp+8我们知道,栈顶指针增大等于栈空间减小,所以这里是在进行栈桢的回退,但为啥是8呢?我们看一下图:
所以回退的是之前压入的形参a,b由于调用完毕了,也就没啥意义了。
总结
- 我们也可以看到 pop出栈到ebp里的地址就是主调函数mian的栈底地址,就是因为每次在调用一个函数就会在被调函数栈底记录其主调函数的栈底地址,所以才能将ebp返回到主调函数的栈底,完成调用过程。那我们刚才说过,main函数本身也是一个函数,也可看成一个被调函数,那又是谁调用main函数呢?
是一个名叫 :mainCRTStartup()的函数调用了main函数。
所以这里这也可以总结:每一个被调函数的栈底都是其主调函数的栈底地址。
题型总结
栈销毁
- 上面说栈销毁了之后只是表面上的销毁,这是啥意思,我们来看一道题:
//下列代码,能打印吗?打印什么?
int *Index()
{
int a = 10;
return &a;
}
int main()
{
int *p = Index();
printf("%d\n",*p);
return 0;
}
- 答案是能打印,打印的是10.
这就是刚才说的,栈的销毁并没有对被销毁的栈内数据进行操作,只是将栈桢回退了。p里面接受了a的地址,就会访问到被销毁的栈上的数据。所以就打印了。注:但这是一个非法的操作。
在看一个:
int *Index()
{
int a = 10;
return &a;
}
void func(){} //加入一句
int main()
{
int *p = Index();
func(); //调用
printf("%d\n",*p);
return 0;
}
- 这个打呢会印乱码(烫或者屯),因为我们上面说一个函数一开始并不是直接执行高级代码的,例如这个,并不是先执行int a = 10;
而是执行了一大段汇编代码,这段代码也解释过,是函数的栈桢开辟及赋初值,这个初值就是0xcccccccch所以在printf之前调用会打印乱码。
再来一道:
char *Getstring()
{
char str[] = "abcdef";
return str;
}
int main()
{
char *p = Getstring();
printf("%s\n",p);
return 0;
}
- 这个是打印乱码,虽然类似于上面的代码,但注意这里的参数不是一个int整型,而是一个字符串。由于printf在main函数里本身也是个被调函数,所以也会做与Getstring()函数一样的事——函数调用。所以在开辟栈桢时字符串被清为0xcccccccch了,但注意,这与之前的第一题并不矛盾。
- 因为在第一题里面,printf是这样写的:
printf(“%d\n”,*p);
这题printf为:
printf(“%s\n”,p); 这两者的差别在于,对于printf函数来说,函数调用的第一步就是压入形参,所以第一题压入的是 *p是一个int类型的具体数值,而对于第二个,压入的是 p一个char类型的指针,是一个地址,所以在压参完成之后进行打印时,*p 这个非法访问操作已经完成了,即10这个值已经取到了。所以可以打印,但p取str的地址后进行打印时,printf函数调用已经开辟栈桢了,所有数据都清为0xcccccccch了,所以无法打印。
上图
char *Getstring()
{
char str[4096] = "abcdef";//字符数组扩大
return str;
}
int main()
{
char *p = Getstring();
printf("%s\n",p);
return 0;
}
- 这个呢,是打印abcdef
好像心态崩了·········。
这是因为,我们将字符数组扩得很大,这样在调用Getstring()压参时,会压好长好长的空间,所以调用printf开辟栈桢时根本到不了那块地址,所以就没有被清为0xcccccccch。
总结
函数调用堆栈是一个程序员必须要了解的C底层知识,因为只有知道代码在内存中的布局才能做到写代码不慌,妈妈在也不用担心我写代码烫了。