进程虚拟地址空间
# 01.进程虚拟地址空间
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 虚拟内存机制
- 4. 代码段与数据段
- 5. 堆区生长机制
- 6. 栈区生长方向
- 7. 共享库与mmap映射
- 8. ASLR与安全机制
- 9. pmap实战剖析
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
先看一段在嵌入式网关上跑了好几个月的代码,看着平平无奇,却在某天大客户投诉"设备死机"时爆了出来:
// file_scanner.c —— 配置文件递归扫描器
#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
void scan_dir(const char *path, int depth) {
char full_path[4096]; // ← 栈上 4KB 缓冲
char depth_prefix[128]; // ← 栈上 128 字节
struct dirent *entry;
DIR *dp;
/* 生成缩进前缀 */
memset(depth_prefix, ' ', depth * 2);
depth_prefix[depth * 2] = '\0';
dp = opendir(path);
if (dp == NULL) return;
while ((entry = readdir(dp)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 ||
strcmp(entry->d_name, "..") == 0)
continue;
snprintf(full_path, sizeof(full_path),
"%s/%s", path, entry->d_name);
printf("%s%s\n", depth_prefix, entry->d_name);
struct stat st;
if (stat(full_path, &st) == 0 && S_ISDIR(st.st_mode)) {
scan_dir(full_path, depth + 1); // ← 递归
}
}
closedir(dp);
}
int main(int argc, char *argv[]) {
scan_dir(argv[1] ? argv[1] : "/etc/config", 0);
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
现象:
- 测试环境(扫描
/etc/config,最多 3 层子目录):100% 通过 - 生产环境(某客户在 U 盘里塞了一个压缩包,解压后 1200+ 层嵌套目录):SIGSEGV,core dump 显示崩溃在
scan_dir自身 - 日志最后一行输出后,进程直接消失——连信号处理函数都没来得及执行
直觉怀疑:是不是 full_path[4096] 太大了?打开 core 用 gdb 一看:
(gdb) bt
#0 0x0000000000401234 in scan_dir (path=0x7ffd9a3b4f80 "...",
depth=1053) at file_scanner.c:8
#1 0x0000000000401567 in scan_dir (path=0x7ffd9a3b2f00 "...",
depth=1052) at file_scanner.c:32
... (重复 1052 帧) ...
(gdb) info proc mappings
Start End Perms Offset File
0x00007ffd00000000 0x00007ffd00021000 ---p 00000000 ← 守护页
0x00007ffd00021000 0x00007ffd00822000 rw-p 00000000 [stack] ← 实际栈
2
3
4
5
6
7
8
9
10
11
崩溃地址落在 ---p(无任何权限)的守护页(guard page) 内——典型的"栈溢出",不是缓冲区越界,是整个调用栈用光了。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
full_path[4096]太大?—— 在栈帧上算一下:4096(full_path)+ 128(depth_prefix)+ 8(指针)+ 8(dp)+ 其他 ≈ 4.3 KB / 帧。 - 假设 2:Linux 默认栈多大?——
ulimit -s显示 8192 KB(8 MB)。 - 假设 3:8 MB 能撑多少层?—— 8192 / 4.3 ≈ 1905 层。但还有栈上的临时变量、
stat系统调用也会用栈,实际 1200 层就扛不住了。 - 假设 4:为什么"测试环境通过"?—— 测试用的目录最多 3 层,栈深不到 13 KB,离 8 MB 远得很。
- 假设 5:那"守护页"是干嘛的?—— 它是内核在栈底插的一页权限为
---的内存,一旦 RSP 踩上去,立刻 SIGSEGV 而不是静默覆盖堆或 mmap 区。
看似"递归就递归"的代码,没毛病在算法,毛病在没有意识到栈是有边界的——这条代码碰到的不是 C 语言的坑,是进程地址空间布局的坑。
疑惑:C 语言不是直接操作内存吗?malloc 出来的东西放哪?局部变量在哪?为什么同一个地址 0x7ffd9a3b4f80 在我机器上跑和我同事机器上跑不一样?为什么指针的值那么大(0x7ffd...)?
这一段事故里至少藏着 7 个原理点:
① 栈在地址空间的哪儿?为什么从高地址往低地址长? → 第 6 章
② 守护页是谁分配的?为什么默认就有? → 第 6.4 / 第 8 章
③ 8 MB 这个数从哪来的?能改吗? → 第 6 章
④ 同一个程序为什么每次启动栈基址都不一样? → 第 8 章 ASLR
⑤ malloc 出来的内存在哪一段?与栈井水不犯河水吗? → 第 5 章
⑥ 全局变量 / static 变量住在哪段? → 第 4 章
⑦ 怎么用 pmap 一眼看出"这是栈、这是堆、这是 .so"? → 第 9 章
2
3
4
5
6
7
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,给出三种修复方案与各自的代价。
本篇路线:
架构总图 (第 2 章)
↓
虚拟内存机制 (第 3 章) ─→ 解开"为什么每个进程都看到独立的地址空间"
↓
代码段 → 堆 → 栈 (第 4-6 章) ─→ 解开"五大段在哪、为什么"
↓
共享库 → ASLR (第 7-8 章) ─→ 解开"动态库与安全机制"
↓
pmap 实战 (第 9 章) ─→ 武器库
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
2
3
4
5
6
7
8
9
10
11
📌 本篇定位:这是整个 C 语言专栏的地基篇。第 02-20 篇讲的指针运算、栈帧原理、内存对齐、动态内存管理,本质都是"在这张地址空间地图上的某一段里发生的事"。读完本篇后,再看后面任何一个 C 语言内存话题,都能立刻回答:"它住在哪一段"。
# 2. 架构概览
# 2.1 五大段总图
我们在 64 位 Linux 上看一个典型 C 进程的地址空间:
高地址 (0xFFFF_FFFF_FFFF_FFFF)
┌─────────────────────────────────────────────────┐
│ 内核空间 │ ← 用户态不可见
│ (所有进程共享一份) │
├─────────────────────────────────────────────────┤ 0xFFFF_8000_0000_0000
│ │
│ ↓↓↓ 栈区 stack ↓↓↓ │ ← 从高向低生长
│ (函数局部变量、调用帧) │
│ │
├─────────────────────────────────────────────────┤
│ │
│ 共享库映射区 (mmap area) │
│ libc.so / libpthread.so / 自家 .so / mmap │
│ │
├─────────────────────────────────────────────────┤
│ │
│ ↑↑↑ 堆区 heap ↑↑↑ │ ← 从低向高生长
│ malloc / free / brk │
│ │
├─────────────────────────────────────────────────┤
│ bss 未初始化全局/静态变量(运行时清零) │
├─────────────────────────────────────────────────┤
│ data 已初始化全局/静态变量 │
├─────────────────────────────────────────────────┤
│ rodata 常量字符串、const 全局 │ ← 只读
├─────────────────────────────────────────────────┤
│ 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
24
25
26
27
28
29
30
31
⚠️ 图示说明:图中"上=高地址、下=低地址"是 Linux/Unix 的传统画法;有些教材反着画,含义一样,别被画法迷惑。
五大段的核心属性速查:
| 段名 | 权限 | 内容 | 何时分配 | 大小由谁定 |
|---|---|---|---|---|
| text | r-x | 函数机器码 | 加载时 mmap 文件 | 编译产物 |
| rodata | r-- | 字符串字面量、const | 加载时 mmap 文件 | 编译产物 |
| data | rw- | 已初始化全局/static | 加载时 mmap 文件 | 编译产物 |
| bss | rw- | 未初始化全局/static | 加载时清零(零页 COW) | 编译产物 |
| heap | rw- | malloc/free | brk/mmap 按需扩展 | 程序运行时 |
| stack | rw- | 局部变量、调用帧 | 内核预分配 + 守护页 | ulimit -s |
| mmap | rw- 或 r-x | 共享库、mmap 文件 | dlopen/mmap 时映射 | 调用方 |
# 2.2 为什么这么切
为什么把进程地址空间切成"五大段 + 共享库区 + 内核区",而不是统一一锅粥?
疑惑:直接给进程一段连续的物理内存,让它自己管理不行吗?
论证:
- 权限隔离的需要——把"代码"放在 r-x 段,是为了防止有 bug 的代码意外修改自己的指令:
void *p = (void *)main;
// *(char *)p = 0x90; // 试图写入代码段 → SIGSEGV
2
text 段 r-x、rodata 段 r--、stack 段 rw- 但不可执行(NX 位)。每一个段就是一种权限承诺,由 CPU 的 MMU 硬件级别强制执行,不依赖任何运行时检查。段是权限的最小单位。
加载时机不同——text/rodata/data 在 ELF 文件里就有,加载器把文件直接 mmap 进虚拟地址;bss 不占文件空间(节省磁盘),运行时才映射到零页;heap/stack 完全运行时动态分配。段对应"何时存在"的差异。
共享与独占不同——同一份 libc.so 被 100 个进程加载时,text 段的物理内存只有一份(多进程共享同一物理页);bss/data/heap/stack 每个进程独占一份(写时复制);段对应"是否共享"的差异。
生长方向相反才能共用空间——
// 堆:从低地址向高地址长
void *p = sbrk(0); // 获取当前 brk 位置
sbrk(4096); // 堆向上扩展 4KB
// 栈:从高地址向低地址长
void foo() {
int arr[1000]; // RSP 向下移动,栈向下扩展
}
2
3
4
5
6
7
8
堆向上长、栈向下长,留中间一大片"待分配区域"给两者动态争抢——这是 1970 年代 Unix 设计者的空间复用智慧。如果两者同向,很快就会撞在一起。
- 反向验证:如果不分段会怎样?参考早期 DOS 的"实模式"——任何指针都能改任何地址,包括代码段。结果就是蓝屏家常便饭,一个野指针就能干掉整个操作系统。
结论:分段不是为了"好看",而是把权限、加载时机、共享性、生长方向这四个独立维度同时编码进地址空间——一段空间一种语义,崩溃定位、安全防护、内存共享、动态扩展全都得益于此。这是 Unix/Linux 内存管理的根基哲学,C 语言是它最直接的受益者——也是最大的责任者。
# 3. 虚拟内存机制
# 3.1 物理与虚拟之分
疑惑:每个进程都看到完整的 64 位地址空间,可机器物理内存只有 32 GB,怎么做到的?
论证:
- CPU 看到的所有地址都是虚拟地址(VA, Virtual Address),不是物理地址。
- 进程间的 VA 完全独立——进程 A 的
0x400000和进程 B 的0x400000指向不同的物理页。 - 当 CPU 执行
mov rax, [0x400000]时,硬件单元 MMU(Memory Management Unit)查"页表"把 VA 翻译成 PA(Physical Address),再去访存:
进程 A 进程 B 物理内存
VA 0x400000 ─┐ VA 0x400000 ─┐ ┌────────────┐
├ MMU 查表 ├ MMU │ 页 0 │
↓ ↓ ├────────────┤
PA 0x12345000 PA 0x67890000 │ 页 1 │
└────────────┘
不同的物理页,互不干扰
2
3
4
5
6
7
- 这套机制带来的红利:
- 进程隔离:A 写自己的内存,绝不会污染 B
- 过量承诺:每个进程"看到" 256 TB 用户空间(x86-64 低 48 位),但物理内存只有 32 GB——只要不全用满即可
- 按需调页:地址虽存在,物理页等真正访问时再分配
- 内存共享:两个 VA 映射到同一个 PA,实现 COW、共享库
结论:虚拟内存把"地址"和"物理存储"解耦——这是 1960 年代 Multics 留给现代操作系统最珍贵的遗产。C 语言对"地址"的直接操作(&a、*p、void *),操作的全是虚拟地址,不是物理地址——程序永远不需要也不可能知道数据在物理内存的哪个芯片上。
# 3.2 页表与MMU翻译
VA → PA 的翻译以页(page) 为单位,x86-64 默认每页 4 KB(也支持 2 MB 大页、1 GB 巨页)。
x86-64 标准 4 级页表(实际只用低 48 位地址,高 16 位是符号扩展):
VA (48 bit): [PML4 9bit][PDPT 9bit][PD 9bit][PT 9bit][offset 12bit]
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────────┐
CR3 ─────► │PML4 │──► │PDPT │──►│ PD │─►│ PT │───►│ 物理页 4K │
└─────┘ └─────┘ └─────┘ └─────┘ └──────────┘
每级 512 项 × 8 字节 = 4 KB(一页装得下一级页表)
每次访存最多 4 次页表查找 + 1 次实际数据读 = 5 次内存访问
→ 现实中 TLB(Translation Lookaside Buffer)缓存常用页表项,绝大多数访问 0 次额外开销
2
3
4
5
6
7
8
9
10
TLB miss 代价:
| 命中情况 | 额外内存访问数 | 典型延迟 |
|---|---|---|
| TLB 命中 | 0 | 1 cycle |
| TLB miss + 页表 in cache | 1~4 | 几十 ns |
| TLB miss + 页表 in RAM | 4 | 几百 ns |
| TLB miss + 缺页中断 | 数百万 cycle | 几 ms |
这就是为什么"热数据要紧凑"——同一页里的访问命中同一个 TLB 项,跨页访问每次都可能 miss。C 语言里的 struct 布局优化(第 10 篇会讲),本质上是在讨好 TLB。
# 3.3 缺页中断流程
疑惑:int *p = malloc(1'000'000'000 * sizeof(int)); 真的立刻分配了 4 GB 物理内存?
论证:跑一下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
int *p = malloc(1000000000UL * sizeof(int)); // 申请约 4 GB
if (p == NULL) {
printf("malloc failed\n");
return 1;
}
printf("malloc 成功,地址: %p\n", p);
printf("此时去另一个终端看 ps -o pid,vsz,rss\n");
sleep(60);
free(p);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ps -o pid,vsz,rss 输出:
PID VSZ RSS
12345 4194304 1024 ← VSZ ≈ 4 GB(虚拟),RSS ≈ 1 MB(物理)
2
4 GB 是 VA 承诺,物理内存只占 1 MB——剩下的页还没真正分配。
什么时候真正分配?第一次写那一页时:
for (size_t i = 0; i < 1000000000UL; i += 1024) {
p[i] = 0; // 触发缺页中断 (page fault)
}
2
3
缺页中断完整流程:
程序:mov DWORD [vaddr], 0
│
▼
MMU 查页表 → 该页不存在(页表项 P=0)
│
▼
CPU 触发 #PF(页错误)异常 → 跳到内核 do_page_fault()
│
├── 检查 vaddr 在不在合法 VMA(虚拟内存区域)里?
│ 不在 → SIGSEGV(崩溃,本篇案例就是这条路径)
│ 在 ↓
├── 是首次访问?分配一个新物理页(zero-fill)
├── 是写时复制?分配新页,复制原内容
├── 是从磁盘换出的?从 swap 读回
├── 是 mmap 文件?从磁盘读对应内容
▼
修改页表,设置 P=1
│
▼
返回到出错指令重新执行 → 这次 MMU 翻译成功
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
结论:虚拟内存是"按需付费"的——你申请多少 VA 都不要钱,真正用到才会变成物理内存。这就是 Linux "overcommit" 的根基——也是 OOM Killer 存在的原因。
# 3.4 mmap与按需分配
mmap 是 Linux 把"虚拟内存"暴露给用户态的核心系统调用,五大段里除了固定加载的部分,其他都是 mmap 的产物:
#include <sys/mman.h>
void *p = mmap(
NULL, // 让内核选地址
1024UL * 1024 * 1024, // 1 GB
PROT_READ | PROT_WRITE, // 可读可写
MAP_PRIVATE | MAP_ANONYMOUS, // 匿名(不映射文件)+ 私有
-1, 0
);
// p 是一个合法 VA,但还没占任何物理内存
// 第一次写每个页时按需分配
2
3
4
5
6
7
8
9
10
11
mmap 的两种核心模式:
| 模式 | 用途 |
|---|---|
MAP_ANONYMOUS | 申请匿名内存(堆/栈本质上都是它) |
MAP_FILE | 把文件映射进地址空间(共享库、零拷贝 IO) |
MAP_PRIVATE | 写时复制,修改只对本进程可见 |
MAP_SHARED | 多进程共享,修改对所有进程可见 |
后续讲堆、共享库时都会回到 mmap——它是 Linux 内存管理的"瑞士军刀",也是 C 语言程序员理解 malloc 大块分配行为的钥匙。
# 4. 代码段与数据段
# 4.1 text只读可执行
text(也叫 .text)段装的是编译产物——机器指令:
int add(int a, int b) { return a + b; }
经 gcc -O2 -c 编译后,add 函数的指令(objdump -d):
0000000000000000 <add>:
0: 8d 04 37 lea eax, [rdi + rsi]
3: c3 ret
2
3
这 4 字节机器码被链接器塞进可执行文件的 .text 节,加载时 mmap 进进程的代码段:
$ readelf -S a.out | grep -E "\.text|\.rodata|\.data|\.bss"
[14] .text PROGBITS 0000000000401050 00001050
0000000000000142 0000000000000000 AX ← A=分配, X=可执行
[16] .rodata PROGBITS 00000000004021a0 000021a0
0000000000000019 0000000000000000 A ← 只 A,无 X
[25] .data PROGBITS 0000000000404020 00003020
0000000000000010 0000000000000000 WA ← WA=可写+分配
[26] .bss NOBITS 0000000000404030 00003030
0000000000000018 0000000000000000 WA ← NOBITS
2
3
4
5
6
7
8
9
关键属性:
- r-x(只读 + 可执行):写入会 SIGSEGV,这是 CPU 硬件的页保护机制
- 共享:同一份可执行/动态库被多个进程加载时,物理上只有一份 .text
# 4.2 rodata常量区
在 C 语言里,字符串字面量住在 .rodata:
char *msg = "hello, world!"; // "hello, world!\0" 在 rodata
// msg[0] = 'H'; // 💥 SIGSEGV:rodata 不可写
const int max_size = 1024; // 可能放 rodata(取决于取不取地址)
2
3
4
经典陷阱——编译器可能把相同的字符串去重(string pooling):
char *s1 = "hello";
char *s2 = "hello";
printf("s1 == s2: %d\n", s1 == s2);
// 输出 1(通常为真)——编译器把两个 "hello" 合并了,指向同一块 rodata
// 可用 -fno-merge-constants 禁用此优化
2
3
4
5
6
语法层面的坑——字符数组和字符指针的区别:
char arr[] = "hello"; // 在栈上分配 6 字节,内容从 rodata 复制
char *ptr = "hello"; // 指针在栈上,指向 rodata 中的字符串
arr[0] = 'H'; // ✅ OK:修改栈上的副本
// ptr[0] = 'H'; // 💥 段错误!修改 rodata 中的数据
2
3
4
5
内存布局对比:
栈区: rodata 段:
+-------+ +----------------+
| arr | = "hello\0" | "hello\0" |
+-------+ +----------------+
| ptr | ──────────→ | |
+-------+ +----------------+
2
3
4
5
6
# 4.3 data已初始化
.data 装已显式初始化的全局变量和 static 变量:
int counter = 100; // .data 段,文件中存"100"这 4 字节
static double rate = 0.05; // .data 段
2
数据段的特点——它在可执行文件中占实际空间:
// data_vs_bss.c
int arr_data[1000000] = {1}; // 每个元素都被初始化 → 约 4MB 在 .data
int arr_bss[1000000]; // 未初始化 → 在 .bss,不占文件空间
int main() { return 0; }
2
3
4
$ gcc data_vs_bss.c -o test_data
$ gcc data_vs_bss.c -o test_bss -DUSE_BSS
$ ls -l test_data test_bss
-rwxr-xr-x 1 user user 4008400 test_data # 约 4MB
-rwxr-xr-x 1 user user 8400 test_bss # 约 8KB
2
3
4
5
4 MB 差在 .data 段的文件占用上。
# 4.4 bss零页惰性
.bss("Block Started by Symbol",名字源自 IBM 旧汇编器)装未初始化或零初始化的全局变量:
int arr[1'000'000]; // 0 初始化,进 .bss
double matrix[10000][10000]; // 0 初始化,进 .bss
char buffer[1'000'000'000]; // 1 GB 大数组,仍在 .bss
2
3
关键特性:bss 不占磁盘空间。
$ readelf -S a.out | grep .bss
[26] .bss NOBITS ... 0000000000000018 ← 18 字节
2
注意 NOBITS——ELF 文件里只记录"我有 18 字节 bss"这个元数据,没有实际内容。1 GB 数组的可执行文件依然只有几十 KB。
加载时怎么"分配 1 GB"?——映射到内核的零页(zero page):
全部 bss 的虚拟地址 → 同一个物理零页(只读)
↓
程序第一次写 arr[i] = ... → 缺页中断 → 分配新物理页(写时复制 from zero page)
2
3
这就是为什么"声明 1 GB 全局数组没问题,真用 1 GB 才花物理内存"。
小细节——显式初始化为 0 的全局变量,也被编译器优化进 .bss:
int zero_var = 0; // 编译器优化:放 .bss 段,不占文件空间
# 4.5 ELF段映射
把上面四段串起来——ELF 加载器看到一个可执行文件后:
ELF 文件 (磁盘) 虚拟内存 (运行时)
┌─────────────────────┐ ┌─────────────────────┐
│ ELF 头 │ │ 栈 (从高向低) │
│ 程序头表 │ ├─────────────────────┤
│ .text (代码) │ mmap r-x │ ... │
│ .rodata (常量) │ mmap r-- │ .text ←─────────┐│
│ .data (已初始化) │ mmap rw- │ .rodata ←────────││
│ .bss 元数据(NOBITS)│ mmap rw- │ .data ←──────────││
│ .symtab (符号表) │ 零页 │ .bss ←──────────┘│
│ .debug (调试信息) │ 跳过 │ │
│ ... │ │ 堆 (从低向高 brk) │
└─────────────────────┘ └─────────────────────┘
↑
程序入口 _start
2
3
4
5
6
7
8
9
10
11
12
13
14
加载流程(极简版):
- execve() 系统调用:内核解析 ELF 头
- 按 程序头表(program headers)逐段
mmap:LOAD段(PT_LOAD):text/rodata/data 从文件 mmap 进 VAbss:分配虚拟空间,初始指向零页
- 加载动态链接器
ld-linux.so(PT_INTERP指定) - 跳转到
_start→ 调用__libc_start_main→ 调用main
# 5. 堆区生长机制
# 5.1 brk历史接口
最古老的堆扩展接口是 brk(2):
#include <unistd.h>
void *old = sbrk(0); // 拿到当前堆顶(program break)
sbrk(1024 * 1024); // 把堆顶往上推 1 MB
2
3
4
brk 的语义极其简单——把"程序中断(program break)"指针上推或下移:
低地址 高地址
┌──────┬───────┬──────┬───────────────────────────┬───────┐
│ text │rodata │ data │ bss │ 堆 (heap) │ ... │
└──────┴───────┴──────┴───────────────────────────┴───────┘
↑ ↑
brk_start brk_end (program break)
─────► sbrk(N) 把它往上推
2
3
4
5
6
7
优点:调用极便宜,glibc 在 malloc 里大量复用。
缺点:
- 只能从堆顶端生长,不能局部释放——中间有个大对象不释放,整个上面都退不下来
- 多线程下竞争同一个
brk,必须加锁 - 高地址的
mmap area撞过来时,brk 就被卡住了
# 5.2 mmap匿名映射
现代 glibc(ptmalloc2)大块内存默认走 匿名 mmap:
void *p = mmap(NULL, 4 * 1024 * 1024,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
2
3
优点:
- 任意位置分配,释放时立刻归还内核(
munmap) - 多线程互不影响(每个 arena 独立)
- 物理内存按需分配(缺页中断)
缺点:
- 系统调用开销比
brk高 - 频繁 mmap/munmap 会产生大量内核操作
# 5.3 ptmalloc的arena
glibc ptmalloc2 的核心结构:
线程 1 ──────► main_arena (主 arena,用 brk)
├── fastbin (≤80 B 的小块,单链表无锁)
├── unsortedbin (新释放的,等待整理)
├── smallbin (≤512 B)
├── largebin (>512 B)
└── tcache (线程本地缓存,glibc 2.26+)
线程 2 ──────► thread_arena_1 (线程 arena,用 mmap)
└── 同上结构
线程 3 ──────► thread_arena_2
└── ...
2
3
4
5
6
7
8
9
10
11
为什么要分 arena:避免多线程争抢同一把 malloc 锁。每线程一个 arena,几乎所有 malloc 调用零锁竞争。
malloc 内部决策(极简版):
malloc(size):
1. size ≤ 64 B → 查 tcache(线程本地,无锁)
2. ≤ 512 B → smallbin
3. 512 B ~ 128 KB → unsortedbin → smallbin/largebin
4. > 128 KB → 直接 mmap(绕过 arena)
5. 都没找到 → sbrk 扩堆 / mmap 新区
2
3
4
5
6
MMAP_THRESHOLD 默认 128 KB,可通过 mallopt(M_MMAP_THRESHOLD, ...) 调整。
# 5.4 大块直接走mmap
为什么 128 KB 是分界线?
// 小块:复用 arena,碎片小但释放后空间未必归还内核
void *p1 = malloc(1000);
free(p1); // 进入 fastbin/tcache,下次 malloc(1000) 直接复用
// 大块:直接 mmap,free 即 munmap,立即归还
void *p2 = malloc(10 * 1024 * 1024);
free(p2); // munmap,物理 + 虚拟一起归还,VSZ/RSS 都降
2
3
4
5
6
7
实战观察:
$ ./test_small_alloc & # 分配 1 万个 1KB
$ ps -o rss # RSS 涨 10 MB
# free 之后再 ps,RSS 几乎不降——因为 glibc 没归还给内核
$ ./test_large_alloc & # 分配 1 个 10 MB
$ ps -o rss # RSS 涨 10 MB
# free 之后 ps,RSS 立刻降回去——mmap 路径
2
3
4
5
6
7
这就是**生产中"内存只涨不降"**的常见诱因——大量小对象 free 了,RSS 看起来一直不降,因为 glibc 还没把碎片整理到能 sbrk 缩堆的程度。这也是为什么第 18 篇要讲自定义内存池——绕过 glibc 的碎片问题。
# 6. 栈区生长方向
# 6.1 栈从高向低长
疑惑:为什么栈是从高地址向低地址生长?
论证:
- 早期 PDP-11 等 CPU 内存小,约定"代码在低地址、栈在高地址",让两者从两端向中间生长,最大化共用空间。
- 这一约定通过指令集传承下来——x86 的
push指令先减小 RSP,再写入:
push rax ; 等价于:
; sub rsp, 8
; mov [rsp], rax
2
3
- 一旦 ABI 规定了"push 让 RSP 减小",所有后继 CPU 都得遵守,否则没法运行老代码。
高地址
├──────────────┐
│ 栈底 (high) │ ← main() 进来时 RSP 在这附近
│ │
│ ↓↓↓ 栈帧 ↓↓↓ │ ← 每次 call 把 RSP 再减一些
│ │
│ 栈顶 (low) │ ← 当前 RSP 指向这里
└──────────────┘
低地址
2
3
4
5
6
7
8
9
# 6.2 栈帧的结构
x86-64 SysV ABI 下一个典型 C 函数栈帧:
int foo(int a, int b) {
int x = a + b;
char buf[64];
return x;
}
2
3
4
5
调用 foo(1, 2) 时栈帧(地址从高到低):
高地址
┌───────────────────────┐
│ 调用者的栈帧 ... │
├───────────────────────┤ ← 进入 foo 前 RSP
│ 返回地址 (8B) │ ← call 指令 push 进去的
├───────────────────────┤
│ 保存的 RBP (8B) │ ← 函数序言:push rbp
├───────────────────────┤ ← RBP 指向这里(帧基址)
│ 局部变量 x (4B) │
│ padding (4B) │ ← 对齐到 8
├───────────────────────┤
│ buf[64] │
├───────────────────────┤ ← 当前 RSP(必须 16 字节对齐!)
低地址
2
3
4
5
6
7
8
9
10
11
12
13
14
关键 ABI 规定:
call指令前 RSP 必须 16 字节对齐(SysV)- 头 6 个整型参数走 RDI/RSI/RDX/RCX/R8/R9 寄存器,不入栈
- 头 8 个浮点参数走 XMM0~XMM7
C 语言程序员不需要手动管理这些——编译器自动生成序言与尾声代码。但理解栈帧结构,是理解"局部变量什么时候销毁""为什么返回局部变量的地址是危险的"这些经典问题的前提(第 02 篇会展开)。
# 6.3 红色区与对齐
x86-64 SysV ABI 还规定了一个红色区(red zone):
RSP 之下 128 字节内的内存,叶子函数可以直接使用而不调整 RSP——保证不被信号处理程序覆盖。
int leaf(void) { // 不调用任何函数 → 叶子函数
int local[10]; // 直接用 [rsp - 40],无需先 sub rsp, 40
return local[0];
}
2
3
4
坑:内核态没有红色区(-mno-red-zone),所以驱动/内核模块开发必须关闭这个特性。
# 6.4 栈溢出与守护页
回到第 1 章的崩溃。Linux 默认每个线程栈:
| 项目 | 默认值 |
|---|---|
| 主线程栈大小 | 8 MB(ulimit -s) |
| pthread 线程栈 | 8 MB(pthread_attr_setstacksize) |
| 守护页大小 | 1 页(4 KB),紧邻栈底(低地址) |
高地址
┌──────────────────────┐
│ 栈底(最初 RSP) │
│ │
│ 有效栈空间 ~8 MB │ rw-p
│ (随调用越深越往下) │
│ │
├──────────────────────┤ ← guard page 上沿
│ 守护页 4 KB │ ---p ← 不可读、不可写、不可执行
├──────────────────────┤
│ 再下面是堆或 mmap 区 │
低地址
2
3
4
5
6
7
8
9
10
11
12
工作机制:
- 一旦递归把 RSP 降到守护页内,CPU 触发
#PF - 内核检查 VMA 标志位,发现这页是
MAP_GROWSDOWN | guard,不会自动扩展 - 直接发送
SIGSEGV给进程
如果没有守护页会怎样?RSP 继续往下踩,静默覆盖堆或 mmap 区——程序看似"还在跑",但数据已经被搞坏,bug 极其难调。守护页是用 4 KB 物理内存换来的"立刻崩"承诺。
栈大小的修改方式:
# 进程级
ulimit -s 16384 # 主线程栈 16 MB
# 程序内(只对新建线程)
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 16 * 1024 * 1024);
pthread_create(&tid, &attr, thread_func, NULL);
2
3
4
5
6
7
8
警告:栈不是越大越好——
- 每个线程吃 16 MB,1000 个线程就是 16 GB
- 大栈让"递归错误"晚发现,bug 更隐蔽
- 真正递归极深的场景,应该改写成迭代 + 显式栈(第 10 章修复方案)
# 7. 共享库与mmap映射
# 7.1 so的载入机制
动态库(.so)在加载时被 mmap 进进程地址空间:
$ cat /proc/self/maps | grep libc
7f3a8b400000-7f3a8b428000 r--p libc.so.6 ← 头部 + .rodata
7f3a8b428000-7f3a8b59f000 r-xp libc.so.6 ← .text(可执行)
7f3a8b59f000-7f3a8b5f4000 r--p libc.so.6 ← .rodata
7f3a8b5f4000-7f3a8b5f8000 rw-p libc.so.6 ← .data
7f3a8b5f8000-7f3a8b605000 rw-p [anon] ← .bss + 临时
2
3
4
5
6
每个 .so 都被切成 4 段(典型):r--、r-x、r--、rw-,与可执行文件类似。
关键红利:100 个进程都加载 libc,物理内存里 r-x 段只有一份:
进程 A: VA 0x7f...400000 (r-x libc.text) ──┐
进程 B: VA 0x7f...500000 (r-x libc.text) ──┼─► 同一个物理页
进程 C: VA 0x7f...600000 (r-x libc.text) ──┘
2
3
rw- 段(libc 的全局变量如 errno)每个进程独占一份(写时复制)。
这就是 Linux "少占物理内存" 的另一个关键技巧——所有进程共享 glibc 几 MB 的代码,物理内存里只算一次。
# 7.2 PLT与GOT延迟绑定
疑惑:每个进程的 libc 加载基址都不一样(ASLR),程序怎么知道 printf 在哪?
论证:C 程序调用 printf("hello") 时,编译器生成:
call printf@PLT ; 跳到 PLT 中 printf 的桩
; PLT 桩(简化为两指令):
printf@PLT:
jmp [GOT[printf]] ; 第一次:GOT 里是"回到 PLT 解析"的地址
; 解析后:GOT 里被改成 libc 中 printf 的真实地址
2
3
4
5
6
- GOT(Global Offset Table):存"函数的真实地址"
- PLT(Procedure Linkage Table):跳板代码,第一次调用时去
ld.so解析填 GOT,之后直接读 GOT 跳转
第一次调用的延迟很高(要走 ld.so 解析),后续直接两次内存访问就跳转完成。这就是 lazy binding——可以用 LD_BIND_NOW=1 强制启动时全部解析(牺牲启动速度换运行时确定性)。
在纯 C 程序里,PLT/GOT 对程序员完全透明——你就是写了个 printf("hello"),链接器帮你生成所有跳板。但理解它,才能看懂为什么 LD_PRELOAD 可以劫持函数、为什么某些动态库加载会失败。
# 7.3 dlopen运行时加载
C 语言可以在运行时加载动态库——这是插件系统的基础:
#include <dlfcn.h>
void *handle = dlopen("./libplugin.so", RTLD_LAZY);
if (handle == NULL) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return;
}
/* 通过符号名获取函数地址 */
typedef int (*plugin_func_t)(int, int);
plugin_func_t func = (plugin_func_t)dlsym(handle, "plugin_add");
if (func != NULL) {
int result = func(3, 4); // 调用插件中的函数
printf("plugin_add(3, 4) = %d\n", result);
}
dlclose(handle);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dlopen 加载的 .so 会被映射到 mmap 区域,在 /proc/PID/maps 中能看到新增的段。
# 7.4 vdso与vsyscall
/proc/self/maps 里你还会看到:
7ffd_xxxx_xxxx-7ffd_xxxx_xxxx r-xp [vdso]
ffffffff_ff600000-ffffffff_ff601000 r-xp [vsyscall]
2
vdso(virtual dynamic shared object):内核映射到每个进程的"虚拟 .so",存放高频系统调用(gettimeofday、time、clock_gettime)的用户态实现,避免陷入内核——延迟从几百 ns 降到几 ns。
#include <sys/time.h>
struct timeval tv;
gettimeofday(&tv, NULL); // 走 vdso,不触发真正的系统调用
2
3
这是 C 语言"极简内核接口"哲学的完美体现——对用户透明,却能产生数量级的性能差异。
# 8. ASLR与安全机制
# 8.1 ASLR随机化基址
疑惑:为什么同一个程序每次启动栈底地址都不一样?
$ for i in 1 2 3; do ./hello & cat /proc/$!/maps | grep stack; done
7ffd_a1234000-7ffd_a1a35000 rw-p [stack]
7ffe_b5678000-7ffe_b5e79000 rw-p [stack]
7ffc_8abcd000-7ffc_8b3ce000 rw-p [stack]
2
3
4
论证:这是 ASLR(Address Space Layout Randomization),把栈、堆、mmap 区、共享库基址都随机化——攻击者在源代码不变的情况下,无法预测函数地址,让经典的"返回地址覆盖→跳到 shellcode"攻击难得多。
ASLR 在 Linux 由 /proc/sys/kernel/randomize_va_space 控制:
| 值 | 含义 |
|---|---|
| 0 | 关闭 ASLR |
| 1 | 随机栈 + mmap + vdso |
| 2 | 加上随机堆基址(默认) |
调试小技巧:跑 gdb 时 set disable-randomization on(默认就是 on),让每次断点地址一致。
# 8.2 PIE位置无关
ASLR 要求整个程序的代码段也能任意基址加载——这就是 PIE(Position Independent Executable):
$ gcc -fPIE -pie hello.c -o hello # 启用 PIE
$ file hello
hello: ELF 64-bit LSB pie executable ← pie executable
$ gcc -no-pie hello.c -o hello_old
$ file hello_old
hello_old: ELF 64-bit LSB executable ← 普通 executable
2
3
4
5
6
7
PIE 的代码用 RIP-relative 寻址(lea rax, [rip + offset]),不依赖绝对地址,可以放在任意 VA 加载。代价:性能损失约 1~3%(额外的 RIP 计算)。GCC 8+ 默认开启 PIE。
# 8.3 NX栈不可执行
NX bit(No Execute):CPU 提供的硬件位,标记某页"不可执行"。x86-64 默认所有数据页都标记 NX——栈、堆、bss、data 全部不可执行。
// 经典攻击:栈缓冲区溢出,写入 shellcode,跳过去执行
char buf[16];
gets(buf); // 用户输入超长,覆盖返回地址为 buf 起始地址
// 返回时 RIP = buf,CPU 准备执行 buf 内的字节...
// → 因为栈页有 NX,CPU 立刻 SIGSEGV
2
3
4
5
NX 让"代码注入攻击"几乎绝迹——但攻击者转向 ROP(Return-Oriented Programming),不写新代码,串联现有代码片段(gadget)实现任意逻辑。这是另一个话题,但理解 NX 是理解现代安全的第一步。
# 8.4 stack canary原理
栈溢出检测:编译器在每个函数序言里插入一个"金丝雀(canary)"值,函数返回前检查是否被改:
void foo(void) {
char buf[64];
// 编译器自动加:
// sub rsp, 80
// mov rax, fs:[0x28] ; 从 TLS 取金丝雀
// mov [rbp - 8], rax
gets(buf); // 用户可能溢出
// 编译器自动加:
// mov rax, [rbp - 8]
// xor rax, fs:[0x28]
// jne __stack_chk_fail ; 不一致 → 立刻 abort
}
2
3
4
5
6
7
8
9
10
11
12
13
14
GCC 用 -fstack-protector-strong(默认)开启。canary 值在程序启动时由内核填入 TLS,每个进程一份随机值——攻击者猜不到。
对付溢出的层层叠加:
- canary 检测溢出(编译期)
- NX 阻止跳到栈上执行(CPU)
- ASLR 让攻击者难以预测地址(OS)
- PIE 把代码段也随机化(编译 + OS)
- RELRO / FORTIFY_SOURCE 保护 GOT、检测危险 API
在 C 语言里写安全代码不是选做题——这些防护是最后的安全网,但前提是你的代码没有 UB(第 19 篇会展开)。
# 9. pmap实战剖析
# 9.1 proc_pid_maps解读
Linux 内核在 /proc/PID/maps 暴露一个进程的完整地址空间映射——这是排查的利器:
$ cat /proc/self/maps
00400000-00401000 r--p 00000000 fd:01 1234567 /usr/bin/cat ← ELF 头
00401000-00405000 r-xp 00001000 fd:01 1234567 /usr/bin/cat ← .text
00405000-00407000 r--p 00005000 fd:01 1234567 /usr/bin/cat ← .rodata
00407000-00408000 r--p 00006000 fd:01 1234567 /usr/bin/cat ← .data 只读
00408000-00409000 rw-p 00007000 fd:01 1234567 /usr/bin/cat ← .data + .bss
01234000-01255000 rw-p 00000000 00:00 0 [heap] ← 堆
7f3a8b400000-7f3a8b428000 r--p 00000000 fd:01 7654321 /lib/libc.so.6
7f3a8b428000-7f3a8b59f000 r-xp 00028000 fd:01 7654321 /lib/libc.so.6
... 共享库其余段 ...
7ffd_a1234000-7ffd_a1a35000 rw-p 00000000 00:00 0 [stack] ← 栈
7ffd_a1c00000-7ffd_a1c01000 r-xp 00000000 00:00 0 [vdso]
2
3
4
5
6
7
8
9
10
11
12
每行 6 列:
| 列 | 含义 |
|---|---|
00400000-00401000 | VA 起止 |
r--p | 权限(r=读, w=写, x=执行, s=共享/p=私有) |
00000000 | 在文件中的偏移 |
fd:01 | 设备号(major:minor) |
1234567 | inode |
/usr/bin/cat | 文件名(匿名映射为空,特殊段方括号) |
# 9.2 pmap分段工具
pmap 命令是 /proc/PID/maps 的友好版:
$ pmap -x 12345
12345: ./my_program
Address Kbytes RSS Dirty Mode Mapping
0000000000400000 16 8 0 r-x-- my_program
0000000000604000 4 4 4 rw--- my_program ← Dirty=4 表示被改过
0000000001234000 20480 12000 12000 rw--- [ anon ] ← 堆
00007f3a8b400000 2024 1500 0 r-x-- libc.so.6 ← 与其他进程共享
00007ffd_a1234000 8192 12 12 rw--- [ stack ] ← 栈
------ ------ ------
total kB 30716 13524 12016
2
3
4
5
6
7
8
9
10
# 9.3 RSS与VSZ差异
四个核心内存指标:
| 指标 | 含义 | 用途 |
|---|---|---|
| VSZ (Virtual Size) | 进程承诺的所有 VA 之和 | 看"申请多少" |
| RSS (Resident Set Size) | 当前在物理内存的字节数 | 看"实际用多少"(含共享) |
| PSS (Proportional Set Size) | 共享内存按使用进程数均摊 | 看"我贡献多少"(最公平) |
| USS (Unique Set Size) | 仅本进程独有的物理内存 | 看"杀掉它能省多少" |
# VSZ / RSS
$ ps -o pid,vsz,rss,comm
# PSS(更精确)
$ smem -p
2
3
4
5
实战经验:
- VSZ 异常大但 RSS 正常 → 申请了大堆但还没真用(OK)
- RSS 持续涨不降 → 内存泄漏 / 碎片
- PSS 涨但 RSS 稳定 → 多进程共享库被换出 / 换入
# 9.4 内存泄漏定位
排查内存泄漏的 60 秒套路:
# 1. 看进程 RSS 趋势
$ while true; do ps -o pid,rss,comm -p $PID; sleep 5; done
# 2. RSS 涨了 → 看哪一段在涨
$ pmap -x $PID | sort -k 2 -n -r | head -20
# 3. 是 [heap] 在涨 → glibc 内部碎片或泄漏
$ gdb -p $PID
(gdb) call malloc_stats() # 看 ptmalloc 内部统计
(gdb) call malloc_trim(0) # 主动归还碎片
# 4. 是 [anon] 在涨 → 大块 mmap 没 munmap
$ cat /proc/$PID/maps | grep anon
# 5. 重型武器:valgrind
$ valgrind --leak-check=full ./my_program
# 6. AddressSanitizer(编译期插桩)
$ gcc -fsanitize=address -g -o test main.c
$ ./test
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 scan_dir 递归,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 栈在地址空间的哪?为什么从高向低长? | 第 6.1:高地址区,PDP-11 时代约定,x86 push 指令绑定 RSP 减小 |
| ② 守护页是谁分配的? | 第 6.4:内核创建栈时在栈底自动插入 1 页 ---p,越界即 SIGSEGV |
| ③ 8 MB 这个数从哪来? | 第 6.4:ulimit -s 默认 8192 KB |
| ④ 为什么每次启动栈基址不同? | 第 8.1:ASLR 随机化所有可变段基址 |
| ⑤ malloc 在哪段?与栈干扰吗? | 第 5:堆在低地址区从下往上长,栈在高地址从上往下长,不干扰 |
| ⑥ 全局/static 变量住哪? | 第 4:已初始化在 .data,未初始化在 .bss |
| ⑦ pmap 怎么一眼分段? | 第 9:方括号特殊段 [heap] [stack],匿名段是 [anon] |
修复方案(按代价从小到大):
方案 A:调大栈(治标)
ulimit -s 65536 # 64 MB,可撑近 15000 层
代价:每个新线程都吃 64 MB 虚拟空间;对调用栈极深仍会撞墙。
方案 B:改递归为迭代(治本)
#include <sys/stat.h>
#include <dirent.h>
#include <string.h>
#include <stdlib.h>
/* 手工栈节点——在堆上分配,不受系统栈大小限制 */
struct dir_node {
char path[4096];
int depth;
};
void scan_dir_iterative(const char *root) {
struct dir_node *stack = malloc(10000 * sizeof(struct dir_node));
int top = 0;
strcpy(stack[top].path, root);
stack[top].depth = 0;
top++;
while (top > 0) {
top--;
char *cur_path = stack[top].path;
int cur_depth = stack[top].depth;
DIR *dp = opendir(cur_path);
if (!dp) continue;
struct dirent *entry;
while ((entry = readdir(dp))) {
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, ".."))
continue;
char full_path[4096];
snprintf(full_path, sizeof(full_path),
"%s/%s", cur_path, entry->d_name);
for (int i = 0; i < cur_depth * 2; i++) putchar(' ');
printf("%s\n", entry->d_name);
struct stat st;
if (stat(full_path, &st) == 0 && S_ISDIR(st.st_mode)) {
strcpy(stack[top].path, full_path);
stack[top].depth = cur_depth + 1;
top++;
}
}
closedir(dp);
}
free(stack);
}
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
代价:代码量增加,但栈深度恒定 1 层,目录嵌套百万层都不会崩。stack 在堆上分配,深度由堆大小而非 ulimit -s 限制。
方案 C:调大每个线程的栈(针对线程)
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 64 * 1024 * 1024); // 64 MB
pthread_create(&tid, &attr, scanner_thread, NULL);
2
3
4
生产建议:方案 B 永远是首选——把递归显式化,深度可控、易于调试、可中断。栈是稀缺资源(每个线程独立一份),堆是共享资源。
# 10.2 一个程序的一生
把 ./my_program 这一行的全过程串成一棵知识树:
./my_program
│
├─ 编译期
│ ├─ .c → .o:函数生成 .text,全局变量进 .data/.bss
│ ├─ 字符串字面量进 .rodata
│ └─ 链接器把多个 .o 合并,给每段确定相对偏移
│
├─ 加载期 (execve)
│ ├─ 内核解析 ELF 程序头表
│ ├─ ASLR 选择栈、堆、mmap 区基址 ─── 第 8 章
│ ├─ mmap 把 .text/.rodata/.data 从文件载入 ─── 第 4.5 节
│ ├─ .bss 映射到零页(COW) ─── 第 4.4 节
│ ├─ 加载 ld-linux.so → 解析所需 .so ─── 第 7 章
│ ├─ 内核分配栈:8 MB rw-p + 4 KB 守护页 ─── 第 6.4 节
│ └─ 跳到 _start → __libc_start_main → main
│
├─ 运行期
│ ├─ 函数调用:push 返回地址 → 栈帧建立 ─── 第 6.2 节
│ ├─ malloc(N):
│ │ N ≤ 64 → tcache(无锁) ─── 第 5.3 节
│ │ ≤ 128 KB → arena(brk 扩堆) ─── 第 5.1 节
│ │ > 128 KB → mmap 直接 ─── 第 5.4 节
│ ├─ printf:PLT → 第一次解析 libc 地址─── 第 7.2 节
│ ├─ 缺页中断 → 真正分配物理页 ─── 第 3.3 节
│ └─ 任何越界访问 → MMU/守护页/canary 触发 SIGSEGV
│
└─ 退出期
├─ atexit 注册函数 → 逆序执行
├─ 冲刷 stdio 缓冲区 → fclose(stdout)
├─ exit_group 系统调用
├─ 内核回收所有 VMA、物理页、文件描述符
└─ 父进程 wait → 拿到退出码
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
理解一个 C 程序的一生,就是理解所有 C 语言程序"在哪、怎么活、怎么死"。这是 20 篇 C 专栏的总入口。
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:分层抽象——CPU 指令永远不该看到物理地址
虚拟内存是 OS 给程序员的最大礼物:每个进程都拥有"独占的 256 TB 空间"幻觉。这种隔离让"我的 bug 不影响你""一份 libc 物理上一份"成为可能。C 语言的指针、地址操作,全部在虚拟地址层面——&x 返回的是 VA,不是 PA。程序员永远不需要知道数据在哪个物理内存芯片上。
哲学 2:按需付费——VA 申请不要钱,访问才付费
malloc(1GB) 不会立刻吃 1 GB 物理内存——直到第一次写每一页。这种"延迟物化"让 Linux 能 overcommit、能让一台机器跑 100 个进程。但代价是 OOM Killer 必须存在——承诺多了兑现不了,总要有人挨刀。
哲学 3:权限即段——一段空间一种语义
text 是 r-x、stack 是 rw-、rodata 是 r--,每段权限和语义绑死。这让"代码不能被修改""数据不能被执行"都是 CPU 硬件级保证,不依赖任何运行时检查。这是分段的本质:把权限编码进地址——也是 C 语言"自由但危险"的根本原因:你可以声明 char *p = (char *)main,但一写就崩。
哲学 4:故障即时——守护页与 canary 的共同信仰
栈溢出可以被静默继续吗?技术上可以(让程序"踩进堆里")。但 Linux 选择守护页让它立刻崩,让"问题立刻可见"。这与 C 语言的"fail fast"理念一致——bug 越早发现,修复成本越低。信号量比静默错误珍贵一千倍。
# 10.4 地址空间速查
一张图保存以备查:
| 段 | 权限 | 内容 | 大小变化 | 工具 |
|---|---|---|---|---|
| text | r-x | 函数指令 | 加载时定 | objdump -d |
| rodata | r-- | 字符串字面量、const | 加载时定 | readelf -p .rodata |
| data | rw- | 已初始化全局/static | 加载时定 | readelf -x .data |
| bss | rw- | 未初始化全局/static | 加载时定 | size a.out |
| heap | rw- | malloc/free | brk/mmap 增长 | pmap、malloc_stats() |
| stack | rw- | 局部变量、调用帧 | 函数调用增长 | ulimit -s、gdb |
| mmap | 各种 | 共享库、mmap 文件 | dlopen/mmap | cat /proc/PID/maps |
| guard | --- | 栈底守护页 | 固定 4 KB | pmap 看 --- |
| vdso | r-x | 内核虚拟 .so | 固定 1 页 | cat maps |
60 秒诊断命令清单:
# 看分段
cat /proc/$PID/maps
pmap -x $PID
# 看大小指标
ps -o pid,vsz,rss,comm -p $PID
smem -p # PSS
# 看 ELF 内部
readelf -S a.out # 节
readelf -l a.out # 段(程序头)
size a.out # text/data/bss 总览
# 看堆内部
gdb -p $PID --batch -ex "call malloc_stats()"
# 看动态库
ldd a.out
LD_DEBUG=bindings ./a.out # 观察 PLT 解析过程
# 看 ASLR/PIE
checksec --file=a.out
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
栈深度估算公式:
最大递归层数 ≈ ulimit -s × 1024 / sizeof(per_frame)
per_frame = 局部变量大小 + 16(返回地址 + 旧 RBP)+ 系统调用栈消耗 + 对齐 padding
2
3
第 1 章案例:8 MB / 4.3 KB ≈ 1905 层。生产 1200 层触发崩溃——因为每次 stat 系统调用也会在内核栈上消耗空间。应改为迭代。
下一篇:我们已经知道了"数据住在地址空间的哪一段",下一步进入 02.栈与堆底层对决——把栈帧的完整生命周期、堆的分配机制,从寄存器级别掰开揉碎。