栈与堆底层对决
# 02.栈与堆底层对决
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 栈的底层机制
- 4. 栈帧结构全拆解
- 5. 堆的扩展与回收
- 6. 栈溢出与防护机制
- 7. 栈 vs 堆性能实测
- 8. 内存分配实战技巧
- 9. 常见线上事故
- 10. 综合案例串讲
# 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;
}
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]
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
2
3
4
5
6
每次 process_batch 调用栈消耗约 32KB——比预期的大得多。原因是:
sensors[64]:64 × 48 = 3KB(栈上分配)buffer[16384]:16KBdispatch_sensors里又有 12KBreport_to_cloud里又有 6KBhttp_send里又有 1KB- 加上返回地址、保存的寄存器、对齐 padding:单次调用链约 40KB
8 MB / 40 KB ≈ 200 次调用链。生产跑了 4 天才崩,因为dispatch_sensors 里的 if (avg > 50.0f) 是概率触发——只有在特定数据下才会深入调用链。当传感器数据波动大时,深调用频率突然升高,累积的栈帧还没释放又被新调用压上,最后踩到守护页。
根因:
- 栈帧 不是函数退出就会被 OS 回收——它只是 RSP 回移,物理页还在
- 这个进程的调用链虽然单次才 5 层,但每次都在上一层的栈帧内分配大缓冲区
- 生产跑了 4 天才崩,是因为崩溃条件依赖数据分布——概率性 bug
这暴露了关于栈与堆的几个深层困惑:
① 栈是"向下长"的,那RSP到底怎么移动的? → 第 3 章
② 局部变量在栈帧里怎么排列的?编译器做了什么手脚? → 第 4 章
③ 堆用 malloc 分配,底层到底怎么拿到内存的? → 第 5 章
④ 栈分配为什么快?一条指令就够了吗? → 第 3.4 / 7.1
⑤ 为什么"返回局部变量的地址"是危险的? → 第 9.3
⑥ 栈溢出除了调大 ulimit -s 还有别的解法吗? → 第 6 / 第 10 章
⑦ 什么情况下应该把栈上数据搬到堆上? → 第 7 章
⑧ alloca 是栈上的"malloc"吗?安全吗? → 第 8.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 章) ─→ 案例彻底剖开、逐条作答
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)
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 为什么存在两种内存
疑惑:既然栈这么快、这么简单,为什么不把所有的变量都放在栈上?
论证:
- 生命周期不匹配——考虑这个经典场景:
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; // ✅ 安全
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
栈变量的生命周期绑死函数调用——函数返回,栈帧就被"逻辑销毁"(RSP 移走)。任何需要跨越函数边界存活的变量,必须放在堆上。
- 大小不确定——编译器在函数的栈帧大小必须在编译期决定。如果运行时才知道需要多少内存:
void process(int n) {
// int arr[n]; // VLA(C99),但不在标准C11中强制要求
int *arr = malloc(n * sizeof(int)); // 堆上动态分配
/* ... */
free(arr);
}
2
3
4
5
6
堆的存在,让 C 语言可以处理"运行时才知道大小"的数据。
- 大小的上限——栈只有 8 MB,而堆可以到几个 GB。图片、音频帧、大型查询结果,从物理上就不可能放在栈上。
void decode_image() {
// char pixels[1920 * 1080 * 4]; // 8.3 MB,超过默认栈大小!
char *pixels = malloc(1920 * 1080 * 4); // 堆上安全
}
2
3
4
- 反向论证——如果只有堆没有栈会怎样?每一个
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(栈顶)
低地址
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 ; 弹出返回地址,跳回调用者
2
3
4
5
6
7
8
9
10
leave 干了两件事:
mov rsp, rbp—— 回收本帧所有局部变量空间(RSP 上移回 RBP)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;
}
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 ; ⑤ 返回调用者
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;
}
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
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);
}
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+ 条指令
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);
}
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 的完整路径)
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;
}
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
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 ; 跳转到目标函数
2
ret 指令等同于:
pop rip ; 从栈顶弹出地址,跳过去
这种设计意味着:返回地址就在栈上,紧挨着局部变量。这就是栈溢出攻击的物理基础:
void vulnerable(char *input) {
char buf[16]; // 栈上
strcpy(buf, input); // 无检查拷贝 ← 可能溢出到返回地址
}
// 如果 input 超过 16 字节,会覆盖:
// buf[16..23] → 保存的 RBP
// buf[24..31] → 返回地址!
2
3
4
5
6
7
攻击者可以精心构造 input,把返回地址改为任意值——这就是 return-to-libc 和 ROP 的起点。
防御机制(第 6 章展开):
- canary(金丝雀):在 buf 和返回地址之间放一个随机值,返回前检查
- NX:栈页不可执行,不能跳到栈上执行 shellcode
- ASLR:栈基址随机化,攻击者无法预测地址
# 4.3 局部变量排列
编译器在栈帧里排列局部变量的规则:
void layout_demo() {
char a; // 1 字节
int b; // 4 字节
short c; // 2 字节
char d; // 1 字节
long e; // 8 字节
}
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
低地址
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;
}
2
3
4
5
6
7
8
9
10
栈不需要 free,是因为:
- 函数返回时 RSP 自动移回(
leave指令),之前分配的局部变量空间逻辑上被回收 - 下一次函数调用时,RSP 继续下移,自然覆盖旧栈帧
- 栈不会泄漏——每个函数调用都是"分配-使用-自动回收"的闭环
与堆的本质区别:堆上分配的内存在 free 之前一直存在,即使所有指向它的指针都已经出作用域。这就是内存泄漏的物理原因——堆不知道你什么时候"用完"了那块内存。
# 5. 堆的扩展与回收
# 5.1 从brk到mmap两条通道
堆不是一开始就很大的——它是按需扩展的:
#include <unistd.h>
void show_brk() {
void *brk_pos = sbrk(0);
printf("当前 brk: %p\n", brk_pos);
}
2
3
4
5
6
堆扩展的两条通道:
通道 1:brk/sbrk(推高 program break)
低地址 高地址
bss 结束点 ─────────────────────────────────────
│ ←──────────────────────────────→ │
↑ heap via brk ↑
program break(旧) program break(新)
←── sbrk(N) 上推 N 字节 ──→
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); // 立即归还内核
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)
├──────────────────────┤
│ 空闲空间 │
└──────────────────────┘
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);
- 根据 ptr 向前偏移 16 字节(跳过 prev_size + size)找到 chunk 头
- 检查本 chunk 的 PREV_INUSE 位:前一个是否空闲?是 → 合并
- 检查下一个 chunk:当前 chunk 的 size 确定下一个 chunk 的位置,检查它再下一个的 PREV_INUSE → 下一个是否空闲?是 → 合并
- 合并后的大 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
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); // 可能需要向系统再申请
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
内部碎片:分配的内存块大于实际请求(对齐 padding、chunk 头部开销):
void *p = malloc(1); // 实际可能分配 16~32 字节(含 chunk 头 + 对齐)
生产级应对:
| 策略 | 说明 |
|---|---|
| 大对象池化 | 预分配固定大小的内存池,避免反复 malloc/free |
| jemalloc/tcmalloc | 换用碎片控制更好的分配器 |
| 对象大小分级 | 相同大小的对象从同一个池分配,消除外部碎片 |
malloc_trim(0) | 手动触发 glibc 归还碎片给内核 |
第 18 篇会展开自定义内存池的完整设计与实现。
# 6. 栈溢出与防护机制
# 6.1 守护页如何工作
Linux 内核在创建栈时,会在栈底自动插入一页守护页(Guard Page):
高地址 ┌──────────────────┐
│ 有效栈空间 (~8MB) │ rw-p
│ RSP 在此范围内 │
│ 函数调用 → RSP↓ │
├──────────────────┤ ← RSP 越过此边界
低地址 │ 守护页 (4KB) │ ---p 不可读/写/执行
├──────────────────┤
│ mmap区域或堆 │
└──────────────────┘
2
3
4
5
6
7
8
9
工作流程:
- 程序递归或分配大局部变量,RSP 降到守护页内
- CPU 尝试访问那个地址 → 页表查不到映射 → #PF 异常
- 内核的
do_page_fault()检查:这个地址在任何 VMA 里吗? - 如果在有效栈 VMA 的扩展范围内 → 分配新页,扩展栈
- 如果在守护页内 → 直接 SIGSEGV
关键点:守护页属于 MAP_GROWSDOWN | guard,内核不会为其分配物理页——越界即崩。
# 6.2 canary金丝雀检测
编译器在每个有栈数组的函数序言中植入一个"金丝雀值":
void foo(void) {
char buf[16];
gets(buf); // ← 危险:可能溢出
}
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
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
低地址
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 次深调用
代价:治标不治本,线程数多时 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); // 显式释放
}
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);
}
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);
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);
}
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×
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);
}
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 锁
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次/秒的分配频率)?
└─ ▶ 栈 或 内存池
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! */
}
2
3
4
5
6
7
8
9
10
; alloca 的底层实现:
sub rsp, rax ; rax 存的是 n * sizeof(int) 的结果
mov rbx, rsp ; 对齐后赋给 arr 指针
2
3
优点:比 malloc 快(不查堆管理器),自动释放。
缺点与陷阱:
- 不可移植——不是 C 标准的一部分(POSIX/BSD 扩展)
- 无法在循环中安全使用——alloca 的 RSP 调整只在函数返回时统一回收:
void bad_loop(int n) {
for (int i = 0; i < 1000000; i++) {
int *p = alloca(n); // ← 每次循环 RSP 不断下移!
/* ... */ // 除非函数结束,否则 RSP 不回来
} // → 100 万次循环 → 栈溢出!
}
2
3
4
5
6
- 跨编译器行为不同——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;
}
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); /* 强制归还能归还的空闲内存给内核 */
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;
}
2
3
4
5
6
7
8
9
10
11
运行时需要设置环境变量:
MALLOC_TRACE=./trace.log ./my_program
mtrace ./my_program ./trace.log
# 输出:未释放的内存及其分配位置
2
3
perf 观测栈大小:
perf record -e cycles -g ./my_program
perf report
# 看调用栈深度分布:如果某一帧的栈消耗特别大(>几十KB),考虑移到堆
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栈帧 */
2
3
4
5
6
7
8
面孔 2:大数组在栈上
void decode() {
char buf[1024 * 1024]; // 1 MB!单次调用就吃掉 1/8 的栈
/* ... */
}
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? 不对...
实际上需要的刚好是可用的,但空闲块分散在各处 */
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();
}
}
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
}
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 段,不在栈上
}
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);
}
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 自动随栈帧回收 */
}
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 |
快速决策口诀:
大小确定又短命 → 栈上声明
大小未知或大块 → 堆上新生
函数返回还要用 → 只能堆中种
频繁分配小对象 → 池化最实在
2
3
4
下一篇:我们已经知道数据是放在栈上还是堆上了,下一步进入 03.指针本质与多级解引——
&x返回的地址到底是什么?为什么编译器说int*和char*是两种不同的东西?从汇编级别看透指针。