本文基于visual studio2022的调试状态下,借助部分反汇编来理解函数栈帧的创建与销毁,涉及到简单的汇编指令,以及部分进程地址空间,和cpu调度的寄存器,来理解栈帧。

有了本文的储备知识可以进一步观看写者的几篇文章:  可变参数 : 深入理解 C 语言的可变参数列表&&宏函数处理可变参数:从栈帧原理到实战应用​_c语言可变参数用法,及多次宏传递可变参数实现方法-CSDN博客

  以及之后要写的函数递归的调用的原理。同大家一起探讨这些经常能看见却又经常头疼的玩意。

一.使用编译器转到反汇编代码

      朋友,我们首先编写一个类似我下面一个简单的程序如加法函数,注意我们尽量让每一步都尽可能详细一些这样理解你的汇编代码就轻松许多咯!

完成主函数之后我们按F11,进入函数的调试环境,然后对着代码右键如下图进入转汇编代码。

  进入这样的地方就是你需要的转汇编代码咯!

 二. 储备知识 

2.1 cpu的调度与进程的地址空间

        你想理解一个程序被调度的过程,就得有一定的进程储备知识,不然总会一知半解,我们的cpu在调度代码的时候,代码这是能被cpu理解已经是一系列的指令了,他会对应的执行相应的指令操作。 

        所以理解cpu在执行创建函数的指令的时候也是一样的,他会借助自身的一些寄存器和逻辑运算单元等等如图

  

    理解一个程序的进程地址空间,以及当我们的cpu下调度代码的时候,要进入一个函数也是要为函数开辟独立的栈帧结构体,在栈区的位置,以及在栈区位置开辟内存地址是向下增长的,所以你就能理解为什么rsp(register stack pointer)栈指针开辟内存的时候 是拿它: 

rsp sub 16这样新的函数的空间 : 如图,我们的这样在rsp 到rsp-16 这个区间就是我们新开辟的栈帧的地址。                                                高地址

                         低地址

2.2 简单的栈帧相关的汇编指令

        汇编是一个指令集的语言,它更接近硬件的指令,所以拿他来研究cpu是如何操作函数的栈帧创建与销毁是很舒服的,首先有一定的储备能力是必要的:

    你得理解cpu自己要调度快速的化,自己上面也是有一定的存储能力的,也就是cpu上也有自己的内存,对应独立的寄存器,他们也有自己的作用:

          关于栈帧的寄存器: rbp和rsp :  rsp在创建栈帧的时候,也就是为我们的函数开辟栈空间大小,比如扩容: 也就是让rsp-=16,向低地址申请空间,然后大小固定以后,让rbp这个base寄存器来执行这个栈的栈底,理解为,当这个栈帧创建好以后反而rsp执行的是栈顶了,rbp指向的却是栈底(毕竟栈底固定不动,为了记忆你也可以理解为base 基: 底)

        cpu还有一大堆通用寄存器他们可以拿来暂时存放各种数据: 如rax,rbx,rcx

r (abcd)x, 或者 r(abc)i 。

         但是有一个寄存器,它就是 rdi 寄存器存放原来函数中的它的形参第一个参数的地址,早在,这个寄存器读者们先留个狠狠的印象。

        再来介绍对应的汇编的常见指令集合

指令 功能简介 在函数栈帧中的作用
push 数据压栈

传递参数、保存返回地址、保存寄存器

这是一个复合指令哦:

 在栈帧中: 如 push rbp 

它的做法就是把: 让rsp移动,同时把rbp指向的

地址的值插入到rsp移动后的区间的栈空间

pop 数据出栈

恢复寄存器、清理栈

这跟跟push指令相对,它会帮你把rsp寄存器移动

然后让原本栈空间的内容弹出来,还给rbp寄存器

mov 数据传输

在寄存器/内存/立即数之间复制数据  赋值操作 

 如 :mov eax  ebx

lea 加载有效地址

