进程地址空间布局
# 01.进程地址空间布局
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 虚拟内存机制
- 4. 代码段与数据段
- 5. 堆区生长机制
- 6. 栈区生长方向
- 7. 共享库映射区
- 8. ASLR与安全机制
- 9. pmap实战剖析
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
先看一段在生产环境真实跑过的代码,看着平平无奇,却把一个常驻服务在 运行 17 小时后 崩溃,错误日志只有一句 Segmentation fault (core dumped):
// recursive_walker.cpp —— 配置目录递归扫描器
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
void walk(const fs::path& p, std::string trace) {
char local_buf[8192]; // ← 栈上 8KB 缓冲
snprintf(local_buf, sizeof(local_buf),
"%s/%s", trace.c_str(), p.c_str());
if (fs::is_directory(p)) {
for (auto& e : fs::directory_iterator(p)) {
walk(e.path(), local_buf); // ← 递归
}
}
/* ... 业务逻辑 ... */
}
int main() {
walk("/data/configs", ""); // 启动入口
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现象:
- 测试环境(目录深度最多 4 层):100% 通过
- 生产环境(某个客户上传了一个 ZIP 解压到 800+ 层嵌套目录):SIGSEGV,core dump 显示
walk自身
直觉怀疑:是不是 fs::path 抛了异常?打开 core 一看,崩溃地址 0x00007ffd_xxxxx,访问的是一个没有任何映射的地址——gdb 打印 info proc mappings 得到这一段:
Start End Perms Offset File
0x00007ffd00000000 0x00007ffd00021000 ---p 00000000 ← 守护页(不可访问)
0x00007ffd00021000 0x00007ffd00822000 rw-p 00000000 [stack] ← 实际栈
2
3
崩溃地址恰好落在那一段 ---p(无任何权限)的守护页(guard page) 内——这就是经典的"栈溢出(stack overflow)",但崩的不是缓冲区越界,是整个调用栈用光了。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
local_buf[8192]太大?—— 在栈帧上算一下:8192(buf)+ 32(path)+ 32(string)+ 几十字节其他局部 ≈ 8.3 KB / 帧。 - 假设 2:Linux 默认栈多大?——
ulimit -s显示 8192 KB(8 MB)。 - 假设 3:那 8 MB 能撑多少层?—— 8192 / 8.3 ≈ 985 层。生产那边 800+ 层接近极限,再加上几个临时变量就溢出了。
- 假设 4:为什么"测试环境通过"?—— 测试用的目录最多 4 层,根本碰不到栈底。
- 假设 5:那"守护页"是干嘛的?—— 它就是为了让"栈一旦越界,立刻 SIGSEGV"——比"静默踩到下一段内存"更安全。
看似 "递归就递归" 的代码,没毛病在算法,毛病在没有意识到栈是有边界的——这条代码碰到的不是 C++ 的坑,是进程地址空间布局的坑。
这一段事故里至少藏着 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
📌 本篇定位:这是整个专栏的地基篇。第 02-08 篇讲的对象布局、虚函数表、内存对齐,本质都是"在这张地址空间地图上的某一段里发生的事"。读完本篇后,再看后面任何一个 C++ 内存话题,都能立刻回答:"它住在哪一段"。
# 2. 架构概览
# 2.1 五大段总图
我们在 64 位 Linux 上看一个典型 C++ 进程的地址空间:
高地址 (0xFFFF_FFFF_FFFF_FFFF)
┌─────────────────────────────────────────────────┐
│ 内核空间 │ ← 用户态不可见
│ (所有进程共享一份) │
├─────────────────────────────────────────────────┤ 0xFFFF_8000_0000_0000
│ │
│ ↓↓↓ 栈区 stack ↓↓↓ │ ← 从高向低生长
│ (函数局部变量、调用帧) │
│ │
├─────────────────────────────────────────────────┤
│ │
│ 共享库映射区 (mmap area) │
│ libc.so / libstdc++.so / 自家 .so / mmap │
│ │
├─────────────────────────────────────────────────┤
│ │
│ ↑↑↑ 堆区 heap ↑↑↑ │ ← 从低向高生长
│ malloc / new / 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/new | brk/mmap 按需扩展 | 程序运行时 |
| stack | rw- | 局部变量、调用帧 | 内核预分配 + 守护页 | ulimit -s |
| mmap | rw- 或 r-x | 共享库、mmap 文件 | dlopen/mmap 时映射 | 调用方 |
# 2.2 为什么这么切
为什么把进程地址空间切成"五大段 + 共享库区 + 内核区",而不是统一一锅粥?
疑惑:直接给进程一段连续的物理内存,让它自己管理不行吗?
论证:
- 权限隔离的需要——把"代码"放在 r-x 段,是为了防止有 bug 的代码意外修改自己的指令(
*(void**)main = 0;立刻 SIGSEGV);把"栈"放在 rw- 但 NX,是为了防止溢出后跳到栈上执行恶意代码(NX 位)。段是权限的最小单位。 - 加载时机不同——text/rodata/data 在 ELF 文件里就有,加载器把文件直接 mmap 进虚拟地址;bss 不占文件空间(节省磁盘),运行时才映射到零页;heap/stack 完全运行时分配。段对应"何时存在"的差异。
- 共享与独占不同——同一份 libc.so 在 100 个进程里,物理内存只有一份(COW),所以共享库段是"多进程共享"的;data/bss/heap/stack 每个进程独占。段对应"是否共享"的差异。
- 生长方向相反才能共用空间——堆向上长、栈向下长,留中间一大片"待分配区域"给两者动态争抢,是 1970 年代 Unix 设计者的空间复用智慧。
- 反向验证:如果不分段会怎样?参考早期 DOS 的"实模式"——任何指针都能改任何地址,包括代码段。结果就是病毒满天飞,蓝屏家常便饭。
结论:分段不是为了"好看",而是把权限、加载时机、共享性、生长方向这四个独立维度同时编码进地址空间——一段空间一种语义,崩溃定位、安全防护、内存共享、动态扩展全都得益于此。这是 Unix/Linux 内存管理的根基哲学。
下面我们从最底层的"虚拟内存"开始,看 CPU 是如何把这张图变成真实存在的。
# 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 地址(Linux x86-64),但物理内存只有 32 GB——只要不全用满即可
- 按需调页:地址虽存在,物理页等真正访问时再分配
- 内存共享:两个 VA 映射到同一个 PA,实现 COW、共享库
结论:虚拟内存把"地址"和"物理存储"解耦——这是 1960 年代 Multics 留给现代操作系统最珍贵的遗产,也是为什么我们能用 new int[1'000'000'000] 而机器还活着的原因(只要不真的全访问)。
# 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。
# 3.3 缺页中断流程
疑惑:int* p = new int[1'000'000'000]; 真的立刻分配了 4 GB 物理内存?
论证:跑一下:
#include <cstdio>
#include <unistd.h>
int main() {
int* p = new int[1'000'000'000]; // 申请 4 GB
sleep(60); // 此时去 ps 看 RES
return 0;
}
2
3
4
5
6
7
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 < 1'000'000'000; 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(
nullptr, // 让内核选地址
1024 * 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
mmap 的两种核心模式:
| 模式 | 用途 |
|---|---|
MAP_ANONYMOUS | 申请匿名内存(堆/栈本质上都是它) |
MAP_FILE | 把文件映射进地址空间(共享库、零拷贝 IO) |
MAP_PRIVATE | 写时复制,修改只对本进程可见 |
MAP_SHARED | 多进程共享,修改对所有进程可见 |
后续讲堆、共享库时都会回到 mmap——它是 Linux 内存管理的"瑞士军刀"。
# 4. 代码段与数据段
# 4.1 text只读可执行
text(也叫 .text)段装的是编译产物——机器指令:
int add(int a, int b) { return a + b; }
经 g++ -O2 -c 编译后,add 函数的指令(objdump -d):
0000000000000000 <_Z3addii>:
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,所以"自我修改的代码"在现代 OS 上不行(除非显式
mprotect(PROT_WRITE)) - 共享:同一份可执行/动态库被多个进程加载时,物理上只有一份 .text
# 4.2 rodata常量区
字符串字面量、const 全局变量、虚函数表(vtable)都住在 .rodata:
const char* msg = "hello"; // "hello\0" 在 rodata
const int pi = 314; // pi 也在 rodata(编译期常量)
struct A { virtual void f(); };
// A 的 vtable 在 rodata
2
3
4
经典 UB 提示:
char* p = (char*)"hello"; // ⚠️ C++11 起,字面量类型是 const char*,强转能过
p[0] = 'H'; // 💥 SIGSEGV:rodata 不可写
2
vtable 落在 rodata 是为了:
- 安全:防止恶意代码篡改虚函数指针
- 共享:同一类的所有对象共用同一份 vtable
# 4.3 data已初始化
.data 装已显式初始化的全局变量和 static 变量:
int counter = 100; // .data,文件中存"100"这 4 字节
static double rate = 0.05; // .data
const int g = 42; // ⚠️ 是 .rodata 还是 .data?看下一节
2
3
疑惑:const int g = 42; 到底在哪?
论证:
- 全局
const int g = 42;→ 大概率被编译器完全优化掉(直接内联进每个引用处);如果取了地址,落在.rodata extern const int g = 42;→ 必须有定义,落在.rodataconst std::string s = "hello";→ 不是 trivially-constructible,必须运行时构造,对象本身在 .data/.bss,字符串内容在 .rodata + 堆
结论:const 在 C++ 里不一定意味着 rodata——只有 trivially-initializable 且无运行时构造的才完全是 rodata。
# 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 不占磁盘空间。
$ ls -l a.out
-rwxr-xr-x 1 user user 12824 ← 仅 12 KB
$ readelf -S a.out | grep .bss
[26] .bss NOBITS ... 0000000000000018 ← 18 字节
2
3
4
5
注意 NOBITS——ELF 文件里只记录"我有 18 字节 bss"这个元数据,没有实际内容。1 GB 数组的可执行文件依然只有几十 KB。
加载时怎么"分配 1 GB"?——映射到内核的零页(zero page):
全部 bss 的虚拟地址 → 同一个物理零页(只读)
↓
程序第一次写 arr[i] = ... → 缺页中断 → 分配新物理页(写时复制 from zero page)
2
3
这就是为什么"声明 1 GB 全局数组没问题,真用 1 GB 才花物理内存"。
# 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); // 拿到当前堆顶
sbrk(1024 * 1024); // 把堆顶往上推 1 MB
2
3
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(nullptr, 4 * 1024 * 1024,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
2
3
优点:
- 任意位置分配,释放时立刻还给内核(
munmap) - 多线程互不影响(每个 arena 独立)
- 物理内存按需分配(缺页中断)
缺点:
- 系统调用开销比
brk高 - 容易碎片化整个地址空间
# 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,物理 + 虚拟一起归还,VSS/RSS 都降
2
3
4
5
6
7
实战观察:
$ ./test_small_alloc & # 分配 1 万个 1KB
$ ps -o rss # RSS 涨 10 MB
# free 之后再 ps,RSS 几乎不降——因为 ptmalloc 没归还给内核
$ ./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 缩堆的程度。
应对:
- 升级 glibc 2.26+ 用 tcache
- 切换到 jemalloc/tcmalloc(碎片更少)
- 显式调用
malloc_trim(0)主动归还 - 大对象池化复用,避免反复 alloc/free
# 6. 栈区生长方向
# 6.1 栈从高向低长
疑惑:为什么栈是从高地址向低地址生长?
论证:
- 早期 PDP-11 等 CPU 内存少,约定"代码在低地址、栈在高地址",让两者从两端向中间生长,最大化共用空间。
- 这一约定通过指令集传承下来——x86 的
push指令先减小 RSP,再写入:
push rax ; 等价于:
; sub rsp, 8
; mov [rsp], rax
2
3
- 一旦 ABI 规定了"push 让 RSP 减小",所有后继 CPU 都得遵守,否则没法运行老代码。
例外:HP PA-RISC 等少数架构栈是向上长的。但 99.9% 的 C++ 代码都跑在"栈向下长"的机器上。
高地址
├──────────────┐
│ 栈底 (high) │ ← main() 进来时 RSP 在这附近
│ │
│ ↓↓↓ 栈帧 ↓↓↓ │ ← 每次 call 把 RSP 再减一些
│ │
│ 栈顶 (low) │ ← 当前 RSP 指向这里
└──────────────┘
低地址
2
3
4
5
6
7
8
9
# 6.2 栈帧的结构
x86-64 SysV ABI 下一个典型栈帧:
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
# 6.3 红色区与对齐
x86-64 SysV ABI 还规定了一个红色区(red zone):
RSP 之下 128 字节内的内存,叶子函数可以直接使用而不调整 RSP——保证不被信号处理程序覆盖。
int leaf() { // 不调用任何函数 → 叶子函数
int local[10]; // 直接用 [rsp - 40],无需先 sub rsp, 40
return local[0];
}
2
3
4
坑:内核态没有红色区(-mno-red-zone),所以驱动/内核模块开发必须关闭这个特性。
栈帧对齐要求是进入函数后 RSP 必须 16 字节对齐——SIMD 指令依赖此约束。漏对齐会导致 MOVAPS 崩溃,是嵌入汇编里最常见的坑之一。
# 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, ...);
2
3
4
5
6
7
8
警告:栈不是越大越好——
- 每个线程吃 16 MB,1000 个线程就是 16 GB
- 大栈会让"递归错误"晚发现,bug 更隐蔽
- 真正递归极深的场景,应该改写成迭代 + 显式栈(第 1 章修复方案)
# 7. 共享库映射区
# 7.1 so与elf载入
动态库(.so / .dll / .dylib)在加载时被 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-,与可执行文件类似。
# 7.2 共享只读代码段
关键红利: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 的全局变量)每个进程独占一份(COW)。
这就是 Linux "少占物理内存" 的另一关键技巧——所有进程共享 glibc 几 MB 的代码,物理内存里只算一次。
# 7.3 PLT与GOT入口
疑惑:每个进程的 libc 加载基址都不一样(ASLR),怎么调用?
论证:编译器生成代码时不知道运行时 libc 在哪——所以用一层间接:
- GOT(Global Offset Table):存"函数的真实地址"
- PLT(Procedure Linkage Table):跳板代码,第一次调用时去 ld.so 解析填 GOT,之后直接读 GOT 跳转
printf("hello");
汇编(极简):
call printf@PLT ; 跳到 PLT 中 printf 的桩
; PLT 桩:
printf@PLT:
jmp [GOT[printf]] ; 第一次:GOT 里是"回到 PLT 解析"的地址
; 解析后:GOT 里被改成 libc 中 printf 的真实地址
2
3
4
5
6
第一次调用的延迟很高(要走 ld.so 解析),后续直接两次内存访问就跳转完成。这就是 lazy binding——可以用 LD_BIND_NOW=1 强制启动时全部解析(牺牲启动速度换运行时确定性)。
# 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。
std::chrono::steady_clock::now() 在 Linux 上正是走 vdso,所以可以"很快"。
# 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 区、共享库基址都随机化——攻击者在源代码不变的情况下,无法预测函数地址,让"返回地址覆盖→跳到 system('/bin/sh')"这种经典攻击难得多。
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 寻址(mov rax, [rip + offset]),不依赖绝对地址,可以放在任意 VA 加载。
代价:性能损失约 1~3%(额外的 RIP 计算)。GCC 8+ 默认开启 PIE。
# 8.3 NX栈不可执行
NX bit(No Execute,AMD 命名;Intel 叫 XD):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),不写新代码,串联现有代码片段实现任意逻辑。这是另一个话题。
# 8.4 stack canary原理
栈溢出检测:编译器在每个函数序言里插入一个"金丝雀(canary)"值,函数返回前检查是否被改:
void foo() {
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
- CFI(Control Flow Integrity)控制流完整性
这一连套防护让 C/C++ 内存攻击的成本指数级上升——但任何一个开关被关都可能让其他失效。生产程序全部开是底线。
# 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]
ffffffff_ff600000-ffffffff_ff601000 r-xp 00000000 00:00 0 [vsyscall]
2
3
4
5
6
7
8
9
10
11
12
13
每行 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
各列含义:
- Kbytes:VSZ(虚拟大小,承诺)
- RSS:实际占用物理内存(含与其他进程共享部分)
- Dirty:被改过、未写回 swap 的脏页
# 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
# 按 Kbytes 倒序,看最大那几段
# 3. 是 [heap] 在涨 → glibc 内部碎片或泄漏
$ gdb -p $PID
(gdb) call malloc_stats() # 看 ptmalloc 内部统计
(gdb) call malloc_trim(0) # 主动归还碎片
# 4. 是 [anon] 在涨 → 大块 mmap 没 unmap
$ cat /proc/$PID/maps | grep anon | sort -k 1,1 -k 2,2n
# 5. 重型武器:valgrind
$ valgrind --leak-check=full ./my_program
# 6. 生产级武器:jemalloc + heap profiling
$ MALLOC_CONF=prof:true,prof_prefix:jeprof ./my_program
$ jeprof --pdf binary jeprof.heap > heap.pdf
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 walk 递归,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 栈在地址空间的哪?为什么从高向低长? | 第 6.1:高地址区,PDP-11 时代约定,x86 push 指令绑定 RSP 减小 |
| ② 守护页是谁分配的? | 第 6.4:内核在创建栈时自动在栈底插入 1 页 ---p,越界即 SIGSEGV |
| ③ 8 MB 这个数从哪来? | 第 6.4:ulimit -s 默认 8192 KB,pthread 默认相同 |
| ④ 为什么每次启动栈基址不同? | 第 8.1:ASLR 随机化所有可变段基址 |
| ⑤ malloc 在哪段?与栈干扰吗? | 第 5:堆在低地址区从下往上长,栈在高地址从上往下长,中间留给 mmap,不干扰但会争 mmap 区 |
| ⑥ 全局/static 变量住哪? | 第 4:已初始化在 .data,未初始化在 .bss |
| ⑦ pmap 怎么一眼分段? | 第 9:方括号特殊段 [heap] [stack],文件名是 .so 库,匿名是 [anon] |
修复方案(按代价从小到大):
方案 A:调大栈(治标)
ulimit -s 65536 # 64 MB,可撑 8000 层
代价:每个新线程都吃 64 MB 虚拟空间;对调用栈极深仍会撞墙。
方案 B:改递归为迭代(治本)
void walk(const fs::path& root) {
std::stack<std::pair<fs::path, std::string>> work; // 显式栈
work.emplace(root, "");
while (!work.empty()) {
auto [p, trace] = std::move(work.top());
work.pop();
if (fs::is_directory(p)) {
for (auto& e : fs::directory_iterator(p))
work.emplace(e.path(), p.string());
}
/* ... 业务逻辑 ... */
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
代价:代码可读性略降;收益是栈深度恒定 1 层,目录嵌套上百万层都不会崩,并且把"深度"从栈转到堆,更可控。
方案 C:尾递归(C++ 不靠谱)
C++ 标准不要求实现"尾调用优化(TCO)"——MSVC 经常不做,GCC/Clang 在 -O2 才做。生产代码不要依赖 TCO。
生产建议:方案 B 永远是首选——把递归显式化,深度可控、易于调试、可中断。
# 10.2 一个程序的一生
把 ./my_program 这一行的全过程串成一棵知识树:
./my_program
│
├─ 编译期
│ ├─ 源代码 → .o:函数生成 .text,全局变量进 .data/.bss
│ ├─ const 字符串进 .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.3 节
│ ├─ 缺页中断 → 真正分配物理页 ─── 第 3.3 节
│ └─ 任何越界访问 → MMU/守护页/canary 触发 SIGSEGV
│
└─ 退出期
├─ atexit 注册析构 → 全局对象析构
├─ 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
理解一个程序的一生,就是理解所有 C++ 程序"在哪、怎么活、怎么死"。这是 60 篇专栏的总入口。
# 10.3 设计哲学回扣
整理本篇的四条跨篇适用的设计哲学:
哲学 1:分层抽象——CPU 指令级永远不该看到物理地址
虚拟内存是 OS 给程序员的最大礼物:每个进程都拥有"独占的 256 TB 空间"幻觉。这种隔离让"我的 bug 不影响你""一份 libc 物理上一份"成为可能。CPU 访存指令看到的永远是 VA,物理地址只是 OS 与硬件的内部细节。
哲学 2:按需付费——VA 申请不要钱,访问才付费
new int[1B] 不会立刻吃 4 GB 物理内存——直到第一次写每一页。这种"延迟物化"让 Linux 能 overcommit、能让一台机器跑 100 个 JVM。但代价是 OOM Killer 必须存在——承诺多了兑现不了,总要有人挨刀。
哲学 3:权限即段——一段空间一种语义
text 是 r-x、stack 是 rw-、rodata 是 r--,每段权限和语义绑死。这让"代码不能被修改""数据不能被执行"都是 CPU 硬件级保证,不依赖任何运行时检查。这是分段的本质:把权限编码进地址。
哲学 4:故障即时——守护页与 canary 的共同信仰
栈溢出可以被静默继续吗?技术上可以(让程序"踩进堆里")。但 Linux 选择守护页让它立刻崩,让"问题立刻可见"。这与 C++ "fail fast" 的理念一致——bug 越早发现,修复成本越低。信号量比静默错误珍贵一千倍。
# 10.4 地址空间速查
一张图保存以备查:
| 段 | 权限 | 内容 | 大小变化 | 工具 |
|---|---|---|---|---|
| text | r-x | 函数指令 | 加载时定 | objdump -d |
| rodata | r-- | const、字面量、vtable | 加载时定 | readelf -p .rodata |
| data | rw- | 已初始化全局/static | 加载时定 | readelf -x .data |
| bss | rw- | 未初始化全局/static | 加载时定 | size a.out |
| heap | rw- | malloc/new | 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 / 8.3 KB ≈ 985 层。生产 800 层接近极限——应改为迭代。
下一篇:我们已经知道了"对象住在哪一段",下一步进入 02.对象内存布局原理——把"一个 struct/class 在那段内存里到底长什么样"剖到字节级别。