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

杨充

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

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 五大段总图
          • 2.2 为什么这么切
        • 3. 虚拟内存机制
          • 3.1 物理与虚拟之分
          • 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. 共享库与mmap映射
          • 7.1 so的载入机制
          • 7.2 PLT与GOT延迟绑定
          • 7.3 dlopen运行时加载
          • 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 地址空间速查
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

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

进程虚拟地址空间

# 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. 共享库与mmap映射
    • 7.1 so的载入机制
    • 7.2 PLT与GOT绑定
    • 7.3 dlopen运行时加载
    • 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 一段崩在哪

先看一段在嵌入式网关上跑了好几个月的代码,看着平平无奇,却在某天大客户投诉"设备死机"时爆了出来:

// 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

现象:

  • 测试环境(扫描 /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]   ← 实际栈
1
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 章
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

📌 本篇定位:这是整个 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)
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/free brk/mmap 按需扩展 程序运行时
stack rw- 局部变量、调用帧 内核预分配 + 守护页 ulimit -s
mmap rw- 或 r-x 共享库、mmap 文件 dlopen/mmap 时映射 调用方

# 2.2 为什么这么切

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

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

论证:

  1. 权限隔离的需要——把"代码"放在 r-x 段,是为了防止有 bug 的代码意外修改自己的指令:
void *p = (void *)main;
// *(char *)p = 0x90;  // 试图写入代码段 → SIGSEGV
1
2

text 段 r-x、rodata 段 r--、stack 段 rw- 但不可执行(NX 位)。每一个段就是一种权限承诺,由 CPU 的 MMU 硬件级别强制执行,不依赖任何运行时检查。段是权限的最小单位。

  1. 加载时机不同——text/rodata/data 在 ELF 文件里就有,加载器把文件直接 mmap 进虚拟地址;bss 不占文件空间(节省磁盘),运行时才映射到零页;heap/stack 完全运行时动态分配。段对应"何时存在"的差异。

  2. 共享与独占不同——同一份 libc.so 被 100 个进程加载时,text 段的物理内存只有一份(多进程共享同一物理页);bss/data/heap/stack 每个进程独占一份(写时复制);段对应"是否共享"的差异。

  3. 生长方向相反才能共用空间——

// 堆:从低地址向高地址长
void *p = sbrk(0);    // 获取当前 brk 位置
sbrk(4096);           // 堆向上扩展 4KB

// 栈:从高地址向低地址长
void foo() {
    int arr[1000];    // RSP 向下移动,栈向下扩展
}
1
2
3
4
5
6
7
8

堆向上长、栈向下长,留中间一大片"待分配区域"给两者动态争抢——这是 1970 年代 Unix 设计者的空间复用智慧。如果两者同向,很快就会撞在一起。

  1. 反向验证:如果不分段会怎样?参考早期 DOS 的"实模式"——任何指针都能改任何地址,包括代码段。结果就是蓝屏家常便饭,一个野指针就能干掉整个操作系统。

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

# 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 用户空间(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 次额外开销
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。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;
}
1
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(物理)
1
2

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

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

