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 栈与堆的整体关系

为了让你一眼看清两者在进程地址空间中的位置,我画了张结构图。别看它简单,搞清楚这个布局很多疑难问题就能推导出来。

进程虚拟地址空间 — 栈与堆的位置 内核空间(用户不可见) 栈(高地址 → 低地址生长) funcA 栈帧 funcB 栈帧 funcC 栈帧 栈顶指针 ESP ↓ 栈向低地址生长 ↓ 空闲内存(mmap 匿名映射区域) 堆向高地址生长 ↑ 堆(低地址 → 高地址生长) 已分配块 空闲块 已分配块(较大,可能由 mmap 获得) BSS / 数据段 / 代码段(低地址) ※ 栈从高地址向下生长,堆从低地址向上生长,两者相向而行,最大可用空间取决于中间空闲区域

关键记忆点

  • 栈顶指针越低,说明嵌套越深,越接近溢出。
  • 堆顶指针(通过 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 又触发了同一个信号……无限递归,栈瞬间爆掉。

快速自查清单

  1. 是否所有 malloc 都配对了 free?(用 mtrace 或 valgrind 检测泄漏)
  2. 是否存在返回栈地址或指向已释放堆地址的指针?
  3. 递归函数是否有明确的终止条件?递归深度是否受输入控制?
  4. 大型缓冲区(> 256 KB)是否用了堆或 mmap,而非栈?
  5. 释放指针后是否立即置 NULL?

栈和堆是程序的两条腿。栈管快速、临时、小数据;堆管灵活、持久、大数据。搞清楚了它们的脾性,很多崩溃和内存问题其实一眼就能看出来。你想想看,是不是这样?