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