【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct
2026/7/1 2:02:12 网站建设 项目流程

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、先回顾程序地址空间的整体布局
    • 1.1 地址空间里有哪些区域
    • 1.2 用代码验证各区域地址
  • 二、父子进程为什么能打印相同地址
    • 2.1 先看父子进程的实验现象
    • 2.2 一个进程一套地址空间和页表
    • 2.3 写时拷贝如何发生
  • 三、虚拟地址空间到底是什么
    • 3.1 用大富翁理解进程视角
    • 3.2 内核怎样管理虚拟地址空间
  • 四、虚拟地址空间如何划分区域
    • 4.1 用三八线理解区域边界
    • 4.2 task_struct如何关联mm_struct
    • 4.3 程序加载时怎样建立映射
  • 五、为什么要有进程地址空间
    • 5.1 把无序物理内存整理成有序视图
    • 5.2 地址转换时同时检查权限
    • 5.3 缺页中断和按需加载
    • 5.4 几个容易混淆的问题
      • 5.4.1 VMA如何管理不连续区域
  • 总结

前言

前面学习进程时,我们已经见过变量、函数和malloc返回的地址。可这些地址是不是物理地址,父子进程为什么能打印相同地址却得到不同数据,都要用程序地址空间解释。

本文只讨论程序地址空间,不再扩展进程状态、调度和环境变量。为了和Linux内核概念对应,后文统一使用更准确的“进程地址空间”。


一、先回顾程序地址空间的整体布局

1.1 地址空间里有哪些区域

在32位平台下,一个进程看到的是连续排列的虚拟地址。代码区、数据区、堆、共享区、栈以及命令行参数和环境变量,都在其中占据自己的位置。通常堆向高地址增长,栈向低地址增长。这里画的是虚拟地址布局,不是物理内存真的连续排好。

堆和栈之间的大段“空白”,不代表物理内存已经提前分配,只是这部分虚拟地址暂时没有被当前进程使用。后面申请空间、加载动态库或建立映射时,其中一部分才会真正参与映射。

1.2 用代码验证各区域地址

字符串常量不能直接修改。下面把函数、全局变量、堆、栈、字符串常量、命令行参数和环境变量的地址都打印出来。字符串常量与代码区地址接近,而对应区域通常只有读和执行权限,没有写权限。具体段名会受编译器和链接方式影响,但结论不变:不能把字符串常量当作普通可写数组。

#include<stdio.h>#include<unistd.h>#include<stdlib.h>intg_unval;intg_val=100;intmain(intargc,char*argv[],char*env[]){constchar*str="helloworld";printf("code addr: %p\n",main);printf("init global addr: %p\n",&g_val);printf("uninit global addr: %p\n",&g_unval);staticinttest=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);//heap_mem(0), &heap_mem(1)printf("heap addr: %p\n",heap_mem1);//heap_mem(0), &heap_mem(1)printf("heap addr: %p\n",heap_mem2);//heap_mem(0), &heap_mem(1)printf("heap addr: %p\n",heap_mem3);//heap_mem(0), &heap_mem(1)printf("test static addr: %p\n",&test);//heap_mem(0), &heap_mem(1)printf("stack addr: %p\n",&heap_mem);//heap_mem(0), &heap_mem(1)printf("stack addr: %p\n",&heap_mem1);//heap_mem(0), &heap_mem(1)printf("stack addr: %p\n",&heap_mem2);//heap_mem(0), &heap_mem(1)printf("stack addr: %p\n",&heap_mem3);//heap_mem(0), &heap_mem(1)printf("read only string addr: %p\n",str);for(inti=0;i<argc;i++){printf("argv[%d]: %p\n",i,argv[i]);}for(inti=0;env[i];i++){printf("env[%d]: %p\n",i,env[i]);}return0;}

普通局部变量一般建立在栈上,而函数内的static变量虽然作用域仍在函数内部,存储周期却贯穿整个进程运行过程。它不会随着函数返回而销毁,所以地址更接近全局数据区。换句话说,static改变的是存储周期和链接属性,不能简单理解成“局部变量变成了全局变量”。