计算局部变量或参数的地址 

用法如 : lea  rsp [rbp+16] 把rbp+16的地址给rsp

call 调用函数

压入返回地址并跳转

这指令也是复合的: 使用以后 自动push 下一条指令的

地址 下一条指令的地址就是返回值

ret 从函数返回 弹出返回地址并跳回
sub esp, N 移动栈顶 为局部变量分配空间
add esp, N 移动栈顶 清理栈上的参数或空间

        这里主要介绍push,pop还有lea 的作用,还有简单的sub,call

其他的可以举一反三: 参考如下汇编代码:  

sub  a b 表示的意思是 : a -=b  注意 是-=哦  如 a =10 ,a-=2  a变为8了

其次就是 : 如果要取值变量的话: cpu在汇编代码中会先让值取出来放到cpu的寄存器上,然后在让寄存器的值进行计算。

再来理解 lea    你可以理解为它就是 取地址的作用:   如下代码

首先你要理解

 ​ 注意这里的 [rsp+20h] 的结果 依然是 rsp+20h是属于lea的一个语法表示&(rsp+20h) 这样理解吧,不会访问内存的 

        可是如 mov eax [rsp] 用mov指令可就要访问了

 综上我们来理解代码吧: 首先 mov命令 赋值,把0这跟硬编码的值,赋给了,[rbp+4]

注意这里表示它是 访问了rbp+4 的内存 相当于 *(rbp+4) 赋值为0 ,以及提示你 dword ptr 表示这跟是 双字的指向(4字节)指向了这块内存。

        接着后面 到了 b= 3+2 可以发现它已经硬编码把2+3 计算出来了 然后直接mov指令了

,以及后面的    i -=b  这样的在栈空间有内存的家伙,都是会借助寄存器计算的,把他们的值都取出来分别放在寄存器上 eax和ecx ,然后让寄存器的值  sub eax ecx 表示就是 :

        eax -=ecx   然后把eax的值赋值给对应的家伙这样你就基本能理解了  

三. 函数栈帧的创建与销毁

3.1  函数栈帧创建的图解

        当你有了关于栈空间的申请是向低地址申请的以及理解进程当中,什么叫做局部变量,局部变量才会被放在我们的栈空间当中,以及栈空间的创建是函数调用过程中创建的,那就开始吧!

   首先如下 ,创建栈帧前,记住几乎所有函数一定是被调用的,如下,被调用的时候一定会保存上一个函数的rbp (基址指针)也就是被称为返回地址的家伙,其次就是存放旧函数的下一条之类的地址放入rdi中,然后 如下 因为调用了 push 指令 rsi的值也跟着移动了

调用函数的函数栈帧结构可以参考右侧的图

       

        之后我们的函数准备工作已经做好了,就可以开始开辟栈帧空间了,让rsp 开辟内存

因为栈空间地址是向申请的结果如下:  对应函数空间的大小是编译阶段编译器会做分析的然后写成汇编代码了,所有你不需要问我为什么每次开辟栈帧大小的时候 : 假设我们接下来开辟是:sub rsp 48h 

这跟16h不是固定的 也可能是其他的,这就是 栈帧空间大小是不一样的。(让 sub -= 16h (16进制的表示而已))

​  开辟好之后 ,我们再来谈谈函数传参的事情。

     函数传参 首先我们的开辟的局部栈帧的大小是给我们的栈帧为了的局部变量的

如果你传递的形参也就是 以下 : rcx,rdx ,r7,r8等,那么那会丢给我们的cpu的寄存器 : 注意rsp会继续移动,

​        ----》            

        到这你就能理解为什么我们的函数栈帧的创建中rsp总是指向当前栈帧的最底部,注意,那我rbp什么时候移动呢 ,当你开辟好栈帧空间的大小了之后,rbp可能是这样的,

让他指向了: mov  rbp  rsp +20h 也就是rbp在rsp的上面 的一个位置 ,

