编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 栈与堆对决全景图
          • 2.2 为什么存在两种内存
        • 3. 栈的底层机制
          • 3.1 RBP与RSP双寄存器
          • 3.2 函数序言与尾声
          • 3.3 调用约定的参数传递
          • 3.4 栈一键分配的秘密
        • 4. 栈帧结构全拆解
          • 4.1 栈帧生命周期
          • 4.2 返回地址劫持
          • 4.3 局部变量排列
          • 4.4 变量免手动管理
        • 5. 堆的扩展与回收
          • 5.1 从brk到mmap两条通道
          • 5.2 malloc/free的chunk模型
          • 5.3 分配策略三级缓存
          • 5.4 堆碎片的本质与应对
        • 6. 栈溢出与防护机制
          • 6.1 守护页如何工作
          • 6.2 canary金丝雀检测
          • 6.3 栈溢出实战与修复
          • 6.4 栈大小调优策略
        • 7. 栈 vs 堆性能实测
          • 7.1 分配速度对比
          • 7.2 缓存局部性
          • 7.3 线程安全对比
          • 7.4 适用场景决策树
        • 8. 内存分配实战技巧
          • 8.1 alloca栈动态分配
          • 8.2 小对象池化设计
          • 8.3 分配器追踪与诊断
        • 9. 常见线上事故
          • 9.1 栈溢出的N张面孔
          • 9.2 堆碎片导致的OOM
          • 9.3 局部地址幽灵
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 栈堆双重生涯
          • 10.3 设计哲学回扣
          • 10.4 栈堆对决速查
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

栈与堆底层对决

# 02.栈与堆底层对决

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 栈与堆对决全景图
    • 2.2 为什么存在两种内存
  • 3. 栈的底层机制
    • 3.1 RBP与RSP双寄存器
    • 3.2 函数序言与尾声
    • 3.3 调用约定的参数传递
    • 3.4 栈一键分配的秘密
  • 4. 栈帧结构全拆解
    • 4.1 栈帧生命周期
    • 4.2 返回地址劫持
    • 4.3 局部变量排列
    • 4.4 变量免手动管理
  • 5. 堆的扩展与回收
    • 5.1 从brk到mmap两条通道
    • 5.2 malloc/free的chunk模型
    • 5.3 分配策略三级缓存
    • 5.4 堆碎片的本质与应对
  • 6. 栈溢出与防护机制
    • 6.1 守护页如何工作
    • 6.2 canary金丝雀检测
    • 6.3 栈溢出实战与修复
    • 6.4 栈大小调优策略
  • 7. 栈 vs 堆性能实测
    • 7.1 分配速度对比
    • 7.2 缓存局部性
    • 7.3 线程安全对比
    • 7.4 适用场景决策树
  • 8. 内存分配实战技巧
    • 8.1 alloca栈动态分配
    • 8.2 小对象池化设计
    • 8.3 分配器追踪与诊断
  • 9. 常见线上事故
    • 9.1 栈溢出的N张面孔
    • 9.2 堆碎片导致的OOM
    • 9.3 局部地址幽灵
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 栈堆双重生涯
    • 10.3 设计哲学回扣
    • 10.4 栈堆对决速查

# 1. 案例引入

# 1.1 一段崩在哪

先看一段在物联网网关上跑的真实代码——设备偶尔重启,日志里只有 Segmentation fault:

// sensor_collector.c —— 传感器数据采集器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_SENSORS 64
#define BUF_SIZE    16384    // 16KB 缓冲区

typedef struct {
    char  id[32];
    float value;
    long  timestamp;
} SensorData;

void process_batch(int count) {
    SensorData sensors[MAX_SENSORS];        // 栈上:64 × 48 ≈ 3KB
    char       buffer[BUF_SIZE];            // 栈上:16KB
    /* 合计:~19KB 每帧 */

    /* 解析传感器原始数据,填充 sensors[] */
    for (int i = 0; i < count; i++) {
        sensors[i].value     = (float)(rand() % 100);
        sensors[i].timestamp = time(NULL);
    }

    /* 回调上层处理 */
    dispatch_sensors(sensors, count);        // ← 递归调用链的入口
}

void dispatch_sensors(SensorData *data, int count) {
    /* 对数据进行滤波、聚合 */
    float avg = 0;
    for (int i = 0; i < count; i++) {
        avg += data[i].value;
    }
    avg /= count;

    if (avg > 50.0f) {
        /* 批量上报到云端 */
        char   payload[8192];               // 栈上:8KB
        char   temp_buf[4096];              // 栈上:4KB
        report_to_cloud(payload, temp_buf);
    }
}

void report_to_cloud(char *payload, char *temp) {
    /* HTTP POST 到云端 */
    char http_header[2048];                  // 栈上:2KB
    char http_body[4096];                    // 栈上:4KB
    /* ...构造HTTP请求... */
    http_send(http_header, http_body);       // ← 再调用一层
}

void http_send(char *header, char *body) {
    char sock_buf[1024];                     // 栈上:1KB
    /* ...通过socket发送... */
}