进程地址空间是内存吗?不是。它是操作系统层面的概念,不是C/C++语言直接创建的一块真实内存。
物理内存中同时放着多个进程、内核和缓存的数据,不可能始终按照某个进程的代码区、堆区、栈区整齐排列。我们在程序中看到的有序布局,是操作系统提供给进程的一种视图。

二、父子进程为什么能打印相同地址

2.1 先看父子进程的实验现象

#include<stdio.h>#include<unistd.h>#include<stdlib.h>intgval=100;intmain(){//c99并不认识pid_t,因此在makefile中不带c99pid_tid=fork();if(id==0){while(1){printf("子:gval:%d,&gval:%p,pid:%d,ppid:%d\n",gval,&gval,getpid(),getppid());sleep(1);gval++;}}else{while(1){printf("父:gval:%d,&gval:%p,pid:%d,ppid:%d\n",gval,&gval,getpid(),getppid());sleep(1);gval++;}}}


前面学习fork时已经知道,父子进程之间存在写时拷贝。子进程修改gval后不会影响父进程,说明它们最终访问的不是同一份可写数据;但程序打印出来的&gval却完全相同。这个现象正好说明:程序打印的地址不是最终的物理地址。

如果这个地址就是物理地址,同一个地址一会儿读到100、一会儿又读到105,内存模型就无法解释。现在结果稳定出现,说明父进程和子进程看到的是相同的虚拟地址,而各自页表可以把它映射到不同的物理位置。

这种由操作系统提供给进程使用的地址称为虚拟地址。回头看C/C++中的指针、取地址操作以及malloc返回值,它们在用户态看到的都是虚拟地址。物理地址由操作系统和硬件共同管理,普通程序不会直接拿到。

2.2 一个进程一套地址空间和页表

结论:一个进程一个虚拟地址空间。

每个pcb都要对应一个虚拟地址空间

在32位平台下,地址由32个二进制位表示,一共可以组合出2^32个不同地址。机器采用按字节编址,一个地址对应一个字节,因此理论虚拟地址范围是4GB。这里的4GB是每个进程能够看到的地址范围,不等于系统必须立刻为每个进程准备4GB物理内存。

0-3GB我们称为用户空间,3-4GB称为内核空间。

用户空间并不是拿到任意地址都能直接访问。只有已经建立合法映射,并且页表权限允许当前操作的地址才能使用;访问未映射地址或违反读写权限,进程通常会收到段错误。

在我们定义全局变量g_val时其肯定在内存中(0x123456),否则其怎么在硬件上被cpu读取。与此同时我们在地址空间上也要有对应的4个字节的全局变量g_val,拿其的起始虚拟地址(0x11111).

因此一个变量会有虚拟地址也会有内存地址。在OS内,为每个进程创建页表。

一个进程一个虚拟地址空间,一套页表。

页表负责记录虚拟页到物理页框之间的映射关系。进程访问虚拟地址时,CPU中的MMU会按照当前进程的页表完成地址转换,再访问对应的物理内存。映射通常以页为单位,而不是为每个int变量单独建立一条记录;同一页中的多个变量会通过页内偏移找到自己的具体位置。

在上面代码中,我们定义的g_val是int类型,也就是四个字节,可是在页表中,我们只说了其通过它的起始虚拟地址即一个地址便可以通过页表找到对应的一个物理地址,可是int类型有四个地址呀。实际上对一个int类型变量取地址只拿到其四个地址中值最小字节的地址即起始地址,

由于系统按字节编址,一个地址定位一个字节。类型信息告诉编译器这次需要连续读取多少字节:int通常读取4个字节,char读取1个字节。指针保存起始虚拟地址,CPU完成地址转换后,再结合页内偏移访问完整对象。

2.3 写时拷贝如何发生