你可能会说这跟栈帧的利用率不高啊,这一点你不用担心那些空间自有考量,你感兴趣你可以去研究一下,俺也不会。

                              

3.2 函数栈帧的销毁图解

        销毁的过程其实是创建过程的逆过程,一方面让rsp和rbp指针恢复到旧函数的值,然后让rdi(指向当前程序的下一条指令)恢复到在上次函数的下一条指令的值。

        有了这些目的我们就开始吧: 

如下图 ,注意我们栈帧空间的大小编译器会帮我记得的放心吧,他会安装这跟帮我们记下来然后编写汇编代码,cpu负责执行就好了,所以你不需要记忆栈空间的大小,

所以 就是让rsp 回到开辟空间后的大小但是还要继续,因为还要回到原来函数栈帧的话,那么就需要pop 因为函数栈帧开辟之前我们还要操作就是push,

所以就pop两次,注意 pop之后,我们的rdi和rbx会自动还给rbi和rdi寄存器了。同时rsp也移动了,到这时候 就已经回去了 。

如下 rbp下面的应图解是 : 旧函数的rdi指向旧函数的第一个形参

3.3 代码来理解函数的栈帧创建与销毁

代码: 

#include<iostream>
#include<stdarg.h>

int add(int x, int y) {
	
	int r = x + y;
	return r;
}

int main() {

	int i = 0;
	int b = 2;
	b = 3+2;
	i -= b;
	i - 10;
	int r = add(2,b);
	printf("%d",r);



	return 0;
}

         如上图,可以看见我们的主函数也在开始push 压栈,然后开辟栈空间 ,下面那个call调用是关于调试程序的我们就不用管他了,call会到jmp指令,因为要去访问对应的代码区。

        call为什么要jum啊 我没理解 jum以后还是在栈空间吗 我们一直访问的调试汇编代码都是在代码区 但是栈空间是我们通过 rbp和rsp 等寄存器来访问而已?

 是的没错我们调试了这么久这些代码都是在代码区的指令,这些都是cpu会调度的指令而数据在栈上没问题啊!  所以跳转本质上是跳转我们的代码地址,因为我们要访问下一个函数地址了。 所以有jmp

call的本质就是 : 自动帮你把call之后的下一条指令压栈(这跟汇编里面看不见是因为call指令已经集成了,call还会帮你跳转到jmp)  之后就是跳转 ,所以这个时候 的rsp 已经在开始 --了 也就是返回地址

3.3.1创建 

        首先 这两个mov你觉得是什么呢,你看看 给了ecx和edx,那就是传给来的形参你可以看见在函数,首先函数在被调用的时候,首先 形参 x 和y都会开辟空间 ,有意思的是 这跟 x和y的空间位置 居然是 rsp+10h 往上走,而且这个时候还没开辟空间哦。

        这里发现栈上创建的形参居然是在上面的 其实我这是这么理解的如下:因为之后就要做函数栈帧的准备工作,在call之后返回地址已经放进去了,所以函数形参的地址在这返回地址的上面 ,所以如下汇编代码也就能解释了。

        把传过来的值给形参 mov过去,然后push工作准备,push rdi(这是一个约定,每次使用rdi都要先存一下,它一般会放一个函数的第一个参数 ,这是我文章前面可能说错的地方,欢迎大家指出我的错误!我更正了) 

        然后开辟栈帧: 就是 rsp 进行一个 sub 然后就是 

lea 取地址 ,把 rsp+20h的地址 给了 rbp 这样rbp就在栈底固定不动,然后可以拿他来访问局部变量的值

 

3.3.2 销毁 

        始终记住一个事情,我们的汇编看见的都是在代码区,而我们这些指令调度的数据,栈空间的在栈区哦! 所以是不同的区域,这跟进程的地址空间有关,如下你看,当销毁的时候,首先会让rsp的地址回到它最开始开辟栈帧的地址值,然后pop rdi rbp 

