ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

虚拟内存(虚拟地址空间)

2026/8/18 10:20:33 拓冰建站 浏览量
虚拟内存(虚拟地址空间) 虚拟内存虚拟地址空间一、虚拟地址空间的基本概念1.1 为什么需要虚拟地址空间直接访问物理内存的问题如果进程直接访问物理地址会出现地址冲突A进程用了地址0x1000B进程就不能再用这个地址程序编写困难程序员必须时刻注意和其他进程抢地址缺乏内存保护A进程可以恶意修改B进程的内存解决方案给每个进程画一张大饼——虚拟地址空间1.2 虚拟地址空间的本质一个进程一个虚拟地址空间每个进程都以为自己独占了整个内存。在32位系统中虚拟地址空间大小为4GB2322^{32}232字节在64位系统中这个范围更大虚拟地址空间中的地址单位为 1 字节1.3 类比大富翁与私生子老师用了一个形象的类比操作系统 大富翁物理内存 十个亿进程 私生子虚拟地址空间 画的大饼大富翁不会真的把十个亿给私生子但会给私生子画饼“这栋楼是你的那块地也是你的”私生子以为这些资产真的属于自己实际上只是名义上的操作系统给每个进程都画了一个4GB的大饼当进程真的要钱要内存时操作系统才从物理内存中分配一小块核心思想虚拟地址空间本质上是先描述再组织——先给进程一个完整的地址范围描述实际使用时按需分配物理内存。二、用户空间与内核空间的划分2.1 地址空间划分高地址 ┌────────────────────────┐ │ 内核空间 (1GB) │ ← 3GB ~ 4GB │ │ [内核代码、内核数据结构] ├────────────────────────┤ │ │ │ 用户空间 (3GB) │ ← 0 ~ 3GB │ │ [进程的代码、数据、堆、栈] │ │ └────────────────────────┘ 低地址 (0x00000000)用户空间0 ~ 3GB共3GB存放进程自己的代码和数据内核空间3GB ~ 4GB共1GB存放操作系统内核代码2.2 为什么要这样划分用户进程不能直接访问内核空间受硬件保护进程需要通过系统调用才能进入内核态执行特权操作这种划分实现了用户态和内核态的隔离是操作系统安全的基础三、页表映射机制3.1 虚拟地址如何变成物理地址页表Page Table是虚拟地址到物理地址的映射表。进程视角虚拟地址 实际硬件物理地址 ┌─────────────┐ ┌─────────────┐ │ 虚拟地址 │ ──页表映射 ─→│ 物理地址 │ │ 0x12345678 │ │ 0xABCDEF00 │ └─────────────┘ └─────────────┘ ↓ [页表查询] ↓ MMU硬件自动完成转换关键理解每个进程都有自己的独立的页表页表存储了虚拟地址 → 物理地址的对应关系地址转换由操作系统自动完成程序员无需关心不同进程可以拥有相同的虚拟地址但通过各自的页表映射到不同的物理地址3.2 MMU内存管理单元MMU 是 CPU 内部的硬件组件负责自动将虚拟地址转换为物理地址每次内存访问都会经过 MMU 的地址翻译如果虚拟地址没有映射到物理地址会触发缺页中断3.3 缺页中断Page Fault进程访问虚拟地址 │ ▼ MMU查页表 │ ┌───┴───┐ │ │ 命中 未命中 │ │ ▼ ▼ 直接访问 触发缺页中断 物理地址 │ ▼ 操作系统介入 │ ▼ 分配物理内存 / 从磁盘加载 │ ▼ 更新页表映射 │ ▼ 重新执行访问指令缺页中断处理流程进程访问一个尚未映射的虚拟地址MMU发现页表中没有该地址的映射 → 触发缺页中断操作系统接管处理中断操作系统分配一块物理内存或从磁盘交换区加载数据更新页表建立虚拟地址到新物理地址的映射恢复进程执行重新执行导致缺页的指令四、fork() 与写时拷贝Copy-on-Write4.1 fork() 的返回值之谜pid_tpidfork();if(pid0){// 子进程}elseif(pid0){// 父进程}问题为什么同一个变量pid既等于 0 又大于 0答案fork() 后子进程拷贝父进程的虚拟地址空间和页表父子进程的pid变量虚拟地址相同但两个进程的页表是独立的这个虚拟地址映射到不同的物理地址父进程的pid物理地址中存的是子进程的PID0子进程的pid物理地址中存的是 0父进程 子进程 ┌──────────┐ ┌──────────┐ │ 虚拟地址 │ │ 虚拟地址 │ │ 0x7fff.. │ │ 0x7fff.. │ ← 相同虚拟地址 └────┬─────┘ └────┬─────┘ │ │ ▼ ▼ 页表A 页表B │ │ ▼ ▼ 物理地址A 物理地址B (存PID0) (存0)4.2 写时拷贝Copy-on-Write, COWfork() 的优化策略fork() 创建子进程时并不会立即复制物理内存而是让父子进程共享相同的物理内存页并将这些页标记为只读。fork() 刚完成时 父进程 子进程 ┌──────────┐ ┌──────────┐ │ 虚拟地址 │ │ 虚拟地址 │ └────┬─────┘ └────┬─────┘ │ │ ▼ ▼ 页表A 页表B │ │ └────────┬─────────────────┘ ▼ 共享物理内存 标记为只读当任意一方要修改数据时触发写保护异常操作系统为修改方分配新的物理内存页将原数据复制到新页更新该进程的页表指向新物理页恢复写操作父进程写数据后 父进程 子进程 ┌──────────┐ ┌──────────┐ │ 虚拟地址 │ │ 虚拟地址 │ └────┬─────┘ └────┬─────┘ │ │ ▼ ▼ 页表A 页表B │ │ ▼ ▼ 新物理页A 原物理页 (已修改) (未修改)COW 的好处避免不必要的内存复制提高 fork() 效率如果子进程只是 exec() 新程序原进程的内存根本不用复制只有真正需要修改的页才会被复制五、mm_struct 数据结构5.1 虚拟地址空间的内核表示mm_struct 是 Linux 内核中表示进程虚拟地址空间的数据结构。structmm_struct{// 代码段unsignedlongcode_start;// 代码区起始地址unsignedlongcode_end;// 代码区结束地址// 数据段unsignedlongdata_start;// 初始化数据区起始unsignedlongdata_end;// 初始化数据区结束// BSS段unsignedlongbss_start;// 未初始化数据区起始unsignedlongbss_end;// 未初始化数据区结束// 堆unsignedlongheap_start;// 堆起始地址unsignedlongheap_end;// 堆结束地址动态增长// 栈unsignedlongstack_start;// 栈起始地址unsignedlongstack_end;// 栈结束地址// ... 其他字段};5.2 先描述再组织内核管理所有进程的 mm_struct 采用的是**“先描述再组织”**思想描述用struct mm_struct描述每个进程的虚拟地址空间组织用struct list_head链表将所有 mm_struct 串联起来管理内核中的 mm_struct 链表 ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ mm_struct│───→│ mm_struct│───→│ mm_struct│───→│ mm_struct│ │ 进程A │ │ 进程B │ │ 进程C │ │ 进程D │ └──────────┘ └──────────┘ └──────────┘ └──────────┘5.3 区域调整的本质调整内存区域如堆增长、栈扩展本质上就是修改 mm_struct 中对应的 start/end 整数值。例如malloc分配内存时操作系统找到合适的虚拟地址范围修改heap_end的值建立新的页表映射返回虚拟地址给进程六、虚拟地址空间布局详解6.1 完整布局图高地址 (0xFFFFFFFF) ┌─────────────────────────────┐ │ 内核空间 (1GB) │ ← 3GB ~ 4GB │ [内核代码 / 内核数据结构] │ ├─────────────────────────────┤ ← 0xC0000000 (3GB) │ │ │ 栈区 (Stack) │ ← 向下增长 │ [局部变量/函数参数] │ │ │ ├─────────────────────────────┤ │ 共享区 │ ← 共享内存/动态库映射 │ │ ├─────────────────────────────┤ │ 堆区 (Heap) │ ← 向上增长 │ [动态分配内存] │ │ │ ├─────────────────────────────┤ │ BSS段 │ ← 未初始化全局/静态变量 │ │ ├─────────────────────────────┤ │ 数据段 (Data) │ ← 已初始化全局/静态变量 │ │ ├─────────────────────────────┤ │ 代码段 (Text/Code) │ ← 程序指令/只读数据 │ │ ├─────────────────────────────┤ │ 保留区 │ ← 空指针解引用保护 │ │ ├─────────────────────────────┤ │ 命令行参数 / 环境变量 │ │ │ └─────────────────────────────┘ 低地址 (0x00000000)6.2 类比同桌三八线老师用了一个非常生动的类比来解释虚拟地址空间的区域划分桌子 地址空间三八线 区域划分刻度 地址start/end 区域边界一张大桌子4GB地址空间用三八线划分成不同区域代码区、数据区、堆区、栈区桌子上的刻度对应内存地址每个区域的 start 和 end 就是边界线区域调整就像移动三八线的位置这个类比的核心地址空间不是随意分配的而是结构化、有明确边界的每个区域有明确的用途和增长方向堆向上栈向下区域之间留有间隙shared area用于映射共享库和共享内存6.3 各区域详解区域内容特点代码段程序指令、只读数据只读可共享数据段已初始化全局变量、静态变量可读写BSS段未初始化全局变量、静态变量不占文件空间运行时清零堆malloc/new 分配的内存向上增长动态分配共享区共享内存、动态链接库进程间共享栈局部变量、函数参数、返回地址向下增长自动管理命令行/环境argv、envp程序启动参数七、变量的虚拟地址与物理地址7.1 每个变量有两个地址程序中的每个变量同时拥有虚拟地址和物理地址用户只能看到虚拟地址。inta10;printf(%p,a);// 打印的是虚拟地址7.2 取地址的本质a取的是变量在虚拟地址空间中的地址这个虚拟地址通过页表映射到某个物理地址用户程序永远无法直接获取或操作物理地址7.3 不同进程的相同虚拟地址进程A 进程B ┌──────────┐ ┌──────────┐ │ int a │ │ int a │ │ 0x7fff.. │ │ 0x7fff.. │ ← 虚拟地址相同 └────┬─────┘ └────┬─────┘ │ │ ▼ ▼ 页表A 页表B │ │ ▼ ▼ 物理地址X 物理地址Y ← 物理地址不同这正是虚拟地址空间的价值所在进程可以假装自己有独立的完整地址空间相同的代码在不同进程中运行时变量的虚拟地址可以相同但实际存储在不同的物理位置互不干扰八、核心结论汇总虚拟地址空间是每个进程的大饼让进程以为自己独占整个内存32位系统虚拟地址空间为 4GB地址单位是 1 字节0~3GB 是用户空间3~4GB 是内核空间页表负责虚拟地址到物理地址的映射由 MMU 硬件自动完成用户只能看到虚拟地址物理地址对进程透明不同进程可以有相同的虚拟地址通过各自页表映射到不同物理地址fork() 创建子进程时拷贝虚拟地址空间和页表但共享物理内存fork() 后同一变量的虚拟地址相同但映射到不同物理地址所以返回值不同写时拷贝COW修改共享页时才真正复制物理内存mm_struct 是内核描述虚拟地址空间的数据结构内核用链表组织所有 mm_struct体现先描述再组织区域调整就是修改 mm_struct 中的 start/end 整数值堆向上增长栈向下增长中间是共享区代码段只读可共享数据段和 BSS 段可读写缺页中断是虚拟内存的核心机制实现了按需分配MMU 是地址转换的硬件基础集成在 CPU 内部虚拟内存使得隔离性、共享性、大地址空间三大目标得以实现九、常见问题 QAQ1: 为什么 32 位系统最大支持 4GB 内存A: 32 位地址线最多表示2322^{32}232 4,294,967,296 个地址每个地址 1 字节所以是 4GB。Q2: 虚拟地址空间和物理内存大小必须一样吗A: 不需要。虚拟地址空间是 4GB但物理内存可以只有 512MB。通过页表映射 磁盘交换swap程序可以运行。Q3: 为什么 fork() 后父子进程的变量地址相同但值不同A: 因为打印的是虚拟地址虚拟地址相同但两个进程的页表独立映射到不同的物理地址物理地址中的值不同。Q4: malloc 分配的是虚拟内存还是物理内存A: malloc 分配的是虚拟地址空间修改 heap_end只有在实际访问这段内存时才会通过缺页中断分配物理内存。Q5: 什么是内存泄漏A: 申请了堆内存malloc但没有释放free导致 heap_end 持续增长进程可用虚拟地址空间减少。