20-2 栈与堆

程序使用的内存通常划分为几个不同的区域,称为段:

  • 代码段code segment(也称为文本段text segment),编译后的程序驻留在内存中的位置。代码段通常为只读。
  • bss段bss segment(也称为未初始化数据段uninitialized data segment),用于存储初始化为零的全局变量和静态变量。
  • 数据段data segment(也称初始化数据段initialized data segment),用于存储已初始化的全局变量和静态变量。
  • 堆区heap,用于动态分配变量。
  • 调用stack,用于存储函数参数、局部变量及其他函数相关信息。

本节课我们将重点探讨堆区和堆栈,因为大多数关键操作都发生在这些区域。


堆段

堆段(也称为“自由存储区”)用于追踪动态内存分配所占用的空间。我们在第19.1节——使用new和delete进行动态内存分配中已简要讨论过堆的概念,因此这里将进行回顾。

在C++中,当使用new运算符分配内存时,该内存会被分配到应用程序的堆区段中。

假设一个整型变量占用4字节:

int* ptr { new int }; // new int allocates 4 bytes in the heap
int* array { new int[10] }; // new int[10] allocates 40 bytes in the heap

该内存地址由new运算符返回,可存储于指针中。您无需关注内存释放与分配给用户的具体机制。但需注意:连续的内存请求未必能获得连续的内存地址!

int* ptr1 { new int };
int* ptr2 { new int };
// ptr1 and ptr2 may not have sequential addresses

当动态分配的变量被删除时,其内存会被“归还”给堆,以便在收到后续分配请求时重新分配。请注意:删除指针并不删除变量,它只是将关联地址的内存归还给操作系统。

堆具有以下优缺点:

  • 在堆上分配内存相对较慢。
  • 分配的内存将持续保留,直至被显式释放(需警惕内存泄漏)或应用程序终止(此时操作系统应自动清理)。
  • 动态分配的内存必须通过指针访问。解引用指针的速度慢于直接访问变量。
  • 由于堆是大型内存池,可在此分配大型数组、结构体或类。

调用栈

调用栈call stack(通常称为“栈”)承担着更为关键的作用。它从程序开始运行直至当前执行点,持续追踪所有活跃函数(即已被调用但尚未终止的函数),并负责分配所有函数参数和局部变量。

调用栈采用栈数据结构实现。因此在探讨其工作原理前,我们需要先理解栈数据结构的本质。


栈数据结构

数据结构data structure是组织数据以实现高效利用的编程机制。你已经接触过多种数据结构,例如数组和结构体。这两种数据结构都提供了高效存储和访问数据的机制。编程中常用的数据结构还有许多,其中不少已实现于标准库中,栈便是其中之一。

想象食堂里一摞餐盘。由于每个盘子都沉重且层层叠放,你实际上只能执行三种操作:

  1. 查看最上层盘子的表面
  2. 取走最上层盘子(露出下方盘子,若存在)
  3. 在摞顶放置新盘子(遮盖下方盘子,若存在)

在计算机编程中,栈是一种用于存储多个变量的容器数据结构(类似于数组)。但数组允许任意顺序访问和修改元素(称为随机访问random access),而栈则更为受限。栈可执行的操作对应上述三种操作:

  1. 查看栈顶元素(通常通过 top() 函数实现,有时也称 peek())
  2. 移除栈顶元素(通过 pop() 函数实现)
  3. 向栈顶添加新元素(通过 push() 函数实现)

栈是后进先出(LIFO)结构。最后压入栈的元素将最先弹出。若在栈顶放置新圆盘,则最先移除的圆盘即为最后推入的圆盘。后进先出。当元素被推入栈中时,栈的大小会增加;当元素被弹出时,栈的大小会减少。

例如,以下简短序列展示了栈的压入与弹出机制:

Stack: empty
Push 1
Stack: 1
Push 2
Stack: 1 2
Push 3
Stack: 1 2 3
Pop
Stack: 1 2
Pop
Stack: 1

板子比喻很好地说明了调用堆栈的工作原理,但我们还能构造更贴切的比喻。设想一堆信箱,它们层层叠叠堆叠在一起。每个信箱只能容纳一件物品,且初始状态均为空。此外,每个信箱都与下方信箱牢牢钉住,因此信箱数量无法改变。既然信箱数量固定,我们如何实现栈状行为?

首先,我们用标记物(如便签纸)来标记最底层空邮箱的位置。初始时标记位于栈底。当向邮箱栈压入物品时,将其放入标记邮箱(即首个空邮箱),同时将标记向上移动一格。当我们从栈中弹出项目时,将标记向下移动一个邮箱(使其指向顶部非空邮箱),并从该邮箱移除项目。标记下方所有邮箱中的项目均视为“栈上”项目,标记位置及标记上方邮箱中的项目则不属于栈上项目。


调用堆栈段

调用堆栈段用于存储调用堆栈所需的内存。应用程序启动时,操作系统会将 main() 函数压入调用堆栈,随后程序开始执行。

遇到函数调用时,该函数会被压入调用堆栈。当前函数执行完毕后,该函数会被弹出调用堆栈(此过程有时称为栈展开unwinding the stack)。因此,通过观察当前位于调用栈中的函数,我们可以追溯所有被调用直至当前执行点的函数链。

