编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 五大段总图
          • 2.2 为什么这么切
        • 3. 虚拟内存机制
          • 3.1 物理与虚拟之分
          • 3.2 页表与MMU翻译
          • 3.3 缺页中断流程
          • 3.4 mmap与按需分配
        • 4. 代码段与数据段
          • 4.1 text只读可执行
          • 4.2 rodata常量区
          • 4.3 data已初始化
          • 4.4 bss零页惰性
          • 4.5 ELF段映射
        • 5. 堆区生长机制
          • 5.1 brk历史接口
          • 5.2 mmap匿名映射
          • 5.3 ptmalloc的arena
          • 5.4 大块直接走mmap
        • 6. 栈区生长方向
          • 6.1 栈从高向低长
          • 6.2 栈帧的结构
          • 6.3 红色区与对齐
          • 6.4 栈溢出与守护页
        • 7. 共享库映射区
          • 7.1 so与elf载入
          • 7.2 共享只读代码段
          • 7.3 PLT与GOT入口
          • 7.4 vdso与vsyscall
        • 8. ASLR与安全机制
          • 8.1 ASLR随机化基址
          • 8.2 PIE位置无关
          • 8.3 NX栈不可执行
          • 8.4 stack canary原理
        • 9. pmap实战剖析
          • 9.1 procpidmaps解读
          • 9.2 pmap分段工具
          • 9.3 RSS与VSZ差异
          • 9.4 内存泄漏定位
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个程序的一生
          • 10.3 设计哲学回扣
          • 10.4 地址空间速查
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-02
目录

进程地址空间布局

# 01.进程地址空间布局

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 五大段总图
    • 2.2 为什么这么切
  • 3. 虚拟内存机制
    • 3.1 物理与虚拟之分
    • 3.2 页表与MMU翻译
    • 3.3 缺页中断流程
    • 3.4 mmap与按需分配
  • 4. 代码段与数据段
    • 4.1 text只读可执行
    • 4.2 rodata常量区
    • 4.3 data已初始化
    • 4.4 bss零页惰性
    • 4.5 ELF段映射
  • 5. 堆区生长机制
    • 5.1 brk历史接口
    • 5.2 mmap匿名映射
    • 5.3 ptmalloc的arena
    • 5.4 大块直接走mmap
  • 6. 栈区生长方向
    • 6.1 栈从高向低长
    • 6.2 栈帧的结构
    • 6.3 红色区与对齐
    • 6.4 栈溢出与守护页
  • 7. 共享库映射区
    • 7.1 so与elf载入
    • 7.2 共享只读代码段
    • 7.3 PLT与GOT入口
    • 7.4 vdso与vsyscall
  • 8. ASLR与安全机制
    • 8.1 ASLR随机化基址
    • 8.2 PIE位置无关
    • 8.3 NX栈不可执行
    • 8.4 stack canary原理
  • 9. pmap实战剖析
    • 9.1 proc_pid_maps解读
    • 9.2 pmap分段工具
    • 9.3 RSS与VSZ差异
    • 9.4 内存泄漏定位
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个程序的一生
    • 10.3 设计哲学回扣
    • 10.4 地址空间速查

# 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;
}
1
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]   ← 实际栈
1
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 章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种修复方案与各自的代价。

本篇路线:

架构总图 (第 2 章)
   ↓
虚拟内存机制 (第 3 章) ─→ 解开"为什么每个进程都看到独立的地址空间"
   ↓
代码段 → 堆 → 栈 (第 4-6 章) ─→ 解开"五大段在哪、为什么"
   ↓
共享库 → ASLR (第 7-8 章) ─→ 解开"动态库与安全机制"
   ↓
pmap 实战 (第 9 章) ─→ 武器库
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开
1
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)
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

⚠️ 图示说明:图中"上=高地址、下=低地址"是 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 为什么这么切

为什么把进程地址空间切成"五大段 + 共享库区 + 内核区",而不是统一一锅粥?

疑惑:直接给进程一段连续的物理内存,让它自己管理不行吗?

论证:

  1. 权限隔离的需要——把"代码"放在 r-x 段,是为了防止有 bug 的代码意外修改自己的指令(*(void**)main = 0; 立刻 SIGSEGV);把"栈"放在 rw- 但 NX,是为了防止溢出后跳到栈上执行恶意代码(NX 位)。段是权限的最小单位。
  2. 加载时机不同——text/rodata/data 在 ELF 文件里就有,加载器把文件直接 mmap 进虚拟地址;bss 不占文件空间(节省磁盘),运行时才映射到零页;heap/stack 完全运行时分配。段对应"何时存在"的差异。
  3. 共享与独占不同——同一份 libc.so 在 100 个进程里,物理内存只有一份(COW),所以共享库段是"多进程共享"的;data/bss/heap/stack 每个进程独占。段对应"是否共享"的差异。
  4. 生长方向相反才能共用空间——堆向上长、栈向下长,留中间一大片"待分配区域"给两者动态争抢,是 1970 年代 Unix 设计者的空间复用智慧。
  5. 反向验证:如果不分段会怎样?参考早期 DOS 的"实模式"——任何指针都能改任何地址,包括代码段。结果就是病毒满天飞,蓝屏家常便饭。

