👦个人主页:Weraphael
✍🏻作者简介:目前正在回炉重造C语言(2023暑假)
✈️专栏:【C语言航路】
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍
目录
前言
在学习C
语言的时候,我们难免会有很多困惑。比如:
- 局部变量是怎么创建?
- 局部变量为什么是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参的关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
以上的问题都和函数栈帧的创建和销毁有关。
说明:在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。本篇博客的开发环境是
vs2019
,不同的开发环境呈现的结果可能不一样。
一、认识相关寄存器和汇编指令
1.1 相关寄存器
-
eax
:通用寄存器。用于存储算术运算的结果、存放函数的返回值等。 -
ebx
:通用寄存器。用于存储临时数据等。 -
ecx
:通用寄存器。通常被用作计数寄存器,在循环和重复操作中非常常见。例如,在LOOP
指令中,ECX
用于控制循环的迭代次数。 -
edx
:通用寄存器。 -
ebp
(本章重点):存放的是地址,记录的是栈底的地址。 -
esp
(本章重点):存放的是地址,记录的是栈顶的地址。 -
eip
:指令寄存器,保留当前指令的下一条指令的地址。
1.2 相关汇编指令
-
mov
:它用于将数据从一个位置复制到另一个位置。 -
push
:数据入栈。同时esp栈顶寄存器也要发生改变。 -
pop
: 数据弹出。同时esp栈顶寄存器也要发生改变。 -
sub
:减法命令。 -
add
:加法命令。 -
call
:函数调用。它的主要功能是,当执行call
指令时,call
指令的下一条指令的地址会被压入栈中。这使得程序可以在子程序执行完成后返回到正确的位置继续执行。 -
ret
:恢复返回地址。 当执行到ret
指令时,CPU
会从栈中弹出返回地址,并将这个地址加载到程序计数器中,继续执行该地址处的指令。ret
指令就是通过从栈中弹出这个地址来实现返回功能。
以上看的可能会有点懵,待会在讲述函数栈帧是如何创建的大家可能就明白了。
二、什么是函数栈帧
-
每一次函数调用时,都会在栈上为该函数分配一个新的空间,这个空间被称为函数栈帧。需要注意的是,我们以上所说的栈通常被称为系统栈,它由操作系统自动管理的。
-
这个系统栈和数据结构的栈的操作是一样的。唯一区别是:系统栈是向下增长的,由高地址到低地址。
-
正在调用的函数栈帧空间通常是通过两个寄存器来维护的,分别是
esp
和ebp
。其中,ebp
记录的是栈底的地址,esp
记录的是栈顶的地址。
三、演示代码
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 5;
int b = 3;
int c = 0;
c = Add(a, b);
printf("%d\n", c);
return 0;
}
四、解析函数栈帧的创建
4.1 main函数是被另一个函数调用
我们可以通过 函数调用堆栈 来观察,因为它是反馈函数调用逻辑的。
通过上图观察到:main()
函数调用之前,其实是由invoke_main()
函数来调用的;而invoke_main()
函数是由__scrt_common_main_seh()
函数调用的;而__scrt_common_main_seh()
函数是由__scrt_common_main()
函数调用的;而__scrt_common_main()
函数又是由mainCRTStartup()
函数调用的。
而我们一直认为,程序运行起来就是一个进程,再加上main
函数是程序的入口点,理应由操作系统调用。而出现以上原因我认为是:编译器会在 main
函数之前插入一些初始化代码,比如初始化运行时环境(设置堆栈、分配内存、处理命令行参数等,以确保程序能够正常运行)。这是编译器的设计机制。(了解即可)
4.2 分析反汇编
栈帧的创建和销毁涉及特定的指令(如push
、pop
、call
、ret
等),这些指令在反汇编中清晰可见,从而能够准确地理解栈的变化。
接下来我将带领大家一步一步分析汇编代码,看看函数(main
函数)的栈帧是如何创建的。
main
函数栈帧还没有创建之前
00FF18B0 push ebp
:将寄存器ebp
的值压入栈中。因为esp
记录的是栈顶的地址,所以当压栈的时候,esp
应该更新。
00FF18B1 mov ebp,esp
:将esp
的值赋值给ebp
00FF18B3 sub esp,0E4h
:将栈指针寄存器esp
的值减少十六进制0E4h
(十进制228
)。注意:减少一定是往低地址偏移的。
00FF18B9 push ebx
00FF18BA push esi
00FF18BB push edi
以上指令就是分别将寄存器ebx、esi、edi
的当前值压入栈中。
00FF18BC lea edi,[ebp-24h]
:lea
指令是加载有效地址(load effective address
),意思就是将地址ebp - 24h
加载到寄存器edi
中00FF18BF mov ecx,9
:将值9
赋值给寄存器ecx
00FF18C4 mov eax,0CCCCCCCCh
:将十六进制0CCCCCCCCh
加载到寄存器eax
中。00FF18C9 rep stos dword ptr es:[edi]
:这条指令用于将eax
寄存器中的值(0CCCCCCCCh
)写入到内存中,目标地址由edi
指定。rep
前缀表示重复操作,stos
表示存储EAX
的值到目标地址。这里的计数器由ecx
决定,所以会将0CCCCCCCCh
写入到[ebp-24h]
指向的地址,重复9
次。
综上,这段代码的作用是将内存中从 ebp-24h
开始,大小为36
字节全部初始化为 0CCCCCCCC
。
所以,以上这些操作不就是在给main
函数创建栈帧,并将main
函数的内存空间初始化为0CCCCCCCC
。说明:不同的编译器可能会初始化不同的值。
假设我们没有为变量初始化,并且会看到程序输出烫
这么一个奇怪的字,这就是因为main
函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC
,而arr
数组是一个未初始化的数组,恰好在这块空间上创建的,0xCCCC
(两个连续排列的0xCC
)的汉字编码就是烫
,所以0xCCCC
被当作文本就是烫
。
接下来开始剖析main
函数的正文段代码:
00FF18D5 mov dword ptr [ebp-8],5
:该指令将数值5
存储到栈中ebp-8
的位置。这通常是用来初始化一个局部变量。
00FF18DC mov dword ptr [ebp-14h],3
:该指令将数值3
存储到栈中ebp-14h
的位置。这通常是用来初始化一个局部变量。
00FF18E3 mov dword ptr [ebp-20h],0
:该指令将数值0
存储到栈中ebp-20h
的位置。这通常是用来初始化一个局部变量。
五、解析函数是怎么被调用的
00FF18EA mov eax,dword ptr [ebp-14h]
:将ebp-14h
内存位置的值加载到eax
寄存器中。00FF18ED push eax
:将eax
压入栈中
00FF18EE mov ecx,dword ptr [ebp-8]
:将ebp-8
内存位置的值加载到ecx
寄存器中。00FF18F1 push ecx
:将ecx
压入栈中
综上,我们可以得出一个结论:函数传参的顺序是从右往左的;并且传参的时候是先将参数值写入到寄存器中,然后再压栈,因此,实参和形参只是只是值相同,但空间是独立的,所以说形参是实参的临时拷贝,改变形参并不会影响实参。
00FF18F2 call 00FF10B4
:调用函数。在执行call
指令之前先会把call
指令的下一条指令的地址进行压栈操作,这个操作是:为了解决当函数调用结束后要回到call
指令的下一条指令的地方,继续往后执行。
接下来就是调用Add
函数:
在执行Add
函数的正文代码之前,以下这些指令和前面分析main
函数一样,就是为Add
函数创建栈帧并初始化内存空间。
我直接给出图了:
接下来我们再来分析Add
函数的正文段代码:
00FF1795 mov dword ptr [ebp-8],0
:该指令将数值0
存储到栈中ebp-8
的位置。这通常是用来初始化一个局部变量。
- 第一条指令:传入函数的第一个参数加载到
eax
寄存器中- 第二条指令:将第二个参数的值加到
eax
寄存器中。此时,eax
中存储的是两个参数的和- 第三条指令:将
eax
中的值存储到ebp-8
所指向的内存位置。
六、函数调用结束后是怎么返回的
我们发现:程序是将ebp-8
的值放入eax
寄存器中。也就是说,如果函数有返回值,它会将返回值拷贝给寄存器,保存到寄存器中的值在函数返回后(函数栈帧销毁)仍然存在,直到下一个函数调用或进一步的运算覆盖该寄存器的内容。
七、解析函数栈帧的销毁
00FF17A8 pop edi
00FF17A9 pop esi
00FF17AA pop ebx
分别将edi
、esi
、ebx
出栈
00FF17B8 mov esp,ebp
:将ebp
的值赋值给esp
。这其实是在将函数Add
的空间还给操作系统
00FF17BA pop ebp
:从栈中弹出保存的栈底指针的值,恢复上一级调用的栈帧。
00FF17BB ret
:恢复返回地址。 当执行到ret
指令时,CPU
会从栈中弹出返回地址,并将这个地址加载到程序计数器中,继续执行该地址处的指令。
子程序结束后,返回到主函数继续执行
00FF18F7 add esp,8
:将esp
的地址加上8
,这一步就是将形参的内存空间还给操作系统。
00FF18FA mov dword ptr [ebp-20h],eax
:将eax
寄存器中的值(返回值)存储到ebp-20h
所指向的内存位置
剩下调用printf
函数已经销毁main
函数栈帧我这里就不继续赘述了。
八、总结
- 局部变量是怎么创建?
局部变量是存储在栈上的,所以,先要为当前函数调用创建函数栈帧,然后在栈帧中为局部变量分配空间。
- 为什么局部变量是随机值?
在vs2019
中,创建栈帧的时候给空间初始化默认是0CCCCCCCC
。不同的编译器可能会有不同的行为。
- 函数是怎么传参的?传参的顺序是怎样的?
开始调用函数之前,就已经把参数从右往左进行压栈,当真正调用函数时,是通过栈顶指针和偏移量来访问。
- 形参和实参的关系?
传参的时候是先将参数值写入到寄存器中,然后再压栈,因此,实参和形参只是只是值相同,但空间是独立的,所以说形参是实参的临时拷贝,改变形参并不会影响实参
- 函数调用是怎么做的?
函数调用时会指向call
指令,但在执行call
指令之前先会把call
指令的下一条指令的地址进行压栈操作,这个操作是为了保证子程序执行完毕后能正确回到call
指令的下一条指令的地方,继续往后执行。
- 函数调用结束后是怎么返回的?
从栈中弹出当前函数的栈帧出栈;回收内存空间;如果函数有返回值,这个值通常会被存放在特定的寄存器中;最后执行ret
指令回到call
指令的下一条指令,继续执行后续的指令。