在上面代码中,我们一共有两个进程,一个是父进程,一个是子进程,子进程也要有自己的虚拟地址空间,一个进程一套页表,所以子进程也要有自己的页表。子进程的pcb是拷贝自父进程,地址空间也是拷贝自父进程,页表的内容也是要拷贝自父进程,拷贝意味着在子进程的初始化数据区里会有全局变量g_val的地址,同时页表也会将父进程的g_val的映射关系拷贝下来,即指针的浅拷贝

因此父子进程打印出的g_val地址相同,是因为它们继承了相同的虚拟地址布局。fork刚完成时,为了避免立刻复制全部物理页,父子页表可以先指向同一批只读共享页。此时“地址相同”说的是虚拟地址相同,并不代表两个进程永远共用同一个可写全局变量。

数据是这样,代码肯定也是如此,那么父子进程的代码和数据是共享的

下面故事变发展成,子进程要对变量进行修改,因为进程具有独立性,所以为了防止子进程的修改对父进程有影响,所以OS会为要修改的变量在物理地址上开辟一个新的空间,将原地址内容数据拷贝到新空间中,也就是子进程会得到一个新的物理地址(0x223344),OS再将子进程的虚拟地址对应的原来物理地址映射改成新的物理地址。

修改发生时,操作系统再为写入方准备新的物理页,复制原数据并调整该进程的页表。于是父子进程中的同一个虚拟地址开始对应不同物理页,这就是写时拷贝

普通用户看不到物理地址。fork之后父进程得到子进程PID,子进程得到0,看起来像“同一次返回出现两个结果”,本质上是两个进程分别恢复执行,并在各自的执行上下文中拿到不同返回值。二者的虚拟地址布局相似,但进程上下文和可写数据彼此独立,这也是if与else能够分别进入的原因。

三、虚拟地址空间到底是什么

3.1 用大富翁理解进程视角

举个例子:有一个大富翁有10个亿家产,要分给其几个孩子,孩子彼此不知道彼此,他和每个孩子都说要好好学习,以后所有家产都由你继承,所有孩子都认为自己可以拥有十个亿,可是事实上不可能,大富翁只有十个亿,但是每个孩子只是想要部分钱是可以做到的,比如孩子1说要交学费5000,孩子2说想要买皮肤,孩子3说想要买吉他,这些父亲都可以满足。

因此我们便可以知道大富翁是对几个孩子画饼,让每个人都认为自己有10个亿

在上面故事中,大富翁就是OS,十个亿就是物理内存,孩子1234就是进程,大饼就是虚拟地址空间。即在32位下每个进程都认为自己有4GB的物理内存,或者每个进程都认为自己在独占物理内存。

当富翁给孩子画了很多饼,每个内容都不一样,会产生混乱,进程(孩子)需要管理,虚拟地址空间(饼)也需要管理。

3.2 内核怎样管理虚拟地址空间

那么怎么管理虚拟地址空间?还是前面说过的思路:先描述,再组织。用结构体描述地址空间,再把需要管理的区域组织起来。

所以从Linux内核的管理角度看,虚拟地址空间不是一块真实内存,而是一组由数据结构描述的地址范围。核心结构是struct mm_struct,进程PCB也就是task_struct通过指针与它关联。先把地址范围描述清楚,操作系统才能继续建立页表、设置权限并管理各个区域。

虚拟地址空间是OS为进程创建的结构体

四、虚拟地址空间如何划分区域

4.1 用三八线理解区域边界

什么叫做区域划分,虚拟地址空间就是从00000…到fffffff…,编址时就是依次增大排序,可是我们发现在其中有很多区域,如正文代码区和堆区,这些是怎么划分的

例子:在幼儿园中,桌子的宽度是100厘米,小明是个补休边幅的男生,旁边坐了一个干净的小红(女生),小红在桌子上划了一条线(三八线),禁止小明越过去。

小红划线本质是划分区域,用计算机量化下小红的行为。

