个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、先回顾程序地址空间的整体布局1.1 地址空间里有哪些区域1.2 用代码验证各区域地址二、父子进程为什么能打印相同地址2.1 先看父子进程的实验现象2.2 一个进程一套地址空间和页表2.3 写时拷贝如何发生三、虚拟地址空间到底是什么3.1 用大富翁理解进程视角3.2 内核怎样管理虚拟地址空间四、虚拟地址空间如何划分区域4.1 用三八线理解区域边界4.2 task_struct如何关联mm_struct4.3 程序加载时怎样建立映射五、为什么要有进程地址空间5.1 把无序物理内存整理成有序视图5.2 地址转换时同时检查权限5.3 缺页中断和按需加载5.4 几个容易混淆的问题5.4.1 VMA如何管理不连续区域总结前言前面学习进程时我们已经见过变量、函数和malloc返回的地址。可这些地址是不是物理地址父子进程为什么能打印相同地址却得到不同数据都要用程序地址空间解释。本文只讨论程序地址空间不再扩展进程状态、调度和环境变量。为了和Linux内核概念对应后文统一使用更准确的“进程地址空间”。一、先回顾程序地址空间的整体布局1.1 地址空间里有哪些区域在32位平台下一个进程看到的是连续排列的虚拟地址。代码区、数据区、堆、共享区、栈以及命令行参数和环境变量都在其中占据自己的位置。通常堆向高地址增长栈向低地址增长。这里画的是虚拟地址布局不是物理内存真的连续排好。堆和栈之间的大段“空白”不代表物理内存已经提前分配只是这部分虚拟地址暂时没有被当前进程使用。后面申请空间、加载动态库或建立映射时其中一部分才会真正参与映射。1.2 用代码验证各区域地址字符串常量不能直接修改。下面把函数、全局变量、堆、栈、字符串常量、命令行参数和环境变量的地址都打印出来。字符串常量与代码区地址接近而对应区域通常只有读和执行权限没有写权限。具体段名会受编译器和链接方式影响但结论不变不能把字符串常量当作普通可写数组。#includestdio.h#includeunistd.h#includestdlib.hintg_unval;intg_val100;intmain(intargc,char*argv[],char*env[]){constchar*strhelloworld;printf(code addr: %p\n,main);printf(init global addr: %p\n,g_val);printf(uninit global addr: %p\n,g_unval);staticinttest10;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(inti0;iargc;i){printf(argv[%d]: %p\n,i,argv[i]);}for(inti0;env[i];i){printf(env[%d]: %p\n,i,env[i]);}return0;}普通局部变量一般建立在栈上而函数内的static变量虽然作用域仍在函数内部存储周期却贯穿整个进程运行过程。它不会随着函数返回而销毁所以地址更接近全局数据区。换句话说static改变的是存储周期和链接属性不能简单理解成“局部变量变成了全局变量”。进程地址空间是内存吗不是。它是操作系统层面的概念不是C/C语言直接创建的一块真实内存。物理内存中同时放着多个进程、内核和缓存的数据不可能始终按照某个进程的代码区、堆区、栈区整齐排列。我们在程序中看到的有序布局是操作系统提供给进程的一种视图。二、父子进程为什么能打印相同地址2.1 先看父子进程的实验现象#includestdio.h#includeunistd.h#includestdlib.hintgval100;intmain(){//c99并不认识pid_t,因此在makefile中不带c99pid_tidfork();if(id0){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 把无序物理内存整理成有序视图所以进程地址空间把物理内存中的无序放置整理成进程视角下稳定、有序的地址布局。程序只按照自己的虚拟地址运行不必关心某一页数据此刻具体放在哪个物理页框。5.2 地址转换时同时检查权限CPU执行代码时为什么不能直接去物理内存查找还要多做一次地址转换举个简单的例子小明在春节拿到压岁钱乱买东西被妈妈发现了将压岁钱进行保管小明想要再去商店买东西必须获得妈妈的允许才行。在虚拟地址上访问我们的代码时OS会查页表页表项里面除了有物理地址和虚拟地址外还有读写执行等权限。也就是说当我们访问页表时对一个代码区进行写入OS查页表发现要进行w操作但是用户对代码区只拥有只读权限此时OS不会对进程的虚拟地址进行转化更有可能直接杀掉你这个进程因此就可以实现对物理内存的保护地址转换时可以同时检查页表权限。代码页可以只读和执行普通数据页可以读写未映射区域不能访问。错误指针越界后操作系统能够阻止它继续破坏其他进程或内核的数据。当我们访问一个在地址空间上未分配的地址用户指针指向这但是OS在查页表时并不存在指定的虚拟地址也就是野指针进程有可能会被杀掉。下面这段代码保留原样重点看它想表达的动作让指针指向字符串常量后尝试写入。原代码中的变量名重复定义需要先修正即使把语法问题改好对字符串常量写入仍会在运行期失败。char*strhelloworld,*strH;为什么向字符串常量所在的只读区域写入会崩溃把重复定义修正后程序仍不能修改字符串常量。访问发生时页表会检查当前页面的权限写操作不符合只读区域的权限要求CPU会触发异常操作系统通常终止当前进程。5.3 缺页中断和按需加载假设一个程序的代码部分就有2GB了物理内存需要跑其他程序因此不会将你的代码全部加载进来可能只加载1/4进来将正文代码区映射为2GB(虚拟地址全填上物理地址只填了1/4)但是我们只将前500MB的虚拟地址和物理地址的映射建立好也就是说还有1.5GB没有加载进来当OS不断访问时发现虚拟地址有但物理地址并不在内存里这个时候OS就可以实现动态加载再把500MB加载进来把物理地址重新填上来再次建立好映射关系再让程序继续运行这种机制就称为缺页中断。地址空间让进程管理和内存管理解耦。创建进程时可以先建立task_struct、mm_struct和合法虚拟区域真正访问某一页时再通过缺页中断分配或调入物理页这就是按需加载。5.4 几个容易混淆的问题创建进程的时候可不可以只创建pcb只创建地址空间再从磁盘里的可执行程序里读取代码和数据各自是多大在虚拟地址上把空间开辟好即只填虚拟地址不填物理地址。就是进程的代码数据一行都不加载可以吗答案是可以的cpu拿到起始地址直接去访问起始地址OS发现虚拟地址与物理地址映射不过来虚拟地址是合法的物理地址不在内存里OS自动做缺页中断自动完成物理地址加载并填充任务缺页中断完成后继续让进程运行。可不可以暂时不加载代码和数据可以先创建PCB、struct mm_struct和页表相关结构真正访问时再按需加载。先创建内核数据结构还是先加载代码和数据先有描述进程和地址空间的内核数据结构再根据访问情况加载代码和数据。如何理解进程挂起内存紧张时操作系统可以把暂时不用的页面换出到swap分区并调整页表状态。进程的PCB和地址空间描述仍然存在所以进程并没有消失只是相关物理页暂时不在内存中。多次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 状态流转与内核双链表设计精髓 》
【Linux进程】程序地址空间详解:虚拟地址、页表、写时拷贝与mm_struct
发布时间:2026/7/1 2:02:34
个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、先回顾程序地址空间的整体布局1.1 地址空间里有哪些区域1.2 用代码验证各区域地址二、父子进程为什么能打印相同地址2.1 先看父子进程的实验现象2.2 一个进程一套地址空间和页表2.3 写时拷贝如何发生三、虚拟地址空间到底是什么3.1 用大富翁理解进程视角3.2 内核怎样管理虚拟地址空间四、虚拟地址空间如何划分区域4.1 用三八线理解区域边界4.2 task_struct如何关联mm_struct4.3 程序加载时怎样建立映射五、为什么要有进程地址空间5.1 把无序物理内存整理成有序视图5.2 地址转换时同时检查权限5.3 缺页中断和按需加载5.4 几个容易混淆的问题5.4.1 VMA如何管理不连续区域总结前言前面学习进程时我们已经见过变量、函数和malloc返回的地址。可这些地址是不是物理地址父子进程为什么能打印相同地址却得到不同数据都要用程序地址空间解释。本文只讨论程序地址空间不再扩展进程状态、调度和环境变量。为了和Linux内核概念对应后文统一使用更准确的“进程地址空间”。一、先回顾程序地址空间的整体布局1.1 地址空间里有哪些区域在32位平台下一个进程看到的是连续排列的虚拟地址。代码区、数据区、堆、共享区、栈以及命令行参数和环境变量都在其中占据自己的位置。通常堆向高地址增长栈向低地址增长。这里画的是虚拟地址布局不是物理内存真的连续排好。堆和栈之间的大段“空白”不代表物理内存已经提前分配只是这部分虚拟地址暂时没有被当前进程使用。后面申请空间、加载动态库或建立映射时其中一部分才会真正参与映射。1.2 用代码验证各区域地址字符串常量不能直接修改。下面把函数、全局变量、堆、栈、字符串常量、命令行参数和环境变量的地址都打印出来。字符串常量与代码区地址接近而对应区域通常只有读和执行权限没有写权限。具体段名会受编译器和链接方式影响但结论不变不能把字符串常量当作普通可写数组。#includestdio.h#includeunistd.h#includestdlib.hintg_unval;intg_val100;intmain(intargc,char*argv[],char*env[]){constchar*strhelloworld;printf(code addr: %p\n,main);printf(init global addr: %p\n,g_val);printf(uninit global addr: %p\n,g_unval);staticinttest10;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(inti0;iargc;i){printf(argv[%d]: %p\n,i,argv[i]);}for(inti0;env[i];i){printf(env[%d]: %p\n,i,env[i]);}return0;}普通局部变量一般建立在栈上而函数内的static变量虽然作用域仍在函数内部存储周期却贯穿整个进程运行过程。它不会随着函数返回而销毁所以地址更接近全局数据区。换句话说static改变的是存储周期和链接属性不能简单理解成“局部变量变成了全局变量”。进程地址空间是内存吗不是。它是操作系统层面的概念不是C/C语言直接创建的一块真实内存。物理内存中同时放着多个进程、内核和缓存的数据不可能始终按照某个进程的代码区、堆区、栈区整齐排列。我们在程序中看到的有序布局是操作系统提供给进程的一种视图。二、父子进程为什么能打印相同地址2.1 先看父子进程的实验现象#includestdio.h#includeunistd.h#includestdlib.hintgval100;intmain(){//c99并不认识pid_t,因此在makefile中不带c99pid_tidfork();if(id0){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 把无序物理内存整理成有序视图所以进程地址空间把物理内存中的无序放置整理成进程视角下稳定、有序的地址布局。程序只按照自己的虚拟地址运行不必关心某一页数据此刻具体放在哪个物理页框。5.2 地址转换时同时检查权限CPU执行代码时为什么不能直接去物理内存查找还要多做一次地址转换举个简单的例子小明在春节拿到压岁钱乱买东西被妈妈发现了将压岁钱进行保管小明想要再去商店买东西必须获得妈妈的允许才行。在虚拟地址上访问我们的代码时OS会查页表页表项里面除了有物理地址和虚拟地址外还有读写执行等权限。也就是说当我们访问页表时对一个代码区进行写入OS查页表发现要进行w操作但是用户对代码区只拥有只读权限此时OS不会对进程的虚拟地址进行转化更有可能直接杀掉你这个进程因此就可以实现对物理内存的保护地址转换时可以同时检查页表权限。代码页可以只读和执行普通数据页可以读写未映射区域不能访问。错误指针越界后操作系统能够阻止它继续破坏其他进程或内核的数据。当我们访问一个在地址空间上未分配的地址用户指针指向这但是OS在查页表时并不存在指定的虚拟地址也就是野指针进程有可能会被杀掉。下面这段代码保留原样重点看它想表达的动作让指针指向字符串常量后尝试写入。原代码中的变量名重复定义需要先修正即使把语法问题改好对字符串常量写入仍会在运行期失败。char*strhelloworld,*strH;为什么向字符串常量所在的只读区域写入会崩溃把重复定义修正后程序仍不能修改字符串常量。访问发生时页表会检查当前页面的权限写操作不符合只读区域的权限要求CPU会触发异常操作系统通常终止当前进程。5.3 缺页中断和按需加载假设一个程序的代码部分就有2GB了物理内存需要跑其他程序因此不会将你的代码全部加载进来可能只加载1/4进来将正文代码区映射为2GB(虚拟地址全填上物理地址只填了1/4)但是我们只将前500MB的虚拟地址和物理地址的映射建立好也就是说还有1.5GB没有加载进来当OS不断访问时发现虚拟地址有但物理地址并不在内存里这个时候OS就可以实现动态加载再把500MB加载进来把物理地址重新填上来再次建立好映射关系再让程序继续运行这种机制就称为缺页中断。地址空间让进程管理和内存管理解耦。创建进程时可以先建立task_struct、mm_struct和合法虚拟区域真正访问某一页时再通过缺页中断分配或调入物理页这就是按需加载。5.4 几个容易混淆的问题创建进程的时候可不可以只创建pcb只创建地址空间再从磁盘里的可执行程序里读取代码和数据各自是多大在虚拟地址上把空间开辟好即只填虚拟地址不填物理地址。就是进程的代码数据一行都不加载可以吗答案是可以的cpu拿到起始地址直接去访问起始地址OS发现虚拟地址与物理地址映射不过来虚拟地址是合法的物理地址不在内存里OS自动做缺页中断自动完成物理地址加载并填充任务缺页中断完成后继续让进程运行。可不可以暂时不加载代码和数据可以先创建PCB、struct mm_struct和页表相关结构真正访问时再按需加载。先创建内核数据结构还是先加载代码和数据先有描述进程和地址空间的内核数据结构再根据访问情况加载代码和数据。如何理解进程挂起内存紧张时操作系统可以把暂时不用的页面换出到swap分区并调整页表状态。进程的PCB和地址空间描述仍然存在所以进程并没有消失只是相关物理页暂时不在内存中。多次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 状态流转与内核双链表设计精髓 》