int main(void) {
    /* 模拟高频率采集——循环中不断调用 process_batch */
    for (int i = 0; i < 1000000; i++) {
        process_batch(rand() % 64);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

现象:

  • 测试环境(跑了几百次循环):100% 通过
  • 生产环境(跑了 4 天后,某个凌晨突然重启):SIGSEGV
  • gdb 查看 core 时,崩溃点分散——有时在 process_batch,有时在 http_send

直觉怀疑:是不是 sensors[64] 越界了?但 count 最大值是 63,数组索引不会越界。

打开 core 用 info proc mappings 一看:

0x7ffd9a000000 0x7ffd9a021000 ---p 00000000   ← 守护页
0x7ffd9a021000 0x7ffd9a822000 rw-p 00000000 [stack]
1
2

崩溃地址在守护页内——栈溢出。

但这就怪了:每个函数分配的局部变量加一起也不到 20KB,8MB 栈空间怎么就不够了?

# 1.2 顺藤摸到根因

带着疑问往下挖,用 gdb 设置 watch 观察 RSP:

(gdb) p $rsp
$1 = (void *) 0x7ffd9a822000    ← main() 刚启动时的栈顶

(gdb) c
(gdb) p $rsp  (出 process_batch 时)
$2 = (void *) 0x7ffd9a81a000    ← 下降了约 32KB
1
2
3
4
5
6

每次 process_batch 调用栈消耗约 32KB——比预期的大得多。原因是:

  • sensors[64]:64 × 48 = 3KB(栈上分配)
  • buffer[16384]:16KB
  • dispatch_sensors 里又有 12KB
  • report_to_cloud 里又有 6KB
  • http_send 里又有 1KB
  • 加上返回地址、保存的寄存器、对齐 padding:单次调用链约 40KB

8 MB / 40 KB ≈ 200 次调用链。生产跑了 4 天才崩,因为dispatch_sensors 里的 if (avg > 50.0f) 是概率触发——只有在特定数据下才会深入调用链。当传感器数据波动大时,深调用频率突然升高,累积的栈帧还没释放又被新调用压上,最后踩到守护页。

根因:

  1. 栈帧 不是函数退出就会被 OS 回收——它只是 RSP 回移,物理页还在
  2. 这个进程的调用链虽然单次才 5 层,但每次都在上一层的栈帧内分配大缓冲区
  3. 生产跑了 4 天才崩,是因为崩溃条件依赖数据分布——概率性 bug

这暴露了关于栈与堆的几个深层困惑:

① 栈是"向下长"的,那RSP到底怎么移动的?                   → 第 3 章
② 局部变量在栈帧里怎么排列的?编译器做了什么手脚?          → 第 4 章
③ 堆用 malloc 分配,底层到底怎么拿到内存的?               → 第 5 章
④ 栈分配为什么快?一条指令就够了吗?                        → 第 3.4 / 7.1
⑤ 为什么"返回局部变量的地址"是危险的?                      → 第 9.3
⑥ 栈溢出除了调大 ulimit -s 还有别的解法吗?                 → 第 6 / 第 10 章
⑦ 什么情况下应该把栈上数据搬到堆上?                        → 第 7 章
⑧ alloca 是栈上的"malloc"吗?安全吗?                       → 第 8.1
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个案例就是本篇的主线。我们沿着"栈的工作方式"和"堆的工作方式"两条线,最终在第 10 章回到这个案例,用"栈→堆迁移"根治之。

本篇路线:

架构总图:栈与堆的对决全景 (第 2 章)
   ↓
栈的底层机制:RBP/RSP、序言尾声 (第 3-4 章) ─→ 解开①~②
   ↓
堆的扩展与回收:brk/mmap、chunk模型 (第 5 章) ─→ 解开③
   ↓
栈溢出防护 (第 6 章) ─→ 解开⑥
   ↓
性能实测对比 (第 7 章) ─→ 解开④~⑦
   ↓
实战技巧 (第 8 章)        ─→ 工具武装
   ↓
线上事故 (第 9 章)        ─→ 安全底线
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开、逐条作答
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:第 01 篇讲了"五大段在哪",本篇把其中最重要的两段——栈和堆——从寄存器级别拆到底。后续第 03 篇讲指针时,&x 返回的地址是"栈上的位置";第 10 篇讲结构体对齐时,所有的优化都在减少"栈上或堆上的浪费"。——每一篇都离不开"栈和堆"这个地基。

# 2. 架构概览

# 2.1 栈与堆对决全景图

在 64 位 Linux 进程地址空间中,栈和堆的位置:

高地址 (0x7FFF_FFFF_FFFF)
  ┌──────────────────────────────────────┐
  │  main 的 RSP ─────────────┐           │
  │          ↓↓↓ 栈 ↓↓↓        │           │
  │  每次函数调用 RSP 往下移   │   ~8 MB   │  ← rw-p,向下生长
  │          ↓↓↓              │           │
  ├──────────────────────────┘───────────┤
  │           守护页 (4 KB)               │  ← ---p
  ├──────────────────────────────────────┤
  │                                      │
  │         共享库 / mmap 区域             │  ← libc.so / 匿名mmap
  │                                      │
  ├──────────────────────────────────────┤
  │                                      │
  │          ↑↑↑ 堆 ↑↑↑                  │
  │   malloc/free 往上涨                 │  ← rw-p,向上生长
  │          ↑↑↑                        │
  ├──────────────────────────────────────┤
  │  bss / data / rodata / text           │
  ├──────────────────────────────────────┤
  │  保留区(NULL 地址区域)               │
  └──────────────────────────────────────┘
低地址 (0x0000_0000_0000_0000)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

栈与堆的 12 维对比:

维度 栈 堆
生长方向 高→低(RSP 减小) 低→高(brk/mmap 上推)
管理者 编译器自动 程序员手动(malloc/free)
分配速度 ~1 条指令(sub rsp, N) 数百 ns ~ 数 ms
释放方式 函数返回自动(add rsp, N) 显式 free / 隐式 munmap
碎片 无 有(外部+内部碎片)
大小上限 默认 8 MB(ulimit -s) 可达 GB 级(受物理内存+swap 限制)
生命周期 函数调用期 程序员控制
线程间 每线程独立 共享(需加锁保护)
默认对齐 16 字节 16 字节
能否快速扩容 否(不可动态增长) 是(brk 上推)
适合存放 小型确定性数据 大型/不确定数据
典型C语法 int x = 5; int *p = malloc(100);

# 2.2 为什么存在两种内存

疑惑:既然栈这么快、这么简单,为什么不把所有的变量都放在栈上?

论证:

  1. 生命周期不匹配——考虑这个经典场景:
typedef struct {
    char  *data;
    size_t len;
} String;

String *read_file(const char *path) {
    String s;                // 栈上——函数返回后,s 的内存会被覆盖
    s.data = malloc(1024);   // 堆上——函数返回后仍然有效
    /* ... 读文件到 s.data ... */
    return &s;               // ❌ 返回栈地址 → 悬空指针!
}

/* 必须改为: */
String *read_file(const char *path) {
    String *s = malloc(sizeof(String));  // s 本身在堆上
    s->data = malloc(1024);              // s->data 在堆上
    return s;                            // ✅ 安全
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

栈变量的生命周期绑死函数调用——函数返回,栈帧就被"逻辑销毁"(RSP 移走)。任何需要跨越函数边界存活的变量,必须放在堆上。

  1. 大小不确定——编译器在函数的栈帧大小必须在编译期决定。如果运行时才知道需要多少内存:
void process(int n) {
    // int arr[n];     // VLA(C99),但不在标准C11中强制要求
    int *arr = malloc(n * sizeof(int));  // 堆上动态分配
    /* ... */
    free(arr);
}
1
2
3
4
5
6

堆的存在,让 C 语言可以处理"运行时才知道大小"的数据。

  1. 大小的上限——栈只有 8 MB,而堆可以到几个 GB。图片、音频帧、大型查询结果,从物理上就不可能放在栈上。
void decode_image() {
    // char pixels[1920 * 1080 * 4];  // 8.3 MB,超过默认栈大小!
    char *pixels = malloc(1920 * 1080 * 4);  // 堆上安全
}
1
2
3
4
  1. 反向论证——如果只有堆没有栈会怎样?每一个 int x = 5; 都要 malloc(sizeof(int)) 然后 free(x)。不仅代码冗长、容易泄漏,而且每次分配要走内核或 ptmalloc 的复杂路径——性能会暴跌 10~100 倍。

结论:栈和堆不是二选一,而是 "快但短命" vs "慢但持久" 的互补设计。C 语言用一条简单的规则把它们分开:函数作用域内的小型确定性数据 → 栈;跨函数、大小不确定、需要长生命周期的数据 → 堆。

# 3. 栈的底层机制

# 3.1 RBP与RSP双寄存器

x86-64 架构用两个寄存器管理栈:

  • RSP(Stack Pointer):始终指向栈顶(当前可用栈空间的最低地址)
  • RBP(Base Pointer):指向当前栈帧的基址("锚"——函数内通过 [RBP - offset] 访问局部变量)
高地址
┌───────────────────────┐
│  调用者的栈帧 ...       │
├───────────────────────┤  ← 进入 callee 前 RSP
│  返回地址 (8B)         │  ← call 指令自动 push
├───────────────────────┤
│  保存的 RBP (8B)       │  ← push rbp
├───────────────────────┤  ← RBP 指向这里(帧基址)
│  局部变量 region       │
│  ...                  │
├───────────────────────┤  ← 当前 RSP(栈顶)
低地址
1
2
3
4
5
6
7
8
9
10
11
12

关键操作:

; 函数序言(prologue)
push rbp              ; 保存调用者的 RBP
mov  rbp, rsp         ; 把当前 RSP 作为本帧的基址
sub  rsp, 48          ; 为局部变量分配 48 字节(RSP 下移)

; ...函数体...         ; 通过 [rbp - N] 访问局部变量

; 函数尾声(epilogue)
leave                 ; 等价于 mov rsp, rbp; pop rbp
ret                   ; 弹出返回地址,跳回调用者
1
2
3
4
5
6
7
8
9
10

leave 干了两件事:

  1. mov rsp, rbp —— 回收本帧所有局部变量空间(RSP 上移回 RBP)
  2. pop rbp —— 恢复调用者的 RBP,同时 RSP+8

ret 弹出返回地址到 RIP。这就是为什么"函数返回后局部变量就没了"——RSP 已经被移回,那片空间逻辑上已不属于本函数。

# 3.2 函数序言与尾声

以实际 C 代码为例看编译器如何生成序言和尾声:

// godbolt.org 上用 gcc 14.1 -O0 -m64 编译
int sum(int a, int b) {
    int result = a + b;
    return result;
}
1
2
3
4
5

生成的汇编(完整版,含注释):

sum:
    ; === 序言 ===
    push   rbp               ; ① 保存调用者帧基址
    mov    rbp, rsp          ; ② 建立自己的帧基址
    sub    rsp, 16           ; ③ 分配局部变量空间(含对齐padding)

    ; === 函数体 ===
    mov    DWORD PTR [rbp-4], edi    ; a 存入 [rbp-4]
    mov    DWORD PTR [rbp-8], esi    ; b 存入 [rbp-8]
    mov    edx, DWORD PTR [rbp-4]
    mov    eax, DWORD PTR [rbp-8]
    add    eax, edx                   ; eax = a + b
    mov    DWORD PTR [rbp-12], eax   ; result 存入 [rbp-12]
    mov    eax, DWORD PTR [rbp-12]   ; 返回值放入 eax

    ; === 尾声 ===
    leave                 ; ④ 恢复 RSP 和 RBP
    ret                   ; ⑤ 返回调用者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

关键观察:

  • 局部变量 a 在 [rbp-4],b 在 [rbp-8],result 在 [rbp-12]
  • 编译器自动管理变量位置——程序员只写 int result = a + b;,不需要关心 result 在栈上的哪个偏移

# 3.3 调用约定的参数传递

x86-64 SysV ABI(Linux/macOS 默认)规定头 6 个整型参数通过寄存器传递,不入栈:

参数位置 寄存器 用途
第 1 个 RDI 整数/指针参数 1
第 2 个 RSI 整数/指针参数 2
第 3 个 RDX 整数/指针参数 3
第 4 个 RCX 整数/指针参数 4
第 5 个 R8 整数/指针参数 5
第 6 个 R9 整数/指针参数 6
第 7+ 栈 超过 6 个的参数从右向左压栈
int many_args(int a, int b, int c, int d, int e, int f, int g, int h) {
    return a + b + c + d + e + f + g + h;
}
1
2
3

调用 many_args(1,2,3,4,5,6,7,8) 时的参数传递:

寄存器:  RDI=1, RSI=2, RDX=3, RCX=4, R8=5, R9=6
栈:     [RSP+8] = 8 (参数h)   ← 注意:g 先入栈被 h 覆盖
         [RSP]   = 7 (参数g)   ← h 在 g 之上

实际入栈顺序(从右向左):push 8 → push 7
1
2
3
4
5

为什么用寄存器传参:寄存器访问远快于内存。6 个以内参数的函数调用几乎不需要内存操作——这对 C 语言"短小精悍函数"的惯用法是天然的加速。

# 3.4 栈一键分配的秘密

疑惑:为什么栈分配这么快?

论证:比较栈和堆的分配:

// 栈分配:编译器生成
void stack_alloc() {
    int arr[1000];          // 等价于 sub rsp, 4000
    /* ... */               // RSP 下移,但物理页暂不分配
}                           // leave → RSP 移回

// 堆分配:调用 malloc()
void heap_alloc() {
    int *arr = malloc(1000 * sizeof(int));
    /* ... */
    free(arr);
}
1
2
3
4
5
6
7
8
9
10
11
12

栈分配只是改了一个寄存器:

; 栈分配 1000 个 int:
sub rsp, 4000          ; 1 条指令,1 个 CPU cycle(不算内存访问)

; 堆分配 1000 个 int(malloc 内部需要):
; → 查 tcache/fastbin/smallbin 是否有空闲块
; → 如果没有,可能需要 brk 扩展堆(系统调用)
; → 系统调用需要用户态↔内核态切换(~数百 ns)
; → 分配成功后,可能还要从空闲链表上卸下 chunk
; 总共可能 50~500+ 条指令
1
2
3
4
5
6
7
8
9

性能实测(100 万次分配 1000 个 int,gcc -O2):

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N 1000000

void bench_stack() {
    clock_t start = clock();
    for (int i = 0; i < N; i++) {
        int arr[1000];
        arr[0] = i;  // 防止被优化掉
    }
    clock_t end = clock();
    printf("栈: %ld ms\n", (end - start) * 1000 / CLOCKS_PER_SEC);
}

void bench_heap() {
    clock_t start = clock();
    for (int i = 0; i < N; i++) {
        int *arr = malloc(1000 * sizeof(int));
        arr[0] = i;
        free(arr);
    }
    clock_t end = clock();
    printf("堆: %ld ms\n", (end - start) * 1000 / CLOCKS_PER_SEC);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

典型结果:

栈: 2 ms       ← 每次分配 ~2 ns(sub rsp 几乎免费)
堆: 45 ms      ← 每次分配 ~45 ns(malloc/free 的完整路径)
1
2

结论:栈"一键分配"的原理是——RSP 下移不涉及任何堆管理器的查找、不触发系统调用、不操作任何链表。编译器在函数开头就知道本帧要多大空间,一条 sub 指令搞定。但代价是:这块空间的大小必须在编译期确定,且函数返回时会自动回收。

# 4. 栈帧结构全拆解

# 4.1 栈帧生命周期

用一个更复杂的例子展示栈帧从"出生"到"死亡"的完整过程:

#include <stdio.h>

int multiply(int x, int y) {
    int result = x * y;        // 局部变量
    return result;
}

int main() {
    int a = 5, b = 3;
    int c = multiply(a, b);
    printf("%d * %d = %d\n", a, b, c);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

栈帧完整时间线:

时刻 1:main 开始执行
┌─────────────────┐
│ main 栈帧        │  RSP → 栈顶(main 局部变量 a=5, b=3, c=?)
└─────────────────┘

时刻 2:准备调用 multiply(a, b)
        → 参数传递:a(5) → EDI, b(3) → ESI
        → call multiply 自动 push 返回地址

┌─────────────────┐
│ main 栈帧        │
├─────────────────┤
│ 返回地址         │  ← call 指令自动 push
├─────────────────┤  ← 进入 multiply 前 RSP 的位置
│ (即将创建)      │
└─────────────────┘

时刻 3:multiply 序言执行
→ push rbp(保存 main 的 RBP,RSP-8)
→ mov rbp, rsp(RBP = 当前 RSP)
→ sub rsp, 16(分配局部变量空间)

┌─────────────────┐
│ main 栈帧        │
├─────────────────┤
│ 返回地址         │
├─────────────────┤
│ 保存的 RBP(main) │
├─────────────────┤ ← RBP 指向这里
│ result (4B)      │ ← [RBP-4]
│ padding (4B)     │
│ padding (8B)     │ ← 对齐到 16
├─────────────────┤ ← RSP
└─────────────────┘

时刻 4:multiply 执行
→ eax = x * y = 15
→ [RBP-4] = 15  (result)

时刻 5:multiply 尾声 + 返回
→ leave:mov rsp, rbp; pop rbp
  RSP 上移回 main 栈帧,RBP 恢复为 main 的 RBP
→ ret:pop 返回地址到 RIP
  RSP + 8,跳回 main 的 call 下一条指令

┌─────────────────┐
│ main 栈帧        │  ← RSP 回到 main 原来的位置(除 c 可能被覆盖)
└─────────────────┘

时刻 6:main 取返回值
→ eax = 15 → c = 15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

关键洞察:leave + ret 只移动了 RSP 和 RBP,没有清零那些栈内存。这就是为什么读取未初始化的局部变量会拿到"垃圾值"——那些值是历史上该栈位置残留的旧数据。

# 4.2 返回地址劫持

call 指令等同于:

push rip       ; 把当前指令地址(call 的下一条)压栈
jmp  target    ; 跳转到目标函数
1
2

ret 指令等同于:

pop  rip       ; 从栈顶弹出地址,跳过去
1

这种设计意味着:返回地址就在栈上,紧挨着局部变量。这就是栈溢出攻击的物理基础:

void vulnerable(char *input) {
    char buf[16];                // 栈上
    strcpy(buf, input);          // 无检查拷贝 ← 可能溢出到返回地址
}
// 如果 input 超过 16 字节,会覆盖:
//   buf[16..23] → 保存的 RBP
//   buf[24..31] → 返回地址!
1
2
3
4
5
6
7

攻击者可以精心构造 input,把返回地址改为任意值——这就是 return-to-libc 和 ROP 的起点。

防御机制(第 6 章展开):

  1. canary(金丝雀):在 buf 和返回地址之间放一个随机值,返回前检查
  2. NX:栈页不可执行,不能跳到栈上执行 shellcode
  3. ASLR:栈基址随机化,攻击者无法预测地址

# 4.3 局部变量排列

编译器在栈帧里排列局部变量的规则:

void layout_demo() {
    char  a;          // 1 字节
    int   b;          // 4 字节
    short c;          // 2 字节
    char  d;          // 1 字节
    long  e;          // 8 字节
}
1
2
3
4
5
6
7

编译器可能排列为(并非标准规定,取决于编译器和优化级别):

高地址(靠近调用者栈帧)
┌──────────────┐
│ 返回地址 (8B) │
├──────────────┤
│ 保存的RBP(8B)│
├──────────────┤ ← RBP
│    a (1B)    │ ← [RBP-1]
│  padding(3B) │ ← 对齐
│    b (4B)    │ ← [RBP-8]
│    c (2B)    │ ← [RBP-10]
│    d (1B)    │ ← [RBP-11]
│  padding(5B) │ ← 对齐到 8
│    e (8B)    │ ← [RBP-24]
├──────────────┤ ← RSP
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

关键规律:

  • 较长的类型(long/double)对齐到 8 字节
  • int/float 对齐到 4 字节
  • 编译器可能重排变量以减少 padding(-O2 下常见)
  • 同一作用域的变量不一定按声明顺序排列

实践意义:如果你想减少栈帧大小,把大对齐类型的变量声明在前面可以省 padding。不过 -O2 下编译器一般会自动优化。

# 4.4 变量免手动管理

void foo() {
    int x = 5;        // 栈上分配
    int y = 10;
    {
        int z = 15;   // 内层作用域,也在 foo 的栈帧里
    }
    // z 出作用域了,但它的栈空间还在(RSP 没动)
    // 编译器可能重用 z 的位置给后续变量
    int w = 20;
}
1
2
3
4
5
6
7
8
9
10

栈不需要 free,是因为:

  1. 函数返回时 RSP 自动移回(leave 指令),之前分配的局部变量空间逻辑上被回收
  2. 下一次函数调用时,RSP 继续下移,自然覆盖旧栈帧
  3. 栈不会泄漏——每个函数调用都是"分配-使用-自动回收"的闭环

与堆的本质区别:堆上分配的内存在 free 之前一直存在,即使所有指向它的指针都已经出作用域。这就是内存泄漏的物理原因——堆不知道你什么时候"用完"了那块内存。

# 5. 堆的扩展与回收

# 5.1 从brk到mmap两条通道

堆不是一开始就很大的——它是按需扩展的:

#include <unistd.h>

void show_brk() {
    void *brk_pos = sbrk(0);
    printf("当前 brk: %p\n", brk_pos);
}
1
2
3
4
5
6

堆扩展的两条通道:

通道 1:brk/sbrk(推高 program break)

低地址                                    高地址
  bss 结束点 ─────────────────────────────────────
             │ ←──────────────────────────────→ │
             ↑      heap via brk                ↑
             program break(旧)          program break(新)
                    ←── sbrk(N) 上推 N 字节 ──→
1
2
3
4
5
6
  • 优点:操作便宜(只改一个指针值)
  • 缺点:只能从顶部释放(尾端空闲才能缩 brk),中间的大块释放后空间回不去

通道 2:mmap(匿名映射)

void *p = mmap(NULL, 4 * 1024 * 1024,
               PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
/* 使用这块内存 */

munmap(p, 4 * 1024 * 1024);  // 立即归还内核
1
2
3
4
5
6
  • 优点:任意位置分配,释放即归还
  • 缺点:系统调用开销大;每个 mmap 至少占一页(4KB)

malloc 内部的分界线:MMAP_THRESHOLD 默认为 128 KB。小于此值的用 brk,大于此值的用 mmap。

# 5.2 malloc/free的chunk模型

glibc 的 ptmalloc2 中,每一块通过 malloc 分配的内存被称为 chunk:

已分配的 chunk(简化):
┌──────────────────────┐
│ prev_size (8B)        │  ← 前一个 chunk 的大小(仅当前一个空闲时有意义)
├──────────────────────┤
│ size (8B)             │  ← 当前 chunk 大小 | 标志位(PREV_INUSE/M/非主arena)
├──────────────────────┤ ← 返回给用户的指针指向这里
│                      │
│   用户数据(payload)    │
│                      │
├──────────────────────┤
│ 下一个 chunk...        │
└──────────────────────┘

空闲的 chunk:
┌──────────────────────┐
│ prev_size             │
├──────────────────────┤
│ size                  │
├──────────────────────┤
│ fd (8B)               │  ← 前向指针(指向 bin 中下一个空闲 chunk)
├──────────────────────┤
│ bk (8B)               │  ← 后向指针(指向 bin 中上一个空闲 chunk)
├──────────────────────┤
│   空闲空间             │
└──────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

size 字段的低 3 位用作标志:

  • bit 0 (PREV_INUSE):前一个 chunk 是否在使用中
  • bit 1 (IS_MMAPPED):是否由 mmap 分配
  • bit 2 (NON_MAIN_ARENA):是否属于非主 arena

free() 的合并逻辑:

free(ptr);
1
  1. 根据 ptr 向前偏移 16 字节(跳过 prev_size + size)找到 chunk 头
  2. 检查本 chunk 的 PREV_INUSE 位:前一个是否空闲?是 → 合并
  3. 检查下一个 chunk:当前 chunk 的 size 确定下一个 chunk 的位置,检查它再下一个的 PREV_INUSE → 下一个是否空闲?是 → 合并
  4. 合并后的大 chunk 放入对应的 bin

这就是为什么 free 不是简单的"标记为空"——它需要检查相邻 chunk 来合并,而检查需要 O(1) 时间(因为 chunk 大小嵌入在 size 字段中)。

# 5.3 分配策略三级缓存

glibc 2.35+ 的 malloc 内部流程:

malloc(size)
      │
      ├─ size ≤ 1032 B(默认tcache上限)
      │      └─ 查 tcache_bin → 命中?取出返回
      │         (线程本地,无锁,极快 ~10 ns)
      │
      ├─ size ≤ 64 B(默认fastbin上限)
      │      └─ 查 fastbin → 取出返回
      │         (单链表,无合并,LIFO)
      │
      ├─ size ≤ 512 B
      │      └─ 查 smallbin → 取出返回
      │         (双链表,FIFO,精确匹配)
      │
      ├─ size > 512 B 且 < 128 KB
      │      └─ 查 unsortedbin → 整理到 smallbin/largebin
      │         → 从 largebin 找合适大小 → 如果太大则切割
      │
      ├─ size ≥ 128 KB
      │      └─ 直接 mmap(绕过所有 bin)
      │
      └─ 所有 bin 都没找到合适块
             └─ brk 扩展堆 / mmap 新区域
                → 从新区域切割所需大小
                → 剩下的放入 unsortedbin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

三级缓存的本质:

  • tcache:线程私有的快速通道(Per-Thread Cache,glibc 2.26 引入)
  • bins:进程全局的空闲链表(需要加锁)
  • 内核层:brk/mmap 系统调用(最慢)

# 5.4 堆碎片的本质与应对

外部碎片:空闲空间分布在多个不连续的 chunk 中,导致虽然总空闲量足够,但无法满足一个大块分配请求:

void fragment_demo() {
    void *ptrs[100];

    /* 分配 100 个 1KB 块 */
    for (int i = 0; i < 100; i++)
        ptrs[i] = malloc(1024);

    /* 释放偶数位置——产生碎片 */
    for (int i = 0; i < 100; i += 2)
        free(ptrs[i]);

    /* 此时有 50 × 1KB = 50KB 的空闲空间 */
    /* 但分配 50KB 的大块可能失败——因为空闲空间不连续! */
    void *big = malloc(50 * 1024);  // 可能需要向系统再申请
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

内部碎片:分配的内存块大于实际请求(对齐 padding、chunk 头部开销):

void *p = malloc(1);  // 实际可能分配 16~32 字节(含 chunk 头 + 对齐)
1

生产级应对:

策略 说明
大对象池化 预分配固定大小的内存池,避免反复 malloc/free
jemalloc/tcmalloc 换用碎片控制更好的分配器
对象大小分级 相同大小的对象从同一个池分配,消除外部碎片
malloc_trim(0) 手动触发 glibc 归还碎片给内核

第 18 篇会展开自定义内存池的完整设计与实现。

# 6. 栈溢出与防护机制

# 6.1 守护页如何工作

Linux 内核在创建栈时,会在栈底自动插入一页守护页(Guard Page):

高地址    ┌──────────────────┐
          │  有效栈空间 (~8MB) │  rw-p
          │  RSP 在此范围内    │
          │  函数调用 → RSP↓  │
          ├──────────────────┤ ← RSP 越过此边界
低地址    │  守护页 (4KB)     │  ---p  不可读/写/执行
          ├──────────────────┤
          │  mmap区域或堆     │
          └──────────────────┘
1
2
3
4
5
6
7
8
9

工作流程:

  1. 程序递归或分配大局部变量,RSP 降到守护页内
  2. CPU 尝试访问那个地址 → 页表查不到映射 → #PF 异常
  3. 内核的 do_page_fault() 检查:这个地址在任何 VMA 里吗?
  4. 如果在有效栈 VMA 的扩展范围内 → 分配新页,扩展栈
  5. 如果在守护页内 → 直接 SIGSEGV

关键点:守护页属于 MAP_GROWSDOWN | guard,内核不会为其分配物理页——越界即崩。

# 6.2 canary金丝雀检测

编译器在每个有栈数组的函数序言中植入一个"金丝雀值":

void foo(void) {
    char buf[16];
    gets(buf);  // ← 危险:可能溢出
}
1
2
3
4

编译后(gcc -fstack-protector-strong,默认开启):

foo:
    ; === 序言 ===
    push   rbp
    mov    rbp, rsp
    sub    rsp, 32             ; 16 + 8(canary) + 8(padding)
    
    mov    rax, QWORD PTR fs:[0x28]  ; 从 TLS 取随机 canary
    mov    QWORD PTR [rbp-8], rax    ; 放在返回地址上方
    
    ; === 函数体 ===
    ; ... gets(buf) ...
    
    ; === canary 检查 ===
    mov    rax, QWORD PTR [rbp-8]
    sub    rax, QWORD PTR fs:[0x28]  ; 对比
    je     .L_ok                      ; 相等 → 正常
    call   __stack_chk_fail           ; 不等 → abort!

.L_ok:
    leave
    ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

栈帧布局(canary 加持版):

高地址
┌──────────────────────┐
│ 调用者的栈帧 ...       │
├──────────────────────┤
│ 返回地址 (8B)         │
├──────────────────────┤
│ 保存的 RBP (8B)       │
├──────────────────────┤ ← RBP
│ canary (8B)           │ ← 随机值,来自 TLS
├──────────────────────┤
│ buf[16]              │  ← 溢出先覆盖 buf,再覆盖 canary
├──────────────────────┤ ← RSP
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13

为什么有效:buf[16] 被溢出时,先覆盖到 canary。gets 返回时检查 canary 值是否变化。只要攻击者不知道 canary 值(每进程一份随机值),就无法构造不触发检测的溢出。

# 6.3 栈溢出实战与修复

返回到第 1 章的核心案例——4 天才崩一次的传感器采集器。问题在于每次调用链用了 ~40KB 栈空间。

修复方案 A(临时)——调大栈:

ulimit -s 32768  # 32 MB,可撑 ~800 次深调用
1

代价:治标不治本,线程数多时 VSZ 膨胀。

修复方案 B(推荐)——大型缓冲区转移到堆:

// 修复前:全局缓冲区在栈上
void process_batch(int count) {
    SensorData sensors[MAX_SENSORS];  // 3KB → 保留(确定性小数组)
    char       buffer[BUF_SIZE];      // 16KB → 移到堆!

    process_with_heap_buffer(count);
}

// 修复后:大缓冲区在堆上
void process_batch_fixed(int count) {
    SensorData sensors[MAX_SENSORS];       // 3KB → 栈 OK
    char       *buffer = malloc(BUF_SIZE); // 16KB → 堆

    for (int i = 0; i < count; i++) {
        sensors[i].value     = (float)(rand() % 100);
        sensors[i].timestamp = time(NULL);
    }

    dispatch_sensors(sensors, count);
    free(buffer);  // 显式释放
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

效果:单次调用栈消耗从 40KB 降到 ~20KB。8MB / 20KB = 400 次深调用,安全裕度大幅提升。

修复方案 C(彻底)——整条调用链的大块都搬上堆:

/* 每个函数用堆分配代替大栈数组 */
void report_to_cloud_heap() {
    char *payload  = malloc(8192);
    char *temp_buf = malloc(4096);
    /* ... */
    free(payload);
    free(temp_buf);
}
1
2
3
4
5
6
7
8

代价:代码变冗长,每个 malloc 都要对应 free。一次 path error 可能导致泄漏。可以考虑用 goto cleanup 模式统一释放。

# 6.4 栈大小调优策略

场景 推荐栈大小 理由
普通服务端进程 8 MB(默认) 线程不多,默认足够
高并发(1000+ 线程) 512 KB ~ 1 MB 8 MB × 1000 = 8 GB VSZ,吃不消
嵌入式/物联网 64 KB ~ 256 KB 内存极其受限
递归密集型(编译器前端) 16 MB+ 递归深度大,需要裕度
信号处理函数 使用默认 信号栈独立分配,不干扰主栈

调栈大小的方式:

# 1. 进程级(一次性)
ulimit -s 16384

# 2. 线程级(per-pthread)
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 512 * 1024);  // 512 KB
pthread_create(&tid, &attr, worker, NULL);
pthread_attr_destroy(&attr);
1
2
3
4
5
6
7
8
9

黄金法则:不要盲目增大栈——检查一下是不是有"大对象迷失在栈上"。大多数栈溢出案例的根因不是栈太小,是不该在栈上的东西在栈上。

# 7. 栈 vs 堆性能实测

# 7.1 分配速度对比

测试场景:分配和释放 1000 万个 100 字节的块:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>

#define ITER 10000000
#define SIZE 100

void bench_stack() {
    clock_t start = clock();
    for (int i = 0; i < ITER; i++) {
        char buf[SIZE];       // 栈分配
        buf[0] = (char)i;     // 使用,防止优化
        buf[SIZE-1] = (char)i;
    }
    clock_t end = clock();
    printf("栈分配 1千万次 × %dB: %ld ms\n",
           SIZE, (end - start) * 1000 / CLOCKS_PER_SEC);
}

void bench_heap() {
    clock_t start = clock();
    for (int i = 0; i < ITER; i++) {
        char *buf = malloc(SIZE);  // 堆分配
        buf[0] = (char)i;
        buf[SIZE-1] = (char)i;
        free(buf);                 // 堆释放
    }
    clock_t end = clock();
    printf("堆分配 1千万次 × %dB: %ld ms\n",
           SIZE, (end - start) * 1000 / CLOCKS_PER_SEC);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

典型输出(Intel i7, glibc 2.35, gcc -O2):

栈分配 1千万次 × 100B: 8 ms     ← ~0.8 ns/次
堆分配 1千万次 × 100B: 412 ms   ← ~41 ns/次

差距:~50×
1
2
3
4

为什么差这么多:栈分配只有一条 sub rsp, N;堆分配要走 tcache→bins→可能 brk/mmap 的完整路径。

# 7.2 缓存局部性

/* 场景 A:栈上的数据 */
void stack_access() {
    int data[1000];
    for (int i = 0; i < 1000; i++)
        data[i] = i;
}

/* 场景 B:堆上的数据 */
void heap_access() {
    int *data = malloc(1000 * sizeof(int));
    for (int i = 0; i < 1000; i++)
        data[i] = i;
    free(data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

缓存局部性对比:

指标 栈 堆
空间连续性 完美连续(RSP 下移即分配) 取决于分配器的返回位置,可能有跨页跳跃
TLB hit rate 高(紧挨着调用者的栈帧) 中等(堆分配散落在不同页)
cache line 利用 高(地址连续) 中等(可能跨 cache line)
预取友好 是(顺序访问模式明显) 取决于使用模式

结论:栈不仅在分配上快,在访问上也快——因为它的空间连续性更好,对 CPU 缓存和 TLB 更友好。

# 7.3 线程安全对比

栈:每个线程独立栈 → 天然线程安全
     线程 A 的 RSP ──→ 线程 A 的栈空间
     线程 B 的 RSP ──→ 线程 B 的栈空间
     (从未交叉,永不竞争)

堆:所有线程共享 → 需要锁保护
     线程 A ──→ tcache (本地,无锁)
     线程 B ──→ tcache (本地,无锁)
     线程 C ──→ smallbin ← 需要 arena 锁
1
2
3
4
5
6
7
8
9

实际影响:在多线程程序中,频繁的 malloc/free 可能成为瓶颈。glibc 用 arena 分片缓解这个问题,但并不是"零开销"的。

# 7.4 适用场景决策树

需要分配内存
      │
      ├─ 大小在编译期确定?
      │      ├─ 是 且 ≤ 几 KB 且 函数返回后不需要?
      │      │      └─ ▶ 栈 int arr[100];
      │      │
      │      ├─ 是 但 > 几 KB 或 生命周期超函数?
      │      │      └─ ▶ 堆 int *p = malloc(N * sizeof(int));
      │      │
      │      └─ 否 → ▶ 堆
      │
      ├─ 需要跨函数传递?
      │      ├─ 是 → ▶ 堆(或用调用者栈传递,见下文)
      │      └─ 否 → ▶ 栈 或 堆(看大小)
      │
      ├─ 大小 > 10KB?
      │      └─ ▶ 堆(避免栈溢出)
      │
      └─ 性能敏感(>1M次/秒的分配频率)?
             └─ ▶ 栈 或 内存池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 8. 内存分配实战技巧

# 8.1 alloca栈动态分配

疑惑:alloca 是栈上的"malloc"吗?安全吗?

alloca(也有 _alloca 版本)在栈上分配一块运行时才知道大小的内存:

#include <alloca.h>

void process_dynamic(int n) {
    /* n 只有在运行时才知道 */
    int *arr = alloca(n * sizeof(int));
    /* arr 指向栈上的空间,函数返回时自动释放 */
    for (int i = 0; i < n; i++)
        arr[i] = i;
    /* 不需要 free! */
}
1
2
3
4
5
6
7
8
9
10
; alloca 的底层实现:
sub rsp, rax        ; rax 存的是 n * sizeof(int) 的结果
mov rbx, rsp        ; 对齐后赋给 arr 指针
1
2
3

优点:比 malloc 快(不查堆管理器),自动释放。

缺点与陷阱:

  1. 不可移植——不是 C 标准的一部分(POSIX/BSD 扩展)
  2. 无法在循环中安全使用——alloca 的 RSP 调整只在函数返回时统一回收:
void bad_loop(int n) {
    for (int i = 0; i < 1000000; i++) {
        int *p = alloca(n);  // ← 每次循环 RSP 不断下移!
        /* ... */            // 除非函数结束,否则 RSP 不回来
    }                        // → 100 万次循环 → 栈溢出!
}
1
2
3
4
5
6
  1. 跨编译器行为不同——MSVC 用的是 _alloca,且行为与 GCC 的 alloca 有细微差异

建议:alloca 适合"小型、确定性、非循环"的动态分配。生产代码里,如果 n 超过几百字节,直接用 malloc 更安全。

# 8.2 小对象池化设计

对于一些频繁分配/释放的固定大小小对象,预分配一个对象池比反复 malloc/free 快得多:

#define POOL_SIZE 1024

typedef struct {
    int   data;
    void *next;   /* 空闲链表指针 */
} PoolNode;

typedef struct {
    PoolNode nodes[POOL_SIZE];  /* 预分配的节点数组 */
    PoolNode *free_head;        /* 空闲链表头 */
} NodePool;

void pool_init(NodePool *pool) {
    for (int i = 0; i < POOL_SIZE - 1; i++)
        pool->nodes[i].next = &pool->nodes[i + 1];
    pool->nodes[POOL_SIZE - 1].next = NULL;
    pool->free_head = &pool->nodes[0];
}

PoolNode *pool_alloc(NodePool *pool) {
    if (pool->free_head == NULL) return NULL;  /* 池已空 */
    PoolNode *node = pool->free_head;
    pool->free_head = pool->free_head->next;
    return node;
}

void pool_free(NodePool *pool, PoolNode *node) {
    node->next = pool->free_head;
    pool->free_head = node;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

关键设计:

  • 预分配数组在 .data 段或堆上,不在栈上
  • 空闲链表用 next 指针串联,分配/释放都是 O(1)
  • 避免了 malloc/free 的 chunk 头部开销和碎片问题

第 18 篇会展开更大规模的内存池设计。

# 8.3 分配器追踪与诊断

追踪当前堆使用情况:

#include <malloc.h>

void dump_heap_stats() {
    malloc_stats();  /* 打印到 stderr */
    /* 输出示例:
       Arena 0:
       system bytes     =    135168
       in use bytes     =      3856
       Total (incl. mmap):
       system bytes     =    135168
       in use bytes     =      3856
       max mmap regions =         0
       max mmap bytes   =         0
    */
}

/* 手动触发归还碎片 */
malloc_trim(0);      /* 强制归还能归还的空闲内存给内核 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

mtrace——glibc 内置的内存追踪:

#include <mcheck.h>

int main() {
    mtrace();  /* 开启追踪 */

    void *p = malloc(100);
    /* 忘记 free(p) —— mtrace 会报告 */

    muntrace();  /* 关闭追踪 */
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11

运行时需要设置环境变量:

MALLOC_TRACE=./trace.log ./my_program
mtrace ./my_program ./trace.log
# 输出:未释放的内存及其分配位置
1
2
3

perf 观测栈大小:

perf record -e cycles -g ./my_program
perf report
# 看调用栈深度分布:如果某一帧的栈消耗特别大(>几十KB),考虑移到堆
1
2
3

# 9. 常见线上事故

# 9.1 栈溢出的N张面孔

面孔 1:递归过深

/* 二叉树遍历——看似无害,实则可能栈溢出 */
void inorder(Node *root) {
    if (!root) return;
    inorder(root->left);
    visit(root);
    inorder(root->right);
}
/* 一个深度为 20000 的退化链表树 → ~40000 层递归 → 150KB栈帧 */
1
2
3
4
5
6
7
8

面孔 2:大数组在栈上

void decode() {
    char buf[1024 * 1024];  // 1 MB!单次调用就吃掉 1/8 的栈
    /* ... */
}
1
2
3
4

面孔 3:alloca 在循环中——见 8.1 节。

面孔 4:信号处理中的栈溢出

信号处理函数使用的是独立的信号栈(sigaltstack 指定),默认只有几 KB。在信号处理器里 printf 可能就爆栈。

# 9.2 堆碎片导致的OOM

碎片型 OOM 的特征:free -h 显示还有空闲内存,ps 看 RSS 也没占满物理内存,但 malloc(n) 返回 NULL。

void *ptrs[2000];

/* 阶段 1:分配大量小块 */
for (int i = 0; i < 2000; i++)
    ptrs[i] = malloc(4096);  /* 每块恰好一页 */

/* 阶段 2:释放奇数位置,产生碎片 */
for (int i = 1; i < 2000; i += 2)
    free(ptrs[i]);

/* 阶段 3:分配一个大块——可能失败 */
void *big = malloc(2000 * 4096);
/* 虽然总空闲 = 1000 × 4096 = 4MB > 需要的 8MB? 不对...
   实际上需要的刚好是可用的,但空闲块分散在各处 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14

malloc 返回 NULL 时的正确处理:

void *p = malloc(SIZE);
if (p == NULL) {
    /* ⚠️ 不要直接 exit - 先做清理 */
    log_error("malloc(%zu) failed", SIZE);
    cleanup_resources();  /* 释放不紧急的缓存 */
    p = malloc(SIZE);     /* 重试 */
    if (p == NULL) {
        /* 实在不行才退出 */
        emergency_shutdown();
    }
}
1
2
3
4
5
6
7
8
9
10
11

# 9.3 局部地址幽灵

这是 C 语言初学者最容易踩的坑——根源在于不理解"函数返回后栈帧被回收":

/* ❌ 错误 */
int *get_local() {
    int x = 42;
    return &x;   // 返回栈地址——函数返回后 x 的内存被回收!
}

int main() {
    int *p = get_local();
    printf("%d\n", *p);  // 可能输出 42(正好还没被覆盖)
                         // 也可能输出随机值
                         // 也可能 SIGSEGV
}
1
2
3
4
5
6
7
8
9
10
11
12

为什么"有时能跑"——栈空间并没有被清零,leave 只是把 RSP 移回去了。如果之后没有新的函数调用来覆盖那片内存,*p 仍能读到旧值。但这是未定义行为——编译器有权做任何优化,包括假设"不会有人读已回收的栈"。

正确做法:

/* 方案 A:返回堆上分配的值 */
int *get_heap() {
    int *p = malloc(sizeof(int));
    *p = 42;
    return p;  // ✅ 安全——堆内存不随函数返回而释放
}

/* 方案 B:通过调用者的指针写入 */
void get_via_ptr(int *out) {
    *out = 42;  // ✅ 安全——调用者的栈变量在调用者作用域内
}

/* 方案 C:返回 static */
int *get_static() {
    static int x = 42;
    return &x;  // ✅ 安全——static 变量在 .data/.bss 段,不在栈上
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的传感器采集器,八个疑问逐条作答:

疑问 答案
① RSP 怎么移动的? 第 3.1:函数调用时 call 指令 push 返回地址(RSP-8),序言 sub rsp 分配局部变量,返回时 leave 恢复
② 局部变量怎么排列的? 第 4.3:编译器按类型大小和平齐要求排列,不一定按声明顺序,有 padding
③ malloc 底层怎么拿到内存? 第 5.1~5.2:小于 128KB 走 brk 推高 program break,大于 128KB 走 mmap
④ 栈分配为什么快? 第 3.4/7.1:只需一条 sub rsp,N 指令,不涉及任何查找/链表/系统调用
⑤ 为什么不能返回局部变量地址? 第 9.3:函数返回后 RSP 移回,那块栈空间逻辑上已不属于本函数,读它可能拿到旧值或垃圾
⑥ 栈溢出除了调大 ulimit 还有什么解法? 第 6.3/10.1:把大缓冲区移到堆上,或改递归为迭代+显式栈
⑦ 什么时候把栈上数据搬到堆上? 第 7.4:大小>10KB、生命周期跨函数、大小运行时确定
⑧ alloca 安全吗? 第 8.1:非循环中少量使用安全,循环中会持续压栈导致溢出

这个传感器的终极修复:

/* 修复要点:
 *  1. 所有 > 4KB 的缓冲区移到堆上
 *  2. 用 goto cleanup 模式保证堆释放
 *  3. 把深调用链拍平(合并小函数,减少调用层级)
 */

typedef struct {
    char *buffer;
    char *payload;
    char *temp_buf;
    char *http_header;
} BatchResources;

void process_batch_fixed(int count) {
    SensorData sensors[MAX_SENSORS];          /* 栈 ~3KB OK */
    BatchResources res = {0};                 /* 栈上控制结构 */

    /* 堆上分配大缓冲区 */
    res.buffer      = malloc(16384);
    res.payload     = malloc(8192);
    res.temp_buf    = malloc(4096);
    res.http_header = malloc(2048);
    if (!res.buffer || !res.payload || !res.temp_buf || !res.http_header)
        goto cleanup;

    /* ... 业务逻辑(单次调用,不深递归)... */

cleanup:
    free(res.buffer);
    free(res.payload);
    free(res.temp_buf);
    free(res.http_header);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

效果对比:

指标 修复前 修复后
单次调用栈消耗 ~40 KB ~5 KB
安全调用层级 ~200 层 ~1600 层
是否能稳定运行 概率崩溃 确定稳定
代码行数 少 多 ~30%
维护成本 低(直觉简单) 中(需小心free)

# 10.2 栈堆双重生涯

以一个数据集排序程序来看栈和堆如何协作:

#include <stdlib.h>
#include <string.h>

#define MAX_NAME 32

typedef struct {
    char  name[MAX_NAME];   /* 在堆上的 struct 内部 */
    int   age;
    float score;
} Student;

/* 步骤 1:栈上的局部变量 */
void process(int count) {
    Student *students;
    char     line[256];           /* ← 栈:小型固定大小缓冲区 */
    Student  tmp;                 /* ← 栈:swap 临时变量 */
    FILE    *fp;

    /* 步骤 2:堆上的动态数组 */
    students = malloc(count * sizeof(Student));  /* ← 堆:运行时确定大小 */

    /* 步骤 3:读文件,line在栈、students在堆 */
    fp = fopen("data.csv", "r");
    while (fgets(line, sizeof(line), fp)) {
        /* line(栈) → students[i](堆) */
        parse_student(line, &students[i]);
    }

    /* 步骤 4:排序——tmp 在栈上做 swap,students 在堆上被排序 */
    qsort(students, count, sizeof(Student), cmp_student);

    /* 步骤 5:释放堆 */    
    free(students);
    fclose(fp);
    /* line 和 tmp 自动随栈帧回收 */
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

这个例子展示了黄金法则:

  • 编译期确定大小 + 函数内临时 → line[256], Student tmp → 栈
  • 运行时确定大小 + 需要持久 → students → 堆

# 10.3 设计哲学回扣

哲学 1:时空间取舍——栈用空间换时间,堆用时间换空间

栈把分配/释放压缩到一条 CPU 指令(改 RSP),代价是大小和生命周期受编译期限制。堆提供几乎无限的大小和自由的生命周期,代价是分配/释放都要走复杂的堆管理器。C 语言没有 GC,这个取舍由程序员决定——没有银弹,只有正确的权衡。

哲学 2:局部性原理——越常用的越近,越近的越快

int x = 5; 在栈上,开一条指令分配,和函数内其他变量紧挨着,cpu cache 友好。如果需要几十 MB 的数据,物理上不可能在栈上——堆虽然慢,但它提供了局部性之外的另一种选择:容量。

哲学 3:责任边界——C 语言信任程序员做正确的选择

Java 把所有对象放堆上,靠 GC 回收。C 语言给你两个选择:栈(自动回收但短命)和堆(手动管理但自由)。自由度越大,责任越大——选栈错了会栈溢出,选堆忘了 free 会内存泄漏。C 语言把"正确性"的责任交给了你。

哲学 4:fail fast——守护页和 canary 的共同信念

栈溢出可以被静默继续吗?技术上可以。但 Linux 选择守护页让它立刻崩。这是"fail fast"哲学:与其让程序带着坏数据继续跑(然后产生神秘的逻辑 bug),不如让它立刻、清晰地崩溃。一个好的崩溃远胜于一个沉默的错误。

# 10.4 栈堆对决速查

问题 快答
栈适合存什么? 编译期确定大小、函数生命周期内的小数据
堆适合存什么? 运行时大小、跨函数存活、大块数据
栈溢出第一反应? 检查是否有大数组在栈上、是否递归太深
堆碎片怎么办? 大对象池化、切换分配器、手动 malloc_trim
如何加速 malloc? 使用 tcache(glibc 2.26+)、对象池、减少分配频率
怎么看栈用了多少? gdb 下 p $rsp,对比 main 时和当前
怎么看堆用了多少? pmap -x $PID、malloc_stats()、valgrind massif

快速决策口诀:

大小确定又短命 → 栈上声明
大小未知或大块 → 堆上新生
函数返回还要用 → 只能堆中种
频繁分配小对象 → 池化最实在
1
2
3
4

下一篇:我们已经知道数据是放在栈上还是堆上了,下一步进入 03.指针本质与多级解引——&x 返回的地址到底是什么?为什么编译器说 int* 和 char* 是两种不同的东西?从汇编级别看透指针。

上次更新: 2026/06/11, 08:54:53
进程虚拟地址空间
指针本质与多级解引

← 进程虚拟地址空间 指针本质与多级解引→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式