👦个人主页:Weraphael
✍🏻作者简介:目前正在回炉重造C语言(2023暑假)
✈️专栏:【C语言航路】
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
前言
在学习C语言的时候,我们可能有很多困惑。比如:
- 局部变量是怎么创建?
- 为什么局部变量是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参的关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
这些都和函数栈帧的创建和销毁有关
一、什么是函数栈帧
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和函数栈帧有关系。
函数栈帧
stack frame
就是函数调用过程中在程序的 调用栈call stack
所开辟的空间,这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
二、什么是栈?
- 栈
stack
是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。- 在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(也就是入栈
push
),也可以将已经压入栈中的数据弹出(出栈pop
),但是栈这个容器必须遵守一条规则:先进的数据后出- 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
- 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
三、认识相关寄存器和汇编指令
3.1 相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值
ebx:通过寄存器,保留临时数据
ebp:栈底寄存器(本章重点)
esp:栈顶寄存器(本章重点)
eip:指令寄存器,保留当前指令的下一条指令的地址
3.2 相关汇编指令
mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时,esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令
四、解析函数栈帧的创建和销毁
2.1 预备知识
首先我们达成一些预备知识才能有效的帮助我们理解函数栈帧的创建和销毁:
- 每一次调用函数,都会在栈区上创建空间,就是函数栈帧的空间
- 这块空间的维护使用了两个寄存器:
esp
和ebp
,ebp
记录的是栈底的地址,esp
记录的是栈顶的地址。
如图所示:
2.2 函数栈帧的创建
2.2.1 main函数也是被其他函数调用
【演示代码】
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 20;
int b = 30;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
在此之前,我们需要理解:main
函数其实也是被其他函数调用的。我们可以通过函数调用堆栈来观察,因为它是反馈函数调用逻辑的,通过下图观察到,main
函数调用之前,其实是由invoke_main
函数来调用的
那我们可以确定,invoke_main
函数也会有自己的栈帧,main
函数和Add
函数也会维护自己的栈帧,每个函数栈帧都有自己的ebp
和esp
来维护栈帧空间。
2.2.2 分析反汇编(主函数的创建)
int main()
{
001C18B0 push ebp
001C18B1 mov ebp,esp
001C18B3 sub esp,0E4h
001C18B9 push ebx
001C18BA push esi
001C18BB push edi
001C18BC lea edi,[ebp-24h]
001C18BF mov ecx,9
001C18C4 mov eax,0CCCCCCCCh
001C18C9 rep stos dword ptr es:[edi]
int a = 20;
001C18D5 mov dword ptr [ebp-8],14h
int b = 30;
001C18DC mov dword ptr [ebp-14h],1Eh
int c = 0;
001C18E3 mov dword ptr [ebp-20h],0
c = Add(a, b);
001C18EA mov eax,dword ptr [ebp-14h]
001C18ED push eax
001C18EE mov ecx,dword ptr [ebp-8]
001C18F1 push ecx
001C18F2 call 001C10B4
001C18F7 add esp,8
001C18FA mov dword ptr [ebp-20h],eax
printf("%d\n", c);
001C18FD mov eax,dword ptr [ebp-20h]
001C1900 push eax
001C1901 push 1C7B30h
001C1906 call 001C10D2
001C190B add esp,8
return 0;
001C190E xor eax,eax
}
001C1910 pop edi
001C1911 pop esi
001C1912 pop ebx
001C1913 add esp,0E4h
001C1919 cmp ebp,esp
001C191B call 001C1244
001C1920 mov esp,ebp
001C1922 pop ebp
001C1923 ret
- 创建main函数的栈帧
001C18B0 push ebp
— 将ebp
的值压入栈中。
因为
ebp
记录的是栈底的地址,esp
记录的是栈顶的地址,所以当压栈的时候,esp
应该更新。
001C18B1 mov ebp,esp
— move
指令会把esp
的值存放到ebp
中
001C18B3 sub esp,0E4h
— esp
减去0E4h(228)
这个值
sub
会让esp
中的地址减去一个八进制数字,产生新的esp
,此时的esp
是main
函数栈帧的esp
,此时结合上一条指令的ebp
和当前的esp
,ebp
和esp
之间维护了一个块栈空间,这块栈空间就是为main
函数开辟的,就是main
函数的栈帧空间,这一段空间中将存储main
函数中的局部变量。
001C18B9 push ebx
001C18BA push esi
— 分别将寄存器ebx、esi、edi
的值压栈
001C18BB push edi
上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。
001C18BC lea edi,[ebp-24h]
001C18BF mov ecx,9
001C18C4 mov eax,0CCCCCCCCh
001C18C9 rep stos dword ptr es:[edi]
上面的这段代码最后4句,等价于下面的伪代码:
edi = ebp-0x24;
ecx = 9;
eax = 0xCCCCCCCC;
for(; ecx = 0; --ecx,edi+=4)
{
*(int*)edi = eax;
}
lea
其实是加载有效地址(load effective address
),把ebp-24h
的地址放在edi
中- 把
9
放在ecx
中- 把
0xCCCCCCCC
放在eax
中- 将从
edp-0x2h
到ebp
这一段的内存的每个字节都初始化为0xCC
接下来我们再来分析main
函数中的核心代码:
int a = 20;
001C18D5 mov dword ptr [ebp-8],14h
int b = 30;
001C18DC mov dword ptr [ebp-14h],1Eh
int c = 0;
001C18E3 mov dword ptr [ebp-20h],0
dword ptr [ebp-8],14h
它对应的汇编指令就是把14h
(20
)这个值放到ebp-8
(a
)这个地址里。以下也同理,其实就是在初始化变量。
补充知识:烫烫烫
假设我们没有为变量初始化,并且会看到程序输出烫
这么一个奇怪的字,是因为main
函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC
,而arr
数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC
(两个连续排列的0xCC
)的汉字编码就是烫
,所以0xCCCC
被当作文本就是烫
。
main
函数调用函数Add
c = Add(a, b);
// 传参的过程
001C18EA mov eax,dword ptr [ebp-14h]
001C18ED push eax
001C18EE mov ecx,dword ptr [ebp-8]
001C18F1 push ecx
001C18EA mov eax,dword ptr [ebp-14h]
— 将ebp-14h
的值也就是b
存入寄存器eax
中- 将
eax
的值压栈001C18EE mov ecx,dword ptr [ebp-8]
— 将ebp-8
的值也就是a
存入寄存器ecx
中- 将
ecx
的值压栈
- 因此我们发现,函数传参的顺序应该先传
b
,最后再传a
001C18F2 call 001C10B4
001C18F7 add esp,8 (调完函数讲解)
001C18FA mov dword ptr [ebp-20h],eax (调完函数讲解)
call
指令是要执行函数调用逻辑的,在执行call
指令之前先会把call
指令的下一条指令的地址进行压栈操作,这个操作是:为了解决当函数调用结束后要回到call
指令的下一条指令的地方,继续往后执行。
接下来就来到Add
函数定义的内部:
int Add(int x, int y)
{
004A1830 push ebp
004A1831 mov ebp,esp
004A1833 sub esp,0CCh
004A1839 push ebx
004A183A push esi
004A183B push edi
004A183C lea edi,[ebp-0Ch]
004A183F mov ecx,3
004A1844 mov eax,0CCCCCCCCh
004A1849 rep stos dword ptr es:[edi]
004A184B mov ecx,4AC003h
004A1850 call 004A131B
int z = 0;
004A1855 mov dword ptr [ebp-8],0
z = x + y;
004A185C mov eax,dword ptr [ebp+8]
004A185F add eax,dword ptr [ebp+0Ch]
004A1862 mov dword ptr [ebp-8],eax
return z;
004A1865 mov eax,dword ptr [ebp-8]
}
004A1910 pop edi
004A1911 pop esi
004A1912 pop ebx
004A1920 mov esp,ebp
004A1922 pop ebp
004A1923 ret
2.2.3 分析反汇编(Add函数的销毁)
首先我们逐步分析:
004A1830 push ebp
004A1831 mov ebp,esp
004A1833 sub esp,0CCh
004A1839 push ebx
004A183A push esi
004A183B push edi
004A183C lea edi,[ebp-0Ch]
004A183F mov ecx,3
004A1844 mov eax,0CCCCCCCCh
004A1849 rep stos dword ptr es:[edi]
004A184B mov ecx,4AC003h
004A1850 call 004A131B
以上这些操作和刚开始进入
main
函数其实是类似的,就是为Add
函数建立栈帧。这里就不过多赘述了(如下图)
接下来开始执行Add
函数内部的有效代码:
int z = 0;
004A1855 mov dword ptr [ebp-8],0
z = x + y;
004A185C mov eax,dword ptr [ebp+8]
004A185F add eax,dword ptr [ebp+0Ch]
004A1862 mov dword ptr [ebp-8],eax
return z;
004A1865 mov eax,dword ptr [ebp-8]
mov dword ptr [ebp-8],0
— 将0
放入ebp-8
地址中去mov eax,dword ptr [ebp+8]
— 把ebp+8
的值放入寄存器eax
中。那ebp+8
的值是多少呢?如上图,+8
后ebp
向下走,也就指向了ecx - 20
,其实就是a
。同理,add eax,dword ptr [ebp+0Ch]
— 就是把刚刚的eax
的值加上ebp+0Ch(其实就是b)
的结果再次存入寄存器eax
中。从这可以看出形参只是实参的一份临时拷贝mov dword ptr [ebp-8],eax
— 再把eax
的值放入ebp-8
的地址处- 最后就是返回:
mov eax,dword ptr [ebp-8]
— 把ebp-8
的值放入eax
中,其实就是把值拷贝给了寄存器,eax
是一个寄存器,它不会因为程序的销毁而销毁。
那么寄存器eax
是如何把值给返回的呢?接着往下看
// pop - 出栈
004A1910 pop edi
004A1911 pop esi
004A1912 pop ebx
004A1920 mov esp,ebp
004A1922 pop ebp
004A1923 ret
- 将
edi、esi、ebx
出栈
mov esp,ebp
— 把ebp
赋值给esp
,这其实是在将函数Add
的空间还给操作系统
pop ebp
— 将ebp
出栈。注意:此时ebp
要维护main
函数的ebp
,其实此时此刻ebp
和esp
维护的是main
函数的栈顶和栈底。
- 注意:刚刚上面在执行
call
指令之前先会把call
指令的下一条指令的地址进行压栈操作,这个操作是:为了解决当函数调用结束后要回到call
指令的下一条指令的地方,继续往后执行。也就是ret
这个指令其实是返回到call
指令的下一条指令的地方,继续往后执行
然后执行add esp,8
— 就是将esp
的地址加上8
,这一步就是将形参还给操作系统
最后mov dword ptr [ebp-20h],eax
— 就是将eax
值给ebp-20h
,也就是赋值给z
五、总结
因此通过函数栈帧的创建和销毁
- 局部变量是怎么创建? — 在某函数的栈帧空间创建的
- 为什么局部变量是随机值?— 在栈区开辟的空间的其中每一个字节都被初始化为
0xCC
- 函数是怎么传参的?传参的顺序是怎样的?— 再开一个新的空间存储参数,其顺序是从右向左的
- 形参和实参的关系?— 地址空间不同,也就是说形参是实参的临时拷贝
- 函数调用是怎么做的? 函数调用结束后是怎么返回的? — 看文章过程即可