当前位置:首页 > 技术学院 > 技术前线
[导读]函数调用是所有编程语言里最基础的代码组织方式,几乎每一个开发者每天都在写大量的函数调用代码,但是绝大多数人对函数调用的底层认知,仅仅停留在“跳转到另一段代码执行,执行完再跳回来”这个表层印象上。你写的一行简单的函数调用语句,背后CPU和操作系统要执行十几步精密的操作,涉及栈帧构建、上下文保存、参数传递、执行跳转、结果返回、栈帧销毁一整套完整的链路。彻底理清函数调用的底层全流程,你就能彻底看懂调试器里栈回溯的原理,也能精准定位很多栈溢出、调用约定不匹配这类底层疑难bug。

函数调用是所有编程语言里最基础的代码组织方式,几乎每一个开发者每天都在写大量的函数调用代码,但是绝大多数人对函数调用的底层认知,仅仅停留在“跳转到另一段代码执行,执行完再跳回来”这个表层印象上。你写的一行简单的函数调用语句,背后CPU和操作系统要执行十几步精密的操作,涉及栈帧构建、上下文保存、参数传递、执行跳转、结果返回、栈帧销毁一整套完整的链路。彻底理清函数调用的底层全流程,你就能彻底看懂调试器里栈回溯的原理,也能精准定位很多栈溢出、调用约定不匹配这类底层疑难bug。

一、调用前的准备:编译期的预计算与指令生成

函数调用的绝大多数准备工作,其实在编译阶段就已经全部完成了。编译器在编译你的代码的时候,会提前为每一个函数计算好这个函数的栈帧总大小,明确所有局部变量的排布位置、参数的传递规则,同时根据你指定的调用约定,确定参数的压栈顺序、哪些参数通过寄存器传递、哪些参数通过栈传递、调用方和被调用方谁负责清理栈上的参数。

编译器会在函数调用的位置,生成对应的函数调用指令序列:先按照约定的顺序,把需要传递的参数准备好,一部分参数直接放到预先约定好的通用寄存器里,剩下的参数按照从右到左的顺序压入当前函数的栈中。所有参数准备完毕之后,CPU会执行专门的函数调用指令,这条指令就是触发整个函数调用流程的开关,从这一刻开始,整个底层硬件层面的函数调用流程正式启动。

很多新手以为函数调用的所有操作都是运行时动态完成的,实际上90%的布局规则在编译期就已经完全确定了,运行时只需要按照编译器提前规划好的步骤机械执行即可,不需要任何额外的动态计算,这也是函数调用能做到极快速度的核心原因。

二、调用指令触发:返回地址压栈与执行流跳转

当CPU执行到函数调用指令的那一刻,首先要做的第一件事,就是把当前指令的下一条指令的内存地址,也就是函数执行完成之后要返回继续执行的位置,直接压入当前进程的栈中保存起来。这个返回地址是整个函数调用链路能正确返回的核心,如果这个地址被意外篡改,函数执行完之后就会跳转到完全错误的内存地址,直接导致程序崩溃,绝大多数经典的栈溢出漏洞,本质上就是通过恶意篡改栈上保存的返回地址,让程序跳转到攻击者指定的恶意代码位置执行。

保存完返回地址之后,CPU会直接修改程序计数器寄存器,把函数的入口地址写入这个寄存器,下一条要执行的指令就直接跳转到目标函数的代码段位置,原本的执行流被暂时挂起,正式进入被调用函数的代码空间开始执行。整个跳转过程全程是CPU硬件层面原生支持的,不需要任何内核的介入,耗时只有几个时钟周期,速度极快。

到这一步为止,调用方的所有准备工作就全部完成了,控制权完全交到了被调用函数的手里,接下来所有的操作都由被调用函数的入口逻辑接管。

三、被调用函数执行:栈帧构建与上下文保存

被调用函数拿到控制权之后,第一件事就是构建自己的独立栈帧。首先它会把当前栈帧的基址寄存器的值压入栈中保存,这个值指向的是调用方函数栈帧的底部位置,保存这个值的目的,就是后续函数返回的时候,能精准恢复调用方的栈帧状态。紧接着,被调用函数会把当前的栈指针赋值给基址寄存器,这样新的基址就指向了当前新函数栈帧的底部位置,整个新的独立栈帧的边界就正式确定了。

接下来,函数会根据编译期预先计算好的栈帧总大小,直接移动栈指针,在栈上划出一块连续的内存空间,用来存放当前函数的所有局部变量、临时变量和需要保存的寄存器上下文。之后函数会把所有后续执行过程中需要用到的通用寄存器的数值,全部依次压入栈中保存起来,避免这些寄存器里原本存放的调用方的数值,在当前函数执行的过程中被意外修改,破坏调用方后续的执行逻辑。

到这一步,被调用函数的完整栈帧就构建完成了,所有的局部变量都已经在栈上分配好了空间,接下来就可以安全地执行函数内部的所有业务逻辑了。在函数执行的整个过程中,基址寄存器全程固定指向当前栈帧的底部位置,所有的局部变量和栈上的参数,都可以通过基址寄存器加上固定偏移量的方式直接访问,完全不需要做任何动态计算,访问速度极快。

四、函数返回:结果传递与栈帧销毁

当被调用函数执行完所有逻辑,走到return语句的时候,就进入了函数返回的流程。首先函数会把要返回的结果,放到预先约定好的通用寄存器里,调用方后续就可以直接从这个寄存器里拿到返回值。接下来函数会把之前压入栈中保存的所有寄存器上下文,按照之前压入的逆序全部弹出,恢复到执行当前函数之前的状态,保证调用方的寄存器数值完全没有被破坏。

之后函数直接把基址寄存器的值赋值给栈指针,直接把栈指针移动到当前栈帧的底部位置,整个当前函数的栈帧就被直接销毁了,栈上的所有局部变量的内存空间全部被回收。紧接着,函数把之前保存在栈上的旧的基址值弹出,恢复调用方的栈帧基址,最后执行函数返回指令,CPU自动从栈上弹出之前保存的返回地址,把程序计数器恢复成这个返回地址的数值,执行流就直接跳回到了调用方函数里,从函数调用指令的下一条位置,继续往下执行原来的逻辑。

最后,根据之前约定的调用约定,如果是调用方负责清理栈上的参数,调用方会直接移动栈指针,把之前压入栈的参数空间直接回收,整个函数调用的全流程就100%完成了,程序的执行流完全回到了函数调用之前的状态,就好像什么都没有发生过一样。

整个函数调用的全流程,是现代计算机体系结构里最精妙的基础设计之一,用极简的栈机制实现了任意深度的函数嵌套调用,支撑起了整个现代软件世界的代码模块化组织。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
关闭