第十七讲:编译链接与函数栈帧
目录
1、翻译环境和运行环境
1.1、翻译环境
1.1.1、预处理(预编译)
1.1.2、编译
1.1.3、汇编
1.1.4、链接
1.2、运行环境
2、函数栈帧
2.1、相关概念
2.1.1、什么是栈
2.1.2、相关寄存器
2.1.3、相关指令
2.2、函数栈帧的创建与销毁
2.2.1、预备知识
2.2.2、准备环境
2.2.3、反汇编
2.2.4、函数栈帧的创建
2.2.5、main的核心代码
2.2.6、Add函数的调用
2.2.7、函数栈帧的销毁
2.3、问题
1、翻译环境和运行环境
在ANSI C的任何⼀种实现中,都存在两个不同的环境。
第1种是翻译环境,我们写出的C语言代码是文本信息,计算机不能直接理解,在翻译环境中源代码被转换为可执行的机器指令(⼆进制指令)。
第2种是执行环境,它用于实际执行代码。也就是用来执行二进制代码的。
1.1、翻译环境
翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(有些书也叫预编译)、编译、汇编三个过程。
⼀个C语言的项目中可能有多个.c文件⼀起构建,那多个 .c 文件如何生成可执行程序呢?
1、多个.c文件单独经过编译器,编译处理生成对应的目标文件。
注:在Windows环境下的目标⽂件的后缀是 .obj ,Linux环境下⽬标⽂件的后缀是.o
2、多个目标文件和链接库⼀起经过链接器处理⽣成最终的可执行程序。
3、链接库是指程序运行的基本函数集合或者第三方库。比如:我们使用的一些库函数等。
注:像VS这样的集成开发环境,集成了:编辑器、编译器(cl.exe)、链接器(link.exe)、调试器等。
1.1.1、预处理(预编译)
预处理阶段主要处理那些源文件中#开始的预编译指令。比如:#include,#define,处理的规则如下:
1、将所有的 #define 删除,并展开所有的宏定义。
2、处理所有的条件编译指令,如: #if 、 #ifdef 、 #elif 、 #else 、 #endif 。
3、处理#include预编译指令,将包含的头⽂件的内容插⼊到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件,递归依次展开。
4、删除所有的注释。
5、添加行号和文件名标识,方便后续编译器生成调试信息等。
经过预处理后的文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到该文件 中。
1.1.2、编译
编译过程就是将预处理后的文件进行⼀系列的:词法分析、语法分析、语义分析及优化和符号汇总,生成相应的汇编代码文件。在该阶段会报告错误的语法信息。
1.1.3、汇编
汇编器是将汇编代码转变成机器可执行的指令,每⼀个汇编语句几乎都对应⼀条机器指令。就是根据汇编指令和机器指令的对照表⼀⼀的进行翻译,也不做指令优化。在汇编这个过程中,有一步操作是形成符号表
1.1.4、链接
链接是⼀个复杂的过程,链接的时候需要把⼀堆文件链接在⼀起才⽣成可执⾏程序。 链接解决的是⼀个项目中多文件、多模块之间互相调用的问题。在链接过程中,其中有两个步骤是:合并段表、符号表的合并和重定位。在该阶段会报告错误的链接信息。
例如:
main.c:
#include <stdio.h>extern int g_val;extern int Add(int x, int y);int main()
{int a = 10;int b = 20;int sum = Add(a, b);printf("%d\n", sum);return 0;
}
test.c:
int g_val = 2022;int Add(int x, int y)
{return x + y;
}
我们在 main.c 的文件中使用了test.c文件中的 Add函数和g_val变量。
我们在main.c文件中每⼀次使用Add 函数和 g_val 变量的时候都必须确切的知道其地址,但是由于每个文件是单独编译的,在编译器编译main.c时,并不知道Add函数和g_val变量的地址,所以暂时把调用Add函数的指令的目标地址和g_val的地址暂时搁置,等待最后链接的时候由链接器根据引用的符号在其他模块中查找对应的地址,查找到后(如果没找到,报错),将main.c中的指令重新修正,让他们的目标地址为真正的有效地址,这个地址修正的过程也被叫做重定位。
1.2、运行环境
1、程序必须载入内存中。在有操作系统的环境中,⼀般这个由操作系统完成。在独立的环境中,程序的载入必须由手工来完成,也可能是通过可执行代码置入只读内存来完成。
2、当载入内存后,程序就开始执行,紧接着便开始调用main函数。
3、开始执行程序代码。这个时候程序将使用⼀个运行时堆栈(也就是函数栈帧),存储函数的局部变量等。程序同时也可以使用静态内存,存储于静态内存中的变量在程序的整个执行过程⼀直保留他们的值。
4、终止程序。正常终止main函数;也有可能是意外终止。
2、函数栈帧
我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,C程序是以函数为基本单位的。 那函数是如何调用的?函数的返回值又是如何返回的?函数参数是如何传递的?这些问题都和函数栈帧有关系。函数栈帧就是函数调用过程中在栈区所开辟的空间,这些空间是用来存放:
1、函数参数和函数返回值
2、临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
3、保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
注意:使用的环境是VS2022。另外,在不同的编译器,函数调用过程中的栈帧是略有差异的,具体细节取决于编译器的实现。
2.1、相关概念
2.1.1、什么是栈
栈是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈),也可以将已经压入栈中的数据弹出(出栈),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈。
2.1.2、相关寄存器
eax:通用寄存器,保留临时数据,常用于返回值。
ebx:通用寄存器,保留临时数据。
ecx:通用寄存器。
edx:通用寄存器。
ebp:栈底寄存器。里面存放的是函数的地址,是用来维护函数栈帧的。
esp:栈顶寄存器。里面存放的是函数的地址,是用来维护函数栈帧的。
eip:指令寄存器,保存当前指令的下一条指令的地址。
2.1.3、相关指令
mov:数据转移指令。
push:数据入栈,同时esp栈顶寄存器也要发生改变。
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变。
sub:减法指令。
add:加法指令。
call:压入返回地址,转入目标函数。同时esp栈顶寄存器也要发生改变。
jump:通过修改eip,转入目标函数,进行调用。只要执行到jmp,接下来就会跳转到后面指定的目标地址。
ret:弹出地址,并加载到eip寄存器中,同时esp栈顶寄存器也要发生改变。
2.2、函数栈帧的创建与销毁
2.2.1、预备知识
1、每一次函数调用,都要为本次函数调用开辟空间,这就是函数栈帧的空间。
2、这块空间的维护是使用了2个寄存器:esp和ebp,ebp记录的是栈底的地址,esp记录的是栈顶的地址。
如下图所示:
注:正在调用哪一个函数,esp和ebp就是在维护哪一个函数栈帧。另外,栈是从高地址到低地址使用的。
3、main函数也是被其他函数调用的。以VS2022为例,如下图所示:
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main函数调用之前,是由 invoke_main函数来调用main函数的。在invoke_main 函数之前的函数调用我们就暂时不考虑了。
4、函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异。
2.2.2、准备环境
使用下面的代码进行演示,main.c如下所示:
#include <stdio.h>
int Add(int x, int y)
{int z = 0;z = x + y;return z;
}int main()
{int a = 10;int b = 20;int c = 0;c = Add(a, b);printf("%d\n", c);return 0;
}
为了让我们研究函数栈帧的过程足够清晰,不要太多干扰,我们可以关闭下面的选项,让汇编代码中排除一些编译器附加的代码,如下图所示:
将项目属性页中的支持仅我的代码调试设置为否即可。
2.2.3、反汇编
调试到main函数开始执行的第一行,右击鼠标转到反汇编。如下:
int main()
{
00331840 push ebp
00331841 mov ebp,esp
00331843 sub esp,0E4h
00331849 push ebx
0033184A push esi
0033184B push edi
0033184C lea edi,[ebp-24h]
0033184F mov ecx,9
00331854 mov eax,0CCCCCCCCh
00331859 rep stos dword ptr es:[edi] int a = 10;
0033185B mov dword ptr [ebp-8],0Ah int b = 20;
00331862 mov dword ptr [ebp-14h],14h int c = 0;
00331869 mov dword ptr [ebp-20h],0 c = Add(a, b);
00331870 mov eax,dword ptr [ebp-14h]
00331873 push eax
00331874 mov ecx,dword ptr [ebp-8]
00331877 push ecx
00331878 call 003310B9
0033187D add esp,8
00331880 mov dword ptr [ebp-20h],eax printf("%d\n", c);
00331883 mov eax,dword ptr [ebp-20h]
00331886 push eax
00331887 push 337B30h
0033188C call 003310D7
00331891 add esp,8 return 0;
00331894 xor eax,eax
}
00331896 pop edi
00331897 pop esi
00331898 pop ebx
00331899 add esp,0E4h
0033189F cmp ebp,esp
003318A1 call 00331253
003318A6 mov esp,ebp
003318A8 pop ebp
003318A9 ret
int Add(int x, int y)
{
00331780 push ebp
00331781 mov ebp,esp
00331783 sub esp,0CCh
00331789 push ebx
0033178A push esi
0033178B push edi int z = 0;
0033178C mov dword ptr [ebp-8],0 z = x + y;
00331793 mov eax,dword ptr [ebp+8]
00331796 add eax,dword ptr [ebp+0Ch]
00331799 mov dword ptr [ebp-8],eax return z;
0033179C mov eax,dword ptr [ebp-8]
}
0033179F pop edi
003317A0 pop esi
003317A1 pop ebx
003317A2 mov esp,ebp
003317A4 pop ebp
003317A5 ret
2.2.4、函数栈帧的创建
00331840 push ebp // //把ebp寄存器中的值进行压栈,此时的ebp中存放的是invoke_main函数栈帧的ebp,同时esp寄存器的的值减去4。
00331841 mov ebp,esp // move指令会把esp的值存放到ebp中,相当于产生了main函数的ebp,这个值就是invoke_main函数栈帧的esp。
00331843 sub esp,0E4h // sub会让esp寄存器中的地址减去一个16进制数字0E4h(也就是228),产生新的esp,此时的esp中地址指向的就是main函数栈帧的esp,此时结合上一条指令的ebp和当前的esp,ebp和esp之间维护了一个块栈空间,这块栈空间就是为main函数开辟的,就是main函数的栈帧空间。
00331849 push ebx // 将寄存器ebx的值压栈,同时esp寄存器的值减4
0033184A push esi // 将寄存器esi的值压栈,同时esp寄存器的值减4
0033184B push edi // 将寄存器edi的值压栈,同时esp寄存器的值减4
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的值在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复。0033184C lea edi,[ebp-24h] // 把ebp-24h这个地址加载到edi中。(24h也就是36)
0033184F mov ecx,9 // 把9的值给ecx。
00331854 mov eax,0CCCCCCCCh // 把0CCCCCCCCh的值给eax。
00331859 rep stos dword ptr es:[edi] // 将从edi到ebp这一段的内存的每个字节都初始化为0xCC。
例如:如下图所示
2.2.5、main的核心代码
int a = 10;
0033185B mov dword ptr [ebp-8],0Ah // 把0Ah(也就是10)放入ebp-8的地址处。int b = 20;
00331862 mov dword ptr [ebp-14h],14h // 把14h(也就是20)放入ebp-14h的地址处。int c = 0;
00331869 mov dword ptr [ebp-20h],0 // 把0放入ebp-20h的地址处。
//以上汇编代码表示的变量a,b,c的创建和初始化,这就是局部的变量的创建和初始化。局部变量的创建是在局部变量所在函数的栈帧空间中创建的。c = Add(a, b);
00331870 mov eax,dword ptr [ebp-14h] //将ebp-14h处放的20放在eax寄存器。
00331873 push eax // 将eax的值压栈,同时esp寄存器的值减4。
00331874 mov ecx,dword ptr [ebp-8] // 将ebp-8处放的10放在ecx寄存器中。
00331877 push ecx // 将ecx的值压栈,同时esp寄存器减4。
00331878 call 003310B9 // call指令是要执行函数调用逻辑的,在执行call指令之前先会把call指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
//当我们跳转到Add函数,就要开始观察Add函数的反汇编代码了。0033187D add esp,8
00331880 mov dword ptr [ebp-20h],eax
例如:如下图所示
call完后会到下图所示的位置:
再经过一次跳转就可以到Add函数的位置,如下图所示:
2.2.6、Add函数的调用
当我们跳转到Add函数,就要开始观察Add函数的反汇编代码了。如下:
int Add(int x, int y)
{
00331780 push ebp //压入main函数栈帧的ebp的值,并且esp寄存器的值减4。
00331781 mov ebp,esp // 将main函数的esp赋值给新的ebp,ebp现在是Add函数的ebp。
00331783 sub esp,0CCh // 给esp减去0xCC(也就是204),求出Add函数的esp。
00331789 push ebx // 将ebx的值压栈,并且esp寄存器的值减4。
0033178A push esi // 将esi的值压栈,并且esp寄存器的值减4。
0033178B push edi // 将edi的值压栈,并且esp寄存器的值减4。int z = 0;
0033178C mov dword ptr [ebp-8],0 // //将0放在ebp-8的地址处,其实就是创建z变量//接下来计算的是x+y,结果保存到z中z = x + y;
00331793 mov eax,dword ptr [ebp+8] // 将ebp+8地址处的数字存储到eax中。
00331796 add eax,dword ptr [ebp+0Ch] // 将ebp+12地址处的数字加到eax寄存中。
00331799 mov dword ptr [ebp-8],eax //将eax的结果保存到ebp-8的地址处,其实就是放到z中return z;
0033179C mov eax,dword ptr [ebp-8] // 将ebp-8地址处的值放在eax中,其实就是把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
}
0033179F pop edi
003317A0 pop esi
003317A1 pop ebx
003317A2 mov esp,ebp
003317A4 pop ebp
003317A5 ret
例如:如下图所示
2.2.7、函数栈帧的销毁
0033179F pop edi //在栈顶弹出一个值,存放到edi中,并且esp寄存器的值加4。
003317A0 pop esi //在栈顶弹出一个值,存放到esi中,并且esp寄存器的值加4。
003317A1 pop ebx //在栈顶弹出一个值,存放到ebx中,并且esp寄存器的值加4。003317A2 mov esp,ebp //再将Add函数的ebp的值赋值给esp,相当于回收了Add函数的栈帧空间003317A4 pop ebp //弹出栈顶的值存放到ebp,栈顶此时的值恰好就是main函数的ebp,并且esp寄存器的值加4,此时恢复了main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向了main函数栈帧的栈底。003317A5 ret //ret指令的执行,首先是从栈顶弹出一个值,弹出的值就是原本call指令下一条指令的地址,此时esp寄存器加4,然后直接跳转到原本call指令下一条指令的地址处,继续往下执行。
回到了call指令的下一条指令的地方:
00331878 call 003310B9
0033187D add esp,8 // esp寄存器的值直接加8。
00331880 mov dword ptr [ebp-20h],eax // //将eax中值,存档到ebp-0x20的地址处,其实就是存储到main函数中c变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
例如:如下图所示
main函数栈帧的销毁也是类似,不再重复。
注意:为函数开辟的栈帧空间的大小是编译器经过计算的,不会存在栈帧空间不够的情况。另外寄存器是集成到CPU上的。
2.3、问题
理解函数栈帧有什么用呢?只要理解了函数栈帧的创建和销毁,以下问题就能够很好的理解了:
1、为什么局部变量不初始化内容是随机的?
答:因为随机值是函数栈帧创建时放进去的。
2、传参的顺序是怎样的?
答:传参的顺序是从右向左传参的。
3、函数的返回值是如何返回的?
答: 通过寄存器返回的。拓展了解: 返回内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果是较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。