栈 - 函数调用的指挥官
程序运行栈是内存中一块很重要的区域, 在我们之前写汇编程序的时候, 并没有使用到这块区域, 但当我们写c程序时, 栈就开始发挥作用了.
如果你不确定自己的理论储备是否合格, 那么请试着回答下面几个问题来检验自己:
- 对一个包含多个c函数的c文件进行编译时, 编译出来的结果是每个函数各成一段还是根据函数的嵌套关系将所有程序 "合并" 到一起?
- 在 Cortex-M0 体系结构中, 栈被映射在哪个存储区? 地址是如何增长的?
- 栈指针是什么? 它被存放在哪个寄存器中? 能否通过指令直接修改这个寄存器的值?
- 栈帧是一个什么样的概念?
- 为什么需要 ATPCS ? ATPCS 主要规定了哪几个方面?
如果你对这些问题感觉到有些吃力, 不要着急, 可以先稍回顾一下教科书, 然后看一看下面的内容...
我们知道, 编译器对一个工程中的多个c文件是独立编译的, 每个文件编译得到的目标文件最终由链接器链接生成可执行文件. 其实, 编译器对一个c文件中的多个c函数也是独立编译处理的, 每个c函数经过编译会得到一个程序段, 每个程序段拥有一个基地址, 作为该程序的入口地址, 所有的程序段共同构成了目标文件中完整的代码段(.text). 当在一个函数中调用其他函数时, 调用语句会被编译成一个跳转指令, 跳转到被调用函数的入口地址.
这意味着, 编译器保留了多个c函数之间的调用关系, 所以当你是一个 "嵌套狂魔" 时, CPU 需要在函数间 "反复横跳", 这会在一定程度上引入性能开销.
程序运行栈运用了 "栈" 的 "先入后出" 的调度思想, 用于管理c函数调用. 函数的调用有个重要的特点: 当多个函数嵌套运行时, 每个函数只和调用它的函数(直接上层)和它调用的函数(直接下层)之间有直接的信息传递, 这是一种分层的设计思想, 而栈就是一种实现分层设计的有效手段. 每嵌套一个函数, 就为它开辟一个栈帧用于存放这个函数的运行数据, 每个栈帧维护一个函数"层", 栈帧在栈中向上"堆积". 注意, 这里的 "先入后出" 是不严格的, 它并不意味着我不能去访问那些被 "压在下面" 的栈单元, 汇编指令允许我可以通过在 SP 上增减任意值作为地址来 LDR 或 STR 任意一个相对寻址的栈单元.
你可能会问: 既然当我写汇编程序时用不到栈, 而当我写c程序时编译器已经帮我把一切都做好了, 我完全不需要关心程序是怎么在栈中运行的, 那我为什么还要学习栈呢?
其实, 学习栈的工作原理能够帮助我们更好地理解c语言编程中的一些"忌讳", 让我们从本质上搞明白一些"玄学"问题, 还能指导我们对程序作出底层的优化. 比如:
- 为什么在c语言中不允许返回局部变量的指针, 但允许返回静态局部变量的指针?
- 为什么函数里不建议定义大型局部数组?
- 怎样提升函数调用时参数传递的效率?
试想, 既然编译器是对每个c函数独立进行编译的, 那它是怎么知道函数传入的参数应该从哪里获取, 返回的数据应该存储在哪里, 以便其他函数使用呢? 另外, 如果这个函数被调用运行, 那么在当前函数中的寄存器操作必然会覆盖之前的寄存器值, 是否需要先将寄存器的原始值保存起来呢? 当这个函数运行结束时, 怎样正确返回到调用它的函数里继续运行呢? 怎样为函数内定义的局部变量分配存储空间呢? ...
对于这些问题, 目前几乎所有的计算机体系都采用了类似的解决方式:
- 规定优先使用寄存器传参和保存局部变量, 寄存器不够用时就用栈.
- 规定使用入栈的方式来保存寄存器值和返回地址, 这一步叫做"保护现场".
另外, 为了实现函数调用, 我们还必须规定好使用哪几个寄存器传参, 使用哪几个寄存器保存局部变量, 还有寄存器的传参顺序, 局部变量在寄存器中的定义顺序, 以及入栈出栈和寄存器间的对接顺序 .... 然而, 由于不同指令集架构中寄存器的数量和功能不同, 所以不同指令集架构中的规定也各不相同, 这些规定被称为指令集架构的函数调用标准, 比如 x86 的 cdel, x86-64 的 fastcall 和我们学到的 ARM 的 ATPCS 等等.
文字的表达有时是无力的, 理论的灌输可能是抽象的. 如果你很有兴趣, 很想弄明白函数调用和栈的深层原理, 为何不去自己动手写个程序编译一下, 向你的编译器学习学习? 在动手的过程中不妨思考这样几个问题:
- SP 的值是怎么发生变化的? 为什么要发生变化?
- LP 是怎么一步步被加载, 保护和重加载的? 它是怎样指导嵌套的程序找到返回地址的?
- "保护现场" 这一步是调用者完成的还是被调用者完成的?
- 栈帧是如何被 "开辟" 出来的?
- 为什么程序只会去访问当前栈帧和相邻栈帧中的数据, 不会去访问更底层的栈帧中的数据?
- 编译器做了哪些 "无用功"? 为什么会做这些 "无用功"?