for (size_t i = 0; i < 1000000000UL; 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(
    NULL,                           // 让内核选地址
    1024UL * 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
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; }
1

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

0000000000000000 <add>:
   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,这是 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(取决于取不取地址)
1
2
3
4

经典陷阱——编译器可能把相同的字符串去重(string pooling):

char *s1 = "hello";
char *s2 = "hello";

printf("s1 == s2: %d\n", s1 == s2);
// 输出 1(通常为真)——编译器把两个 "hello" 合并了,指向同一块 rodata
// 可用 -fno-merge-constants 禁用此优化
1
2
3
4
5
6

语法层面的坑——字符数组和字符指针的区别:

char arr[] = "hello";     // 在栈上分配 6 字节,内容从 rodata 复制
char *ptr  = "hello";     // 指针在栈上,指向 rodata 中的字符串

arr[0] = 'H';  // ✅ OK:修改栈上的副本
// ptr[0] = 'H'; // 💥 段错误!修改 rodata 中的数据
1
2
3
4
5

内存布局对比:

栈区:                    rodata 段:
+-------+              +----------------+
| arr   | = "hello\0"  | "hello\0"      |
+-------+              +----------------+
| ptr   | ──────────→  |                |
+-------+              +----------------+
1
2
3
4
5
6

# 4.3 data已初始化

.data 装已显式初始化的全局变量和 static 变量:

int   counter = 100;             // .data 段,文件中存"100"这 4 字节
static double rate = 0.05;       // .data 段
1
2

数据段的特点——它在可执行文件中占实际空间:

// data_vs_bss.c
int arr_data[1000000] = {1};  // 每个元素都被初始化 → 约 4MB 在 .data
int arr_bss[1000000];         // 未初始化 → 在 .bss,不占文件空间
int main() { return 0; }
1
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
1
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
1
2
3

关键特性:bss 不占磁盘空间。

$ readelf -S a.out | grep .bss
  [26] .bss   NOBITS  ...  0000000000000018  ←  18 字节
1
2

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

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

全部 bss 的虚拟地址 → 同一个物理零页(只读)
                      ↓
程序第一次写 arr[i] = ... → 缺页中断 → 分配新物理页(写时复制 from zero page)
1
2
3

这就是为什么"声明 1 GB 全局数组没问题,真用 1 GB 才花物理内存"。

小细节——显式初始化为 0 的全局变量,也被编译器优化进 .bss:

int zero_var = 0;    // 编译器优化:放 .bss 段,不占文件空间
1

# 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);                  // 拿到当前堆顶(program break)
sbrk(1024 * 1024);                    // 把堆顶往上推 1 MB
1
2
3
4

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(NULL, 4 * 1024 * 1024,
               PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
1
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
                └── ...
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,物理 + 虚拟一起归还,VSZ/RSS 都降
1
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 路径
1
2
3
4
5
6
7

这就是**生产中"内存只涨不降"**的常见诱因——大量小对象 free 了,RSS 看起来一直不降,因为 glibc 还没把碎片整理到能 sbrk 缩堆的程度。这也是为什么第 18 篇要讲自定义内存池——绕过 glibc 的碎片问题。

# 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 都得遵守,否则没法运行老代码。
高地址
  ├──────────────┐
  │  栈底 (high)  │  ← main() 进来时 RSP 在这附近
  │              │
  │  ↓↓↓ 栈帧 ↓↓↓ │  ← 每次 call 把 RSP 再减一些
  │              │
  │  栈顶 (low)   │  ← 当前 RSP 指向这里
  └──────────────┘
低地址
1
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;
}
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

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];
}
1
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 区 │
低地址
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, thread_func, NULL);
1
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 + 临时
1
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) ──┘
1
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 的真实地址
1
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);
1
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]
1
2

vdso(virtual dynamic shared object):内核映射到每个进程的"虚拟 .so",存放高频系统调用(gettimeofday、time、clock_gettime)的用户态实现,避免陷入内核——延迟从几百 ns 降到几 ns。

#include <sys/time.h>
struct timeval tv;
gettimeofday(&tv, NULL);   // 走 vdso,不触发真正的系统调用
1
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]
1
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
1
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
1
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
}
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

在 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]
1
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
1
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
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

# 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
1
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 层
1

代价:每个新线程都吃 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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

代价:代码量增加,但栈深度恒定 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);
1
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 → 拿到退出码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

理解一个 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
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 / 4.3 KB ≈ 1905 层。生产 1200 层触发崩溃——因为每次 stat 系统调用也会在内核栈上消耗空间。应改为迭代。


下一篇:我们已经知道了"数据住在地址空间的哪一段",下一步进入 02.栈与堆底层对决——把栈帧的完整生命周期、堆的分配机制,从寄存器级别掰开揉碎。

上次更新: 2026/06/10, 19:52:45
README
栈与堆底层对决

← README 栈与堆底层对决→

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