2221 lines
35 KiB
Markdown
2221 lines
35 KiB
Markdown
高地址
|
||
┌──────────────────────────────┐
|
||
│ 内核空间 (kernel space) │ ← 所有进程共享
|
||
│ │
|
||
│ - 内核代码 (.text) │
|
||
│ - 内核数据 (.data/.bss) │
|
||
│ - task_struct / 内核栈 │
|
||
│ - 页表 / slab / kmalloc │
|
||
│ │
|
||
│ ⚠ 用户态不可访问 │
|
||
└──────────────────────────────┘
|
||
↑
|
||
│ (用户/内核分界线,典型:0x00007fffffffffff)
|
||
↓
|
||
┌──────────────────────────────┐
|
||
│ 用户栈 (stack) ↑向下增长 │
|
||
├──────────────────────────────┤
|
||
│ mmap 区域(共享库 / 文件) │
|
||
│ - libc.so │
|
||
│ - ld.so │
|
||
│ - mmap文件 │
|
||
├──────────────────────────────┤
|
||
│ 堆 (heap) ↑向上增长 │
|
||
│ malloc/new │
|
||
├──────────────────────────────┤
|
||
│ .bss(未初始化全局变量) │
|
||
│ .data(已初始化全局变量) │
|
||
│ .rodata(只读数据) │
|
||
│ .text(代码) │
|
||
└──────────────────────────────┘
|
||
低地址
|
||
|
||
---
|
||
|
||
【内核空间(高地址)】(所有进程共享)
|
||
- 内核代码 / 数据
|
||
- task_struct(在这里,但你访问不到)
|
||
|
||
-------------------------
|
||
【用户空间(每个进程独立)】
|
||
|
||
- stack(向下)
|
||
- mmap(共享库 / 文件映射)
|
||
- heap(向上)
|
||
- .bss / .data / .rodata
|
||
- .text
|
||
|
||
---
|
||
|
||
好,这一问已经进入**操作系统理解的核心区了**——我给你讲清楚两件事:
|
||
|
||
1. 👉 **虚拟地址空间每一块到底在干嘛(彻底拆开)**
|
||
2. 👉 **用一个 Fibonacci 程序,从“加载 → 运行 → 调用函数 → 返回”完整走一遍**
|
||
|
||
我会尽量按你现在的知识体系(偏底层 + CPU视角)来讲,不搞虚的。
|
||
|
||
---
|
||
|
||
# 🧠 一、经典虚拟地址空间:逐块拆解
|
||
|
||
我们按从低地址 → 高地址来讲(更符合程序执行逻辑)
|
||
|
||
---
|
||
|
||
## 1️⃣ `.text`(代码段)
|
||
|
||
👉 存什么?
|
||
|
||
* 机器指令(编译后的)
|
||
* 只读
|
||
|
||
比如:
|
||
|
||
```c
|
||
int fib(int n) { return n <= 1 ? n : fib(n-1)+fib(n-2); }
|
||
```
|
||
|
||
👉 编译后就在 `.text`
|
||
|
||
👉 CPU执行:
|
||
|
||
```asm
|
||
call fib
|
||
```
|
||
|
||
👉 本质就是:
|
||
|
||
👉 **从 `.text` 里一条一条取指令执行**
|
||
|
||
---
|
||
|
||
## 2️⃣ `.rodata`(只读数据)
|
||
|
||
👉 存什么?
|
||
|
||
* 字符串常量
|
||
* const 数据
|
||
|
||
比如:
|
||
|
||
```c
|
||
printf("Hello\n");
|
||
```
|
||
|
||
👉 `"Hello\n"` 在这里
|
||
|
||
---
|
||
|
||
## 3️⃣ `.data`(已初始化全局变量)
|
||
|
||
```c
|
||
int a = 10;
|
||
```
|
||
|
||
👉 存:
|
||
|
||
* 有初值的全局变量
|
||
* 静态变量
|
||
|
||
👉 在 ELF 里有实际数据
|
||
|
||
---
|
||
|
||
## 4️⃣ `.bss`(未初始化全局变量)
|
||
|
||
```c
|
||
int b;
|
||
```
|
||
|
||
👉 特点:
|
||
|
||
* **ELF文件里不占空间**
|
||
* 加载时全部置 0
|
||
|
||
👉 OS做的事情:
|
||
|
||
```c
|
||
memset(bss, 0)
|
||
```
|
||
|
||
---
|
||
|
||
## 5️⃣ Heap(堆)
|
||
|
||
👉 动态分配:
|
||
|
||
```c
|
||
malloc()
|
||
new
|
||
```
|
||
|
||
👉 特点:
|
||
|
||
* **向上增长**
|
||
* 本质是:
|
||
|
||
👉 `brk()` 或 `mmap()`
|
||
|
||
👉 glibc 会管理(不是内核直接管 malloc)
|
||
|
||
---
|
||
|
||
## 6️⃣ mmap 区域(重点!)
|
||
|
||
👉 这里非常关键(你前面问过)
|
||
|
||
存:
|
||
|
||
* 动态链接库(libc.so)
|
||
* 文件映射
|
||
* 大块 malloc
|
||
|
||
👉 比如:
|
||
|
||
```c
|
||
printf()
|
||
```
|
||
|
||
👉 实际函数在:
|
||
|
||
👉 `libc.so` → 映射到 mmap 区
|
||
|
||
---
|
||
|
||
## 7️⃣ 用户栈(stack) ✅
|
||
|
||
👉 你问的关键问题:
|
||
|
||
> 运行栈是不是用户栈?
|
||
|
||
✔ **是的!!完全就是同一个东西**
|
||
|
||
👉 存:
|
||
|
||
* 函数调用帧(stack frame)
|
||
* 返回地址
|
||
* 局部变量
|
||
* 参数
|
||
|
||
👉 特点:
|
||
|
||
* **向下增长**
|
||
|
||
---
|
||
|
||
## 8️⃣ 内核空间(高地址)⚠
|
||
|
||
👉 用户态不能访问
|
||
|
||
里面有:
|
||
|
||
* 内核代码
|
||
* 页表
|
||
* `task_struct`
|
||
* 内核栈
|
||
|
||
👉 每个进程:
|
||
|
||
✔ 用户空间不同
|
||
✔ 内核空间映射相同
|
||
|
||
---
|
||
|
||
# 🧠 二、用 Fibonacci 程序跑一遍(重点来了)
|
||
|
||
我们用这个:
|
||
|
||
```c
|
||
#include <stdio.h>
|
||
|
||
int fib(int n) {
|
||
if (n <= 1) return n;
|
||
return fib(n-1) + fib(n-2);
|
||
}
|
||
|
||
int main() {
|
||
int x = fib(3);
|
||
printf("%d\n", x);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
# 🚀 阶段 1:程序加载(execve)
|
||
|
||
你执行:
|
||
|
||
```bash
|
||
./a.out
|
||
```
|
||
|
||
---
|
||
|
||
## 🔹 内核做的事:
|
||
|
||
1. 创建进程(fork/exec)
|
||
2. 创建虚拟地址空间
|
||
3. 加载 ELF:
|
||
|
||
| 段 | 放哪 |
|
||
| ------- | ---- |
|
||
| `.text` | 代码区 |
|
||
| `.data` | 数据区 |
|
||
| `.bss` | 清零 |
|
||
| libc.so | mmap |
|
||
|
||
👉 建立页表(虚拟 → 物理)
|
||
|
||
---
|
||
|
||
# 🚀 阶段 2:进入 `main`
|
||
|
||
CPU开始执行:
|
||
|
||
```asm
|
||
_start → main
|
||
```
|
||
|
||
---
|
||
|
||
## 🔹 栈初始化:
|
||
|
||
用户栈里已经有:
|
||
|
||
```id="stack_init"
|
||
argc
|
||
argv
|
||
envp
|
||
```
|
||
|
||
---
|
||
|
||
# 🚀 阶段 3:调用 fib(3)
|
||
|
||
执行:
|
||
|
||
```c
|
||
fib(3)
|
||
```
|
||
|
||
👉 CPU干:
|
||
|
||
```asm
|
||
call fib
|
||
```
|
||
|
||
---
|
||
|
||
## 🔥 栈变化(关键!)
|
||
|
||
### 初始栈:
|
||
|
||
```id="stack0"
|
||
(main frame)
|
||
```
|
||
|
||
---
|
||
|
||
### 调用 fib(3)
|
||
|
||
```id="stack1"
|
||
fib(3)
|
||
main
|
||
```
|
||
|
||
---
|
||
|
||
### fib(3) → fib(2)
|
||
|
||
```id="stack2"
|
||
fib(2)
|
||
fib(3)
|
||
main
|
||
```
|
||
|
||
---
|
||
|
||
### fib(2) → fib(1)
|
||
|
||
```id="stack3"
|
||
fib(1)
|
||
fib(2)
|
||
fib(3)
|
||
main
|
||
```
|
||
|
||
---
|
||
|
||
### fib(1) 返回
|
||
|
||
👉 弹栈
|
||
|
||
```id="stack4"
|
||
fib(2)
|
||
fib(3)
|
||
main
|
||
```
|
||
|
||
---
|
||
|
||
👉 然后 fib(2) 调 fib(0)
|
||
|
||
---
|
||
|
||
👉 整个过程:
|
||
|
||
✔ **全部发生在“用户栈”**
|
||
|
||
---
|
||
|
||
# 🚀 阶段 4:printf 执行
|
||
|
||
```c
|
||
printf("%d\n", x);
|
||
```
|
||
|
||
---
|
||
|
||
## 🔥 发生了什么?
|
||
|
||
### 1️⃣ 调用 libc
|
||
|
||
👉 `.text` 没有 printf
|
||
|
||
👉 跳到:
|
||
|
||
👉 mmap 区 → `libc.so`
|
||
|
||
---
|
||
|
||
### 2️⃣ 进入内核
|
||
|
||
printf 最终会调用:
|
||
|
||
👉 `write()` 系统调用
|
||
|
||
---
|
||
|
||
👉 CPU做:
|
||
|
||
```asm
|
||
syscall
|
||
```
|
||
|
||
---
|
||
|
||
## 🔥 切换:
|
||
|
||
👉 用户态 → 内核态
|
||
|
||
---
|
||
|
||
## 🔹 内核做:
|
||
|
||
* 找 stdout
|
||
* 写入终端
|
||
|
||
---
|
||
|
||
👉 返回用户态
|
||
|
||
---
|
||
|
||
# 🚀 阶段 5:程序结束
|
||
|
||
```c
|
||
return 0;
|
||
```
|
||
|
||
---
|
||
|
||
👉 调用:
|
||
|
||
```c
|
||
exit()
|
||
```
|
||
|
||
---
|
||
|
||
👉 内核:
|
||
|
||
* 回收内存
|
||
* 销毁页表
|
||
* 销毁 task_struct
|
||
|
||
---
|
||
|
||
# 🧠 三、把“空间”和“行为”对上号
|
||
|
||
| 行为 | 发生在哪 |
|
||
| ------- | -------------- |
|
||
| 执行代码 | `.text` |
|
||
| 常量 | `.rodata` |
|
||
| 全局变量 | `.data/.bss` |
|
||
| malloc | heap |
|
||
| 函数调用 | stack |
|
||
| printf | mmap (libc.so) |
|
||
| syscall | 内核空间 |
|
||
|
||
---
|
||
|
||
# 🧠 四、一个你必须建立的核心模型
|
||
|
||
👉 程序运行 = 三个东西在互动:
|
||
|
||
### 1️⃣ `.text`
|
||
|
||
👉 CPU执行指令
|
||
|
||
### 2️⃣ stack
|
||
|
||
👉 控制流程(函数调用)
|
||
|
||
### 3️⃣ heap / mmap
|
||
|
||
👉 提供数据
|
||
|
||
---
|
||
|
||
# 🧠 五、再回答你一句核心问题(非常关键)
|
||
|
||
> 运行栈 = 用户栈?
|
||
|
||
✔ 完全正确
|
||
|
||
👉 更精确说:
|
||
|
||
👉 **运行栈 = 当前线程的栈(在用户空间)**
|
||
|
||
⚠ 注意:
|
||
|
||
* 每个线程都有自己的栈
|
||
* 不止一个栈(多线程)
|
||
|
||
---
|
||
|
||
# 🚀 六、如果你想再深入(我建议下一步)
|
||
|
||
你现在已经可以进阶到:
|
||
|
||
👉 **页表 + 缺页异常 + mmap 懒加载**
|
||
|
||
也就是:
|
||
|
||
👉 fib(3) 根本不会一开始就全部加载进内存
|
||
|
||
而是:
|
||
|
||
👉 **访问才加载(page fault)**
|
||
|
||
---
|
||
|
||
如果你愿意,我可以下一步给你讲一个非常爽的东西:
|
||
|
||
👉 **第一次执行 printf 时,libc.so 是怎么被“动态加载 + 重定位 + PLT/GOT解析”的**
|
||
|
||
这个和你前面问的 GOT/PLT 是一整条线,会直接打通。
|
||
|
||
---
|
||
|
||
你这个想法**非常接近真实机制了**,而且已经踩到一个很关键的点:
|
||
👉 **“既然 libc.so 已经在内存里了,能不能直接复用?”**
|
||
|
||
答案是:
|
||
👉 **可以复用,但不是你想的那种“直接从某个进程的内存映射过来”**
|
||
👉 而是通过一个更底层的东西:**页缓存(page cache)**
|
||
|
||
我给你讲清楚这个“微妙但关键”的区别。
|
||
|
||
---
|
||
|
||
# 🧠 一句话先定性
|
||
|
||
👉 **mmap 永远是“映射文件”,不是“映射别的进程的内存”**
|
||
👉 但多个进程会**共享同一份物理页(来自 page cache)**
|
||
|
||
---
|
||
|
||
# 🧠 一、你设想的模型 vs 真实模型
|
||
|
||
---
|
||
|
||
## ❌ 你想的模型(直觉版)
|
||
|
||
```text
|
||
进程A:
|
||
libc.so → 已经在物理内存
|
||
|
||
进程B:
|
||
mmap → 直接指向进程A的那块内存
|
||
```
|
||
|
||
👉 这其实是:
|
||
|
||
👉 ❌ **进程间直接共享内存(不是 mmap 的默认行为)**
|
||
|
||
---
|
||
|
||
## ✅ 真实模型(Linux)
|
||
|
||
```text
|
||
磁盘: libc.so
|
||
↓
|
||
页缓存(page cache,内核)
|
||
↓
|
||
进程A 映射
|
||
进程B 映射
|
||
进程C 映射
|
||
```
|
||
|
||
---
|
||
|
||
👉 关键点:
|
||
|
||
✔ 所有进程都 mmap “同一个文件”
|
||
✔ 内核让它们**指向同一份物理页**
|
||
|
||
---
|
||
|
||
# 🧠 二、page cache 是核心
|
||
|
||
👉 当第一个进程加载 libc.so:
|
||
|
||
```text
|
||
page fault
|
||
→ 内核从磁盘读 libc.so
|
||
→ 放入 page cache
|
||
→ 建立页表映射
|
||
```
|
||
|
||
---
|
||
|
||
👉 当第二个进程也用 libc.so:
|
||
|
||
```text
|
||
mmap libc.so
|
||
→ 访问时 page fault
|
||
→ 发现 page cache 里已经有
|
||
→ 直接复用物理页
|
||
```
|
||
|
||
---
|
||
|
||
👉 结果:
|
||
|
||
✔ 没有重复读磁盘
|
||
✔ 没有重复占用内存
|
||
|
||
---
|
||
|
||
# 🧠 三、那你说的“直接从内存映射”本质上是什么?
|
||
|
||
👉 其实你描述的就是:
|
||
|
||
👉 **page cache + mmap 的效果**
|
||
|
||
只是:
|
||
|
||
| 你以为 | 实际 |
|
||
| -------------- | -- |
|
||
| 从别的进程内存拿 | ❌ |
|
||
| 从 page cache 拿 | ✔ |
|
||
|
||
---
|
||
|
||
# 🧠 四、MAP_SHARED vs MAP_PRIVATE 在这里的区别
|
||
|
||
---
|
||
|
||
## 🔹 对 `.text`(代码段)
|
||
|
||
```c
|
||
mmap(..., MAP_PRIVATE)
|
||
```
|
||
|
||
👉 但因为是只读:
|
||
|
||
✔ 实际效果 = 共享
|
||
✔ 不会 COW
|
||
|
||
---
|
||
|
||
## 🔹 对 `.data`
|
||
|
||
👉 初始:
|
||
|
||
✔ 共享 page cache
|
||
|
||
👉 写时:
|
||
|
||
✔ 触发 COW
|
||
✔ 复制新物理页
|
||
|
||
---
|
||
|
||
## 🔹 如果是 MAP_SHARED
|
||
|
||
👉 修改会:
|
||
|
||
✔ 写回文件
|
||
✔ 所有进程可见
|
||
|
||
---
|
||
|
||
# 🧠 五、为什么不能“直接映射别的进程内存”?
|
||
|
||
👉 因为:
|
||
|
||
### 1️⃣ 安全性
|
||
|
||
* 进程隔离
|
||
* 不能随便访问别人的内存
|
||
|
||
---
|
||
|
||
### 2️⃣ 生命周期问题
|
||
|
||
* 进程A退出怎么办?
|
||
* 内存归谁?
|
||
|
||
---
|
||
|
||
### 3️⃣ 内核统一管理
|
||
|
||
👉 page cache 是:
|
||
|
||
✔ 全局
|
||
✔ 可复用
|
||
✔ 可回收
|
||
|
||
---
|
||
|
||
# 🧠 六、但!你这个想法其实在某种机制里是对的
|
||
|
||
👉 有一种东西真的可以:
|
||
|
||
👉 **直接共享内存**
|
||
|
||
---
|
||
|
||
## 🔥 那就是:
|
||
|
||
### 1️⃣ `mmap + MAP_SHARED + 同一个文件`
|
||
|
||
👉 标准共享内存
|
||
|
||
---
|
||
|
||
### 2️⃣ `shm`(共享内存)
|
||
|
||
```c
|
||
shm_open()
|
||
mmap()
|
||
```
|
||
|
||
---
|
||
|
||
### 3️⃣ `memfd_create`(高级玩法)
|
||
|
||
👉 直接在内存里创建“文件”
|
||
|
||
---
|
||
|
||
👉 这些才是:
|
||
|
||
✔ 真正的“进程间共享内存”
|
||
|
||
---
|
||
|
||
# 🧠 七、给你一个最终“通透模型”
|
||
|
||
---
|
||
|
||
## 🟢 mmap 的本质
|
||
|
||
👉 映射的是:
|
||
|
||
```text
|
||
虚拟地址 → 文件(或匿名内存)
|
||
```
|
||
|
||
---
|
||
|
||
## 🟡 page cache 的作用
|
||
|
||
👉 让:
|
||
|
||
```text
|
||
多个进程 → 共享同一份物理页
|
||
```
|
||
|
||
---
|
||
|
||
## 🔴 COW 的作用
|
||
|
||
👉 写的时候:
|
||
|
||
```text
|
||
共享 → 私有
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 八、用一句话总结你的问题(建议背)
|
||
|
||
👉 **mmap 不是从“已有内存”映射,而是从“文件”映射;多个进程之所以共享,是因为内核用 page cache 复用了同一份物理页**
|
||
|
||
---
|
||
|
||
# 🚀 给你一个你现在可以思考的进阶问题
|
||
|
||
你已经到这个层级了,可以思考这个:
|
||
|
||
👉 **如果 page cache 里的 libc.so 被回收了(内存紧张),会发生什么?**
|
||
|
||
提示你一下方向:
|
||
|
||
* 页被 reclaim
|
||
* 页表还在
|
||
* 再访问 → page fault
|
||
|
||
---
|
||
|
||
如果你想,我可以带你把:
|
||
|
||
👉 **page cache + LRU + 内存回收机制(kswapd)**
|
||
|
||
这一整套讲清楚,这就是 Linux 内存管理的“内功心法”了。
|
||
|
||
---
|
||
|
||
当运行./main的时候,运行ELF文件,查文件头的时候发现需要libc.so,于是通过mmap在虚拟内存中圈地。加载剩余的.text,.bss/.data到虚拟内存。然后执行代码段,第一次执行到call printf的时候,执行的其实是call printf@plt,此时plt第一条指令是跳转到GOT[x],然后初始GOT[x]跳转到的是PLT的下一条指令,而这条指令就是jmp到mmap开辟的虚拟内存映射到的物理内存中。PLT跳转发现内存不存在,于是发送page fault到内核。内核加载页,并且从磁盘加载libc.so到mmap映射到的那部分内存中。修改GOT[x]指向mmap映射的内存。第二次访问的时候直接跳转GOT即可。
|
||
|
||
对于已经在内存中加载了的运行库.so,在上面发现内存不存在的时候,page fault访问到页缓存,发现存在缓存则直接调用缓存;
|
||
|
||
你这版已经**80%正确了,而且思路是通的**👍——差的那 20% 主要集中在两个点:
|
||
|
||
1. **PLT/GOT 的跳转细节(你有一点“想象多了一步”)**
|
||
2. **page fault 触发的位置和时机(这个很关键)**
|
||
|
||
我帮你**逐句订正 + 给你一版“完全正确、可以当标准答案背”的流程**。
|
||
|
||
---
|
||
|
||
# 🔧 一、逐句帮你“debug”
|
||
|
||
我引用你原话,逐条改:
|
||
|
||
---
|
||
|
||
## ✅ 第一段(基本正确)
|
||
|
||
> 当运行 ./main 的时候,运行 ELF 文件,查文件头的时候发现需要 libc.so,于是通过 mmap 在虚拟内存中圈地。
|
||
|
||
👉 ✔ 基本正确,但补充:
|
||
|
||
* 是内核加载 ELF 后
|
||
* **把控制权交给动态链接器(ld.so)**
|
||
* **ld.so 用 mmap 加载 libc.so**
|
||
|
||
---
|
||
|
||
## ⚠ 第二段(小问题)
|
||
|
||
> 加载剩余的 .text,.bss/.data 到虚拟内存
|
||
|
||
👉 ✔ 对,但更精确:
|
||
|
||
* `.text/.data` 是 **映射(mmap)**
|
||
* `.bss` 是 **匿名页(清零)**
|
||
|
||
---
|
||
|
||
## ⚠ 第三段(PLT部分有误)
|
||
|
||
> plt第一条指令是跳转到 GOT[x],然后初始 GOT[x] 跳转到的是 PLT 的下一条指令,而这条指令就是 jmp 到 mmap 开辟的虚拟内存映射到的物理内存中
|
||
|
||
👉 ❌ 这里有一个关键错误:
|
||
|
||
👉 **PLT 并不会直接跳到 libc**
|
||
|
||
正确是:
|
||
|
||
```asm
|
||
printf@plt:
|
||
jmp *GOT[printf] ← 第一步
|
||
push reloc_index ← 第二步
|
||
jmp plt0 ← 第三步
|
||
```
|
||
|
||
---
|
||
|
||
👉 初始:
|
||
|
||
```text
|
||
GOT[printf] = printf@plt + 6
|
||
```
|
||
|
||
---
|
||
|
||
👉 所以第一次执行:
|
||
|
||
```text
|
||
jmp GOT → 回到 PLT 自己 → push → jmp plt0
|
||
```
|
||
|
||
👉 ❌ 并不会跳到 libc
|
||
👉 ✔ 而是跳到 **动态解析器**
|
||
|
||
---
|
||
|
||
## ❌ 第四段(最大误区)
|
||
|
||
> PLT跳转发现内存不存在,于是发送page fault到内核
|
||
|
||
👉 ❌ 这里是**最大错误点**
|
||
|
||
👉 **PLT 跳转本身不会触发 page fault**
|
||
|
||
---
|
||
|
||
👉 page fault 发生在:
|
||
|
||
✔ 访问 libc 代码页时
|
||
✔ 或访问 GOT / 数据页时
|
||
|
||
👉 ❌ 不是因为“跳转失败”
|
||
|
||
---
|
||
|
||
## ⚠ 第五段(部分正确)
|
||
|
||
> 内核加载页,并且从磁盘加载 libc.so
|
||
|
||
👉 ✔ 条件成立时正确:
|
||
|
||
👉 如果 page cache 里没有:
|
||
|
||
✔ 从磁盘加载
|
||
|
||
👉 如果有:
|
||
|
||
✔ 直接用 page cache
|
||
|
||
---
|
||
|
||
## ❌ 第六段(小错)
|
||
|
||
> 修改 GOT[x] 指向 mmap 映射的内存
|
||
|
||
👉 ❌ 不是内核改的!
|
||
|
||
👉 ✔ 是:
|
||
|
||
👉 **动态链接器(ld.so)修改 GOT**
|
||
|
||
---
|
||
|
||
## ✅ 最后一段(很好)
|
||
|
||
> page fault访问到页缓存,发现存在缓存则直接调用缓存
|
||
|
||
👉 ✔ 完全正确 👍
|
||
|
||
---
|
||
|
||
# 🚀 二、给你一版“标准正确流程”(可以当答案用)
|
||
|
||
我帮你整理成一条**完全正确的执行链**:
|
||
|
||
---
|
||
|
||
# 🧠 【完整流程:从启动到 printf】
|
||
|
||
---
|
||
|
||
## 🟢 阶段 1:程序启动
|
||
|
||
```text
|
||
./main
|
||
→ execve
|
||
```
|
||
|
||
---
|
||
|
||
### 内核做:
|
||
|
||
1. 加载 ELF
|
||
2. 发现需要动态链接
|
||
3. 启动动态链接器(ld.so)
|
||
|
||
---
|
||
|
||
## 🟢 阶段 2:动态链接器初始化
|
||
|
||
ld.so 做:
|
||
|
||
* mmap 主程序
|
||
* mmap libc.so(建立 VMA,不一定加载物理页)
|
||
* 建立 GOT / PLT 初始状态
|
||
|
||
---
|
||
|
||
## 🟢 阶段 3:执行到 printf
|
||
|
||
```asm
|
||
call printf → call printf@plt
|
||
```
|
||
|
||
---
|
||
|
||
## 🟢 阶段 4:第一次进入 PLT
|
||
|
||
```asm
|
||
printf@plt:
|
||
jmp *GOT[printf]
|
||
```
|
||
|
||
---
|
||
|
||
👉 此时:
|
||
|
||
```text
|
||
GOT[printf] → printf@plt + 6
|
||
```
|
||
|
||
---
|
||
|
||
👉 所以跳回:
|
||
|
||
```asm
|
||
push reloc_index
|
||
jmp plt0
|
||
```
|
||
|
||
---
|
||
|
||
## 🟢 阶段 5:进入动态解析器
|
||
|
||
```text
|
||
plt0 → _dl_runtime_resolve
|
||
```
|
||
|
||
---
|
||
|
||
👉 动态链接器做:
|
||
|
||
1. 在 libc.so 中查找 printf
|
||
2. 得到地址
|
||
|
||
---
|
||
|
||
## 🟢 阶段 6:可能发生 page fault(关键点)
|
||
|
||
👉 当访问 libc.so 的代码页:
|
||
|
||
```text
|
||
虚拟地址 → 没有物理页 → page fault
|
||
```
|
||
|
||
---
|
||
|
||
### 内核处理:
|
||
|
||
* 查 VMA(来自 mmap)
|
||
* 查 page cache
|
||
|
||
---
|
||
|
||
👉 分两种:
|
||
|
||
### ✔ 情况 A:缓存存在
|
||
|
||
```text
|
||
直接映射物理页
|
||
```
|
||
|
||
---
|
||
|
||
### ✔ 情况 B:缓存不存在
|
||
|
||
```text
|
||
从磁盘读取 libc.so → 放入 page cache → 映射
|
||
```
|
||
|
||
---
|
||
|
||
## 🟢 阶段 7:修复 GOT(关键)
|
||
|
||
👉 动态链接器:
|
||
|
||
```c
|
||
GOT[printf] = libc中printf地址
|
||
```
|
||
|
||
---
|
||
|
||
## 🟢 阶段 8:跳转到真正 printf
|
||
|
||
👉 开始执行 libc.so 中的代码
|
||
|
||
---
|
||
|
||
## 🟢 阶段 9:第二次调用 printf
|
||
|
||
```asm
|
||
call printf@plt
|
||
→ jmp *GOT[printf]
|
||
→ 直接跳 libc
|
||
```
|
||
|
||
---
|
||
|
||
👉 ❌ 不再走解析器
|
||
👉 ❌ 不再走 PLT fallback
|
||
|
||
---
|
||
|
||
# 🧠 三、帮你总结“关键修正点”
|
||
|
||
---
|
||
|
||
## 🔥 你要记住的 5 个核心点
|
||
|
||
---
|
||
|
||
### 1️⃣ libc.so 早就 mmap 了
|
||
|
||
👉 ❌ 不是第一次 printf 才加载
|
||
👉 ✔ 启动时就建立映射
|
||
|
||
---
|
||
|
||
### 2️⃣ PLT 不负责找地址
|
||
|
||
👉 ✔ PLT = 跳板
|
||
👉 ✔ ld.so = 真正解析
|
||
|
||
---
|
||
|
||
### 3️⃣ GOT 不是空
|
||
|
||
👉 ✔ 初始指向 PLT 自己
|
||
|
||
---
|
||
|
||
### 4️⃣ page fault ≠ 跳转失败
|
||
|
||
👉 ✔ 是访问未加载页
|
||
👉 ❌ 不是因为“地址不存在”
|
||
|
||
---
|
||
|
||
### 5️⃣ GOT 是用户态改的
|
||
|
||
👉 ✔ ld.so 修改
|
||
👉 ❌ 内核不管
|
||
|
||
---
|
||
|
||
# 🧠 四、你现在的理解水平(实话)
|
||
|
||
你现在已经:
|
||
|
||
✅ 理解虚拟内存
|
||
✅ 理解 mmap
|
||
✅ 理解 page cache
|
||
✅ 理解 PLT/GOT
|
||
|
||
👉 差的只是:
|
||
|
||
👉 **“谁在什么时候做什么”这个时间顺序的精度**
|
||
|
||
---
|
||
|
||
# 🚀 如果你再往前一步(我强烈建议)
|
||
|
||
下一步你可以直接进入:
|
||
|
||
👉 **fork() + COW + 页表复制**
|
||
|
||
或者更狠一点:
|
||
|
||
👉 **自己用 gdb + objdump 追踪一次 printf@plt 执行**
|
||
|
||
我可以带你一步一步把汇编跑出来,那会非常爽,直接“从理解变成掌控”。
|
||
|
||
---
|
||
|
||
|
||
malloc = 在用户空间的“堆(heap)或 mmap 区域”分配虚拟内存,
|
||
小块通过 brk 扩展 heap,大块通过 mmap 创建匿名映射,
|
||
实际物理内存通过 page fault 按需分配
|
||
|
||
VMA → 删除
|
||
页表 → 删除
|
||
物理页:
|
||
匿名页 → 释放
|
||
文件页 → 留在 cache你这段总结已经**非常接近“内核真实行为”了**,可以说 90% 正确 👍
|
||
但有两个地方需要**精确修正一下**,不然以后在 page cache / 回收策略这里会卡住。
|
||
|
||
---
|
||
|
||
# ✅ 先给你一个“修正版结论”(可以直接记)
|
||
|
||
```text
|
||
进程退出时:
|
||
|
||
VMA → 删除
|
||
页表 → 删除
|
||
|
||
物理页:
|
||
匿名页(heap/stack) → 立即释放
|
||
文件页(mmap 文件) → 留在 page cache(可能被回收)
|
||
```
|
||
|
||
👉 你写的整体结构 ✔ 完全正确
|
||
👉 下面我帮你把**两个关键细节补严谨**
|
||
|
||
---
|
||
|
||
# 🧠 一、你这句话的第一个问题
|
||
|
||
> 物理内存我不管,里面的东西保留,直到下一次重新加载文件到该物理内存区域
|
||
|
||
👉 ❌ 这里有个“时间理解错误”
|
||
|
||
---
|
||
|
||
## ❗ 正确是:
|
||
|
||
👉 **不是“保留到下一次加载”**
|
||
|
||
👉 而是:
|
||
|
||
👉 **保留在 page cache,直到内核决定回收它**
|
||
|
||
---
|
||
|
||
## 🔥 内核不会保证:
|
||
|
||
```text
|
||
下一次一定还在同一个物理页
|
||
```
|
||
|
||
---
|
||
|
||
👉 实际是:
|
||
|
||
* 有内存 → 留着(加速)
|
||
* 内存紧张 → 被回收(LRU)
|
||
|
||
---
|
||
|
||
👉 所以更准确说法是:
|
||
|
||
```text
|
||
文件页可能继续留在内存中(page cache),
|
||
也可能被内核回收,并不是等“下一次加载”才处理
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 二、你这句话的第二个细节
|
||
|
||
> 删除 mmap 建立的虚拟 → 物理映射
|
||
|
||
👉 ✔ 对,但再精确一点:
|
||
|
||
---
|
||
|
||
## 🔹 删除的其实是:
|
||
|
||
```text
|
||
虚拟地址 → 物理页 的映射(页表项 PTE)
|
||
```
|
||
|
||
---
|
||
|
||
👉 不是:
|
||
|
||
❌ “删除 mmap 本身”
|
||
✔ 而是:
|
||
|
||
```text
|
||
VMA 删除 + 页表清空
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 三、帮你把“文件页”的生命周期讲清楚(关键)
|
||
|
||
---
|
||
|
||
## 🟢 阶段 1:程序运行中
|
||
|
||
```text
|
||
libc.so → mmap
|
||
→ page fault
|
||
→ 加载到 page cache
|
||
→ 建立页表映射
|
||
```
|
||
|
||
---
|
||
|
||
## 🟡 阶段 2:进程退出
|
||
|
||
```text
|
||
页表删除
|
||
→ 引用计数 -1
|
||
```
|
||
|
||
---
|
||
|
||
👉 如果:
|
||
|
||
```text
|
||
没有进程再用
|
||
```
|
||
|
||
---
|
||
|
||
👉 页会:
|
||
|
||
```text
|
||
留在 page cache(暂时)
|
||
```
|
||
|
||
---
|
||
|
||
## 🔴 阶段 3:内存紧张时
|
||
|
||
👉 内核(kswapd):
|
||
|
||
```text
|
||
回收 page cache
|
||
→ 丢弃干净页(clean page)
|
||
```
|
||
|
||
---
|
||
|
||
👉 如果是脏页:
|
||
|
||
```text
|
||
写回磁盘再释放
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 四、匿名页为什么直接释放?
|
||
|
||
👉 因为:
|
||
|
||
```text
|
||
heap / stack → 没有“文件来源”
|
||
```
|
||
|
||
---
|
||
|
||
👉 所以:
|
||
|
||
```text
|
||
只能:
|
||
→ 直接 free 物理页
|
||
```
|
||
|
||
---
|
||
|
||
👉 对比一下:
|
||
|
||
| 类型 | 来源 | 能否缓存 |
|
||
| --- | ---- | ---- |
|
||
| 文件页 | 磁盘文件 | ✔ |
|
||
| 匿名页 | 无 | ❌ |
|
||
|
||
---
|
||
|
||
# 🧠 五、你现在的理解可以升级成这个模型
|
||
|
||
---
|
||
|
||
## 🟢 文件映射(libc.so)
|
||
|
||
```text
|
||
磁盘 ↔ page cache ↔ 进程页表
|
||
```
|
||
|
||
---
|
||
|
||
👉 进程退出:
|
||
|
||
```text
|
||
页表断开
|
||
page cache 还在
|
||
```
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 🔴 匿名内存(heap/stack)
|
||
|
||
```text
|
||
进程页表 ↔ 物理页
|
||
```
|
||
|
||
---
|
||
|
||
👉 进程退出:
|
||
|
||
```text
|
||
直接释放
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 六、帮你把原话“升维改写”
|
||
|
||
你原话:
|
||
|
||
```text
|
||
删除虚拟→物理映射,物理内存保留直到下次加载
|
||
```
|
||
|
||
---
|
||
|
||
👉 更精确版本:
|
||
|
||
```text
|
||
进程退出时会删除所有虚拟地址到物理页的映射,
|
||
匿名页会立即释放,
|
||
文件页会留在 page cache 中,
|
||
由内核根据内存压力决定是否回收,
|
||
而不是等到下一次加载才处理
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 七、一个非常关键的直觉(建议你记住)
|
||
|
||
👉 **page cache 不是“为某个进程服务”,而是“为整个系统服务”**
|
||
|
||
---
|
||
|
||
👉 所以:
|
||
|
||
✔ 进程死了,缓存还在
|
||
✔ 其他进程可以复用
|
||
✔ 内核可以随时回收
|
||
|
||
---
|
||
|
||
# 🚀 你现在已经到了一个很高的层级了
|
||
|
||
你现在其实已经掌握了:
|
||
|
||
* mmap 本质
|
||
* page fault
|
||
* page cache
|
||
* 进程退出回收机制
|
||
|
||
---
|
||
|
||
👉 下一步你可以直接挑战一个“内核级核心问题”:
|
||
|
||
👉 **LRU 是怎么决定回收哪一页的?active/inactive list 是怎么工作的?**
|
||
|
||
这个一通,你对 Linux 内存管理就基本“内功圆满”了。
|
||
|
||
---
|
||
|
||
你这个问题问到**本质核心了**,这一点一旦想通,前面所有 mmap / page fault / cache 全都串起来。
|
||
|
||
我给你用**最直白 + 严谨**的方式讲清楚👇
|
||
|
||
---
|
||
|
||
# 🧠 一、结论先说死
|
||
|
||
## ✅ 用户程序 **不能直接访问物理地址**
|
||
|
||
👉 必须经过:
|
||
|
||
```text
|
||
虚拟地址 → 页表 → 物理地址
|
||
```
|
||
|
||
---
|
||
|
||
👉 你写的代码:
|
||
|
||
```c
|
||
int *p = malloc(4);
|
||
*p = 123;
|
||
```
|
||
|
||
👉 CPU 实际干的是:
|
||
|
||
```text
|
||
虚拟地址 p
|
||
→ 查页表(MMU做的)
|
||
→ 找到物理页 + offset
|
||
→ 写入物理内存
|
||
```
|
||
|
||
---
|
||
|
||
❗ **用户态根本没有“访问物理地址”的能力**
|
||
|
||
否则会出现:
|
||
|
||
* 读别的进程数据
|
||
* 改内核数据
|
||
* 系统直接崩
|
||
|
||
👉 所以这是操作系统最核心的隔离机制
|
||
|
||
---
|
||
|
||
# 🧠 二、那“物理页”到底是什么?
|
||
|
||
你可以这样理解:
|
||
|
||
---
|
||
|
||
## 🔹 物理内存长这样(简化)
|
||
|
||
```text
|
||
RAM:
|
||
[4KB][4KB][4KB][4KB][4KB]...
|
||
↑ ↑ ↑
|
||
页0 页1 页2
|
||
```
|
||
|
||
---
|
||
|
||
👉 每一个 **4KB块**:
|
||
|
||
```text
|
||
就叫:物理页(physical page)
|
||
```
|
||
|
||
---
|
||
|
||
## 📌 定义一句话:
|
||
|
||
```text
|
||
物理页 = 物理内存中最小的分配单位(通常4KB)
|
||
```
|
||
|
||
---
|
||
|
||
👉 比如:
|
||
|
||
```text
|
||
物理地址 0x111111
|
||
```
|
||
|
||
其实是:
|
||
|
||
```text
|
||
页号 + 页内偏移
|
||
```
|
||
|
||
---
|
||
|
||
## 🔹 举个具体例子
|
||
|
||
```text
|
||
虚拟地址:0x7fff12345678
|
||
```
|
||
|
||
CPU做的事:
|
||
|
||
1. 拆分:
|
||
|
||
```text
|
||
页号 + offset
|
||
```
|
||
|
||
2. 查页表:
|
||
|
||
```text
|
||
页号 → 物理页(比如 0x111000)
|
||
```
|
||
|
||
3. 拼起来:
|
||
|
||
```text
|
||
物理地址 = 0x111000 + offset
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 三、为什么必须走虚拟地址?
|
||
|
||
这是操作系统的“核心设计”:
|
||
|
||
---
|
||
|
||
## 🔒 1. 安全隔离
|
||
|
||
进程 A:
|
||
|
||
```text
|
||
虚拟地址 0x1000 → 物理页 A
|
||
```
|
||
|
||
进程 B:
|
||
|
||
```text
|
||
虚拟地址 0x1000 → 物理页 B
|
||
```
|
||
|
||
👉 地址一样,但物理完全不同
|
||
|
||
---
|
||
|
||
## 🔥 结果:
|
||
|
||
```text
|
||
两个进程互相看不到对方
|
||
```
|
||
|
||
---
|
||
|
||
## 🧠 2. 灵活管理
|
||
|
||
内核可以:
|
||
|
||
* 随便换物理页(swap / compact)
|
||
* 做写时复制(COW)
|
||
* 做 mmap
|
||
* 做共享库
|
||
|
||
👉 程序完全不知道
|
||
|
||
---
|
||
|
||
## ⚡ 3. 支持“看起来连续”的内存
|
||
|
||
你 malloc:
|
||
|
||
```text
|
||
0x1000
|
||
0x2000
|
||
0x3000
|
||
```
|
||
|
||
👉 实际物理可能是:
|
||
|
||
```text
|
||
0x111000
|
||
0x9a3000
|
||
0x004000
|
||
```
|
||
|
||
👉 完全不连续,但你感觉是连续的
|
||
|
||
---
|
||
|
||
# 🧠 四、那我能不能访问物理地址?
|
||
|
||
👉 分情况:
|
||
|
||
---
|
||
|
||
## ❌ 用户态
|
||
|
||
```text
|
||
不行 ❌
|
||
```
|
||
|
||
* 没指令权限
|
||
* 没页表控制权
|
||
|
||
---
|
||
|
||
## ✅ 内核态(Linux内核)
|
||
|
||
可以:
|
||
|
||
* 直接操作物理页
|
||
* 管理页表
|
||
* 分配内存
|
||
|
||
---
|
||
|
||
## ⚠️ 特殊情况(你可能听过)
|
||
|
||
### 1. `/dev/mem`
|
||
|
||
👉 可以访问物理内存(但现在基本被禁)
|
||
|
||
---
|
||
|
||
### 2. 驱动 / mmap 设备
|
||
|
||
👉 内核帮你映射:
|
||
|
||
```text
|
||
物理地址 → 用户虚拟地址
|
||
```
|
||
|
||
👉 你还是用“虚拟地址访问”
|
||
|
||
---
|
||
|
||
# 🧠 五、把你刚才那个问题彻底收束
|
||
|
||
你问:
|
||
|
||
> 物理地址0x111111里面的数据还在吗?
|
||
|
||
👉 正确答案:
|
||
|
||
---
|
||
|
||
## ✔ 数据“可能还在”
|
||
|
||
* 如果页没被覆盖
|
||
* 如果是 page cache
|
||
|
||
---
|
||
|
||
## ❌ 但你“永远不能直接访问它”
|
||
|
||
因为:
|
||
|
||
```text
|
||
你没有虚拟地址 → 映射不到它
|
||
```
|
||
|
||
---
|
||
|
||
👉 就像:
|
||
|
||
```text
|
||
银行金库里有钱(物理页)
|
||
但你没有账户(虚拟地址)
|
||
```
|
||
|
||
---
|
||
|
||
# 🧠 六、最终统一模型(非常重要)
|
||
|
||
```text
|
||
用户程序
|
||
↓
|
||
虚拟地址
|
||
↓(页表 / MMU)
|
||
物理页(4KB块)
|
||
↓
|
||
RAM
|
||
```
|
||
|
||
---
|
||
|
||
👉 所有东西:
|
||
|
||
* malloc
|
||
* mmap
|
||
* libc.so
|
||
* stack
|
||
|
||
本质都是:
|
||
|
||
```text
|
||
虚拟地址 → 映射到某些物理页
|
||
```
|
||
|
||
---
|
||
|
||
# 🚀 你现在已经踩到“操作系统核心三件套”了
|
||
|
||
你已经在理解:
|
||
|
||
* 虚拟内存
|
||
* 页表
|
||
* 物理页
|
||
* page fault
|
||
* mmap
|
||
* COW
|
||
|
||
---
|
||
|
||
👉 下一步如果你继续深入,会自然进入:
|
||
|
||
👉 **TLB(快表)是怎么加速页表的?**
|
||
|
||
👉 这个一通,你基本就是“系统级选手”了 😄
|
||
|
||
---
|
||
|
||
|
||
|
||
|
||
这个问题切中了 Linux 内核执行程序的“第一现场”。
|
||
|
||
当你在终端输入 `./main` 并按下回车时,内核并不是直接运行你的二进制代码,而是经历了一场从 **`execve` 系统调用** 开始的“虚拟内存大拆迁与大建设”。
|
||
|
||
---
|
||
|
||
### 1. 谁发起的 mmap?
|
||
在 Linux 中,分配虚拟内存的动作主要发生在两个阶段:**内核态阶段**(映射程序本身)和**用户态阶段**(映射动态库)。
|
||
|
||
#### **第一阶段:内核的“强拆与重建”**
|
||
当你调用 `execve` 时,内核里的 `do_execve` 函数开始工作:
|
||
1. **清空旧领地**:它会把当前进程(之前是 Bash)原来的虚拟内存布局全部释放掉(Unmap)。
|
||
2. **映射 ELF 段**:内核读取 `main` 文件的程序头表(Program Header Table)。
|
||
* 内核会针对 ELF 中的 `LOAD` 段(通常是 `.text` 和 `.data`)调用内部的 `mmap` 函数。
|
||
* **此时,虚拟内存开始分配。** 比如,它把文件偏移 0 处的内容映射到虚拟地址 `0x400000`(这只是个例子,现代系统有 ASLR 地址随机化)。
|
||
3. **映射堆栈**:内核还会自动 `mmap` 出一块区域作为**用户栈**。
|
||
|
||
#### **第二阶段:动态链接器的“圈地”**
|
||
如果你的程序是动态链接的(绝大多数情况),内核在映射完 `main` 之后,会发现需要一个叫 `ld-linux.so` 的解释器。
|
||
1. 内核把控制权交给**动态链接器**。
|
||
2. 链接器读取 `main` 的依赖列表(比如需要 `libc.so`)。
|
||
3. 链接器亲自调用 `mmap` 系统调用,在虚拟内存的中间区域(通常是栈和堆之间的空隙)给 `libc.so` 找个位置。
|
||
|
||
|
||
|
||
---
|
||
|
||
### 2. 内存分配的层级(从上往下看)
|
||
|
||
你可以把这个过程看作一个“俄罗斯套娃”:
|
||
|
||
1. **用户代码**:调用 `malloc(100)`。
|
||
2. **C 库 (Glibc)**:发现手里没余粮了,调用系统调用 `brk` 或 `mmap`。
|
||
3. **内核系统调用接口**:接收到 `mmap` 请求。
|
||
4. **内核内存管理 (VMA)**:在进程的虚拟地址空间里找一段空白区域,塞进去一个 `vm_area_struct` 结构体(这就算是“分配”了虚拟内存)。
|
||
5. **MMU/页表**:此时页表还是空的。直到你第一次读写这块内存,触发 **Page Fault**,内核才真正去翻“物理页”的牌子。
|
||
|
||
---
|
||
|
||
### 3. 总结:mmap 的起点
|
||
如果你问的是**时间点**:
|
||
* **最早起点**:在 `execve` 系统调用执行过程中,内核加载 ELF 文件头之后,就开始为程序的代码段进行第一次 `mmap` 映射了。
|
||
|
||
如果你问的是**空间位置**:
|
||
* **代码段**通常在较低的地址。
|
||
* **共享库**(通过 `mmap` 显式调用的)通常从虚拟地址空间的中间某处开始向下或向上生长。
|
||
|
||
---
|
||
|
||
### 验证一下?
|
||
你可以写一个最简单的 `hello world`,然后在 Fedora 上运行:
|
||
```bash
|
||
strace ./main 2>&1 | grep mmap
|
||
```
|
||
你会惊奇地发现,即便你程序里一行 `mmap` 都没有写,输出里也会有一大堆 `mmap(...)`。这些全都是内核和动态链接器在帮你“打江山”。
|
||
|
||
**既然你对 `mmap` 分配内存的起始点这么感兴趣,想不想聊聊 `mmap` 分配出来的虚拟地址,是怎么通过“多级页表”这种像树一样的结构最终找到物理内存的?**
|
||
|
||
---
|
||
|
||
好,这个问题你要是彻底搞懂了,基本就把“用户态内存管理”这一块打通了。我们从**最底层一路往上**讲,把 `malloc` 的整个链路拆开讲清楚(从进程虚拟内存 → 内核 → glibc → 分配算法)。
|
||
|
||
---
|
||
|
||
# 一、先搞清楚:堆到底是什么?
|
||
|
||
在一个进程的虚拟地址空间里,大致长这样:
|
||
|
||
```
|
||
低地址
|
||
├── 代码段(text)
|
||
├── 数据段(data / bss)
|
||
├── 堆(heap) ← malloc 在这
|
||
│ ↑ 向上增长
|
||
│
|
||
│ (中间空闲区域)
|
||
│
|
||
├── 栈(stack)
|
||
│ ↓ 向下增长
|
||
高地址
|
||
```
|
||
|
||
👉 关键点:
|
||
|
||
* **堆是一个“向高地址增长”的区域**
|
||
* `malloc` 本质就是:**从堆里切一块内存给你**
|
||
|
||
但问题来了:
|
||
|
||
👉 堆的空间从哪来?
|
||
👉 怎么管理?
|
||
👉 怎么避免碎片?
|
||
|
||
这就进入核心。
|
||
|
||
---
|
||
|
||
# 二、malloc 的本质:三层结构
|
||
|
||
你可以把 `malloc` 看成三层:
|
||
|
||
```
|
||
你的代码
|
||
↓
|
||
glibc malloc(用户态内存分配器)
|
||
↓
|
||
系统调用(brk / mmap)
|
||
↓
|
||
Linux 内核(分配物理页)
|
||
```
|
||
|
||
---
|
||
|
||
# 三、第一步:程序刚启动时,堆在哪里?
|
||
|
||
程序启动时:
|
||
|
||
* 内核加载 ELF
|
||
* 初始化虚拟地址空间
|
||
* 设置一个“堆起点”:`brk`
|
||
|
||
👉 可以理解为:
|
||
|
||
```
|
||
heap_start = end_of_bss
|
||
heap_end = brk
|
||
```
|
||
|
||
一开始:
|
||
|
||
```
|
||
heap_start == heap_end
|
||
```
|
||
|
||
👉 也就是说:
|
||
|
||
🟡 **一开始堆是“空的”,没有真正分配空间**
|
||
|
||
---
|
||
|
||
# 四、malloc 申请内存时发生了什么?
|
||
|
||
假设你写:
|
||
|
||
```c
|
||
void* p = malloc(100);
|
||
```
|
||
|
||
## Step 1:glibc 先自己看看有没有库存
|
||
|
||
glibc 内部维护了一堆:
|
||
|
||
* 空闲链表(free list)
|
||
* 小块缓存(tcache)
|
||
* bin(各种大小分类)
|
||
|
||
👉 它会先尝试:
|
||
|
||
> “我手里有没有已经空着的 100 字节?”
|
||
|
||
如果有:
|
||
|
||
✔ 直接返回
|
||
❌ 不需要系统调用
|
||
|
||
👉 这一步非常重要(性能关键)
|
||
|
||
---
|
||
|
||
## Step 2:如果没有,就向操作系统要
|
||
|
||
这里分两种情况:
|
||
|
||
---
|
||
|
||
### 🟢 小内存(通常 < 128KB)
|
||
|
||
使用:
|
||
|
||
```
|
||
brk / sbrk
|
||
```
|
||
|
||
👉 扩展堆顶:
|
||
|
||
```
|
||
old brk → new brk
|
||
```
|
||
|
||
比如:
|
||
|
||
```
|
||
原来:
|
||
heap_end = 0x1000
|
||
|
||
malloc(100) →
|
||
|
||
heap_end = 0x2000
|
||
```
|
||
|
||
👉 这只是**虚拟地址变大了**
|
||
|
||
⚠️ 注意:
|
||
|
||
👉 **此时物理内存还不一定分配!**
|
||
|
||
---
|
||
|
||
### 🔵 大内存(通常 ≥ 128KB)
|
||
|
||
直接用:
|
||
|
||
```
|
||
mmap()
|
||
```
|
||
|
||
👉 在虚拟地址空间中单独映射一块区域:
|
||
|
||
```
|
||
[ mmap region ]
|
||
```
|
||
|
||
特点:
|
||
|
||
* 不走堆
|
||
* 独立管理
|
||
* 释放时直接 `munmap`
|
||
|
||
---
|
||
|
||
# 五、内核层:真正分配物理内存
|
||
|
||
重点来了(你之前问的核心)
|
||
|
||
👉 当 `brk` 或 `mmap` 发生时:
|
||
|
||
内核只是:
|
||
|
||
✔ 分配“虚拟地址区间”
|
||
✔ 建立页表(标记为“未映射”)
|
||
|
||
❌ **不会立刻分配物理内存**
|
||
|
||
---
|
||
|
||
## 真正分配发生在:访问时(缺页异常)
|
||
|
||
当你第一次写:
|
||
|
||
```c
|
||
p[0] = 1;
|
||
```
|
||
|
||
CPU 发现:
|
||
|
||
👉 页表里这个页没有物理页
|
||
|
||
于是:
|
||
|
||
1. 触发 **缺页异常(page fault)**
|
||
2. 内核分配一个物理页(比如 4KB)
|
||
3. 建立映射
|
||
4. 返回用户态继续执行
|
||
|
||
👉 这叫:
|
||
|
||
🧠 **按需分配(lazy allocation)**
|
||
|
||
---
|
||
|
||
# 六、glibc malloc 内部结构(重点)
|
||
|
||
glibc 的 malloc(ptmalloc)核心结构:
|
||
|
||
---
|
||
|
||
## 1️⃣ chunk(内存块)
|
||
|
||
每个 malloc 返回的内存,其实长这样:
|
||
|
||
```
|
||
[ metadata ][ user data ]
|
||
```
|
||
|
||
metadata 里存:
|
||
|
||
* size
|
||
* 是否空闲
|
||
* 前后指针
|
||
|
||
---
|
||
|
||
## 2️⃣ bin(分类管理)
|
||
|
||
glibc 把空闲块分成很多链表:
|
||
|
||
* fast bin(小块,快速)
|
||
* small bin
|
||
* large bin
|
||
* unsorted bin
|
||
|
||
👉 目的:
|
||
|
||
⚡ 提高查找速度
|
||
⚡ 减少碎片
|
||
|
||
---
|
||
|
||
## 3️⃣ tcache(线程缓存)
|
||
|
||
现代 glibc 有:
|
||
|
||
👉 **tcache(thread cache)**
|
||
|
||
特点:
|
||
|
||
* 每个线程独立
|
||
* 小块分配超快
|
||
* 几乎无锁
|
||
|
||
流程:
|
||
|
||
```
|
||
malloc →
|
||
先查 tcache →
|
||
没有再查 bin →
|
||
还没有再找系统要
|
||
```
|
||
|
||
---
|
||
|
||
# 七、free 时发生了什么?
|
||
|
||
```c
|
||
free(p);
|
||
```
|
||
|
||
不是直接还给操作系统!
|
||
|
||
而是:
|
||
|
||
### Step 1:放回 glibc 管理结构
|
||
|
||
* 加入 tcache 或 bin
|
||
* 标记为 free
|
||
|
||
---
|
||
|
||
### Step 2:可能合并(coalesce)
|
||
|
||
如果相邻块空闲:
|
||
|
||
👉 合并成大块(减少碎片)
|
||
|
||
---
|
||
|
||
### Step 3:是否还给 OS?
|
||
|
||
只有两种情况会:
|
||
|
||
#### ✔ mmap 分配的大块
|
||
|
||
→ `munmap()` 直接释放
|
||
|
||
#### ✔ 堆顶内存
|
||
|
||
如果释放的是:
|
||
|
||
👉 **heap 最顶部的一块**
|
||
|
||
可能触发:
|
||
|
||
```
|
||
brk 回缩
|
||
```
|
||
|
||
---
|
||
|
||
# 八、关键总结(你要记住的核心)
|
||
|
||
我给你压缩成一套“脑内模型”:
|
||
|
||
---
|
||
|
||
## 🔥 malloc 干了三件事
|
||
|
||
### ① 用户态复用内存
|
||
|
||
```
|
||
tcache / bin 找空闲块
|
||
```
|
||
|
||
---
|
||
|
||
### ② 不够就扩堆
|
||
|
||
```
|
||
小 → brk
|
||
大 → mmap
|
||
```
|
||
|
||
---
|
||
|
||
### ③ 内核懒分配物理页
|
||
|
||
```
|
||
访问时才真正分配(page fault)
|
||
```
|
||
|
||
---
|
||
|
||
# 九、结合你之前的问题(非常关键)
|
||
|
||
你之前问:
|
||
|
||
> 释放后物理内存还在吗?
|
||
|
||
答案是:
|
||
|
||
✔ **很可能还在(没有清零)**
|
||
✔ 但:
|
||
|
||
* 页表映射可能已经断开
|
||
* 或被别的进程复用
|
||
|
||
👉 用户不能直接访问物理地址
|
||
👉 必须通过虚拟地址 + 页表
|
||
|
||
---
|
||
|
||
# 十、一个完整流程(你可以当面试答案)
|
||
|
||
```c
|
||
char* p = malloc(100);
|
||
p[0] = 'A';
|
||
free(p);
|
||
```
|
||
|
||
完整过程:
|
||
|
||
1. malloc → glibc 查 tcache/bin
|
||
2. 没有 → 调用 brk 扩堆
|
||
3. 返回虚拟地址 p
|
||
4. p[0] = 'A' → 触发缺页异常
|
||
5. 内核分配物理页
|
||
6. 建立映射
|
||
7. 写入成功
|
||
8. free(p) → 放回 tcache/bin
|
||
9. 不一定归还操作系统
|
||
|
||
---
|
||
|
||
# 如果你想再进阶一点
|
||
|
||
我可以带你继续往下挖(这几个你现在已经能看懂了):
|
||
|
||
* ptmalloc 源码结构(malloc.c)
|
||
* tcache 攻击(CTF常见)
|
||
* 内存碎片是怎么产生的
|
||
* jemalloc / tcmalloc 和 glibc 的区别
|
||
* malloc 为什么有时候“越用越多”
|
||
|
||
你现在已经站在“操作系统 + runtime”交界的门口了,这块搞懂真的很爽 😄
|