CPU眼里的:函数调用 | 返回
“为什么有人说C/C++语言的函数返回,是最高效、脆弱的设计,让我们用CPU的视角一探究竟”
01
提出问题
请问当函数执行完毕后,函数怎么知道:自己应该返回到哪里?它是否有走错路的可能?为什么有人说函数返回机制:是C/C++最脆弱的设计呢?
今天,让我们用 64位CPU 的视角,回答这个问题。一起揭示:如此精妙的设计背后,隐藏的致命缺陷。
02
代码分析
打开 Compiler Explorer,写一个简单的函数func;定义一个临时数组,作一下赋值;然后,作一下函数调用,如图所示。
左上角是:C语言的源码;左下角是:对应的汇编指令,其中左下角的黑色数字代表:每条汇编指令所在的内存地址(由于是64位的CPU,每个内存地址占用8个字节);
右边的内存块,是当前线程的“堆栈”,为了方便展示:“堆栈”的堆叠结构,下面是高端地址,上面是低端地址;每个内存块的字节长度为:8 个字节。(如果习惯:高端地址在上的“堆栈”结构,可以把书籍旋转180度观看,二者只有视角的差异,没有本质的区别)
初始“栈帧”,是 main 函数的“栈帧”,位于 红、蓝 两条线之间,如图所示。
红色水位线,是CPU寄存器 rsp 的值,用来标识:“栈顶”的内存地址;蓝色基准线,是CPU寄存器rbp的值,用来标识:main 函数的“栈帧”基地址。不用关心 main 函数的“栈帧”,一切从调用函数func开始(对应的指令地址是:4011059),如图所示。
call 指令,它包含了 2 个操作:
操作 1:把下一条指令的地址,也就是函数 func 的返回地址(0x401105e)压入堆栈,红色的“栈顶”水位线,也随之升高8个字节。
操作 2:CPU 跳转到函数 func 的首地址。
至此,函数 func 的调用过程就完成了。接着,开始执行函数 func的第 1 条push指令,如图所示。
先把 rbp 寄存器的值(0x80000030),压入“栈顶”;“栈顶”水位线,也随之升高。至此,main 函数的“栈帧”保护工作,完成!
随后的 mov 指令,更新一下“栈帧”基准线,让它与“栈顶”水位线齐平,如图所示。
至此,函数 func 的“栈帧”设置,完成!
关于“栈帧”的详细分析,请参看“CPU眼里的{函数括号}”,这里只点到为止。
随后两条mov指令,对数组赋值,如图所示。
以蓝色基准线为基准,分别在偏移为 8 和 16 的地方,写入:2 和 1。至此,函数功能完成,可以返回了。
pop 指令,把事先压入“栈顶”的 rbp 值(0x80000030),返还给寄存器 rbp,如图所示。
这样蓝色基准线,就恢复到了最开始的位置;同时,“栈顶”的红色水位线,也随之下降。
最后的 ret 指令,跟 pop 指令类似,如图所示。
把“栈顶”处的返回值(0x401105e),弹给 CPU 寄存器 rip,这样,CPU 就可以跳转到:主调函数 main 被打断的地方:0x401105e,继续执行了。
同时,随着“栈顶”的下降,红色水位线也随之下降。这样,红、蓝两条线,都恢复到了最开始的位置。堆栈内存完璧归赵,一点没多,一点没少。一切恢复如初,就跟没有发生过函数调用一样。
至此,整个函数的调用、返回过程,完成!必须称赞这种巧妙的设计,高效,简洁,还节省空间;但优点即缺点,这种就地存放返回地址的方法,即方便了函数返回,也方便了黑客入侵。
让时间倒退到:给数组赋值的阶段,如图所示。
如果以此类推的话,数组的第 3 号元素,就对应着函数的返回地址。如果我们让数组越界,强行给不存在的:第 3 号元素赋值,不就可以改变函数 func 的返回地址了吗?
说干就干!我们将返回地址改为:一个恶意函数malfunc的内存首地址,也就是:0x401165,让我们看看运行结果,如图所示。
不出所料,输出了:4 个骷髅头!恶意函数被执行了。
03
总结
1. 主调函数,在调用函数时,会把返回地址,偷偷存放在:“堆栈”中。
2. 被调函数返回时,会从“堆栈”中取出返回地址,引导 CPU 跳回到:主调函数。
3. 不同编译器,在实现函数返回上,会略有不同;但殊途同归,一通百通。
最后,函数返回的设计方法,简洁、高效;但缺点是:返回地址这种关键数据,离临时变量太近。容易被越界访问,导致程序意外崩溃;也为黑客攻击,留下了难以弥补的窟窿。
所以,用C/C++编写代码,对程序员的要求很高。即便语法规则,滚瓜烂熟,也难以百毒不侵;需要:眼中有代码,心中有指令;强大的内功,才是避坑的关键。
04
热点问题
Q1:你怎么知道malfunc的内存地址?
A1:可以选择打印malfunc的内存地址;也可以在Compiler Explorer里面,打开设置,勾选“Compile to binary”,直接显示malfunc的内存地址。
或者可以通过使用malfunc这个函数名称,获得它的内存地址,代码如下:
a[3] = malfunc;
Q2:章节的最后,是“堆栈”溢出攻击吗?如何才能让正常的程序,“堆栈”溢出呢?
A2:是的,这就是大家常说的“堆栈”溢出攻击的基本工作原理,现实操作起来,还需要考虑很多细节问题。一般来说,无论是C/C++,还是JavaScript等前端开发语言,用户输入,是一个堆栈溢出的攻击点;当输入量过大后,就很可能导致“堆栈”溢出。
Q3:递归函数,也符合本章节所描述的规律吗?
A3:是的,递归函数也是函数,虽然它看起来非常的玄幻?,如图?所示:
?但回调函数的调用、返回机制,跟普通函数完全一致。但需要注意的是:递归函数,最后一定要满足“返回”条件,否则就会无穷递归下去,也就是无穷调用下去,如图所示。
如你所见,无论多少行代码,CPU都是可以执行的,但是“堆栈”内存的数量是有限的。
不断的消耗内存,而不是释放、归还内存,“堆栈”内存迟早会被消耗殆尽(也叫:“堆栈”溢出),最终会触发操作系统、或CPU的安全机制。这也就是为什么写递归函数时,特别容易导致程序崩溃的原因。如果递归函数一直没有返回,“堆栈”溢出只是一个时间问题。
07
更多知识
如果喜欢阿布这种解读方式,希望更加系统学习这些编程知识的话,也可以考虑看看由阿布亲自编写,并由多位微软大佬联袂推荐的新书《CPU眼里的C/C++》