资讯动态

Linux进程万字详解(四) | 进程地址空间深度剖析:虚拟内存、页表映射与缺页中断

发布时间:2026/10/26 1:55:56 来源:尧图企业网站定制
目录引言一、程序内存布局的直观观察1.1 一段揭示内存分布的代码1.2 运行结果与分析1.3 关键观察与结论二、从程序地址空间到进程地址空间2.1 一个关键认知转变2.2 证明虚拟地址的存在三、虚拟地址空间的核心机制3.1 虚拟地址空间的本质3.2 页表虚拟地址到物理地址的桥梁3.3 父子进程的页表机制3.4 回顾fork的返回值之谜四、虚拟地址空间的内核实现4.1 画饼的比喻4.2 mm_struct虚拟地址空间的内核表示4.3 vm_area_struct细粒度的区域管理五、为什么需要虚拟地址空间5.1 将无序变为有序5.2 保护物理内存5.3 进程管理与内存管理的解耦六、进程创建的完整图景6.1 创建顺序6.2 延迟分配与缺页中断七、进程的独立性再理解结语引言在Linux系统中每个进程都生活在一个幻觉之中——它以为自己独占了整个计算机的内存资源。这种幻觉并非系统的欺骗而是一种精心设计的工程智慧它就是我们今天要深入探讨的进程地址空间Process Address Space也称为虚拟地址空间Virtual Address Space。进程地址空间是连接进程管理与内存管理的核心枢纽。它既与硬件CPU的MMU内存管理单元密切相关又深度嵌入操作系统内核既影响着编译器生成可执行程序的方式又决定了动态链接库加载的底层逻辑甚至在多线程编程中线程共享与隔离的边界也由它划定。正因为其牵涉面如此之广理解进程地址空间成为掌握Linux系统编程的关键一步。本文将从一段简单的C程序出发通过观察内存地址的分布规律逐步揭示虚拟地址空间的本质通过父子进程的奇妙现象证明虚拟地址与物理地址的分离最终深入内核源码剖析mm_struct与vm_area_struct的数据结构设计理解页表映射、写时拷贝、缺页中断与进程挂起的底层机制。让我们开始这段从用户代码到内核结构的深度之旅。一、程序内存布局的直观观察1.1 一段揭示内存分布的代码让我们从一个实验开始用C语言打印程序中各类数据的内存地址#include stdio.h #include unistd.h #include stdlib.h int g_unval; // 未初始化全局变量 int g_val 100; // 已初始化全局变量 int main(int argc, char *argv[], char *env[]) { const char *str helloworld; printf(code addr: %p\n, main); printf(init global addr: %p\n, g_val); printf(uninit global addr: %p\n, g_unval); static int test 10; // 静态局部变量 char *heap_mem (char*)malloc(10); char *heap_mem1 (char*)malloc(10); char *heap_mem2 (char*)malloc(10); char *heap_mem3 (char*)malloc(10); printf(heap addr: %p\n, heap_mem); printf(heap addr: %p\n, heap_mem1); printf(heap addr: %p\n, heap_mem2); printf(heap addr: %p\n, heap_mem3); printf(test static addr: %p\n, test); printf(stack addr: %p\n, heap_mem); printf(stack addr: %p\n, heap_mem1); printf(stack addr: %p\n, heap_mem2); printf(stack addr: %p\n, heap_mem3); printf(read only string addr: %p\n, str); for(int i 0; i argc; i) { printf(argv[%d]: %p\n, i, argv[i]); } for(int i 0; env[i]; i) { printf(env[%d]: %p\n, i, env[i]); } return 0; }1.2 运行结果与分析code addr: 0x40059d init global addr: 0x60103c uninit global addr: 0x601048 heap addr: 0x1211c20 heap addr: 0x1211c40 heap addr: 0x1211c60 heap addr: 0x1211c80 test static addr: 0x601040 stack addr: 0x7ffc574ca958 stack addr: 0x7ffc574ca950 stack addr: 0x7ffc574ca948 stack addr: 0x7ffc574ca940 read only string addr: 0x400840 argv[0]: 0x7ffc574cb770 env[0]: 0x7ffc574cb778 ...地址分布规律区域地址范围特征代码区Text0x400000附近最低地址包含可执行指令只读数据区0x400840与代码区地址接近字符串常量存放于此已初始化数据区Data0x60103c全局变量、静态变量未初始化数据区BSS0x601048未初始化的全局变量堆区Heap0x1211c20起向上增长malloc分配栈区Stack0x7ffc...向下增长局部变量、函数参数命令行参数与环境变量0x7ffc574cb...位于栈区上方1.3 关键观察与结论1常量字符串的存放位置常量字符串helloworld的地址0x400840与main函数地址0x40059d十分接近。这说明字符串常量实际上被硬编码在代码段中。由于代码段是只读的因此字符串常量自然也是只读的。试图修改字符串常量会触发段错误Segmentation Fault这正是操作系统的内存保护机制在起作用。2static变量的本质test是局部变量理论上应在栈上但用了static修饰后其地址0x601040与全局变量处于同一区域。这说明static修饰的局部变量本质上就是全局变量只是其作用域被限制在定义它的函数内部生命周期则延长至整个程序运行期间。3全局变量的地址顺序已初始化全局变量0x60103c的地址低于未初始化全局变量0x601048。这符合标准的内存布局规范Data段在前BSS段在后。4堆与栈的增长方向堆地址从低向高增长0x1211c20→0x1211c40→0x1211c60→0x1211c80栈地址从高向低增长0x7ffc574ca958→0x7ffc574ca950→ ...。两者之间存在大片未使用的地址空间。二、从程序地址空间到进程地址空间2.1 一个关键认知转变曾经我们学的程序地址空间它不是物理内存。如果每个进程都按照上述固定布局直接占用物理内存那么新进程如何加入老进程如何退出多个进程同时运行时地址如何分配如何保证每个进程都有独立的地址范围物理内存不可能保证每个进程都是这种规则的布局。因此我们需要一个更本质的概念——进程地址空间Process Address Space也称为虚拟地址空间Virtual Address Space。这是一个操作系统层面的概念而非语言层面的概念。C/C指针使用的地址、进程看到的地址全都是虚拟地址。物理地址对用户完全不可见由操作系统统一管理。2.2 证明虚拟地址的存在让我们通过一个实验来证明这一点#include iostream #include unistd.h using namespace std; int gval 100; int main() { pid_t id fork(); if(id 0) { // 子进程 while(1) { cout 子gval: gval gval: gval pid: getpid() ppid: getppid() endl; sleep(1); gval; } } else { // 父进程 while(1) { cout 父gval: gval gval: gval pid: getpid() ppid: getppid() endl; sleep(1); } } return 0; }运行结果父gval:100 gval:0x60108c pid:28800 ppid:13036 子gval:100 gval:0x60108c pid:28801 ppid:28800 父gval:100 gval:0x60108c pid:28800 ppid:13036 子gval:101 gval:0x60108c pid:28801 ppid:28800奇妙的现象子进程的gval在修改100 → 101 → 102...父进程的gval保持不变始终100但两个进程的gval地址完全相同0x60108c结论这绝不可能是物理内存地址。同一个物理地址不可能同时存储两个不同的值100和101。因此我们看到的地址是虚拟地址C/C指针使用的全都是虚拟地址。三、虚拟地址空间的核心机制3.1 虚拟地址空间的本质每个进程拥有独立的虚拟地址空间。在32位系统中虚拟地址空间的范围是0到2^32-1即4GB。其中0 ~ 3GB用户空间User Space3GB ~ 4GB内核空间Kernel Space用户可以直接访问用户空间的数据。代码编译后变量名消失替换为内存地址通过这些地址即可访问代码、数据、环境变量等。3.2 页表虚拟地址到物理地址的桥梁当定义一个全局变量g_val时物理内存中存储g_val其地址是物理地址虚拟地址空间中也有一个对应的g_val位于初始化数据区其地址是虚拟地址操作系统为每个进程维护一张页表Page Table页表左侧记录虚拟地址右侧记录对应的物理地址访问流程进程访问虚拟地址 → OS查页表 → 转换为物理地址 → 访问实际数据这个转换过程由硬件MMU内存管理单元自动完成对程序完全透明。程序加载的完整流程将程序加载到内存本质上需要完成两个核心步骤在虚拟地址空间中申请指定大小的空间操作系统根据可执行文件的信息调整mm_struct中的区域划分设置各段的start和end在物理内存中申请空间并加载内容将代码和数据写入物理内存这两个步骤通过页表建立映射关系。从操作系统的视角看是先确定了物理地址再将其翻译为虚拟地址提供给上层用户。因此也可以理解为OS将物理地址转化为虚拟地址。以代码段为例编译后代码占多少字节OS在加载时是清楚的。先将代码写入物理内存然后在虚拟地址空间中开辟同等大小的区域最后在页表中建立两者的映射。这组映射关系在加载时就已准备好因此后续访问虚拟地址时一定能通过页表找到对应的物理地址。mm_struct作为内核对象需要初始化其初始值的主要来源正是程序加载到内存的过程。编译器在生成可执行文件时已确定各段的大小和属性加载器读取这些信息后填充到mm_struct中从而完成虚拟地址空间的初始化。3.3 父子进程的页表机制子进程的创建子进程的task_struct拷贝自父进程子进程的虚拟地址空间拷贝自父进程子进程的页表也拷贝自父进程浅拷贝因此在子进程未修改数据前父子进程的虚拟地址相同页表映射的物理地址也相同所以看到的全局变量值相同写时拷贝Copy-On-Write当子进程试图修改全局变量时操作系统介入因为进程需要保持独立性为子进程在物理内存中开辟新空间将原变量的内容拷贝到新空间修改子进程页表的映射关系虚拟地址不变物理地址改变子进程在新物理地址上进行修改这就是写时拷贝机制。它解释了为什么父子进程的全局变量值不同但虚拟地址相同——虚拟地址一样物理地址已经分道扬镳。代码段也是类似的共享机制。默认情况下程序的代码和数据都被父子进程共享通过页表映射到同一物理内存直到发生写操作时才分离。3.4 回顾fork的返回值之谜在《Linux进程万字详解一》中我们提出了fork的第三个问题为什么同一个变量既0又0现在可以进一步解释fork()返回前父子进程共享同一段代码和数据返回时对返回值变量的写入触发了写时拷贝父进程中的返回值被写入父进程的物理内存子进程中的返回值被写入子进程的物理内存两者虚拟地址相同都是接收返回值的变量地址但物理地址不同因此父进程看到子进程PID0子进程看到0所有这些操作都由操作系统自动完成无需用户干预。四、虚拟地址空间的内核实现4.1 画饼的比喻理解虚拟地址空间可以用一个生动的比喻一位大富翁有10亿美元资产他有多个互不相识的私生子。大富翁对每个私生子都说只要你好好干将来我的资产全是你的。私生子们零散地向大富翁要小钱大富翁都会给但要大钱就会被拒绝。这里大富翁就是操作系统10亿美元就是物理内存私生子们就是进程那张将来资产全给你的大饼就是虚拟地址空间。操作系统给每个进程画饼让每个进程都以为自己独占4GB内存32位系统。这种设计需要被管理——先描述再组织。4.2 mm_struct虚拟地址空间的内核表示虚拟地址空间在内核中的实现是一个数据结构——struct mm_struct。区域划分的原理想象课桌上的三八线桌子宽度100cm刻度0~99不需要保存每个刻度只需记录区域的起始和结束调整区域只需修改起始/结束值虚拟地址空间同理地址范围0到0xFFFFFFFF32位用整数记录每个区域的start和endmm_struct的核心属性struct mm_struct { // 代码段 unsigned long start_code, end_code; // 初始化数据段 unsigned long start_data, end_data; // 堆 unsigned long start_brk, brk; // 栈 unsigned long start_stack; // 命令行参数 unsigned long arg_start, arg_end; // 环境变量 unsigned long env_start, env_end; // ... };在当前进程的task_struct内部有mm_struct*类型的成员这个指针指向当前进程自己的地址空间。当程序变成进程时代码和数据加载到物理内存占用多少物理字节就在虚拟地址空间中同批次开辟同等大小填充页表时建立一一映射关系。4.3 vm_area_struct细粒度的区域管理堆区并非连续的一块区域。动态内存分配malloc可能产生多个离散的内存块每个块有自己的起始和结束地址。mm_struct中的start_brk和brk只能描述整个堆的范围如何管理内部的多个子区域答案是vm_area_struct虚拟内存区域struct vm_area_struct { unsigned long vm_start; // 虚存区起始 unsigned long vm_end; // 虚存区结束 struct vm_area_struct *vm_next, *vm_prev; // 链表指针 struct rb_node vm_rb; // 红黑树节点 struct mm_struct *vm_mm; // 所属的mm_struct unsigned long vm_flags; // 标志位读写执行权限 // ... };组织方式虚拟区域较少时使用单链表mmap指针指向链表头虚拟区域较多时使用红黑树mm_rb指向树根实现O(log n)查找mm_struct描述整个地址空间的概貌而vm_area_struct精确管理每个子区域。进程访问代码时可以直接定位到对应的vm_area_struct无需关心mm_struct中的整体边界。五、为什么需要虚拟地址空间虚拟地址空间的存在解决了三个核心问题5.1 将无序变为有序物理内存中多个进程的代码和数据交叉存放位置随机。但每个进程的虚拟地址空间都遵循统一的布局规则代码段→数据段→堆→栈。通过页表的映射操作系统可以将物理内存中的任意位置映射到虚拟地址空间的有序位置。上层应用程序看到的始终是规则、连续的地址分布无需关心物理内存的实际混乱状态。5.2 保护物理内存地址转换不仅是映射更是合法性检查。页表除了存储虚拟地址和物理地址的映射还记录访问权限读/写/执行。当进程试图写入只读的代码段 → 权限检查失败 → 触发段错误访问未分配的地址 → 页表无映射 → 触发段错误执行数据段的代码 → 权限检查失败 → 触发段错误野指针的本质访问一个虚拟地址但页表中无此映射未分配或已释放操作系统直接终止进程。这就是为什么char* str hello; *str H;能编译通过但运行崩溃——字符常量区在页表中被标记为只读。所以这种语法特性和你是CC没有关系这是操作系统层面的原因5.3 进程管理与内存管理的解耦假设进程代码有2GB但物理内存紧张只能加载500MB。虚拟地址空间可以映射完整的2GB虚拟地址只建立前500MB的物理映射访问剩余1.5GB时发现虚拟地址合法但物理地址缺失操作系统暂停进程从磁盘加载更多数据到物理内存更新页表映射恢复进程运行这就是缺页中断Page Fault。它使得进程只需关心自己的虚拟地址空间内存管理加载、换出、分配由操作系统透明处理两者通过页表解耦互不干扰进程挂起的实现当内存紧张时操作系统找到等待队列中的进程查页表清空物理地址映射将对应物理内存的数据写入磁盘swap分区进程依然存在但代码和数据已换出下次访问时触发缺页中断从swap重新加载。这正是解耦设计的优雅体现。反向思考没有虚拟地址空间会怎样假设去掉虚拟地址空间和页表让进程直接指向物理内存。此时进程要加载代码和数据就必须直接与物理内存打交道——每申请一块内存都要修改task_struct中的指针来记录位置内存紧张时需要换出数据又要再次更新PCB。进程管理与内存管理将强耦合在一起任何内存变动都会波及进程结构。而有了虚拟地址空间和页表进程管理只需关心虚拟地址的分配调整mm_struct内存管理只需关心物理内存的调配加载、换出、回收两者通过页表交互互不干扰。代码加载内存时只需修改页表映射PCB保持不变实现了优雅的解耦。六、进程创建的完整图景6.1 创建顺序先有内核数据结构后有代码和数据。创建进程时分配task_struct分配mm_struct虚拟地址空间建立页表初始为空或复制父进程后续逐步加载代码和数据按需分配延迟加载6.2 延迟分配与缺页中断malloc申请内存时只在虚拟地址空间上分配调整mm_struct中的区域边界物理内存可能一个字节都不分配真正访问时触发缺页中断操作系统执行内存分配算法申请物理内存建立页表映射这种延迟分配策略提高了内存使用效率由操作系统自动完成用户和进程完全无感知。七、进程的独立性再理解我们每一个进程都有自己独立的PCB、独立的虚拟地址空间、独立的页表而页表映射时映射到不同的物理内存这样进程就具有独立性了。进程 PCB 自己的代码和数据今天我们补充了PCB里的两个直接和间接成员类型mm_struct直接成员指向进程的虚拟地址空间vm_area_struct间接成员通过mm_struct访问管理虚拟地址空间的各个子区域描述Linux下进程地址空间的所有信息的结构体是mm_struct内存描述符。每个进程只有一个mm_struct结构在每个进程的task_struct结构中有一个指向该进程mm_struct结构体的指针struct task_struct { // ... struct mm_struct *mm; // 对于普通用户进程指向其虚拟地址空间的用户空间部分 struct mm_struct *active_mm; // 内核线程使用 // ... };对于内核线程mm字段为NULL因为内核线程没有用户空间内存地址空间。但所有进程关于内核的映射都是一样的所以内核线程可以使用任意进程的地址空间。结语通过本文的深入探讨我们完整揭开了Linux进程地址空间的神秘面纱。从一段简单的C程序出发我们观察到了内存布局的规律通过父子进程的奇妙实验我们证明了虚拟地址与物理地址的分离最终深入内核源码理解了mm_struct与vm_area_struct的设计哲学以及页表映射、写时拷贝、缺页中断等核心机制。虚拟地址空间的设计堪称操作系统中最精妙的抽象之一。它通过画饼的方式让每个进程都以为自己独占内存通过页表的映射将物理内存的无序变为虚拟地址的有序通过权限检查守护了系统的安全稳定通过解耦设计让进程管理与内存管理各司其职。这些机制相互配合共同构成了现代操作系统高效、安全、灵活的内存管理基石。回顾我们走过的路从冯诺依曼体系结构到进程控制块从进程状态到调度算法再到今天的虚拟地址空间——Linux进程管理的全景图正在逐步展开。进程地址空间不仅是理解内存管理的钥匙更是后续学习动态链接、线程机制、进程间通信的必备基础。希望本文能为读者建立坚实的认知框架在系统编程的道路上走得更远、更稳。操作系统的设计之美正在于这些看似抽象的机制背后蕴含着对硬件特性的深刻洞察、对软件需求的精准把握以及对工程复杂度的优雅掌控。进程地址空间正是这种设计之美的绝佳体现。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