函数调用
函数调用完成后返回到哪里了呢?当用IDE查看函数调用栈的时候,IDE是如何回溯出函数调用轨迹的呢?
操作系统会为每一个线程准备一段内存,专门用来记录该线程的函数调用轨迹,为了方便展示,上方为低地址,下方为高地址。用一根水位线标识该内存的使用量。
使用例子
函数的调用过程
执行这些汇编指令,看看内存是如何记录函数调用轨迹的:
首先从main函数开始,第一条push指令,把rbp寄存器的值存入内存。(具体值不重要,用rbp-main代替)
mov指令对内存无影响,略
call指令把下一条指令的地址存放到内存。
然后cpu跳转到func_1继续执行。函数func_1的push指令会把rbp寄存器的值存入内存。mov对内存无影响。call指令把下一条指令的地址存入内存。
然后cpu跳转到func_2继续执行,func_2会将push指令会把rbp寄存器的值存到内存。
func_2运行完成后就通过40111a返回到func_1,函数func_1运行完就通过401125返回到main函数,这就是一条完整的函数调用轨迹。
函数的返回过程
略过两条mov指令,pop指令会把水位线上的值赋值给寄存器rbp
ret指令会把水位线上的值赋值给寄存器rip从而让rip引导cpu返回到func_1
返回到func_1后执行pop指令,把水位线上的值赋值给寄存器rbp
ret指令则把水位线上的值赋值给寄存器rip,从而让rip引导cpu返回到main函数
回到main函数后越过nop指令随后的pop指令会把水位线上的值,赋值给寄存器rbp
至此,所有的函数调用结束,水位线又落回到了起点。
内存为什么叫堆栈
因为它的存储方式是堆叠的,水位线是指的栈顶,它也是一个内存地址,保存才rsp寄存器里。
总结
- 堆栈是一段普通的内存,每次函数调用都需要占用一定数量的内存用来存放地址和其他的信息
- 每次函数 的返回都会如数的返回刚才调用的时占用的内存,但不会清理数据
- 如果函数嵌套调用过深,函数一直没有机会返回并释放占用的内存地址,就可能出现水位线超标的情况,如使用函数递归产生的问题,堆栈溢出。
- 堆栈不仅能存放函数返回地址,还能存放参数、栈变量和其他的数据,这也是每次函数调用都要存储恢复rbp寄存器的原因
堆栈溢出例子:无穷递归
手动回溯函数调用轨迹:
从CPU视角认识函数指针
两个函数的汇编指令完全相同,都是把0x401106,存放到一个临时的栈变量里面。
所以函数指针跟 普通变量一样,依然是变量。
前面得知函数调用就是cpu调转到某个函数的首地址 继续执行,但是仅仅知道函数的首地址还是完全不够的 ,因为在调用之前,主调函数还需要为被调函数准备参数,如何知道函数指针需要几个参数,需要什么类型的参数呢?
就是预先指定的函数指针的类型,也就是typedef,他告诉我们调用这个函数的时候需要为它准备一个int类型的参数。
函数指针的运作条件已经具备,下面做函数调用。
func_1使用常规函数调用,func_2使用非常规函数调用,发现汇编指令完全相同。
函数指针也可以叫做函数类型的变量。
总结
- 函数指针存放这某个函数的内存首地址,当然用普通变量存放:变量,或函数的首地址也是可以的,但是不提倡。
- 普通变量因为用法、字节长度的不同需要定义不同的变量类型,函数也不例外,参数返回值的不同也需要事先定义(typedef)相应类型的函数指针,从而帮助主调函数正确的给函数指针传递参数和获取返回值。
- 传递函数指针其实就是在传递某个个函数的内存首地址,能得到内存地址就能随时调用这个函数,带来了极大的遍便利和灵活性。例如回调函数,虚函数,都是利用函数指针来实现的。
- 函数指针虽然灵活但是无法看出它调用的是那一个函数,因此函数指针会损害程序的可读性。
PS:
无论是普通变量,函数指针,指针变量都是变量,都是某个内存地址的别名,只是存放的数据的用途不同才做了细分。
堆栈隐患
实例:编写一个程序:其中malfunc()
函数被认为是恶意函数代码,func()
是正常函数代码,目前没有机会调用malfunc()
函数,但是利用堆栈隐患可以使恶意函数malfunc()
被调用。
函数的调用和返回
假设这个内存就是当前线程的堆栈,上面是高端地址,下面是低端地址,每个内存块的字节长度为8个字节。红色水位线是寄存器rsp的值,用来表示栈顶的内存地址,蓝色基准线是寄存器rbp 的值,用来表示main函数的栈帧基地址。
首先执行call指令,包含了两个操作:
- 把下一条指令的地址也就是函数func()的地址压入堆栈,栈顶水位线也随之升高
- 之后cpu跳转到函数func()的首地址,至此函数func的调用就完成了
开始执行函数func()
把rbp寄存器的值压入栈顶,栈顶水位线也随之升高,至此main函数的栈帧保护工作完成。
然后通过mov指令更新一下栈帧基准线,让其与栈顶水位线齐平,至此函数func的栈帧设置完成。
随后的两条指令对数组赋值,以蓝色基准线为基准,分别在偏移为8和16的地方写入2和1,至此函数功能完成,可以返回了。
pop指令把事先压入栈顶的rbp值返回给寄存器rbp,这样蓝色基准线就恢复到了最开始的位置,同时栈顶水位线也随之下降。
最后的ret指令跟pop指令类似,把栈顶出的返回值弹给cpu寄存器 rip,这样cpu就可以跳转到主调函数main中继续执行。
随着栈顶的下降,红色水位线也随之下降,这样红蓝两条线都恢复到了最开始的位置。
至此整个函数的调用返回过程完成。
这种设计高效简洁,还节省内存,但是缺点明显,这种就地存放返回地址的方法,既方便了函数返回也方便了恶意入侵。
设计缺点
倒退到给数组赋值的阶段
发现数组的第三号元素对应着函数的返回地址,如果我们让数组越界,强行给不存在的第三号元素赋值,不就等于改变了函数func()的返回地址了吗
强行将数组的第三个元素改成恶意函数的首地址,然后运行输出,发现恶意函数被执行。
总结
- 主调函数在调用函数时会把返回地址偷偷存放在堆栈中
- 被调函数返回时会从堆栈中取出返回地址,引导cpu跳回主调函数
- 不同编译器在实现函数上会略有不同,但大致原理相通