结论:分段不是为了"好看",而是把权限、加载时机、共享性、生长方向这四个独立维度同时编码进地址空间——一段空间一种语义,崩溃定位、安全防护、内存共享、动态扩展全都得益于此。这是 Unix/Linux 内存管理的根基哲学。

下面我们从最底层的"虚拟内存"开始,看 CPU 是如何把这张图变成真实存在的。

# 3. 虚拟内存机制

# 3.1 物理与虚拟之分

疑惑:每个进程都看到完整的 64 位地址空间,可机器物理内存只有 32 GB,怎么做到的?

论证:

  1. CPU 看到的所有地址都是虚拟地址(VA, virtual address),不是物理地址。
  2. 进程间的 VA 完全独立——进程 A 的 0x400000 和进程 B 的 0x400000 是不同的物理页。
  3. 当 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       │
                                       └────────────┘
              不同的物理页,互不干扰
1
2
3
4
5
6
7
  1. 这套机制带来的红利:
    • 进程隔离: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 次额外开销
1
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;
}
1
2
3
4
5
6
7

ps -o pid,vsz,rss 输出:

PID    VSZ        RSS
12345  4194304    1024     ← VSZ ≈ 4 GB(虚拟),RSS ≈ 1 MB(物理)
1
2

4 GB 是 VA 承诺,物理内存只占 1 MB——剩下的页还没真正分配。

什么时候真正分配?第一次写那一页时:

for (size_t i = 0; i < 1'000'000'000; i += 1024) {
    p[i] = 0;       // 触发缺页中断 (page fault)
}
1
2
3

缺页中断完整流程:

程序:mov DWORD [vaddr], 0
              │
              ▼
MMU 查页表 → 该页不存在(页表项 P=0)
              │
              ▼
CPU 触发 #PF(页错误)异常 → 跳到内核 do_page_fault()
              │
              ├── 检查 vaddr 在不在合法 VMA(虚拟内存区域)里?
              │     不在 → SIGSEGV(崩溃,本篇案例就是这条路径)
              │     在   ↓
              ├── 是首次访问?分配一个新物理页(zero-fill)
              ├── 是写时复制?分配新页,复制原内容
              ├── 是从磁盘换出的?从 swap 读回
              ├── 是 mmap 文件?从磁盘读对应内容
              ▼
            修改页表,设置 P=1
              │
              ▼
        返回到出错指令重新执行 → 这次 MMU 翻译成功
1
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,但还没占任何物理内存
// 第一次写每个页时按需分配
1
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; }
1

经 g++ -O2 -c 编译后,add 函数的指令(objdump -d):

0000000000000000 <_Z3addii>:
   0:   8d 04 37             lea    eax, [rdi + rsi]
   3:   c3                   ret
1
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,文件不占空间
1
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
1
2
3
4

经典 UB 提示:

char* p = (char*)"hello";        // ⚠️ C++11 起,字面量类型是 const char*,强转能过
p[0] = 'H';                      // 💥 SIGSEGV:rodata 不可写
1
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?看下一节
1
2
3

疑惑:const int g = 42; 到底在哪?

论证:

  • 全局 const int g = 42; → 大概率被编译器完全优化掉(直接内联进每个引用处);如果取了地址,落在 .rodata
  • extern const int g = 42; → 必须有定义,落在 .rodata
  • const 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
1
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 字节
1
2
3
4
5

注意 NOBITS——ELF 文件里只记录"我有 18 字节 bss"这个元数据,没有实际内容。1 GB 数组的可执行文件依然只有几十 KB。

加载时怎么"分配 1 GB"?——映射到内核的零页(zero page):

全部 bss 的虚拟地址 → 同一个物理零页(只读)
                      ↓
程序第一次写 arr[i] = ... → 缺页中断 → 分配新物理页(写时复制 from zero page)
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14

加载流程(极简版):

  1. execve() 系统调用:内核解析 ELF 头
  2. 按 程序头表(program headers)逐段 mmap:
    • LOAD 段(PT_LOAD):text/rodata/data 从文件 mmap 进 VA
    • bss:分配虚拟空间,初始指向零页
  3. 加载动态链接器 ld-linux.so(PT_INTERP 指定)
  4. 跳转到 _start → 调用 __libc_start_main → 调用 main