还原最初的状态,之后保存返回值在 ret 其实还有就是 这跟时候我们的rsp指向了返回地址,然后就回去了,第二张图,参考一下你能理解,它就是把返回值又交给了eax ,然后给了我们的目标变量。 

四 .进阶 函数递归

4.1 递归

        首先你要理解一个事情,函数调用是存在调用和被调用的关系的,其次 递归调用也就是所谓的自身调用自身的思路,不断地的调用递归的过程本身就是不断的创建函数的栈帧,然后保存旧函数的 返回地址啊,rbp啊 以及rdi等 ,如下图就是

当你符合条件 返回了 ,也就是 函数栈帧的销毁过程,就是 移动 rsp和rbp寄存器又返回去,下图的rbp和rsp 寄存器 你懂哦!

           函数的独立性,你可以理解,函数的栈帧是具有独立性的哪怕是同一个函数不断的调用自身他也是存在一个事情就是调用者与被调用者的关系的,以及可能会存在一个返回条件导致其中一个函数栈帧被销毁了一步步返回。 

        最后谈谈栈帧空间的大小,你可以发现每次栈帧都是存在一个问题都要维护栈帧大小的,如果始终不返回或者开辟了很多栈帧才解决问题开始返回,就会导致栈帧的维护空间很大,甚至把进程的栈区爆满溢出了,那就会报错 segment error 段错误。 

        如下图,我可没有退出条件哦,我就一直创建栈帧一直创建直到溢出,操作系统看见我溢出了,直接发一个信号给我让我结束进程。

4.2 涉及到递归的时候时间复杂度如何理解

        首先涉及到时间复杂度,这是另一方面的知识了,那就是cpu调度的时间,以及时间复杂度的渐近表示了。 总之我们用渐近表示,我们的cpu调度的时间,那我们要理解一些事情就是,递归的时间复杂度,你可以理解为,它对你的代码同样进行了多次计算。 

        算了,千言万语不如上手吧: 如代码

void func(int n) {
	if (n == 1)
		return;
	func(n/3);
}


int main() {


	func(100);
	return 0;
}

        这代码里面我们来屡屡这跟递归函数调用,首先我们观察func函数,进入之后,如果n!=1 它就会调用下面的func()函数,现在抛弃你的以前的束缚,你就理解为一个普通的函数调用即可,只不过,进入到另一个函数调用它又调用了另一个函数调用,直到最后一个函数调用结束它才开始返回。

        所以到底谁在消耗我们的时间呢,首先你要基本理解就是函数栈帧创建的过程时间这个东西你可以基本先忽略,空间也不是我们要在乎的事情,那么你看这跟函数当中还有谁在消耗时间,是if判断吗 ,这跟语句是O(1)的每一次都在执行,我们列入进来,是 func(n/3)吗它每次都会被调用,那么我们思考一下,一共会调用多少次func 以及每次调用func之后的时间复杂度是多说: 是的除了调用func之外的时间复杂度 每一次就是 判断语句 if了 他是 O(1) 每次调用递归的时间复杂度我们说了可以忽略那么也可以理解为O(1)

 所以 这两条语句的时间复杂度 调用一次我们就可以理解为时间复杂度为 O(1)+O(1)

那么func调用了多少次呢 : n/3/3/3 直到为1 那么结束 log 3 n 

if调用的次数 一想跟func调用次数是类似的 : 所以最终的时间复杂度就是 log3 n

        嗯 有点意思,为什么能算出来,基于了两个前提: 我们的递归        调用的时间是 O(1)

Logo

昇腾计算产业是基于昇腾系列(HUAWEI Ascend)处理器和基础软件构建的全栈 AI计算基础设施、行业应用及服务,https://devpress.csdn.net/organization/setting/general/146749包括昇腾系列处理器、系列硬件、CANN、AI计算框架、应用使能、开发工具链、管理运维工具、行业应用及服务等全产业链

更多推荐