前文提到的邮箱类比与调用栈的工作原理高度契合。栈本身是固定大小的内存地址区块,邮箱即为内存地址,而我们在栈上压入和弹出的“物体”称为栈帧stack frames。栈帧用于记录与单次函数调用相关的全部数据。稍后我们将深入探讨栈帧机制。而“标记”则由称为栈指针(有时简写为“SP”)的寄存器(CPU内的小型存储单元)承担,该指针实时标记调用栈顶端当前位置。

我们还能进行一项优化:从调用栈弹出项时,只需将栈指针向下移动——无需清理或清零被弹出栈帧占用的内存(相当于清空邮箱)。这部分内存不再被视为“栈上”资源(栈指针将位于该地址或更低处),因此不会被访问。若后续将新栈帧压入相同内存区域,将直接覆盖未清理的旧值。


调用栈的工作原理

让我们更详细地探讨调用栈的运作机制。函数被调用时会经历以下步骤:

  1. 程序遇到函数调用指令。

  2. 构建栈帧并将其压入栈中。栈帧包含:

    • 函数调用指令之后的指令地址(称为返回地址return address)。这是CPU记住被调用函数退出后返回位置的方式。
    • 所有函数参数。
    • 局部变量所需的内存空间
    • 函数修改过的寄存器副本(需在函数返回时恢复)
  3. CPU跳转至函数起始点。

  4. 函数内部指令开始执行。

函数终止时执行以下步骤:

  1. 从调用栈恢复寄存器
  2. 栈帧从栈中弹出。此操作释放所有局部变量和参数的内存。
  3. 处理返回值。
  4. CPU在返回地址处继续执行。

返回值的处理方式因计算机架构而异:部分架构将返回值作为栈帧组成部分,另一些则使用CPU寄存器。

通常无需深入了解调用栈的全部细节。但理解函数调用时被有效压入栈中,返回时被弹出(展开)的机制,能为你掌握递归原理以及调试时有用的其他概念奠定基础。

技术说明:某些架构中调用栈向内存地址0相反方向增长,另一些架构则向内存地址0方向增长。因此新压入的栈帧内存地址可能高于或低于前一个栈帧。


一个快速且简陋的调用堆栈示例

考虑以下简单应用程序:

int foo(int x)
{
    // b
    return x;
} // foo is popped off the call stack here

int main()
{
    // a
    foo(5); // foo is pushed on the call stack here
    // c

    return 0;
}

在标记点处,调用堆栈如下所示:

a:

main()

b:

foo() (including parameter x)
main()

c:

main()

栈溢出

栈的大小有限,因此只能容纳有限量的信息。在Windows版Visual Studio中,默认栈大小为1MB。而Unix变体系统使用g++/Clang时,最大可达8MB。若程序试图向栈中放置过多信息,将导致栈溢出。当栈内所有内存均被占用时,便会发生栈溢出Stack overflow——此时后续分配将溢出至内存的其他区域。

栈溢出通常由以下原因引发:在栈上分配过多变量,以及/或进行过多嵌套函数调用(如函数A调用函数B,函数B调用函数C,函数C调用函数D等循环调用)。在现代操作系统中,栈溢出通常会触发操作系统发出访问冲突错误并终止程序。

以下示例程序极易引发栈溢出。您可在系统上运行该程序并观察其崩溃过程:

#include <iostream>

int main()
{
    int stack[10000000];
    std::cout << "hi" << stack[0]; // we'll use stack[0] here so the compiler won't optimize the array away

    return 0;
}

该程序试图在栈上分配一个巨大的数组(可能达40MB)。由于栈空间不足以容纳该数组,导致数组分配溢出至程序无权使用的内存区域。

在Windows(Visual Studio)环境下,该程序会产生以下结果:

HelloWorld.exe (process 15916) exited with code -1073741571.

-1073741571 在十六进制中表示为 c0000005,这是 Windows 操作系统中访问违规的代码。请注意,由于程序在此之前就终止了,因此“hi”永远不会被打印出来。

下面是另一个因不同原因导致栈溢出的程序:

// h/t to reader yellowEmu for the idea of adding a counter
#include <iostream>

int g_counter{ 0 };

void eatStack()
{
    std::cout << ++g_counter << ' ';

    // We use a conditional here to avoid compiler warnings about infinite recursion
    if (g_counter > 0)
        eatStack(); // note that eatStack() calls itself

    // Needed to prevent compiler from doing tail-call optimization
    std::cout << "hi";
}

int main()
{
    eatStack();

    return 0;
}

image

在上面的程序中,每次调用 eatStack() 函数时,都会将一个栈帧压入栈中。由于 eatStack() 会不断调用自身(且永远不会返回给调用方),最终栈空间将耗尽导致溢出。

作者注

在作者的Windows 10机器上运行时(通过Visual Studio Community IDE),eatStack()在调试模式下调用4848次后崩溃,发布模式下则在调用128,679次后崩溃。

相关内容
关于自调用函数的深入探讨将在后续第20.3节——递归中展开。

栈具有以下优缺点:

  • 在栈上分配内存相对较快。
  • 栈上分配的内存只要存在于栈中就保持作用域有效,当从栈弹出时即被销毁。
  • 所有栈内存都在编译时确定,因此可通过变量直接访问。
  • 由于栈空间有限,通常不建议进行大量消耗栈空间的操作,例如分配或复制大型数组等内存密集型结构。

作者注:
此处补充说明(简化版)栈中变量的布局方式及其在运行时获取实际内存地址的机制。

posted @ 2026-01-19 23:44  游翔  阅读(2)  评论(0)    收藏  举报