文章目录1. C/C内存布局的验证2. 虚拟地址的引入3. 进程地址空间的引入4. 解决历史遗留问题5. 什么是进程地址空间如何理解如何管理6. 如何理解空间中的区域划分7. 看看源码8. 补充第一点内核空间/用户空间第二点简单谈谈页表第三点命令行参数和环境变量区第四点fork之后子进程的地址空间9. 为什么要有进程地址空间9.1 安全9.2 按需分配与惰性加载9.3 有序9.4 解耦10. 进程地址空间的组织方式11. 虚拟内存块的管理1. C/C内存布局的验证之前C/C的文章中我们讲解内存管理的时候我们了解过这样一张图之前我们简单了解了这个内存区域的划分了解了每个区域大致存放的是哪些内容。不过如果我们去看一本C语言或C的书籍大概率书上并不会有这张图。是因为这个东西其实不算语言的内容而是操作系统的内容之前我们把它叫做内存区域的划分但其实它叫做进程地址空间/虚拟地址空间不是真正的物理内存)。这篇文章我们来使用这张图右边这篇文章我们来开始真正地学习进程地址空间。首先通过一段代码我们来验证一下这个内存区域的分布#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);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(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;}上面的图中下面低地址上面高地址我们这段代码打印一些不同类型变量或者空间的地址看大小关系是否符合上面的分布我们来分析一下首先首先定义两个全局变量一个初始化一个未初始化然后在main函数中首先定义一个字符指针指向了一个常量字符串所以str存的就是该常量字符串的起始地址首字符地址再看下面三个打印首先打印main函数的地址对于函数来说函数名和函数名表示的意义是完全一样的都表示函数的地址main函数是一段可执行代码所以放在正文代码区域代码段在图中地址最小。接着打印了两个全局变量的地址全局变量之前我们讲它和static修饰的静态变量都是放在静态区在C/C内存布局中就是数据段对应进程地址空间中就是已初始化数据区未初始化数据区。g_unval未初始化所以应该放在未初始化数据区g_val初始化所以放在已初始化数据区再往下看调用4次malloc在堆上开辟四块空间malloc返回开辟内存块的起始地址所以这里四个指针变量存储4块堆上空间的地址。堆是向上增长的即先使用低地址再使用高地址后面我们看这四个地址的大小关系再往下我们打印了一个局部变量的地址局部变量是在栈上的。再往下打印了四个指针变量的地址这四个也是局部变量所以也在栈上目前先看这些我们来在Linux机器上运行这段代码看一下我们来看一下这些地址首先整体上各区域的地址是递增的这都符合我们的预期不过我当时看到这个结果我疑惑的是这四个指针变量的地址test定义在它们四个后面所以地址比他们低没毛病。但是这四个指针为什么后定义的地址更高呢而且我让其他人也在Linux云服务器我们的版本不同上执行了这段代码他的结果这四个指针变量的地址是依次递减的。问了一些大模型大都是如是说的连续定义时编译器通常会给它们分配连续且顺序固定可能正序也可能逆序的栈偏移。你看到的“后定义地址高”说明你的编译器是按定义顺序从低地址向高地址分配即第一个变量在最低地址最后一个在最高地址。这与栈增长方向并不矛盾因为函数栈帧内偏移是编译器静态决定的与栈动态增长方向rsp 移动方向无关。编译器对局部变量在栈帧内的偏移量分配顺序并没有统一标准。你和另一个人运行“相同代码”却得到相反的地址顺序根源在于编译器实现细节不同包括编译器类型、版本、甚至默认的栈帧布局策略。当然无需深究这种细节整体上这个地址的变化是符合我们对进程地址空间布局的理解的。而且如果你在Windows下执行这段代码可能结果又会有所差异堆是向上增长的栈上向下增长的堆栈相向而生。那我们继续现在把test变量加上static关键字修饰局部变量被static修饰后将存储在静态区出作用域后将不会被销毁而是保留在静态区生命周期改变本质上改变了存储类型这时它的生命周期就是程序的生命周期我们来看下此时它的地址再往下我们来打印一下strstr是一个字符指针指向一个常量字符串所以str存的就是这个常量字符串的首地址。看一下结果我们能看出来它和main函数的地址是挨着的这也和我们之前的理解一致常量字符串是存在代码段正文代码区域的因为常量字符串和可执行代码一样是只读的不能修改这也是我们在前面加上const修饰的原因。2. 虚拟地址的引入之前在进程创建那篇文章我们还遗留了一个问题我们再来写一写当时的代码回顾一下这个场景新建一个源文件写这样一段代码代码相信大家都可以看懂我们来运行一下父子进程代码共享数据写时拷贝。所以父子进程都可以打印全局变量g_val。然后我们把代码做一下修改子进程不仅打印这个全局变量还对他进行修改结果是这样的同一个全局变量子进程在自己的执行流中修改它并不会影响父进程因为进程之间具有独立性子进程修改这个数据的时候会发生写时拷贝。这都是我们之前讲过的内容了今天我们要重点关注的问题就是子进程修改后父子进程的执行流都打印g_val这个变量我们看到它们的值是不一样的但是它们的地址居然是一样的同一个变量地址相同但是值不同的这是不符合逻辑的。那么原因在于这里我们看到的地址并不是真正的物理地址而是虚拟地址我们之前学C/C看过的各种变量的地址通过调式窗口观察的各种内存地址都是虚拟地址那为什么呢为什么会有虚拟地址呢这个虚拟地址是哪里的地址呢那下面就引出我们今天的主角——进程地址空间/虚拟地址空间3. 进程地址空间的引入我们来介绍一下什么是进程地址空间我们之前讲一个程序被执行变成进程除了要把代码和数据加载到内存中操作系统还会在内核中给进程创建对应的进程控制块——task_struct那么在task_struct中会有一个指针指向该进程的进程地址空间每个进程都有自己独立的虚拟地址空间。空间的地址从全0到全f所以我们上面看到的变量地址就是这里面的地址就叫做虚拟地址这张图是以32位机器为标准画的所以地址范围就是32位0~32位1那他跟物理内存有什么关系嘛进程地址空间中的地址是可以映射到对应的物理内存的。因为我们进程的代码和数据在执行的时候肯定是加载到物理内存的只不过站在进程的角度我们看到的是虚拟地址但是它会被映射到对应的物理内存。那谁来进行这个映射谁来完成虚拟地址到物理地址的转换呢——页表我们在使用时访问的都是虚拟地址但是页表会在底层帮我们完成虚拟地址到物理地址的转换进而访问真正的物理内存。4. 解决历史遗留问题有了上面的铺垫我们就可以来解决一下我们之前遗留的这个问题了子进程修改g_val后父子进程打印g_val变量地址相同但是值不同怎么回事呢我们来讲解一下其中的原理先看这张图g_val是父进程定义的一个全局变量对他进行了初始化所以它是在进程地址空间的已初始化数据区的。我们说了进程地址空间的地址是虚拟地址我们去访问这个变量的时候操作系统会在底层通过页表把虚拟地址转换为物理地址然后就可以访问到真实物理内存中的这个变量。然后呢我们调用fork创建了一个子进程那进程程序加载到内存中的指令和数据内核中与之关联的进程控制块task_struct前面我们讲过子进程会以父进程的task_struct作为模板只修改诸如pidppid这些属性大部分直接拷贝父进程task_struct中的属性值。然后代码共享数据写时拷贝。每个进程都有自己的页表fork时操作系统会为子进程创建一个新的页表并把父进程页表的内容即页表项PTE逐项复制到子进程的页表中即子进程的页表也是以父进程的页表为模板创建的。子进程没有修改g_val之前父子进程就共享这个数据所以父子进程中g_val的虚拟地址相同并且页表映射的物理地址也相同这就是一开始我们看到的父子进程打印g_val值相同虚拟地址也相同。后来子进程修改了g_val这时为了保证进程间的独立性子进程修改了但是我父进程没有修改你不能影响我啊所以这时就会发生写时拷贝那这个写时拷贝在物理内存中如何实现呢当发生写入时进行写时拷贝内核会分配新物理页、拷贝数据然后修改发生写入的那个进程的页表项使其指向新的物理页。而另一个进程的页表项仍然指向原来的物理页。所以子进程修改了g_val操作系统就会在物理内存中新开辟一块空间拷贝g_val过去这个新的g_val作为子进程独立的g_val你要修改改你自己的父进程不受影响。但此时子进程的g_val对应的物理地址就变化了所以子进程的对应页表项也会被修改把g_val的虚拟地址映射到一个新的物理地址虚拟地址无需改动。最终就变成了这样当然这些工作由操作系统自动完成对我们用户完全透明。所以就出现了上面我们看到的现象——父子进程都打印g_val虚拟地址相同但是打印的值不同因为虽然虚拟地址相同但是两者映射的物理地址是不同的在物理内存中其实是两个变量保证了进程的独立性。5. 什么是进程地址空间如何理解下面带大家来理解一下到底什么是进程地址空间。首先我们来讲一个故事有一个富翁假设他有10个亿。然后他有很多私生子/私生女。这些私生子/私生女之间他们彼此之间不知道其他人的存在。大富翁对每一个私生子都说等以后老了我的这些钱都是你的。所以每一个私生子都认为自己以后可以独占这十个亿且都不知道其它私生子的存在这时候私生子4过来说我要交学费你给我100块钱大富翁说可以就给他了。私生女3过来说我要买化妆品需要200块钱大富翁也给了。然后私生子2过来说我要做项目给我10个亿这时大富翁直接把私生子2骂了一顿说“我还活得好好的呢你就想把我10个亿全都要走“私生子2说好吧其实200块钱就够了。私生子1说我要1万块钱读研大富翁也给了。所以大富翁对于私生子呢只要是正常的请求都是有求必应然后所有的私生子都认为自己可以拥有这10个亿但是正常情况下他们也不会直接要10个亿。所以虽然大富翁给每个私生子说你以后可以拥有这十个亿但是只要大富翁还在就不会真的一次给他们10个亿。所以大富翁这样说其实就是在画大饼那这里的大富翁就对应操作系统私生子/女就对应一个个的进程这里画的大饼就是进程地址空间每个进程都有一套独立的页表Page Table。对于进程而言它看到的内存地址是连续的全0-全f、独占的但实际进程运行时不可能一次申请特别大块的空间。它不需要关心物理内存哪一块被占用了他认为自己进程地址空间中的任何空间都可以使用但实际通过页表映射会帮它把虚拟地址映射到合适的物理地址。每个进程都觉得自己独占了内存这就是虚拟地址空间的魅力如何管理那下一个问题进程可能有很多操作系统给每一个进程都画了一个大饼进程地址空间那操作系统要不要将这么多的大饼管理起来呢当然需要如何管理呢先描述再组织先用一个结构体描述进程地址空间然后再用一种数据结构把所有的结构体变量组织起来然后对进程地址空间的管理就变成了对特定数据结构的增删查改。所以进程地址空间在操作系统中本质就是一个结构体结构体内部就是划分了一个个的区域。那如何理解这其中的区域划分呢6. 如何理解空间中的区域划分举个栗子有一张桌子长100厘米现在想把它划分成两部分55分假设每一厘米都有一个下标那就是0~99那平均划分两半的话就0~49是一半50~99是另一半即可假设左边是小胖同学的位置右边是小美同学的位置那在一个结构体中如何描述这种区域划分呢很简单一块区域只需要有两个变量标识它的起始和结束位置即可这中间的空间都是我拥有的那我后续想扩大或缩小某个区域呢只需要改对应的start和end的值就行了。7. 看看源码那下面我们就来看看Linux中进程地址空间对应的结构体看看它内部是如何做的Linux中描述进程地址空间的结构体叫做mm_struct进程内存描述符在每个进程的task_struct中会有一个mm_struct类型的结构体指针——mm指向当前进程的进程地址空间然后我们来看mm_struct结构体mm_struct里面的成员很多我们现在也不需要全部看但是通过上面的铺垫我们猜测mm_struct中一定会有很多类似上面startend这样的字段来标识各个内存区域从源码中我们确实能找到比如第三行的start_codeend_code就标识了代码段正文代码区域的起始和结束位置内核空间大富翁操作系统自己的「私人领地」私生子无权访问。mm_struct中还有一个指针pgd_t *pgd;指向当前进程的页表一图总结8. 补充然后我们再来做一些补充第一点内核空间/用户空间我们看到进程地址空间其实被分成两大部分内核空间和用户空间。而我们目前比较熟悉的其实是用户空间的这几个区域内核空间我们先不关心。那么什么是用户空间呢所谓的用户空间即我们用户可以通过地址直接访问的空间而如果要访问内核空间必须通过操作系统提供的系统调用来访问。第二点简单谈谈页表通过上面的了解我们现在至少知道页表主要是进行虚拟地址到物理地址的转换的。然后在进程地址空间中正文代码区域代码段通常是只读的其它区域比如数据段又是可读可写的。那如何做到这种权限的约束呢那在页表中还会有一列用来记录权限信息标识当前映射地址所属区域的读写权限。回头来看我们之前的那段代码下面还有一小段现在我们把注释放开。运行一下先来看这个常量字符串的地址这个我们其实上面观察过了常量字符串和代码是放在一起的在正文代码区域代码段都是只读的不能修改。那如何实现不能修改呢如果我要访问代码段的地址去修改这块地址的内容那么在页表映射的时候会检查对应的权限发现你对这块空间只有读权限现在你想写那就直接拒绝。第三点命令行参数和环境变量区在用户空间中我们比较熟悉的是蓝色框中的这几个区域。但是我们看到用户空间的最上面还有一个区域——命令行参数和环境变量。什么是命令行参数和环境变量我们之前的文章讲解过了。所以这块区域存的就是命令行参数和环境变量吗我们来验证一下看一下刚才打印的地址看图命令行参数和环境变量的地址应该在栈区上面比栈的地址高没有问题就是在栈的上面。即命令行参数和环境变量也是存在进程的地址空间中的在栈的上面。所以命令行参数和环境变量也属于进程数据的一部分父子进程之间就遵循数据默认共享修改写时拷贝。这也解释了我们之前讲的——环境变量可以被子进程继承第四点fork之后子进程的地址空间我们现在已经知道在每个进程的task_struct中会有一个mm_struct类型的结构体指针——mm指向当前进程的进程地址空间本质是一个mm_struct结构体变量。fork创建子进程除了上面我们讲的那些细节还要补充的就是子进程会以父进程的task_struct作为模板只修改诸如pidppid这些属性大部分直接拷贝父进程task_struct中的属性值。这是我们讲过的那指向当前进程的进程地址空间的mm指针不是也在task_struct中嘛那子进程的mm指针也是直接拷贝父进程的嘛对于mm指针来说fork之后它会进行一个深拷贝浅拷贝的话就是直接拷贝指针的内容嘛两个指针指向同一个mm_struct结构体即fork 后子进程的 mm 指针指向新分配的 mm_struct但是其内容拷贝自父进程结构体级别深拷贝因此父子进程拥有独立的地址空间我们上面也提到每个进程都有自己独立的虚拟地址空间这里大家就更加清晰了然后页表直接复制父进程的页表项所以父子进程都有自己独立的进程地址空间但是起始时里面映射到的物理内存一样代码和数据共享。后续如果某个进程进行了数据写入就比如我们上面举的例子则发生物理内存层面的写时拷贝这里的细节我们上面讲过了。一句话总结即“结构体级别深拷贝深拷贝mm指针父子进程的mm指针指向不同的mm_struct结构体变量拥有独立的进程地址空间物理内存级别写时拷贝如果发生数据写入则进行物理层面的写时拷贝”9. 为什么要有进程地址空间这个问题其实可以转化为如果程序直接可以操作物理内存会造成什么问题在早期的计算机中没有进程地址空间即程序中访问的内存地址都是实际的物理内存地址。当计算机同时运⾏多个程序时必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运⾏多个程序时操作系统是如何为这些程序分配内存的呢例如某台计算机总的内存大小是128M现在同时运行两个程序A和BA需占⽤内存10MB需占⽤内存110M。计算机在给程序分配内存时会采取这样的⽅法先将内存中的前10M分配给程序A接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配⽅法虽然可以保证程序A和程序B都能运⾏但是这种简单的内存分配策略问题很多。9.1 安全想象一下如果没有进程地址空间没有虚拟地址。我们在C/C程序中使用一个指针就可以随意地访问物理内存。那就会导致缺乏隔离一个进程可能错误的访问另一个进程的内存空间一个程序的错误指针可能破坏其他程序甚至操作系统内核的数据。这就会造成进程之间的相互干扰一个进程出问题可能会干扰其它进程那这不就违背了进程之间的独立性嘛。那反过来因为有了进程地址空间的存在我们在程序中使用的都是虚拟地址那访问内存的时候就必须先进行虚拟地址到物理地址的转换相当于在进程和物理内存之间添加了一层软件层在计算机科学中存在一个经典的计算机科学谚语因为存在这样一层转换那么就可以在虚拟地址转换为物理地址的时候进行相关的安全性的审核。如果你进行了一个非法的访问那操作系统就可以拦截你。这防止了进程间的相互干扰一个进程的崩溃或缓冲区溢出不会破坏其他进程甚至内核的数据极大提升了系统的稳定性和安全性。这就变相地保证了物理内存的安全维护了进程之间的独立性给大家举个例子这就好比你每年过年的时候都能收到很多压岁钱。之前一直都是你自己管这些钱所以你想买啥就买啥没人管你各种垃圾食品疯狂地买各种奶茶外卖狠狠地吃。现在呢你妈妈每次都会把你的压岁钱收走管理起来。你妈妈跟你说的是“我帮你管起来但是这钱还是你的你要买啥问我要就行了”你说你要买本书买支笔。没问题要花多少钱你妈就给你多少。你说你想买十包辣条就着十瓶可乐吃你妈骂了你一顿说天天吃这种垃圾食品有什么好处啊不准买此时你妈妈操作系统虚拟地址空间页表就可以对你进程对这些钱物理内存的使用进行限制和审核让你只把钱用在“合理”的地方。9.2 按需分配与惰性加载再来看这张图问大家一个问题双击执行一个可执行程序让他变成进程的时候它所有的代码和数据是不是都全部一块加载到内存中通常不是这样的而是采用按需分配与惰性加载的策略按需分配执行一个程序先只加载目前需要的部分给他分配物理空间此时可能并不需要执行所有的代码和数据其它不需要的就先不加载惰性加载如果进程使用malloc申请了一块空间先只给他分配虚拟内存空间并不立刻分配物理页。只有当进程真正访问该地址时出现缺页异常才触发物理页的分配并建立页表中的映射。因为有地址空间的存在所以我们在C、C语⾔上new, malloc空间的时候其实是在地址空间上申请的物理内存可以甚⾄⼀个字节都不给你。当你真正进行对物理地址空间访问的时候才执行内存的相关管理算法帮你申请内存构建页表映射关系延迟分配这是由操作系统自动完成用户包括进程完全0感知这就可以做到程序启动极快只加载极少代码比如你玩一个大型游戏此时你在玩这个场景/地图那就只需加载当前场景的代码和数据在内存中其它没用到的就不加载减少物理内存的浪费只给实际使用的页分配物理页支持运行比物理内存更大的程序结合换入换出你玩一个大型游戏此时你在玩这个场景/地图那就只需加载当前场景的代码和数据在内存中其它没用到的就不加载省出来的这部分空间还可以先被其它进程使用等你玩其它场景的时候把之前不需要的代码和数据换出去把需要的加载进来通过这一系列的手段就可以使得从用户的感知上来看内存好像比实际的要大多9.3 有序什么意思呢操作系统中可能运行很多进程物理内存经过长时间分配释放会产生难以利用的“外碎片”比如许多几KB的小空隙。所以进程的代码和数据加载到物理内存中可能是非常零散、非常分散的。但是因为有进程地址空间的存在站在进程的视角他看到的就是虚拟地址空间虚拟地址空间允许进程的连续虚拟地址映射到物理内存中不连续的、分散的页面。即进程能够以一种比较规整有序的视角看待自己的代码和数据无需关心它在物理内存中有多么混乱无序同时这样可以充分利用内存碎片提高了物理内存的利用率。9.4 解耦进程地址空间的存在使得进程管理和内存管理可以解耦合对进程而言每个进程都以为自己独占了从 0 到最大地址的连续内存无需关心物理内存的碎片、容量上限、其他进程的占用。进程只需使用虚拟地址程序编写和编译不需要知道最终的物理位置。对操作系统而言物理内存可以按页自由分配给任意进程的任意虚拟页物理页帧可以是非连续的。内存分配、回收、移动如压缩碎片变得非常灵活不影响进程运行只需更新页表。两者通过页表连接但不耦合10. 进程地址空间的组织方式前面讲了描述linux下进程的地址空间的结构体是mm_struct内存描述符。每个进程只有⼀个mm_struct结构在每个进程的task_struct结构中有⼀个指向该进程mm_struct的结构体指针。先描述再组织。mm_struct结构体描述了进程地址空间那具体是如何组织呢task_struct中有一个mm指针指向mm_struct所以操作系统把所有进程的task_struct 组织起来就变相的已经把进程地址空间mm_struct组织起来了。11. 虚拟内存块的管理但是还有一些情况需要我们考虑在进程地址空间中一个内存区域堆、栈、代码段…对应一整块但是在实际使用的过程中比如堆空间我们正常是一小块一小块的使用的比如在一个程序中我malloc了多次申请多块空间每一块都有起始地址和空间大小当然通过上面的理解我们知道我们malloc得到的都是虚拟地址空间当我们真正使用申请的空间时才会开辟物理内存块然后建立页表映射。所以不仅进程地址空间要被管理起来我们在进程地址空间中申请的一块块虚拟内存块也需要被管理起来如何管理先描述再组织内核中使用vm_area_struct这个结构体来描述我们在进程地址空间中申请的一块虚拟内存块VMA。每一个vm_area_struct就代表进程地址空间中「一块已经被申请、被使用、有明确用途」的虚拟内存区域如何组织呢单链表红黑树同时管理mmap链表用于遍历所有 VMAmm_rb红黑树用于快速查找某个虚拟地址属于哪个 VMA比如缺页异常时具体怎么做呢看看源码从mm_struct结构体的定义中除了上面我们了解过的还可以找到mmap和mm_rb首先我们看到vm_area_struct中有一个struct vm_area_struct *vm_next;指针通过这个指针就可以把所有的vm_area_struct链接成链表进行管理。同时每个vm_area_struct结构体内部内嵌了一个struct rb_node成员 vm_rb红黑树结点。通过这个内嵌的rb_node内核将所有的vm_area_struct同时组织成红黑树。即vm_area_struct即属于链表同时又属于红黑树。总结就是首先我们申请的所有的虚拟内存块vm_area_struct都被一个链表链接了起来我们上面图中的版本内核源码的做法就是和普通的链表一样通过一个next指针链接。新版本的内核也是通过vm_area_struct内嵌一个list_head的链表结点进而被双向链表管理起来和进程管理那里task_struct链表一样的方法。同时每个 vm_area_struct 结构体内部内嵌了一个 struct rb_node 成员名为 vm_rb。通过这个内嵌的 rb_node红黑树结点内核也将所有的 vm_area_struct 组织成红黑树。即vm_area_struct即属于链表同时又属于红黑树。需要遍历的场景则使用链表需要快速查找则可以使用红黑树同时被两个数据结构管理但是不同的应用场景可以选择不同的数据结构进查找。另外在vm_area_struct中也是有访问权限相关的字段的。那前面我们说页表里面也有权限字段那他俩是什么关系呢先简单了解两者的角色分工vm_area_structVMA进程地址空间的逻辑描述。它记录了某一段虚拟地址区域应该具有的权限如可读、可写、可执行以及该区域是映射文件还是匿名内存。这是内核管理虚拟内存的“意图”。页表项PTE硬件 MMU 直接使用的数据结构。它记录了虚拟页到物理页的映射以及该页当前在硬件层面的访问权限通常是 VMA 权限的子集比如因为写时复制暂时设为只读这句话我们后面会用到。非法访问最终是被 VMA 权限拦截的通过内核判断后发送信号。页表权限的作用是触发异常让内核有机会介入决策如果页表权限过于宽松比如直接允许写一个本该只读的 VMA那么硬件不会产生异常非法访问就会成功——但这不会发生因为内核在建立页表时总是根据 VMA 设置权限且不会超过 VMA。图片截取自deepseek的回答
【Linux系统编程】进程地址空间
发布时间:2026/5/27 3:31:11
文章目录1. C/C内存布局的验证2. 虚拟地址的引入3. 进程地址空间的引入4. 解决历史遗留问题5. 什么是进程地址空间如何理解如何管理6. 如何理解空间中的区域划分7. 看看源码8. 补充第一点内核空间/用户空间第二点简单谈谈页表第三点命令行参数和环境变量区第四点fork之后子进程的地址空间9. 为什么要有进程地址空间9.1 安全9.2 按需分配与惰性加载9.3 有序9.4 解耦10. 进程地址空间的组织方式11. 虚拟内存块的管理1. C/C内存布局的验证之前C/C的文章中我们讲解内存管理的时候我们了解过这样一张图之前我们简单了解了这个内存区域的划分了解了每个区域大致存放的是哪些内容。不过如果我们去看一本C语言或C的书籍大概率书上并不会有这张图。是因为这个东西其实不算语言的内容而是操作系统的内容之前我们把它叫做内存区域的划分但其实它叫做进程地址空间/虚拟地址空间不是真正的物理内存)。这篇文章我们来使用这张图右边这篇文章我们来开始真正地学习进程地址空间。首先通过一段代码我们来验证一下这个内存区域的分布#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);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(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;}上面的图中下面低地址上面高地址我们这段代码打印一些不同类型变量或者空间的地址看大小关系是否符合上面的分布我们来分析一下首先首先定义两个全局变量一个初始化一个未初始化然后在main函数中首先定义一个字符指针指向了一个常量字符串所以str存的就是该常量字符串的起始地址首字符地址再看下面三个打印首先打印main函数的地址对于函数来说函数名和函数名表示的意义是完全一样的都表示函数的地址main函数是一段可执行代码所以放在正文代码区域代码段在图中地址最小。接着打印了两个全局变量的地址全局变量之前我们讲它和static修饰的静态变量都是放在静态区在C/C内存布局中就是数据段对应进程地址空间中就是已初始化数据区未初始化数据区。g_unval未初始化所以应该放在未初始化数据区g_val初始化所以放在已初始化数据区再往下看调用4次malloc在堆上开辟四块空间malloc返回开辟内存块的起始地址所以这里四个指针变量存储4块堆上空间的地址。堆是向上增长的即先使用低地址再使用高地址后面我们看这四个地址的大小关系再往下我们打印了一个局部变量的地址局部变量是在栈上的。再往下打印了四个指针变量的地址这四个也是局部变量所以也在栈上目前先看这些我们来在Linux机器上运行这段代码看一下我们来看一下这些地址首先整体上各区域的地址是递增的这都符合我们的预期不过我当时看到这个结果我疑惑的是这四个指针变量的地址test定义在它们四个后面所以地址比他们低没毛病。但是这四个指针为什么后定义的地址更高呢而且我让其他人也在Linux云服务器我们的版本不同上执行了这段代码他的结果这四个指针变量的地址是依次递减的。问了一些大模型大都是如是说的连续定义时编译器通常会给它们分配连续且顺序固定可能正序也可能逆序的栈偏移。你看到的“后定义地址高”说明你的编译器是按定义顺序从低地址向高地址分配即第一个变量在最低地址最后一个在最高地址。这与栈增长方向并不矛盾因为函数栈帧内偏移是编译器静态决定的与栈动态增长方向rsp 移动方向无关。编译器对局部变量在栈帧内的偏移量分配顺序并没有统一标准。你和另一个人运行“相同代码”却得到相反的地址顺序根源在于编译器实现细节不同包括编译器类型、版本、甚至默认的栈帧布局策略。当然无需深究这种细节整体上这个地址的变化是符合我们对进程地址空间布局的理解的。而且如果你在Windows下执行这段代码可能结果又会有所差异堆是向上增长的栈上向下增长的堆栈相向而生。那我们继续现在把test变量加上static关键字修饰局部变量被static修饰后将存储在静态区出作用域后将不会被销毁而是保留在静态区生命周期改变本质上改变了存储类型这时它的生命周期就是程序的生命周期我们来看下此时它的地址再往下我们来打印一下strstr是一个字符指针指向一个常量字符串所以str存的就是这个常量字符串的首地址。看一下结果我们能看出来它和main函数的地址是挨着的这也和我们之前的理解一致常量字符串是存在代码段正文代码区域的因为常量字符串和可执行代码一样是只读的不能修改这也是我们在前面加上const修饰的原因。2. 虚拟地址的引入之前在进程创建那篇文章我们还遗留了一个问题我们再来写一写当时的代码回顾一下这个场景新建一个源文件写这样一段代码代码相信大家都可以看懂我们来运行一下父子进程代码共享数据写时拷贝。所以父子进程都可以打印全局变量g_val。然后我们把代码做一下修改子进程不仅打印这个全局变量还对他进行修改结果是这样的同一个全局变量子进程在自己的执行流中修改它并不会影响父进程因为进程之间具有独立性子进程修改这个数据的时候会发生写时拷贝。这都是我们之前讲过的内容了今天我们要重点关注的问题就是子进程修改后父子进程的执行流都打印g_val这个变量我们看到它们的值是不一样的但是它们的地址居然是一样的同一个变量地址相同但是值不同的这是不符合逻辑的。那么原因在于这里我们看到的地址并不是真正的物理地址而是虚拟地址我们之前学C/C看过的各种变量的地址通过调式窗口观察的各种内存地址都是虚拟地址那为什么呢为什么会有虚拟地址呢这个虚拟地址是哪里的地址呢那下面就引出我们今天的主角——进程地址空间/虚拟地址空间3. 进程地址空间的引入我们来介绍一下什么是进程地址空间我们之前讲一个程序被执行变成进程除了要把代码和数据加载到内存中操作系统还会在内核中给进程创建对应的进程控制块——task_struct那么在task_struct中会有一个指针指向该进程的进程地址空间每个进程都有自己独立的虚拟地址空间。空间的地址从全0到全f所以我们上面看到的变量地址就是这里面的地址就叫做虚拟地址这张图是以32位机器为标准画的所以地址范围就是32位0~32位1那他跟物理内存有什么关系嘛进程地址空间中的地址是可以映射到对应的物理内存的。因为我们进程的代码和数据在执行的时候肯定是加载到物理内存的只不过站在进程的角度我们看到的是虚拟地址但是它会被映射到对应的物理内存。那谁来进行这个映射谁来完成虚拟地址到物理地址的转换呢——页表我们在使用时访问的都是虚拟地址但是页表会在底层帮我们完成虚拟地址到物理地址的转换进而访问真正的物理内存。4. 解决历史遗留问题有了上面的铺垫我们就可以来解决一下我们之前遗留的这个问题了子进程修改g_val后父子进程打印g_val变量地址相同但是值不同怎么回事呢我们来讲解一下其中的原理先看这张图g_val是父进程定义的一个全局变量对他进行了初始化所以它是在进程地址空间的已初始化数据区的。我们说了进程地址空间的地址是虚拟地址我们去访问这个变量的时候操作系统会在底层通过页表把虚拟地址转换为物理地址然后就可以访问到真实物理内存中的这个变量。然后呢我们调用fork创建了一个子进程那进程程序加载到内存中的指令和数据内核中与之关联的进程控制块task_struct前面我们讲过子进程会以父进程的task_struct作为模板只修改诸如pidppid这些属性大部分直接拷贝父进程task_struct中的属性值。然后代码共享数据写时拷贝。每个进程都有自己的页表fork时操作系统会为子进程创建一个新的页表并把父进程页表的内容即页表项PTE逐项复制到子进程的页表中即子进程的页表也是以父进程的页表为模板创建的。子进程没有修改g_val之前父子进程就共享这个数据所以父子进程中g_val的虚拟地址相同并且页表映射的物理地址也相同这就是一开始我们看到的父子进程打印g_val值相同虚拟地址也相同。后来子进程修改了g_val这时为了保证进程间的独立性子进程修改了但是我父进程没有修改你不能影响我啊所以这时就会发生写时拷贝那这个写时拷贝在物理内存中如何实现呢当发生写入时进行写时拷贝内核会分配新物理页、拷贝数据然后修改发生写入的那个进程的页表项使其指向新的物理页。而另一个进程的页表项仍然指向原来的物理页。所以子进程修改了g_val操作系统就会在物理内存中新开辟一块空间拷贝g_val过去这个新的g_val作为子进程独立的g_val你要修改改你自己的父进程不受影响。但此时子进程的g_val对应的物理地址就变化了所以子进程的对应页表项也会被修改把g_val的虚拟地址映射到一个新的物理地址虚拟地址无需改动。最终就变成了这样当然这些工作由操作系统自动完成对我们用户完全透明。所以就出现了上面我们看到的现象——父子进程都打印g_val虚拟地址相同但是打印的值不同因为虽然虚拟地址相同但是两者映射的物理地址是不同的在物理内存中其实是两个变量保证了进程的独立性。5. 什么是进程地址空间如何理解下面带大家来理解一下到底什么是进程地址空间。首先我们来讲一个故事有一个富翁假设他有10个亿。然后他有很多私生子/私生女。这些私生子/私生女之间他们彼此之间不知道其他人的存在。大富翁对每一个私生子都说等以后老了我的这些钱都是你的。所以每一个私生子都认为自己以后可以独占这十个亿且都不知道其它私生子的存在这时候私生子4过来说我要交学费你给我100块钱大富翁说可以就给他了。私生女3过来说我要买化妆品需要200块钱大富翁也给了。然后私生子2过来说我要做项目给我10个亿这时大富翁直接把私生子2骂了一顿说“我还活得好好的呢你就想把我10个亿全都要走“私生子2说好吧其实200块钱就够了。私生子1说我要1万块钱读研大富翁也给了。所以大富翁对于私生子呢只要是正常的请求都是有求必应然后所有的私生子都认为自己可以拥有这10个亿但是正常情况下他们也不会直接要10个亿。所以虽然大富翁给每个私生子说你以后可以拥有这十个亿但是只要大富翁还在就不会真的一次给他们10个亿。所以大富翁这样说其实就是在画大饼那这里的大富翁就对应操作系统私生子/女就对应一个个的进程这里画的大饼就是进程地址空间每个进程都有一套独立的页表Page Table。对于进程而言它看到的内存地址是连续的全0-全f、独占的但实际进程运行时不可能一次申请特别大块的空间。它不需要关心物理内存哪一块被占用了他认为自己进程地址空间中的任何空间都可以使用但实际通过页表映射会帮它把虚拟地址映射到合适的物理地址。每个进程都觉得自己独占了内存这就是虚拟地址空间的魅力如何管理那下一个问题进程可能有很多操作系统给每一个进程都画了一个大饼进程地址空间那操作系统要不要将这么多的大饼管理起来呢当然需要如何管理呢先描述再组织先用一个结构体描述进程地址空间然后再用一种数据结构把所有的结构体变量组织起来然后对进程地址空间的管理就变成了对特定数据结构的增删查改。所以进程地址空间在操作系统中本质就是一个结构体结构体内部就是划分了一个个的区域。那如何理解这其中的区域划分呢6. 如何理解空间中的区域划分举个栗子有一张桌子长100厘米现在想把它划分成两部分55分假设每一厘米都有一个下标那就是0~99那平均划分两半的话就0~49是一半50~99是另一半即可假设左边是小胖同学的位置右边是小美同学的位置那在一个结构体中如何描述这种区域划分呢很简单一块区域只需要有两个变量标识它的起始和结束位置即可这中间的空间都是我拥有的那我后续想扩大或缩小某个区域呢只需要改对应的start和end的值就行了。7. 看看源码那下面我们就来看看Linux中进程地址空间对应的结构体看看它内部是如何做的Linux中描述进程地址空间的结构体叫做mm_struct进程内存描述符在每个进程的task_struct中会有一个mm_struct类型的结构体指针——mm指向当前进程的进程地址空间然后我们来看mm_struct结构体mm_struct里面的成员很多我们现在也不需要全部看但是通过上面的铺垫我们猜测mm_struct中一定会有很多类似上面startend这样的字段来标识各个内存区域从源码中我们确实能找到比如第三行的start_codeend_code就标识了代码段正文代码区域的起始和结束位置内核空间大富翁操作系统自己的「私人领地」私生子无权访问。mm_struct中还有一个指针pgd_t *pgd;指向当前进程的页表一图总结8. 补充然后我们再来做一些补充第一点内核空间/用户空间我们看到进程地址空间其实被分成两大部分内核空间和用户空间。而我们目前比较熟悉的其实是用户空间的这几个区域内核空间我们先不关心。那么什么是用户空间呢所谓的用户空间即我们用户可以通过地址直接访问的空间而如果要访问内核空间必须通过操作系统提供的系统调用来访问。第二点简单谈谈页表通过上面的了解我们现在至少知道页表主要是进行虚拟地址到物理地址的转换的。然后在进程地址空间中正文代码区域代码段通常是只读的其它区域比如数据段又是可读可写的。那如何做到这种权限的约束呢那在页表中还会有一列用来记录权限信息标识当前映射地址所属区域的读写权限。回头来看我们之前的那段代码下面还有一小段现在我们把注释放开。运行一下先来看这个常量字符串的地址这个我们其实上面观察过了常量字符串和代码是放在一起的在正文代码区域代码段都是只读的不能修改。那如何实现不能修改呢如果我要访问代码段的地址去修改这块地址的内容那么在页表映射的时候会检查对应的权限发现你对这块空间只有读权限现在你想写那就直接拒绝。第三点命令行参数和环境变量区在用户空间中我们比较熟悉的是蓝色框中的这几个区域。但是我们看到用户空间的最上面还有一个区域——命令行参数和环境变量。什么是命令行参数和环境变量我们之前的文章讲解过了。所以这块区域存的就是命令行参数和环境变量吗我们来验证一下看一下刚才打印的地址看图命令行参数和环境变量的地址应该在栈区上面比栈的地址高没有问题就是在栈的上面。即命令行参数和环境变量也是存在进程的地址空间中的在栈的上面。所以命令行参数和环境变量也属于进程数据的一部分父子进程之间就遵循数据默认共享修改写时拷贝。这也解释了我们之前讲的——环境变量可以被子进程继承第四点fork之后子进程的地址空间我们现在已经知道在每个进程的task_struct中会有一个mm_struct类型的结构体指针——mm指向当前进程的进程地址空间本质是一个mm_struct结构体变量。fork创建子进程除了上面我们讲的那些细节还要补充的就是子进程会以父进程的task_struct作为模板只修改诸如pidppid这些属性大部分直接拷贝父进程task_struct中的属性值。这是我们讲过的那指向当前进程的进程地址空间的mm指针不是也在task_struct中嘛那子进程的mm指针也是直接拷贝父进程的嘛对于mm指针来说fork之后它会进行一个深拷贝浅拷贝的话就是直接拷贝指针的内容嘛两个指针指向同一个mm_struct结构体即fork 后子进程的 mm 指针指向新分配的 mm_struct但是其内容拷贝自父进程结构体级别深拷贝因此父子进程拥有独立的地址空间我们上面也提到每个进程都有自己独立的虚拟地址空间这里大家就更加清晰了然后页表直接复制父进程的页表项所以父子进程都有自己独立的进程地址空间但是起始时里面映射到的物理内存一样代码和数据共享。后续如果某个进程进行了数据写入就比如我们上面举的例子则发生物理内存层面的写时拷贝这里的细节我们上面讲过了。一句话总结即“结构体级别深拷贝深拷贝mm指针父子进程的mm指针指向不同的mm_struct结构体变量拥有独立的进程地址空间物理内存级别写时拷贝如果发生数据写入则进行物理层面的写时拷贝”9. 为什么要有进程地址空间这个问题其实可以转化为如果程序直接可以操作物理内存会造成什么问题在早期的计算机中没有进程地址空间即程序中访问的内存地址都是实际的物理内存地址。当计算机同时运⾏多个程序时必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运⾏多个程序时操作系统是如何为这些程序分配内存的呢例如某台计算机总的内存大小是128M现在同时运行两个程序A和BA需占⽤内存10MB需占⽤内存110M。计算机在给程序分配内存时会采取这样的⽅法先将内存中的前10M分配给程序A接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配⽅法虽然可以保证程序A和程序B都能运⾏但是这种简单的内存分配策略问题很多。9.1 安全想象一下如果没有进程地址空间没有虚拟地址。我们在C/C程序中使用一个指针就可以随意地访问物理内存。那就会导致缺乏隔离一个进程可能错误的访问另一个进程的内存空间一个程序的错误指针可能破坏其他程序甚至操作系统内核的数据。这就会造成进程之间的相互干扰一个进程出问题可能会干扰其它进程那这不就违背了进程之间的独立性嘛。那反过来因为有了进程地址空间的存在我们在程序中使用的都是虚拟地址那访问内存的时候就必须先进行虚拟地址到物理地址的转换相当于在进程和物理内存之间添加了一层软件层在计算机科学中存在一个经典的计算机科学谚语因为存在这样一层转换那么就可以在虚拟地址转换为物理地址的时候进行相关的安全性的审核。如果你进行了一个非法的访问那操作系统就可以拦截你。这防止了进程间的相互干扰一个进程的崩溃或缓冲区溢出不会破坏其他进程甚至内核的数据极大提升了系统的稳定性和安全性。这就变相地保证了物理内存的安全维护了进程之间的独立性给大家举个例子这就好比你每年过年的时候都能收到很多压岁钱。之前一直都是你自己管这些钱所以你想买啥就买啥没人管你各种垃圾食品疯狂地买各种奶茶外卖狠狠地吃。现在呢你妈妈每次都会把你的压岁钱收走管理起来。你妈妈跟你说的是“我帮你管起来但是这钱还是你的你要买啥问我要就行了”你说你要买本书买支笔。没问题要花多少钱你妈就给你多少。你说你想买十包辣条就着十瓶可乐吃你妈骂了你一顿说天天吃这种垃圾食品有什么好处啊不准买此时你妈妈操作系统虚拟地址空间页表就可以对你进程对这些钱物理内存的使用进行限制和审核让你只把钱用在“合理”的地方。9.2 按需分配与惰性加载再来看这张图问大家一个问题双击执行一个可执行程序让他变成进程的时候它所有的代码和数据是不是都全部一块加载到内存中通常不是这样的而是采用按需分配与惰性加载的策略按需分配执行一个程序先只加载目前需要的部分给他分配物理空间此时可能并不需要执行所有的代码和数据其它不需要的就先不加载惰性加载如果进程使用malloc申请了一块空间先只给他分配虚拟内存空间并不立刻分配物理页。只有当进程真正访问该地址时出现缺页异常才触发物理页的分配并建立页表中的映射。因为有地址空间的存在所以我们在C、C语⾔上new, malloc空间的时候其实是在地址空间上申请的物理内存可以甚⾄⼀个字节都不给你。当你真正进行对物理地址空间访问的时候才执行内存的相关管理算法帮你申请内存构建页表映射关系延迟分配这是由操作系统自动完成用户包括进程完全0感知这就可以做到程序启动极快只加载极少代码比如你玩一个大型游戏此时你在玩这个场景/地图那就只需加载当前场景的代码和数据在内存中其它没用到的就不加载减少物理内存的浪费只给实际使用的页分配物理页支持运行比物理内存更大的程序结合换入换出你玩一个大型游戏此时你在玩这个场景/地图那就只需加载当前场景的代码和数据在内存中其它没用到的就不加载省出来的这部分空间还可以先被其它进程使用等你玩其它场景的时候把之前不需要的代码和数据换出去把需要的加载进来通过这一系列的手段就可以使得从用户的感知上来看内存好像比实际的要大多9.3 有序什么意思呢操作系统中可能运行很多进程物理内存经过长时间分配释放会产生难以利用的“外碎片”比如许多几KB的小空隙。所以进程的代码和数据加载到物理内存中可能是非常零散、非常分散的。但是因为有进程地址空间的存在站在进程的视角他看到的就是虚拟地址空间虚拟地址空间允许进程的连续虚拟地址映射到物理内存中不连续的、分散的页面。即进程能够以一种比较规整有序的视角看待自己的代码和数据无需关心它在物理内存中有多么混乱无序同时这样可以充分利用内存碎片提高了物理内存的利用率。9.4 解耦进程地址空间的存在使得进程管理和内存管理可以解耦合对进程而言每个进程都以为自己独占了从 0 到最大地址的连续内存无需关心物理内存的碎片、容量上限、其他进程的占用。进程只需使用虚拟地址程序编写和编译不需要知道最终的物理位置。对操作系统而言物理内存可以按页自由分配给任意进程的任意虚拟页物理页帧可以是非连续的。内存分配、回收、移动如压缩碎片变得非常灵活不影响进程运行只需更新页表。两者通过页表连接但不耦合10. 进程地址空间的组织方式前面讲了描述linux下进程的地址空间的结构体是mm_struct内存描述符。每个进程只有⼀个mm_struct结构在每个进程的task_struct结构中有⼀个指向该进程mm_struct的结构体指针。先描述再组织。mm_struct结构体描述了进程地址空间那具体是如何组织呢task_struct中有一个mm指针指向mm_struct所以操作系统把所有进程的task_struct 组织起来就变相的已经把进程地址空间mm_struct组织起来了。11. 虚拟内存块的管理但是还有一些情况需要我们考虑在进程地址空间中一个内存区域堆、栈、代码段…对应一整块但是在实际使用的过程中比如堆空间我们正常是一小块一小块的使用的比如在一个程序中我malloc了多次申请多块空间每一块都有起始地址和空间大小当然通过上面的理解我们知道我们malloc得到的都是虚拟地址空间当我们真正使用申请的空间时才会开辟物理内存块然后建立页表映射。所以不仅进程地址空间要被管理起来我们在进程地址空间中申请的一块块虚拟内存块也需要被管理起来如何管理先描述再组织内核中使用vm_area_struct这个结构体来描述我们在进程地址空间中申请的一块虚拟内存块VMA。每一个vm_area_struct就代表进程地址空间中「一块已经被申请、被使用、有明确用途」的虚拟内存区域如何组织呢单链表红黑树同时管理mmap链表用于遍历所有 VMAmm_rb红黑树用于快速查找某个虚拟地址属于哪个 VMA比如缺页异常时具体怎么做呢看看源码从mm_struct结构体的定义中除了上面我们了解过的还可以找到mmap和mm_rb首先我们看到vm_area_struct中有一个struct vm_area_struct *vm_next;指针通过这个指针就可以把所有的vm_area_struct链接成链表进行管理。同时每个vm_area_struct结构体内部内嵌了一个struct rb_node成员 vm_rb红黑树结点。通过这个内嵌的rb_node内核将所有的vm_area_struct同时组织成红黑树。即vm_area_struct即属于链表同时又属于红黑树。总结就是首先我们申请的所有的虚拟内存块vm_area_struct都被一个链表链接了起来我们上面图中的版本内核源码的做法就是和普通的链表一样通过一个next指针链接。新版本的内核也是通过vm_area_struct内嵌一个list_head的链表结点进而被双向链表管理起来和进程管理那里task_struct链表一样的方法。同时每个 vm_area_struct 结构体内部内嵌了一个 struct rb_node 成员名为 vm_rb。通过这个内嵌的 rb_node红黑树结点内核也将所有的 vm_area_struct 组织成红黑树。即vm_area_struct即属于链表同时又属于红黑树。需要遍历的场景则使用链表需要快速查找则可以使用红黑树同时被两个数据结构管理但是不同的应用场景可以选择不同的数据结构进查找。另外在vm_area_struct中也是有访问权限相关的字段的。那前面我们说页表里面也有权限字段那他俩是什么关系呢先简单了解两者的角色分工vm_area_structVMA进程地址空间的逻辑描述。它记录了某一段虚拟地址区域应该具有的权限如可读、可写、可执行以及该区域是映射文件还是匿名内存。这是内核管理虚拟内存的“意图”。页表项PTE硬件 MMU 直接使用的数据结构。它记录了虚拟页到物理页的映射以及该页当前在硬件层面的访问权限通常是 VMA 权限的子集比如因为写时复制暂时设为只读这句话我们后面会用到。非法访问最终是被 VMA 权限拦截的通过内核判断后发送信号。页表权限的作用是触发异常让内核有机会介入决策如果页表权限过于宽松比如直接允许写一个本该只读的 VMA那么硬件不会产生异常非法访问就会成功——但这不会发生因为内核在建立页表时总是根据 VMA 设置权限且不会超过 VMA。图片截取自deepseek的回答