structDestop{//记录小明在桌子上活动范围的开始和结束intxiaoming_start;intxiaoming_end;//小红的区域intxiaohong_start;intxiaohong_end;}structDestoparea={0,49,50,99};

区域划分只需要定好开始与结束即可。

桌子的宽度是100厘米,即桌子上有100个刻度,小明的区域是0-49,小明想要将笔放在第3个刻度上,铅笔盒放在第11个刻度上,这些刻度在虚拟地址空间中就是虚拟地址。桌子是100厘米,刻度是线性连续的,这个过程就是对桌子进行统一编址,即每个刻度都有对应的地址。地址可以用int来保存

桌子称为地址空间,桌子对应的刻度是地址空间上的地址即虚拟地址,桌子上的朋友划分各自的范围。

因此在mm_struct结构体中需要保存

//正文代码区的开始与结束longcode_start;longcode_end;//初始化数据区的开始与结束longinit_start,init_end;//未初始化数据区的开始与结束longunint_start,unint_end;//等等等...

虚拟地址空间是结构体,结构体里存放的是是每个区域的开始虚拟地址和结束虚拟地址,因此便可以划分区域了!

后来小明同学很嚣张,动不动越过规定的线,小红就将三八线往小明这移动了20cm,变成三七分了,这个行为就是调整区域,用计算机语言表达就是对整数变量进行加减即可

area.xiaohong_start-=20;area.xiaoming_end-=20;

4.2 task_struct如何关联mm_struct

下面继续从内核结构看两者的关系。

在进程中会给我们创建task_struct,同时里面包含了mm_struct指针(即虚拟地址空间)

structtask_struct{/*...*/structmm_struct*mm;//对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。structmm_struct*active_mm;// 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。/*...*/}

在进程的task_struct中包含了指针struct mm_struct*mm,这个指针指向当前进程自己的虚拟地址空间。

4.3 程序加载时怎样建立映射

不同程序的代码和数据大小不同,所以每个进程的区域边界也会不同。程序运行后,内核先根据可执行文件描述虚拟区域,再按页建立映射。物理页不要求整体连续,也不必一次全部加载;只要页表能够把连续的虚拟页正确映射到相应物理页,进程看到的地址布局仍然是连续、有序的。

把程序变成可运行进程,地址空间这一侧主要需要做三件事:
1.在虚拟地址空间中申请指定大小的空间(调整区域划分,start/end值发生变化即可)
2.加载程序,需要申请物理空间
3.将虚拟地址和物理地址放在页表中进行映射

五、为什么要有进程地址空间

5.1 把无序物理内存整理成有序视图

所以

  1. 进程地址空间把物理内存中的无序放置,整理成进程视角下稳定、有序的地址布局。程序只按照自己的虚拟地址运行,不必关心某一页数据此刻具体放在哪个物理页框。

5.2 地址转换时同时检查权限

CPU执行代码时,为什么不能直接去物理内存查找,还要多做一次地址转换?

举个简单的例子,小明在春节拿到压岁钱乱买东西,被妈妈发现了,将压岁钱进行保管,小明想要再去商店买东西,必须获得妈妈的允许才行。

在虚拟地址上访问我们的代码时,OS会查页表,页表项里面除了有物理地址和虚拟地址外,还有读,写,执行等权限。也就是说当我们访问页表时,对一个代码区进行写入,OS查页表,发现要进行w操作,但是用户对代码区只拥有只读权限,此时OS不会对进程的虚拟地址进行转化,更有可能直接杀掉你这个进程,因此就可以实现对物理内存的保护

  1. 地址转换时可以同时检查页表权限。代码页可以只读和执行,普通数据页可以读写,未映射区域不能访问。错误指针越界后,操作系统能够阻止它继续破坏其他进程或内核的数据。

当我们访问一个在地址空间上未分配的地址,用户指针指向这,但是OS在查页表时并不存在指定的虚拟地址,也就是野指针,进程有可能会被杀掉。

下面这段代码保留原样,重点看它想表达的动作:让指针指向字符串常量后尝试写入。原代码中的变量名重复定义需要先修正;即使把语法问题改好,对字符串常量写入仍会在运行期失败。

char*str="helloworld",*str="H";

为什么向字符串常量所在的只读区域写入会崩溃?

把重复定义修正后,程序仍不能修改字符串常量。访问发生时,页表会检查当前页面的权限;写操作不符合只读区域的权限要求,CPU会触发异常,操作系统通常终止当前进程。

5.3 缺页中断和按需加载

假设一个程序的代码部分就有2GB了,物理内存需要跑其他程序,因此不会将你的代码全部加载进来,可能只加载1/4进来,将正文代码区映射为2GB(虚拟地址全填上,物理地址只填了1/4),但是我们只将前500MB的虚拟地址和物理地址的映射建立好,也就是说还有1.5GB没有加载进来,当OS不断访问时,发现虚拟地址有,但物理地址并不在内存里,这个时候OS就可以实现动态加载,再把500MB加载进来,把物理地址重新填上来,再次建立好映射关系,再让程序继续运行,这种机制就称为缺页中断

  1. 地址空间让进程管理和内存管理解耦。创建进程时可以先建立task_struct、mm_struct和合法虚拟区域,真正访问某一页时再通过缺页中断分配或调入物理页,这就是按需加载。

5.4 几个容易混淆的问题

创建进程的时候可不可以只创建pcb,只创建地址空间,再从磁盘里的可执行程序里读取代码和数据各自是多大,在虚拟地址上把空间开辟好,即只填虚拟地址不填物理地址。就是进程的代码数据一行都不加载可以吗?

答案是可以的,cpu拿到起始地址,直接去访问起始地址,OS发现虚拟地址与物理地址映射不过来,虚拟地址是合法的,物理地址不在内存里,OS自动做缺页中断,自动完成物理地址加载并填充任务,缺页中断完成后,继续让进程运行。

  1. 可不可以暂时不加载代码和数据?

    可以先创建PCB、struct mm_struct和页表相关结构,真正访问时再按需加载。

  2. 先创建内核数据结构,还是先加载代码和数据?

    先有描述进程和地址空间的内核数据结构,再根据访问情况加载代码和数据。

  3. 如何理解进程挂起?

    内存紧张时,操作系统可以把暂时不用的页面换出到swap分区,并调整页表状态。进程的PCB和地址空间描述仍然存在,所以进程并没有消失,只是相关物理页暂时不在内存中。

  4. 多次malloc形成的区域怎么管理?

    地址空间不是只靠一组堆区起止地址解决所有问题。不同连续范围还要交给更细的VMA结构描述。

5.4.1 VMA如何管理不连续区域

mm_struct还要管理多段虚拟区域。Linux使用vm_area_struct描述一段连续、权限相同、用途相近的虚拟地址范围,里面记录vm_start、vm_end、权限和映射文件等信息。多个VMA通过链表以及更适合查找的数据结构组织起来,所以动态库、匿名映射和多次malloc形成的不同区域,都可以统一管理。

总结

程序里打印出来的地址是虚拟地址,不是物理地址。每个进程都有自己的地址空间和页表,所以父子进程可以拥有相同的虚拟地址,却在写时拷贝之后访问不同的物理页。

mm_struct描述整个进程地址空间,vm_area_struct描述其中一段段虚拟区域。页表完成地址映射并记录权限,因此进程看到稳定有序的布局,物理内存仍可按页分配、按需加载。

再看代码区只读、malloc返回虚拟地址、野指针段错误和fork后的写时拷贝,它们都是同一套地址空间机制的不同表现。

资源分享
【Linux系统编程】环境变量深度解析——从 fork 继承到 export 内建命令,两张表打通进程上下文
【Linux 性能优化基石:全景拆解 PRI/NI 优先级算力争夺与 O(1) 调度算法精髓】


《 从OS通用理论到Linux内核源码:全景拆解 task_struct 状态流转与内核双链表设计精髓 》

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询