Files
Operating-System/Memory/readme.md
2026-03-22 17:40:44 +08:00

29 KiB
Raw Permalink Blame History

高地址 ┌──────────────────────────────┐ │ 内核空间 (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(代码段)

👉 存什么?

  • 机器指令(编译后的)
  • 只读

比如:

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

🔹 内核做的事:

  1. 创建进程fork/exec
  2. 创建虚拟地址空间
  3. 加载 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)


👉 整个过程:

全部发生在“用户栈”


🚀 阶段 4printf 执行

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% 主要集中在两个点:

  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

正确是:

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

内核做:

  1. 加载 ELF
  2. 发现需要动态链接
  3. 启动动态链接器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

👉 动态链接器做:

  1. 在 libc.so 中查找 printf
  2. 得到地址

🟢 阶段 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做的事

  1. 拆分:
页号 + offset
  1. 查页表:
页号 → 物理页(比如 0x111000
  1. 拼起来:
物理地址 = 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 函数开始工作:

  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):发现手里没余粮了,调用系统调用 brkmmap
  3. 内核系统调用接口:接收到 mmap 请求。
  4. 内核内存管理 (VMA):在进程的虚拟地址空间里找一段空白区域,塞进去一个 vm_area_struct 结构体(这就算是“分配”了虚拟内存)。
  5. MMU/页表:此时页表还是空的。直到你第一次读写这块内存,触发 Page Fault,内核才真正去翻“物理页”的牌子。

3. 总结mmap 的起点

如果你问的是时间点

  • 最早起点:在 execve 系统调用执行过程中,内核加载 ELF 文件头之后,就开始为程序的代码段进行第一次 mmap 映射了。

如果你问的是空间位置

  • 代码段通常在较低的地址。
  • 共享库(通过 mmap 显式调用的)通常从虚拟地址空间的中间某处开始向下或向上生长。

验证一下?

你可以写一个最简单的 hello world,然后在 Fedora 上运行:

strace ./main 2>&1 | grep mmap

你会惊奇地发现,即便你程序里一行 mmap 都没有写,输出里也会有一大堆 mmap(...)。这些全都是内核和动态链接器在帮你“打江山”。

既然你对 mmap 分配内存的起始点这么感兴趣,想不想聊聊 mmap 分配出来的虚拟地址,是怎么通过“多级页表”这种像树一样的结构最终找到物理内存的?