# 5. 堆区生长机制

# 5.1 brk历史接口

最古老的堆扩展接口是 brk(2):

#include <unistd.h>
void* old = sbrk(0);                  // 拿到当前堆顶
sbrk(1024 * 1024);                    // 把堆顶往上推 1 MB
1
2
3

brk 的语义极其简单——把"程序中断(program break)"指针上推或下移:

低地址                                                高地址
┌──────┬───────┬──────┬───────────────────────────┬───────┐
│ text │rodata │ data │ bss │     堆 (heap)        │   ... │
└──────┴───────┴──────┴───────────────────────────┴───────┘
                            ↑                      ↑
                         brk_start              brk_end (program break)
                                          ─────► sbrk(N) 把它往上推
1
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);
1
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
                └── ...
1
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 新区
1
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 都降
1
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 路径
1
2
3
4
5
6
7

这就是**生产中"内存只涨不降"**的常见诱因——大量小对象 free 了,但 RSS 看起来一直不降,因为 glibc 还没把碎片整理到能 sbrk 缩堆的程度。

应对:

  • 升级 glibc 2.26+ 用 tcache
  • 切换到 jemalloc/tcmalloc(碎片更少)
  • 显式调用 malloc_trim(0) 主动归还
  • 大对象池化复用,避免反复 alloc/free

# 6. 栈区生长方向

# 6.1 栈从高向低长

疑惑:为什么栈是从高地址向低地址生长?

论证:

  1. 早期 PDP-11 等 CPU 内存少,约定"代码在低地址、栈在高地址",让两者从两端向中间生长,最大化共用空间。
  2. 这一约定通过指令集传承下来——x86 的 push 指令先减小 RSP,再写入:
push rax        ; 等价于:
                ; sub rsp, 8
                ; mov [rsp], rax
1
2
3
  1. 一旦 ABI 规定了"push 让 RSP 减小",所有后继 CPU 都得遵守,否则没法运行老代码。

例外:HP PA-RISC 等少数架构栈是向上长的。但 99.9% 的 C++ 代码都跑在"栈向下长"的机器上。

高地址
  ├──────────────┐
  │  栈底 (high)  │  ← main() 进来时 RSP 在这附近
  │              │
  │  ↓↓↓ 栈帧 ↓↓↓ │  ← 每次 call 把 RSP 再减一些
  │              │
  │  栈顶 (low)   │  ← 当前 RSP 指向这里
  └──────────────┘
低地址
1
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;
}
1
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 字节对齐!)
低地址
1
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];
}
1
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 区   │
低地址
1
2
3
4
5
6
7
8
9
10
11
12

工作机制:

  1. 一旦递归把 RSP 降到守护页内,CPU 触发 #PF
  2. 内核检查 VMA 标志位,发现这页是 MAP_GROWSDOWN | guard,不会自动扩展
  3. 直接发送 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, ...);
1
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 + 临时
1
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) ──┘
1
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");
1

汇编(极简):

call printf@PLT          ; 跳到 PLT 中 printf 的桩

; PLT 桩:
printf@PLT:
    jmp [GOT[printf]]    ; 第一次:GOT 里是"回到 PLT 解析"的地址
                         ; 解析后:GOT 里被改成 libc 中 printf 的真实地址
1
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]
1
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]
1
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
1
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
1
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

GCC 用 -fstack-protector-strong(默认)开启。canary 值在程序启动时由内核填入 TLS,每个进程一份随机值——攻击者不知道。

对付溢出的层层叠加:

  1. canary 检测溢出(编译期)
  2. NX 阻止跳到栈上执行(CPU)
  3. ASLR 让攻击者难以预测地址(OS)
  4. PIE 把代码段也随机化(编译 + OS)
  5. RELRO / FORTIFY_SOURCE 保护 GOT、检测危险 API
  6. 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]
1
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
1
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
1
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
1
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 层
1

代价:每个新线程都吃 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());
        }
        /* ... 业务逻辑 ... */
    }
}
1
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 → 拿到退出码
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

理解一个程序的一生,就是理解所有 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
1
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
1
2
3

第 1 章案例:8 MB / 8.3 KB ≈ 985 层。生产 800 层接近极限——应改为迭代。


下一篇:我们已经知道了"对象住在哪一段",下一步进入 02.对象内存布局原理——把"一个 struct/class 在那段内存里到底长什么样"剖到字节级别。

上次更新: 2026/06/10, 11:13:41
README
对象内存布局原理

← README 对象内存布局原理→

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