大家好,你们可以叫我凌,是个16岁的网络安全学习者。
上篇我们讲的内容涉及到了堆栈,相信不少人肯定一头雾水。那我们今天就以纯理论的方式讲解下这个东西,帮助大家更好地进行理解!当然,不会涉及到代码,因为到后面真正讲到堆栈的时候我们才会引入汇编代码。这篇只是上篇的扫盲及为未来做铺垫。
那我们就直接开始吧!
什么是堆栈
堆栈(Stack)是计算机内存中一块具有特殊访问规则的区域。它的核心规则是:后进先出(Last In, First Out,LIFO)。也就是说,最后存入堆栈的数据,会被最先取出。
生活类比
想象一叠盘子:
你每次把新盘子放在最上面(压栈)。
当需要取走盘子时,你总是先取最上面的盘子(弹栈)。
你不可能直接从中间抽走一个盘子,必须先拿走它上面所有盘子。
这就是后进先出。堆栈的行为完全一样。
堆栈在计算机中的作用
堆栈不是用来长期存储数据的(那是硬盘或内存其他区域的任务),而是用于临时存放一些关键信息,例如:
函数调用结束后应该返回到哪里(返回地址)。
函数内部的局部变量(这些变量在函数退出后自动失效)。
被保存的寄存器值(为了恢复调用者的现场)。
可以说,没有堆栈,函数调用就无法嵌套或递归,程序将变得非常原始。
堆栈的"生长方向"
在 x64 架构中,堆栈在内存中是向低地址方向生长的。也就是说:
当你往堆栈里放数据(压栈),栈顶的地址值变小。
当你从堆栈里取出数据(弹栈),栈顶的地址值变大。
你可以把内存地址想象成一条从上到下的数轴:
高地址在"上边",低地址在"下边"。
堆栈像钟乳石一样向下生长。
这一点与传统思维正好相反(通常我们认为"往上增加"),但只需要记住:
压栈 → 地址减小;
弹栈 → 地址增大。
堆栈区的预分配
堆栈并非无限大。操作系统在加载程序时,会为堆栈预留一定大小的内存区域(例如 Linux 默认 8MB)。如果程序压栈的数据超过这个预留空间,就会发生堆栈溢出(Stack Overflow),导致程序崩溃。这将在后续章节讨论。
小结:堆栈是块后进先出的内存区域,它向低地址方向生长,用于临时存放函数调用相关的数据。
为什么需要堆栈
你可能会有疑问:既然 CPU 有那么多寄存器(x64 有 16 个通用寄存器),为什么还要专门划出一块内存区域作为堆栈?原因有以下几点:
寄存器的数量仍然不够
虽然 16 个通用寄存器比 32 位时代多了不少,但它们在函数调用、临时存储、参数传递等场景下依然捉襟见肘。例如:
一个函数可能包含多个局部变量(数组、结构体等),无法全部存放在寄存器中。
函数嵌套调用时,每个函数都需要保存自己的返回地址和局部变量,寄存器会被频繁覆盖。
堆栈作为"寄存器扩展",提供了几乎无限的临时存储空间(只要不超过操作系统预留的大小)。
函数调用必须记住"回家的路"
当程序调用一个函数时,CPU 需要知道函数执行完后应该回到哪里继续执行。这个"返回地址"必须被保存起来,并且当多个函数嵌套调用时(比如 A 调用 B,B 调用 C),每个返回地址都要按照"后进先出"的顺序正确返回。
堆栈天然的后进先出特性完美匹配这个需求:最后一个调用的函数最先返回。
假如没有堆栈,每次函数调用就只能跳转到固定地址,无法实现递归或深层嵌套。
局部变量的自动生命周期
高级语言(如 C、C++)中,函数内部的局部变量在进入函数时分配,在函数退出时自动释放。这种"自动"的机制正是通过堆栈实现的:
函数开始时,在堆栈上预留一块空间(大小等于所有局部变量所需的总和)。
函数退出时,这块空间被一次性回收,无需手动管理。
如果用寄存器或堆来存储局部变量,程序员必须手动分配和释放,极易出错。
函数参数的传递(当参数过多时)
在 x64 调用约定中,前 6 个整数或指针参数通过寄存器传递(rdi、rsi、rdx、rcx、r8、r9)。但如果一个函数需要 7 个或更多参数,第 7 个及之后的参数就必须通过堆栈传递。调用者将多余参数压入堆栈,被调用函数从堆栈中读取它们。
浮点数参数的传递也使用 xmm0--xmm7 ,超出部分同样用堆栈。
保存和恢复寄存器值(避免相互覆盖)
当一个函数准备调用另一个函数时,它可能希望保留某些寄存器的值,以免被调函数修改。按照调用约定:
一部分寄存器(如 rbx、rbp、r12--r15)由被调用者保存:被调函数如果要用到它们,必须先压栈保存,返回前再弹栈恢复。
另一部分寄存器(如 rax、rcx、rdx、rsi、rdi、r8--r11)由调用者保存:调用者如果希望保留这些寄存器的值,需要在调用前自己压栈保存,调用后再弹栈恢复。
这些保存操作同样利用了堆栈。
小结
堆栈是函数调用机制的核心:
- 它提供了无限(相对寄存器而言)的临时存储。
- 它自动管理局部变量的生命周期。
- 它保证嵌套函数能正确返回。
- 它作为补充通道传递过多参数。
- 它允许寄存器值被安全地保存和恢复。
没有堆栈,现代编程语言中的函数、过程、方法、递归等概念都将无法高效实现。
堆栈的两个关键指针:RSP 和 RBP
在 x64 架构中,CPU 专门提供了两个寄存器来管理堆栈:RSP(Stack Pointer,栈指针)和 RBP(Base Pointer,基址指针)。它们不用于普通算术运算,而是专门用于记录堆栈的位置。
本章只讲解这两个寄存器的概念和作用,不涉及如何用指令修改它们。后续章节会结合具体指令演示它们的实际变化。
RSP:栈指针(Stack Pointer)
RSP 始终指向当前栈顶。所谓栈顶,就是堆栈中最后一个被压入的数据所在的位置。
当你向堆栈中压入数据时,RSP 的值会减小(因为堆栈向低地址生长)。
当你从堆栈中弹出数据时,RSP 的值会增大。
你可以把 RSP 想象成一张"即时贴",它时刻贴在最新数据的上面。CPU 通过读取 RSP 就知道现在栈顶在内存的哪个地址。
在大多数时候,你不需要直接修改 RSP,压栈和弹栈指令会自动更新它。
RBP:基址指针(Base Pointer)
RBP 通常用于固定地访问堆栈中的数据,特别是函数内部的局部变量和参数。
为什么有了 RSP 还需要 RBP?
因为 RSP 会随着压栈和弹栈频繁变化。如果在函数执行过程中,你想访问某个局部变量,而 RSP 可能已经移动了多次,导致计算偏移量非常麻烦。
解决办法是:在函数开始处,把当前 RSP 的值复制到 RBP 中,然后以 RBP 为基准,通过 固定偏移量 来访问数据。例如:
RBP - 8 可能指向第一个局部变量。
RBP + 16 可能指向某个参数。
这样,即使 RSP 后来上下移动,RBP 始终保持不变,访问数据就简单可靠。
注意:RBP 的使用并非强制。现代编译器有时会省略 RBP,直接用 RSP 加偏移。但理解 RBP 的作用对理解传统栈帧至关重要。
类比:RSP 是流动的工人,RBP 是固定的标记桩
想象你在一个仓库里搬运货物:
RSP 就像一个不停移动的推车,永远停在当前正在处理的那堆货物旁边(栈顶)。
RBP 则是在货架上钉的一个固定标记,你从这个标记往左数几步(负偏移)或往右数几步(正偏移)就能拿到你要的东西。
没有 RBP 时,你必须记住推车当前的位置,再计算离货物的距离,很容易出错。有了 RBP,就有了一个稳定的参考点。
两个指针在函数调用中的典型配合
**调用前:**RSP 指向某个位置,RBP 无特殊要求。
**进入函数:**先把调用者的 RBP 保存到堆栈(压栈),然后将当前 RSP 的值赋给 RBP,这样新的 RBP 就成了当前函数的栈帧基址。
**函数执行中:**通过 RBP - 偏移 访问局部变量,通过 RBP + 偏移 访问参数。
**退出函数:**先恢复旧的 RSP(释放局部变量空间),再弹出保存的 RBP 值,RBP 就回到了调用者的值。
这些具体步骤会在后续的章节中详细演示。
小结
- RSP:指向栈顶,随压栈/弹栈自动移动。
- RBP:可选但常用的栈帧基址,提供稳定的偏移参考。
- 二者配合,使得函数可以方便地管理局部变量和参数,同时支持嵌套调用。
堆栈的基本操作
堆栈只有两种核心操作:压栈 和 弹栈。
压栈(Push)
压栈 是指将一份数据放到堆栈的顶部。
**- 操作前:**栈顶指针(RSP)指向当前栈顶(最后一个数据的位置)。
- 操作中:
RSP 的值首先减小(因为堆栈向低地址生长),新位置成为新的栈顶。
将数据复制到 RSP 指向的新地址处。
**- 操作后:**RSP 指向新压入的数据,该数据成为新的栈顶。原有数据不受影响,位于它的下方。
生活类比:往一叠盘子上再放一个新盘子。你先把最上面的位置空出来(实际上不需要"空",直接放上去),然后把新盘子放在最上面。放好后,新盘子成为新的顶部。
弹栈(Pop)
弹栈 是指从堆栈顶部取出一份数据。
**- 操作前:**RSP 指向当前栈顶(最后压入的数据)。
- 操作中:
将 RSP 指向的数据复制到某个目标位置(例如寄存器或内存)。
RSP 的值增大,移动到下一个数据的位置(即之前栈顶的下方)。
**- 操作后:**原先的栈顶数据被移出(但内存中的原值可能仍然存在,只是不再被堆栈管理)。RSP 指向新的栈顶。
生活类比:从一叠盘子上拿走最上面的盘子。你先取出最上面的盘子,然后剩下的盘子中原来第二位的变成了新的顶部。
压栈和弹栈的关键特性
**- 后进先出:**最后压入的数据,一定最先被弹出。
**- 自动管理:**RSP 的增减完全自动,不需要程序员手动计算偏移。
**- 数据不会自动消失:**弹栈后,原来的内存位置上的数据可能仍然存在,但下一个压栈操作会覆盖它。
**- 可以重复弹栈:**只要栈中有数据,就可以连续弹出。
堆栈为空的情况
当堆栈为空时,RSP 指向堆栈区域的最高地址(因为堆栈向低地址生长,空栈时栈顶在最上面)。此时执行弹栈操作会导致错误(堆栈下溢),因为没有数据可弹。同样,压栈操作总是安全的(只要不超过预留的堆栈空间)。
小结
- 压栈:RSP 减小,数据存放到新栈顶。
- 弹栈:从栈顶取出数据,RSP 增大。
- 这两种操作维持了堆栈后进先出的特性。
函数调用时堆栈的完整变化过程
现在开始描述"调用前"到"返回后"的堆栈状态变化。可以结合上面的"压栈/弹栈"的行为,想象每步栈顶指针(RSP)和基址指针(RBP)如何移动。
调用前的准备(由调用者完成)
在调用一个函数之前,调用者可能需要做两件事:
传递参数:按照调用约定,前几个参数放入寄存器,多余参数压入堆栈。参数压栈的顺序通常是从右向左(即最后一个参数最先压入)。
保存某些寄存器的值:如果调用者希望保留某些寄存器(如 rax、rcx、rdx 等)的值不被被调函数破坏,它可以在调用前将这些寄存器压栈保存。
完成这些后,RSP 可能已经减小(如果压入了参数或保存了寄存器)。
执行 call 指令
当 CPU 执行 call 指令时,它自动完成两件事:
压入返回地址:将 call 下一条指令的地址压入堆栈。此时 RSP 减小 8 字节(64 位地址长度)。
跳转:将程序计数器(RIP)设置为被调函数的起始地址。
此时堆栈顶部是返回地址,RSP 指向它。
函数序言(被调函数的开头)
被调函数开始执行时,通常(但不强制)会执行"序言"序列,包括以下步骤:
保存旧的 RBP:将调用者的 RBP 值压入堆栈。RSP 再减小 8 字节。
设置新的 RBP:将当前 RSP 的值复制到 RBP 中。从此 RBP 成为当前函数的栈帧基址,指向栈中保存旧 RBP 的位置。
分配局部变量空间:将 RSP 减小 N 字节(N 为局部变量所需的总空间)。这样在 RBP 和 RSP 之间就留出了一块区域,用于存放局部变量。
此时堆栈布局从高地址到低地址依次为:
调用者的栈帧(高地址)
返回地址
保存的旧 RBP
局部变量区域(RSP 指向其底部)
函数体执行期间
在函数体执行期间:
通过 RBP - 偏移 访问局部变量(偏移量为正,但 RBP 指向旧 RBP 位置,所以局部变量在更低地址)。
通过 RBP + 偏移 访问参数(偏移量为正,返回地址和参数在高地址方向)。
由于 RBP 固定不动,即使 RSP 因临时压栈(如保存寄存器)而变化,RBP 依然提供稳定的访问点。
函数尾声(被调函数返回前)
在返回之前,被调函数通常执行"尾声"序列:
撤销局部变量空间:将 RSP 重新设置为 RBP 的值。这相当于释放了局部变量区域(RSP 向上移动)。
恢复旧的 RBP:弹栈,将保存的旧 RBP 值恢复回 RBP 寄存器。RSP 增加 8 字节。
执行 ret 指令:ret 自动弹栈,将返回地址弹出并跳转到该地址。RSP 再次增加 8 字节。
此时堆栈恢复到了调用前的状态(如果调用者没有额外清理参数的话)。
调用后的清理(由调用者完成)
根据调用约定,可能需要调用者清理传入的参数(如果参数是通过堆栈传递的)。例如,在 Microsoft x64 调用约定中,调用者负责清理堆栈参数;而在 System V AMD64 ABI 中,参数主要用寄存器传递,但如果有堆栈参数,也由调用者清理。
清理方式很简单:调用者将 RSP 增加相应的字节数,使 RSP 回到调用前的位置。
嵌套函数调用的堆栈行为
当函数 A 调用函数 B,B 再调用函数 C 时,堆栈会逐层增长:
每次 call 压入返回地址。
每个函数自己的序言压入旧 RBP 并分配局部变量空间。
返回时按相反顺序释放。
从堆栈上可以清晰看到调用链:最顶层的栈帧属于当前正在执行的函数,下面依次是它的调用者,再下面是其调用者的调用者......这正是调试器中 backtrace 命令所显示的内容。
小结
函数调用过程中堆栈的变化可概括为:
- 调用前:参数准备(部分可能压栈)。
- call:自动压入返回地址。
- 序言:压旧 RBP、设新 RBP、分配局部变量空间。
- 函数体:通过 RBP 访问局部变量和参数。
- 尾声:释放局部变量空间、恢复旧 RBP、`ret` 弹出返回地址。
- 调用后:调用者可能清理堆栈参数。
这套机制使得函数可以任意嵌套、递归,而不会相互干扰。
堆栈帧的布局图
为了直观理解函数调用过程中堆栈的变化,下面用一张示意图展示典型堆栈帧的布局。

