C++ 栈与堆详解:内存管理与程序行为

程序所使用的内存通常被划分为若干不同的区域,称为“段”(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),指示当前栈顶。

优化:弹栈时只需下移栈指针,无需清空原栈帧内存;后续压栈将直接覆盖旧值。

调用栈的运行流程

函数调用时,依次发生:

  1. 程序遇到函数调用;
  2. 构建栈帧并入栈,栈帧包含:
    • 返回地址(函数调用指令之后的下一条指令);
    • 所有实参;
    • 局部变量所需内存;
    • 需恢复的寄存器副本;
  3. CPU 跳转至函数入口;
  4. 执行函数体指令;
  5. 函数返回时:
    • 从栈恢复寄存器;
    • 弹出栈帧,释放局部变量及参数内存;
    • 处理返回值;
    • 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 次。

相关内容:我们将在后续《递归》中进一步讨论自调用函数。


栈的优缺点

  • 栈上分配内存速度较快。
  • 栈上内存随栈帧存在而存在,弹栈即销毁。
  • 所有栈内存大小在编译期已知,可直接通过变量访问。
  • 因栈容量有限,应避免大量占用栈空间,如分配或复制大型数组等资源密集型结构。

作者注:本注释另附简化信息,说明栈变量在运行时的布局及如何获得实际内存地址。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"