大白话函数的调用过程
写了很多年代码了,却说不上来当我调用一次函数,是怎么样一个过程,今天研究一下。
先看几个概念:
寄存器:
先来看寄存器。CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。
先对几个常用寄存器有点印象,具体的去百度这里不展开。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直
栈 stack
栈读我另外一篇文章
https://blog.csdn.net/yanchendage/article/details/110201400
栈帧
函数调用经常是嵌套的,a调用b,b有调用c,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。
这点注意点,我们平时总说调用函数会开辟栈,其实严格说是栈帧
步入正题
写一段代码
int add_a_and_b(int a, int b) {
return a + b;
}
int main() {
return add_a_and_b(2, 3);
}
翻译成汇编语言是
_add_a_and_b:
push %ebx
mov %eax, [%esp+8]
mov %ebx, [%esp+12]
add %eax, %ebx
pop %ebx
ret
_main:
push 3
push 2
call _add_a_and_b
add %esp, 8
ret
什么是汇编语言
通俗点我们写的c、java、go、php乱七八糟的都是高级语言,但是这东西计算机根本就不认识,最后需要把你写的代码翻译成计算机认识的低级语言他才能执行,这个低级语言就是汇编语言。
谁来翻译的?编译器
简单讲,编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的程序
下面开始一步一步的分析!
你看到上面翻译的这堆乱七八糟的东西了吧,什么push,mov,add,pop这堆,这叫cpu指令。你写的程序就是按照这一个一个指令来执行的,你仔细看看那个 add %eax, %ebx,猜一猜好像是在给cpu下a+b的命令,是不是呢?咱一步一步来。
首先代码在哪里执行的?
操作系统为了执行这段代码开辟了一个进程,每个进程都有4g的虚拟地址空间,每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间。剩余的 3G 是自己可以用的用户空间,这里可以参考我的另外一篇文章.
https://blog.csdn.net/yanchendage/article/details/110393617
总之为什么用虚拟地址空间是因为:
- 让每个进程拥有了相同的、独立内存空间,相互之间不会干扰
- 读写内存更安全。由于系统和MMU的限制,使得进程无法操作到其他进程的数据。
- 不连续的物理空间可以映射成连续的虚拟地址空间
- 进程分配的内存空间只有在实际使用时,才会触发缺页异常来分配实际物理空间,从而最大程度减少了内存空间的浪费。
如下图可以看出分成了上下两部分。
Linux 对下面3g用户空间进程地址空间有个标准布局,地址空间中由各个不同的内存段组成 (Memory Segment),主要的内存段如下:
-
程序段 (Text Segment):可执行文件代码的内存映射
-
数据段 (Data Segment):可执行文件的已初始化全局变量的内存映射
-
BSS段 (BSS Segment):未初始化的全局变量或者静态变量(用零页初始化)
-
堆区 (Heap) : 存储动态内存分配,匿名的内存映射
-
栈区 (Stack) : 进程用户空间栈,由编译器自动分配释放,存放函数的参数值、局部变量的值等
-
映射段(Memory Mapping Segment):任何内存映射文件
看到了吧,有一个醒目的 ***程序段 (Text Segment)***,代码就放在这里。
开始执行main函数
首先我们从main函数看起,这是程序的第一个函数对吧,我们从各种文章中都能到过一句话。调用函数会开辟栈帧。现在在栈区 (Stack) 为main函数开辟了一段空间。
为什么栈底在上面,栈顶在下面,是因为栈在内存空间中是从上到下的,不懂的去看看我写的栈。
不管你干什么你申请空间肯定是要告诉别人你要多少对吧,现实里你跟人家租房子你肯定也是跟中介说我要一个20平米的房子,栈帧也一样,栈帧用的是EBP、和ESP这两个东西表示空间大小的。
ESP(Stack Pointer)是堆栈指针寄存器,存放执行函数对应栈帧的栈顶地址(也是系统栈的顶部),且始终指向栈顶;EBP(Base Pointer)是栈帧基址指针寄存器,存放执行函数对应栈帧的栈底地址。
他也是有单位来限制他的,栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。
为更具描述性,以下称EBP为帧基指针, ESP为栈顶指针,并在引用汇编代码时分别记为%ebp和%esp。
栈帧里都有什么东西呢?
总结一句话栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。
调用add_a_and_b(2, 3)函数
main函数内部调用了add_a_and_b函数。执行到这一行的时候,操作系统也会为add_a_and_b函数在**栈区 (Stack)**新建一个栈帧,也就是说,此时同时存在两个帧:main和add_a_and_b。一般来说,调用栈有多少层,就有多少帧。
看到这里是不是稍微有点感觉了?烧脑的来了。
卧槽这幅图什么玩意?咱一步一步来。
首先每一块都是什么
这幅图分成四个部分pre stack frame(上一个栈帧),红色部分(main函数的栈帧),蓝色部分(add_a_and_b函数的栈帧),next stack frame(下一这个栈帧)。
接着每一块里都有什么
lcocal variables ,argument,return address都代表什么呢?上面说的,函数参数,局部变量及返回地址。函数参数和局部变量好理解,返回地址是什么?下一条待执行指令的地址。比如说当你代码执行到了add_a_and_b(2, 3),底层编译器会翻译成汇编语言, “call _add_a_and_b” 这条指令,下一条指令就是“add %esp, 8”,也不难理解,当main调用add_a_and_b执行完毕后,main要知道执行到哪条指令接着执行吧。
_add_a_and_b:
push %ebx
mov %eax, [%esp+8]
mov %ebx, [%esp+12]
add %eax, %ebx
pop %ebx
ret
_main:
push 3
push 2
call _add_a_and_b //开辟栈帧,记录下一条指令的地址
add %esp, 8
ret
执行main函数
看图中粉红色部分,这部分对应的是main主调函数,从上往下看
-
EBP:先有一个栈基,这段栈帧的开始
-
caller-saved registers(主调函数保存寄存器)
%eax(累加(Accumulator)寄存器)
%edx(数据(Data)寄存器)
%ecx(计数器(Counter)寄存器)
为主调函数保存寄存器,当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中,被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。也就是a调用b之前先把几个寄存器的值保存起来以防b更改,这就是我们常说的保存函数调用的上下文。 -
Local Variables:局部变量入栈
-
将参数按照调用约定依次入栈,可以看到先是arg2,后是arg1,为什么不是先arg1入栈再arg2入栈我也没明白。查百科是因为函数调用约定。
-
return address,然后将指令指针EIP入栈以保存主调函数的返回地址(EIP 指令指针(Instruction Pointer)寄存器:总是指向下一条指令的地址; 所有已执行的指令都被它指向过),这个就是前面说过的返回地址吧。
main函数调用add_a_and_b函数
看图中蓝色部分,这部分对应的是add_a_and_b被调函数,从上往下看
- EBP:当前EBP寄存器的值还是main的栈基,ESP寄存器的值还是main的栈顶,为了保存mainEBP的值,把EBP寄存器的值取出来并入栈,那add_a_and_b的EBP是什么呢?因为main的结尾是add_a_and_b的开始,所以把ESP寄存器的值赋值给EBP,那add_a_and_b的ESP是什么呢?ESP的值会变化,始终指向当前函数栈帧的栈顶。这样以add_a_and_b的EBP基准,向上(栈底方向)可获取主调函数的返回地址,向下(栈顶方向)能获取被调函数的局部变量值。
- callee-saved registers(被调函数保存寄存器)
%ebx (基址(Base)寄存器)
%esi(来源索引(Source Index)寄存器)
%edi(目的索引(Destination Index)寄存器)
为被调函数保存寄存器,被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。 - Local Variables:局部变量入栈
add_a_and_b函数调用结束并返回
- 当前的EBP寄存器的值为add_a_and_b栈基,ESP寄存器的值为add_a_and_b栈顶,当调用完成时我们都听过一句话函数执行完毕销毁栈空间,那么是怎么实现的呢?将EBP寄存器的值赋给ESP寄存器,使ESP再次指向被调函数栈底以释放局部变量,看没看见?有点收缩意思,再将已压栈的主调函数帧基指针弹出到EBP寄存器,此时EBP寄存器又变成了main的栈基,ESP又变成了main的栈顶。
- 最后将main栈帧中保存的return address出栈,也就是返回将EIP的值改为下一条应该执行的指令。在main栈帧中调用add_a_and_b入栈的参数用完了也应该销毁,所以ESP继续上移越过参数,最终回到函数调用前的状态。