3、堆与栈:栈的分配与释放、堆的管理(malloc/free)、栈溢出与堆溢出
内存里最常打交道的两个区域,就是栈和堆。很多人把它们混着用,或者分不清谁归谁管。我刚开始写 C 的时候也踩过坑——明明逻辑没错,程序跑着跑着就崩了。后来才发现,要么是栈撑爆了,要么是堆被写穿了。这一节咱们把这两块儿彻底说透。
3.1 栈:自动分配,自动释放
栈由编译器自动管理。每次调用函数,系统就会在栈上压入一块“栈帧”,用来放局部变量、参数、返回地址。函数返回时,栈帧弹出,这块空间立刻被回收。你想想看,分配和释放都是硬件级的操作——只需移动栈顶指针,速度极快。
我个人习惯把栈想象成一个弹簧式书夹:后放进去的书先被取出来(后进先出)。每个线程有自己的栈,大小通常在 1~8 MB 之间。嗯,注意这个“小”字,很多崩溃就是因为它太小。
栈的特点
- 分配速度极快(只改
esp/rsp寄存器) - 碎片少(连续分配、连续释放)
- 大小有限,超出即溢出(
segmentation fault)
3.1.1 局部变量与作用域
局部变量默认放在栈上。作用域结束,变量就失效了。但要注意:不要返回栈变量的地址。我在项目中遇到过一位同事,写了个函数返回局部数组的指针,主程序一用就乱码。为什么?因为函数返回后那块栈空间已经被后面的调用覆盖了。
// 错误示例 —— 返回栈地址
int* bad_func() {
int arr[10] = {1,2,3};
return arr; // arr 在栈上,函数退出后无效
}
int* good_func() {
int* p = malloc(10 * sizeof(int));
// 填充数据
return p; // 堆上分配,必须手动释放
}
3.1.2 栈溢出
栈溢出通常只干两件事:递归太深,或者局部数组巨大。我调优过一个视频解码器,每次处理一帧时在栈上分配了一个 2 MB 的缓冲区,递归调用 3 层就直接爆了。解决方案很简单:改成堆分配,或者用循环代替递归。
识别栈溢出的常用方法
- 程序在函数递归或大数组附近崩溃,报
SIGSEGV。 - GDB 中查看
backtrace发现栈指针异常低(接近栈底)。 - Windows 可以用
!analyze -v看STACK_OVERFLOW标识。 - Linux 下可以用
ulimit -s查看当前栈大小限制。
3.2 堆:灵活分配,手动管理
堆是程序员的后花园。你需要多大,就在运行时动态申请多大。C 里用 malloc/free,C++ 用 new/delete。但灵活性背后藏着代价——管理成本高、容易碎片、容易泄漏。
3.2.1 malloc/free 的内部逻辑
很多人以为 malloc 直接调用系统调用从内核拿内存。实际上,标准库(glibc 的 ptmalloc、jemalloc)会在进程启动时向系统申请一大块连续内存(通过 brk 或 mmap),然后内部切成小块链表管理。这就是“堆”。
每次 malloc 都会在分配的内存块前面写一个“元数据头”,记录块大小、空闲状态等。释放时,free 根据这个头把内存块归还到空闲链表,并尝试合并相邻空闲块减少碎片。
| 对比项 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 自动 | 手动(malloc/new) |
| 释放方式 | 自动(函数返回) | 手动(free/delete) |
| 速度 | 极快(寄存器级) | 较慢(查找空闲块) |
| 大小限制 | 通常 1~8 MB | 接近物理内存 |
| 碎片 | 极少(线性伸缩) | 易产生外部碎片 |
| 使用场景 | 局部变量、函数调用 | 动态大小、长生命周期数据 |
3.2.2 堆溢出
堆溢出指的是向堆块边界以外写入数据。比如你 malloc 了 10 个字节,却写入了 20 个。后面那个堆块的元数据头就被覆盖了。等下次 free 或 realloc 时,内部逻辑会读取被篡改的元数据……轻则崩溃,重则被攻击者利用。
我记得有一次做嵌入式固件调试,程序运行 72 小时后准时死机。反复定位发现是一个循环缓冲区越界写了一个字节,恰好覆盖了相邻堆块的 size 字段。free 时 size 变成了 0xFFFFFFFF,链表遍历死循环,系统挂死。这个案例让我养成了“所有动态分配都写满 0xAA 再释放”的调试习惯——如果释放后还有代码读它,0xAA 能帮我一秒识别野指针。
调试堆溢出的常用工具
- Valgrind memcheck:检测越界读写、使用未初始化内存、释放后使用。
- AddressSanitizer (ASan):编译时插桩,快速定位堆/栈/全局溢出。
- Windows 的 PageHeap:把每个堆块放到单独页面,越界立即触发异常。
- 自定义封装:在 malloc 周围加护城河(红色区域),释放时校验完整性。
3.3 栈与堆的整体关系
为了让你一眼看清两者在进程地址空间中的位置,我画了张结构图。别看它简单,搞清楚这个布局很多疑难问题就能推导出来。
关键记忆点
- 栈顶指针越低,说明嵌套越深,越接近溢出。
- 堆顶指针(通过
sbrk维护)越高,说明分配的内存越多。 - 两者相向增长,中间的空闲区用来做
mmap大块分配。
3.4 避坑指南 — 我踩过的那些坑
最后,总结几条实战经验,都是真金白银换来的。
- 栈上别存大数组——我曾经重构一个网络协议栈,把原来的 256 字节缓冲改成 64 KB,结果小流量正常,大流量直接段错误。查了半天是每个连接都分配一个栈数组,线程栈只有 2 MB,几个请求就爆了。改成堆分配后问题消失。
- malloc 后一定要判空——尤其在嵌入式或长期运行的服务里。我接手过一个模块,malloc 不判空,某次内存紧张时返回 NULL,后续写入直接写了地址 0,导致设备死机。加一行
if (ptr == NULL) { /* 错误处理 */ }就解决问题。 - free 后必须置 NULL——否则一旦代码流程异常,再次 free 同一个指针会 double free 崩溃。我习惯在 free 之后立刻写
ptr = NULL;,这样即使误用也会在第一次访问时 crash,而不是偷偷破坏堆结构。 - 小心隐式递归——C++ 的虚函数间接调用、信号处理函数里的 printf,都有可能引入隐藏的栈消耗。我排查过一个诡异死机,最后发现是信号处理函数里调用了
syslog,而syslog又触发了同一个信号……无限递归,栈瞬间爆掉。
快速自查清单
- 是否所有
malloc都配对了free?(用 mtrace 或 valgrind 检测泄漏) - 是否存在返回栈地址或指向已释放堆地址的指针?
- 递归函数是否有明确的终止条件?递归深度是否受输入控制?
- 大型缓冲区(> 256 KB)是否用了堆或 mmap,而非栈?
- 释放指针后是否立即置 NULL?
栈和堆是程序的两条腿。栈管快速、临时、小数据;堆管灵活、持久、大数据。搞清楚了它们的脾性,很多崩溃和内存问题其实一眼就能看出来。你想想看,是不是这样?