从上到下(高地址 → 低地址)依次为:
1. 调用者的栈帧(高地址区域)
调用者函数在执行过程中使用的局部变量、保存的寄存器等。这部分不属于当前函数的栈帧。
2. 参数区域(仅当参数超过6个时)
调用者压入的多余参数。在 System V AMD64 ABI 中,前6个参数通过寄存器传递,第7个及之后的参数按从右向左的顺序压入堆栈。这些参数位于返回地址的高地址侧。
3. 返回地址
由 call 指令自动压入,指向调用者中 call 下一条指令的地址。固定占用 8 字节。
4. 保存的 RBP(旧基址指针)
被调函数序言中压入的调用者的 RBP 值。固定占用 8 字节。
5. 局部变量区域
被调函数用于存放局部变量的空间。大小由函数中局部变量所需的总字节数决定。RBP 减去偏移量访问此处。
6. 可能的寄存器保存区
被调函数如果需要使用某些非易失性寄存器(如 rbx、r12--r15),会在此区域保存它们的值。RSP 指向该区域的底部(栈顶)。
7. 其他临时空间(如调用其他函数时的参数传递)
如果当前函数要调用另一个函数,可能在此区域构造参数。这是栈顶可扩展的部分。
关键偏移规律:
局部变量:RBP - 偏移(负偏移,因为局部变量在 RBP 的低地址侧)
保存的 RBP 自身:RBP
返回地址:RBP + 8
参数(如果有堆栈参数):RBP + 16、RBP + 24 等
通过这张图,可以清晰地看到 RBP 作为"锚点"的作用。无论 RSP 如何移动,只要 RBP 不变,局部变量和参数的访问偏移就是固定的。
堆栈与函数嵌套/递归
堆栈的后进先出特性使其天然支持函数的嵌套调用和递归。
函数的嵌套调用
假设有三个函数:A 调用 B,B 调用 C。
执行流程:
A 被调用,A 的栈帧被创建(包含返回地址、保存的 RBP、局部变量等),堆栈顶部是 A 的栈帧。
A 调用 B:CPU 将返回地址(A 中 call B 的下一条指令)压栈,然后跳转到 B。
B 创建自己的栈帧(压入 A 的 RBP、分配局部变量等)。此时堆栈顶部是 B 的栈帧,A 的栈帧在其下方。
B 调用 C:类似地压入返回地址,跳转到 C。
C 创建自己的栈帧,成为新的栈顶。
返回过程:
C 执行完毕,它的尾声释放局部变量空间、恢复 B 的 RBP,ret 弹出返回地址,堆栈回到 B 的栈帧顶部。
B 继续执行,完成后类似地返回到 A。
A 完成后返回到它的调用者。
堆栈状态:任何时候,当前正在执行的函数对应的栈帧位于栈顶,其下方依次是它的调用者、调用者的调用者......这正是 backtrace 显示的内容。
关键点:
每个函数都有自己的栈帧,相互独立,互不干扰。
堆栈的深度等于函数调用的层数(未返回的函数数量)。
返回时严格按照"后调用先返回"的顺序。
递归函数
递归函数是指一个函数直接或间接地调用自身。例如计算阶乘的递归函数。
递归调用时的堆栈行为
第一次调用 factorial(5),创建栈帧 F5,包含参数 5、返回地址、局部变量等。
在 F5 中,又调用 factorial(4),创建新栈帧 F4,压入 F5 中的返回地址,F4 位于栈顶。
同理,依次创建 F3、F2、F1,直到基准条件(如 n == 1)。
基准条件触发后,最顶层的栈帧 F1 先返回,其返回值被上一层 F2 使用。
F2 返回,以此类推,直到 F5 返回最终结果。
堆栈特点
每一层递归都会生成一个新的栈帧,包含该层独立的参数、局部变量和返回地址。
堆栈深度等于递归调用的次数。
如果递归深度过大(例如没有正确设置基准条件,或输入数字太大),堆栈会不断增长,最终耗尽预留的堆栈空间,导致堆栈溢出。
与普通嵌套调用的区别
普通嵌套调用是不同函数之间的调用,每个函数只出现一次。
递归是同一个函数的多次嵌套,每次调用创建的同名但不同的栈帧,它们彼此独立。
堆栈溢出(Stack Overflow)
定义:当堆栈的增长超过了操作系统为其预留的空间时,栈顶会进入未映射的内存区域,触发页错误,程序崩溃(通常表现为段错误)。
常见原因
无限递归:递归函数忘记设置终止条件,导致无限调用自身,堆栈无限增长。
过深的递归:即使有终止条件,但输入值过大导致递归层数过多(例如计算 100000 的阶乘)。
在栈上分配过大的局部变量:例如定义一个 char buffer1024\*1024(1MB 数组),如果栈空间不足,会立即溢出。
预防措施
确保递归函数有正确的终止条件。
对于可能深度很大的递归,改用循环(迭代)实现。
超大数组或数据结构尽量分配在堆上(通过 malloc 等),而不是栈上。
注意:堆栈溢出与堆溢出(Heap Overflow)不同。堆溢出是指向堆内存写入超出分配大小的数据,通常不会立即崩溃,但可能破坏相邻堆块。
小结
- 堆栈通过后进先出机制,完美支持函数嵌套和递归。
- 每次函数调用都会在堆栈上创建一个新的栈帧。
- 递归过深或局部变量过大可能导致堆栈溢出,程序崩溃。
- 理解堆栈的行为,有助于避免此类错误,并更好地理解程序的调用链。
堆栈对齐
堆栈对齐是 x64 调用约定中一个容易被忽略但非常重要的要求。
什么是对齐
对齐是指数据在内存中的起始地址必须是某个数值(通常是 2、4、8、16 等)的倍数。例如:
2 字节对齐:地址末位为 0(二进制)。
4 字节对齐:地址末两位为 00。
8 字节对齐:地址末三位为 000。
16 字节对齐:地址末四位为 0000。
CPU 访问对齐的数据更快,某些指令甚至要求数据必须对齐,否则会触发异常(例如 SSE 指令要求 16 字节对齐)。
为什么堆栈需要对齐
在 x64 调用约定(System V AMD64 ABI)中,规定 在 call 指令执行之前,栈指针 RSP 的值必须是 16 的倍数。原因如下:
SSE/AVX 指令要求:许多 SIMD 指令(如 movaps、addps)要求内存地址 16 字节对齐。printf 等 C 库函数内部可能使用这些指令,如果栈未对齐,会导致程序崩溃。
性能优化:对齐的数据能减少 CPU 内存访问次数,提高执行效率。
ABI 强制要求:调用约定明确规定了这一规则,违反即视为非法,可能导致难以调试的错误。
对齐的时机
对齐规则针对的是 call 指令之前的 RSP 值。也就是说,当你准备调用一个函数时,RSP 必须是 16 的倍数。
然而,call 指令本身会将返回地址(8 字节)压入堆栈,这会导致 RSP 减少 8。因此,在函数入口处(即被调函数的第一条指令执行时),RSP 的值原本是 16 的倍数,减去 8 后变成了 16×N + 8(即 8 模 16)。所以被调函数内部看到的 RSP 不是 16 对齐的,这是正常的。
如何保证对齐
通常,在程序的启动点(如 _start 或 main 被系统调用时),RSP 已经满足 16 字节对齐(由操作系统或 C 运行时负责)。但是,在函数内部再调用其他函数时,需要小心维护对齐。
常见的对齐方法
-
利用 push 指令:push 会减少 RSP 8 字节,可能破坏对齐。如果本来 RSP 是 16 的倍数,执行一个 push 后 RSP 变为 16×N + 8,再执行另一个 push 又回到 16 的倍数。因此,可以通过控制 push 的次数来维持对齐。
-
显式调整 RSP:在调用其他函数之前,可以执行 sub rsp, 8 或 add rsp, 8 来强制使 RSP 变成 16 的倍数。例如,如果当前 RSP 是 16×N + 8,则 sub rsp, 8 即可。
-
编译器自动处理:如果你使用 C 语言编写代码,编译器会自动生成对齐指令。汇编程序员则需要手动保证。
典型模式(以 main 调用 printf 为例):
cpp
main:
push rbp
mov rbp, rsp
sub rsp, 32 ; 分配局部变量空间 + 可能的对齐填充
; ... 调用 printf 等
leave
ret
注意:sub rsp, 32 中 32 是 16 的倍数,确保在 call printf 之前 RSP 是 16 对齐的。
未对齐的后果
如果调用函数时 RSP 不是 16 对齐的,可能会发生:
程序崩溃(段错误),尤其是调用了使用 SSE 指令的库函数(如 printf)。
数据异常:某些指令会静默忽略对齐问题,但效率降低,或产生错误结果。
难以调试:崩溃可能发生在被调函数内部,与调用点看起来无关,排查困难。
小结
- x64 调用约定要求 call 前 RSP 必须是 16 的倍数。
- 原因在于 SIMD 指令的对齐要求和性能优化。
- 可以通过调整压栈次数或显式加减 RSP 来保证对齐。
- 未对齐会导致程序崩溃或难以预料的行为。
堆栈溢出(Stack Overflow)
堆栈溢出是指程序在堆栈上使用的内存超过了操作系统为其预留的空间,导致栈顶进入未映射或受保护的内存区域,从而触发异常(通常是段错误)。
堆栈溢出的常见原因
无限递归
递归函数没有正确的终止条件,导致函数不断调用自身,每层调用都创建新的栈帧,堆栈持续增长直到耗尽。
递归深度过大
即使递归有终止条件,如果输入数值极大(如计算 1000000 的阶乘),递归层数仍然可能超过堆栈容量。
局部变量过大
在函数内部定义大数组(例如 char buffer1024\*1024 即 1MB),如果多个这样的函数嵌套调用,或者单个数组就接近堆栈上限,极易触发溢出。
无限循环的函数调用
函数 A 调用 B,B 又调用 A(间接递归),没有终止条件。
堆栈溢出的后果
**- 立即崩溃:**大多数情况下,程序会收到 SIGSEGV 信号,显示"段错误"(Segmentation Fault)。
- 数据损坏: 如果栈溢出恰巧未立即触发页错误,可能会覆盖相邻内存(如其他变量的值、甚至代码段),导致不可预测的行为。
**- 安全漏洞:**经典的"栈缓冲区溢出"攻击可利用此漏洞覆盖返回地址,执行恶意代码(这是另一门安全课题,此处仅提及)。
如何避免堆栈溢出
使用迭代代替深度递归:能用循环解决的问题尽量不用递归。
增加堆栈大小:某些编译器或操作系统允许设置更大的堆栈(例如 Linux ulimit -s),但这只是延后问题,不治本。
将大对象分配到堆上:使用 malloc(或类似机制)在堆上分配大数组或结构体,而不是栈上的局部变量。
检查递归深度:在调试版本中加入深度计数,超过阈值时终止。
静态分析工具:利用工具检测可能的无限递归或超大栈帧。
堆栈溢出 vs 堆溢出
|------|---------------|-----------------|
| 特性 | 堆栈溢出 | 堆溢出 |
| 发生区域 | 堆栈(函数调用、局部变量) | 堆(动态分配的内存) |
| 触发原因 | 递归过深、局部变量过大 | 写入数据超过已分配内存块的大小 |
| 崩溃时机 | 通常立即(栈顶越界) | 可能延迟(破坏相邻堆块) |
| 安全性 | 历史上被利用于代码注入 | 也是常见漏洞来源 |
全文总结
-
堆栈的本质:后进先出的内存区域,向低地址生长。
-
为什么需要堆栈:寄存器不足、存储返回地址、管理局部变量、传递多余参数、保存寄存器值。
-
关键指针 RSP 和 RBP:RSP 指向栈顶,RBP 用作固定的栈帧基址。
-
压栈与弹栈:压栈使 RSP 减小,数据存入;弹栈取出数据,RSP 增大。
-
函数调用时的完整过程:参数准备 → call 压入返回地址 → 序言(保存 RBP、分配局部空间) → 函数体(通过 RBP 访问变量) → 尾声(恢复 RBP、ret) → 调用者清理。
-
堆栈帧布局:高地址依次为调用者栈帧、参数(如有)、返回地址、保存的 RBP、局部变量、低地址临时空间。
-
嵌套与递归:堆栈天然支持多层调用,递归深度过大会导致溢出。
-
堆栈对齐:call 前 RSP 必须是 16 的倍数,否则可能崩溃。
-
堆栈溢出:由无限递归或过大局部变量引起,后果严重,应尽量避免。