程序所使用的内存通常被划分为若干不同的区域,称为“段”(segment):
- 代码段(亦称文本段),存放已编译的程序指令,通常为只读。
- BSS 段(亦称未初始化数据段),存放被零初始化的全局变量与静态变量。
- 数据段(亦称已初始化数据段),存放已显式初始化的全局变量与静态变量。
- 堆(heap),用于动态内存分配。
- 调用栈(call stack),用于存储函数参数、局部变量及其他与函数调用相关的信息。
本课重点讨论堆与栈,因为大多数关键行为均发生于此二者之中。
堆段
堆段(又称“自由存储区”)负责追踪通过动态内存分配获得的内存。我们在《使用 new 与 delete 进行动态内存分配》中已初步介绍过堆,此处予以回顾。
在 C++ 中,使用 new
运算符分配的内存位于应用程序的堆段。
假设 int
占 4 字节:
int* ptr { new int }; // new int 在堆上分配 4 字节
int* array { new int[10] }; // new int[10] 在堆上分配 40 字节
该内存地址由 operator new
返回,并存储于指针中。用户无需关心系统在何处、如何寻找并分配空闲内存。但应知晓:连续申请内存未必得到连续的地址。
int* ptr1 { new int };
int* ptr2 { new int };
// ptr1 与 ptr2 的地址可能并不连续
当使用 delete
释放动态分配的变量时,内存被“归还”给堆,可供后续分配请求复用。记住:删除指针并非删除变量本身,而是将对应地址的内存交还操作系统。
堆的优缺点:
- 堆上分配内存速度相对较慢。
- 已分配内存将一直保留,直至显式释放(谨防内存泄漏)或进程结束(此时操作系统回收)。
- 动态内存必须通过指针访问;解引用指针比直接访问变量慢。
- 因堆为大型内存池,故可在此分配大型数组、结构体或类。
调用栈
调用栈(通常简称“栈”)扮演更为关键的角色:它追踪自程序启动至当前执行点的所有活跃函数(已调用但尚未返回者),并负责为函数参数及局部变量分配空间。
栈本身以“栈”数据结构实现,因此须先理解何为栈数据结构。
栈数据结构
数据结构是为高效组织与使用数据而设计的机制。你已接触过数组、结构体等,它们均提供存储与访问数据的方法。标准库亦实现了多种数据结构,其中就包括栈。
想象自助餐厅的一摞盘子:由于盘子沉重且叠放,你只能做三件事:
- 查看最顶端盘子的表面;
- 取走最顶端盘子(露出下一个盘子,如有);
- 将新盘子置于最顶端(覆盖下方盘子,如有)。
在计算机中,栈是一种容器型数据结构,可容纳多个变量(类似数组)。然而,数组支持随机访问,而栈受限:
- top()(或 peek()):查看栈顶元素;
- pop():移除栈顶元素;
- push():将新元素压入栈顶。
栈遵循“后进先出”(LIFO)原则:最后压入的元素最先弹出。若不断压栈,栈增长;不断弹栈,栈缩小。
示例操作序列:
栈:空
push 1
栈:1
push 2
栈:1 2
push 3
栈:1 2 3
pop
栈:1 2
pop
栈:1
盘子比喻虽形象,但可再精确些:设想一摞固定数量的信箱,每个信箱只能存放一件物品,最初皆空。信箱彼此钉牢,数量不可变。我们借助一张便签标记“最底空信箱”的位置。初始时,便签指向最底信箱。压栈时,物品放入便签指示的信箱,并将便签上移一格;弹栈时,便签下移一格,再取出该信箱物品。便签以下视为“栈内”,便签及其上方不在栈内。
栈段
栈段存放调用栈所用内存。程序启动时,操作系统将 main()
压栈,程序由此开始执行。遇到函数调用,则将该函数压栈;函数返回,则将其弹栈(此过程称为“栈展开”)。因此,观察当前栈内函数即可知调用链。
上述信箱比喻与调用栈极为相似:栈是一段固定大小的连续内存地址,信箱即地址,信箱内物品即“栈帧”(stack frame)。栈帧保存一次函数调用的全部数据。便签即 CPU 中的“栈指针”(stack pointer,SP),指示当前栈顶。
优化:弹栈时只需下移栈指针,无需清空原栈帧内存;后续压栈将直接覆盖旧值。
调用栈的运行流程
函数调用时,依次发生:
- 程序遇到函数调用;
- 构建栈帧并入栈,栈帧包含:
- 返回地址(函数调用指令之后的下一条指令);
- 所有实参;
- 局部变量所需内存;
- 需恢复的寄存器副本;
- CPU 跳转至函数入口;
- 执行函数体指令;
- 函数返回时:
- 从栈恢复寄存器;
- 弹出栈帧,释放局部变量及参数内存;
- 处理返回值;
- CPU 从返回地址继续执行。
返回值处理方式依体系结构而异:部分架构将返回值置于栈帧,部分则利用寄存器。
通常无需深究细节,但须知:函数调用即压栈,函数返回即弹栈,这有助于理解递归及调试。
技术注记:某些架构中栈向远离地址 0 的方向增长,另一些则相反,故新栈帧地址可能高于或低于旧栈帧。
简例
int foo(int x)
{
// b
return x;
} // foo 在此处弹栈
int main()
{
// a
foo(5); // foo 在此处压栈
// c
return 0;
}
在标记点处,栈状态为:
a:
main()
b:
foo()(含参数 x) main()
c:
main()
栈溢出
栈容量有限,仅能保存有限信息。Windows 上 Visual Studio 默认栈大小为 1 MB;g++ / Clang 在 Unix 变体上可达 8 MB。若栈空间耗尽,即发生“栈溢出”(stack overflow)。
栈溢出常因:
- 栈上分配过多变量;
- 过度嵌套的函数调用(如 A 调 B 调 C 调 D …)。
现代操作系统中,栈溢出通常触发访问违规,终止程序。
示例 1:在栈上申请过大数组
#include <iostream>
int main()
{
int stack[10000000];
std::cout << "hi" << stack[0]; // 使用 stack[0] 防止编译器优化掉数组
return 0;
}
该程序尝试在栈上分配约 40 MB 数组,超出栈容量,溢出至禁区。
Windows 下输出:
HelloWorld.exe (process 15916) exited with code -1073741571.
-1073741571
(16 进制 c0000005)为 Windows 访问违规码;hi
尚未打印程序即被终止。
示例 2:无限递归
#include <iostream>
int g_counter{ 0 };
void eatStack()
{
std::cout << ++g_counter << ' ';
if (g_counter > 0)
eatStack(); // 递归调用自身
std::cout << "hi"; // 防止尾调用优化
}
int main()
{
eatStack();
return 0;
}
每次调用 eatStack()
均压入新栈帧,永不返回,最终导致栈空间耗尽。
作者注:在作者 Windows 10 + Visual Studio Community IDE 上,Debug 模式下递归 4848 次后崩溃,Release 模式下为 128,679 次。
相关内容:我们将在后续《递归》中进一步讨论自调用函数。
栈的优缺点
- 栈上分配内存速度较快。
- 栈上内存随栈帧存在而存在,弹栈即销毁。
- 所有栈内存大小在编译期已知,可直接通过变量访问。
- 因栈容量有限,应避免大量占用栈空间,如分配或复制大型数组等资源密集型结构。
作者注:本注释另附简化信息,说明栈变量在运行时的布局及如何获得实际内存地址。