“调用栈”(call stack)既可以指具体实现,也可以指一种抽象概念——由“栈帧”(stack frame)或者叫“活动记录”(activation record)构成的栈。
函数调用的局部状态之所以用栈来记录是因为这些数据的存活时间满足“后入先出”(LIFO)顺序,而栈的基本操作正好就是支持这种顺序的访问。
举例说,假如有下面程序:
int main() {
a();
return 0;
}
void a() {
b();
}
void b() {
c();
}
void c() {
}
那么整个程序的函数活动时间可以表示为:
main() a() b() c()
- main()
|
+> - a()
. |
. +> - b()
. . |
. . +> - c()
. . . |
. . + <- return from c()
. . |
. + <- return from b()
. |
+ <- return from a()
|
- return from main()
可以看到,函数的调用有完美的嵌套关系——调用者的生命期总是长于被调用者的生命期,并且后者在前者的之内。
这样,被调用者的局部信息所占空间的分配总是后于调用者的(后入),而其释放则总是先于调用者的(先出),所以正好可以满足栈的LIFO顺序,选用栈这种数据结构来实现调用栈是一种很自然的选择。
这个道理在MIT的6.001 SICP课的第一节里就有非常好的讲解了:
1B: Procedures and Processes; Substitution Model
有兴趣的同学请把SICP整门课都看看,会有许多收获。
(扩展:顺着SICP的线索学下去可以看到函数调用的顺序虽然跟LIFO顺序一致,但是调用者的栈帧并不一定要保留,在特殊情况下可以不保留调用者栈帧——尾调用(tail call)的情况。
关键点在于调用者是否在一个函数调用之后还有待执行的计算。如果没有了(这个函数调用是尾调用),那调用者的局部状态就没有必要保留。)
在满足LIFO顺序的情况下,实际映射到体系结构上,倒不一定要真的在内存里实现完整的调用栈。
例如说像SPARC有寄存器窗口,局部信息大都可以维持在寄存器里;当寄存器不够用的时候再由CPU与OS协作把部分寄存器的值暂时存到内存里——这部分看起来就会跟传统的用内存实现的调用栈类似。
然而不是所有编程语言的“函数”调用中局部信息的存储都是满足LIFO顺序的。