前言:在Linux中,每个正在运行的进程都有自己独立的虚拟地址空间,该虚拟地址空间是逻辑上的抽象,用于在进程间提供隔离和保护。它将进程的内存分配和访问从物理内存中分离出来,为每个进程提供了一个独立的地址空间。这究竟是怎么一回事?本文我们一起来了解一下~~
那我们话不多说,Linux,启动!!!
目录
地址空间和区域划分
OS是如何将进程与页表相对应的,又是如何将虚拟地址转换为物理地址的?
1.程序空间地址
程序地址空间回顾
我们在学习C/C++的时候,肯定知道一些关于程序分布的相关结构图,如下:
那现在我们学了Linux的相关知识,在理解了冯诺依曼计算机体系的基础上,就有了这样的疑问,这个结构到底是不是内存呢?下面我们不妨来验证一下,
我们给出Linux下的这样一段代码和运行结果,用于显示各种变量的地址,
堆栈相对而生
堆向上生长,栈向下生长,我们平常在写代码时经常遇到的栈溢出或者堆溢出,就是这个特性所导致的问题,我们来进行一下验证,
这里我们需要注意的是,对于栈区变量的定义,虽然其是向下生长的,但是这只是对于变量的定义来说的,比如数组,结构体之类的具有连续空间的结构,其在栈区的使用是向上增长的,我们平常看到的经验来说,我们定义数组a[10],都是下标小的对应的地址比下标大的要小,对于结构体也是一样,数据成员中,最后一个声明的往往具有较大的地址值,换句话来说,栈区的使用是整体向下增长,但是对于局部变量来说,是向上增长的。
命令行参数和环境变量
命令行参数和环境变量表保存在栈区地址上方,具体,我们可以再来验证:
2.进程地址空间
虚拟地址&物理地址
还记得我们前面在学习父子进程,fork函数的时候留下的疑问吗?
现在,我们再次将这个问题复现,为了能够对这个返回值做修改操作,并且我们已经知道了fork函数创建的子进程和父进程共享一份代码和不共享数据,子进程会根据需要将数据做写时拷贝保存到自己的数据信息中,而对于fork函数创建父子进程来说,父进程的完整的代码都能被子进程看到和共享(fork函数之前的代码也是),于是,我们可以利用一个全局变量 g_val,将其在父子进程做不一样的操作,来分别验证父子进程中的同一个变量的地址和数据的变化,我们给出如下的测试代码:
对同一个地址进行读取,竟然读取到了不同的值,这种现象我们该如何理解呢?计算机不会骗我们,说明这种现象肯定是正确的,所以,我们只能按照现象做出符合该现象的假设:这个地址,绝对不是物理地址!
事实上,我们平时看到的,用到的地址,其实都是虚拟地址/线性地址,我们上面展示的程序空间结构图,其实专业的叫做进程地址空间,每一个进程在被创建时都有一个属于自己的进程地址空间,在这个进程地址空间上是不能存储数据的,需要特定的方式将进程地址空间上的地址以某些映射关系映射到真实的物理地址上去,为了先大致了解这其中的原理,我们先对上面的现象做出解释,然后再其原理进行分析。
父进程在运行时,会有一个自己专门的进程地址空间,专门用于保存进程/虚拟地址的,这个虚拟地址要想和物理地址建立联系,就需要存在一定的映射关系,我们将这种映射关系称为页表,父进程通过页表,将虚拟地址映射到物理地址上去,就能找到数据真正存储的位置并且获取它的值。而子进程可以共享父进程的代码,同样也可以共享父进程所创建的进程地址空间和页表,使得此时父子进程中的同一个全局变量当前是执指向同一个物理地址的。
子进程被创建时会进程父进程的信息,包括大部分PCB,进程地址空间、页表等信息,此时父子进程中的全局变量是真真正正的同一个变量。
可是,一旦当子进程想要尝试修改数据,此时就需要子进程进行写时拷贝操作。当子进程发现该变量还有别的进程正在使用时,而进程具有独立性,我们不能影响别的进程的使用,所以,操作系统就在物理内存中单独给该子进程开辟了一段空间,一旦子进程想要修改数据,发生写时拷贝,此时子进程变量实际的物理地址就会改变,并且会修改其对应的页表映射关系。
地址空间和区域划分
每个进程都有一个地址空间,操作系统为每一个进程画了一个大饼,它们都认为自己在独占物理内存,系统中存在大量进程,需要管理地址空间,那么就需要先描述、再组织,进程地址空间本质上在内核中是一个数据类型 ,可以定义具体的进程地址空间变量,在Linux当中进程地址空间具体由结构体mm_struct实现。
struct mm_struct
{
//进程地址空间
};
我们的进程地址空间在开辟时就已经为每一段空间做出了区域划分,像我们上面所介绍的代码区、常量区、栈区、堆区等等,从而我们可以这样描述一个进程地址空间的结构体,我们在描述进程地址空间时,是以虚拟地址为标准来描述的,
struct mm_struct
{
unsigned int code_start;
unsigned int code_end;
unsigned int init_data_start;
unsigned int init_data_end;
unsigned int uninit_data_start;
unsigned int uninit_data_end;
//....
unsigned int stack_start;
unsigned int stack_end;
};
进程地址空间存在的意义
为什么要如此麻烦的将物理内存地址映射成虚拟地址才能被我们使用,为何不直接在PCB中以物理地址的方式存储地址?这里给出以下的几点原因:
<1> 使得无需内存数据变得有序
首先,我们知道,进程在加载到内存的时候是随机选择内存地址的,一个进程的代码和数据在内存中被分别随机加载到任意位置,这就让我们的进程的寻址变得困难,但是引入了进程地址空间,通过进程地址空间+页表的方式,我们可以用统一的虚拟地址空间来映射物理内存的地址,可以将乱序的内存数据变得有序,方便统一管理和规划。
同时,如果进程在执行期间发生了挂起或者进程切换导致了代码和数据的内存地址发生改变,我们只需要将页表的物理地址部分锦绣修改即可,无需对虚拟地址进程修改,方便了管理。
<2> 增强数据访存的安全性
不知道你是否有这样一个疑问,就是进程地址空间是如何区分这些划分出来的区域的?比如字符常量区的数据为什么不能被修改这样的访存权限问题。事实上,这和我们的页表结构有关,我们的页表除了有虚拟地址和物理地址间的映射关系结构以外,对于每个映射关系,还存在一个访问权限字段,这个字段就可以表示规定好的区域划分后的各个空间所具有的特殊的权限问题,比如我们的字符常量区,页表在创建时,就会识别对应的字符常量区的虚拟地址范围,并将其权限设置为只读(r),这样,我们在通过虚拟地址经过页表寻找物理地址并试图进行修改操作时,就因为页表的访问权限字段而被拦截下来,换句话说,物理地址是无法直接拦截访存操作的,是通过页表进行拦截的。
OS是如何将进程与页表相对应的,又是如何将虚拟地址转换为物理地址的?
操作系统中可能会有众多的页表,那么操作系统是如何找到当前进程所对应的那个页表的呢?事实上,我们知道,每一个进程在需要页表的时候一定是进程正在被执行,此时CPU内部有一个特殊的寄存器(CR3),该寄存器存储的就是当前正在执行的进程的页表的物理地址 ,当该进程在CPU上执行时,通过CR3就能找到进程对应的页表信息。页表信息本质上也是保存在进程PCB中的一个结构体数据,这就使得在进程切换时,页表也能够根据上下文信息进行对应的切换,从而实现页表随进程的动态切换。
CPU通过内存管理单元(MMU)来执行虚拟地址到物理地址的转换。CPU使用虚拟地址进行内存访问, CPU内部的MMU获取虚拟地址,并将其拆分成不同的部分,通过对其进行相应的查找和执行过程找到对应的物理地址,这个过程我们不展开,后续的内容会深入的了解其转换机制。
<3> 将进程管理和内存管理更好的解耦
Linux中的挂起状态
可执行程序在执行时,不一定需要全部加载到内存,当可执行程序过大,操作系统并不需要将这个可执行全部加载到内存,甚至是根据需要再进行加载到内存。
在PCB的页表结构中,还存在着这样一个字段,该字段的作用就是标识在当前映射关系下,虚拟地址(在页表中,虚拟地址是可以是固定在页表左栏的,因为虚拟地址是固定的,但是映射关系是不固定的,也就是虚拟地址所映射的物理地址是不固定的,需要靠操作系统进行分配)是否分配了对应的物理地址。在执行可执行程序的部分代码时,能够根据需要将部分代码片段加载内存中,申请部分物理空间,然后将该物理地址加入到映射关系中去,而我们能看到的,只是进程地址空间上的虚拟地址,至于程序的部分加载分配到内存,和将物理地址加入到进程页表中的操作,也就是内存管理模块,对我们来说是透明的,其中,进程在访问虚拟地址时,发现其物理地址还没有被分配,从而进入暂停等待物理内存分配的过程,叫做缺页中断,这个我们后续再做深入了解,现在,我们只是知道,这是Linux进程挂起的一种状态即可。
页表映射实现进程独立性
在Linux中,页表映射是实现进程独立性的关键机制之一。每个进程在Linux中都有自己独立的虚拟地址空间,页表映射就是将进程的虚拟地址映射到物理内存的过程。
当一个进程进行内存访问时,CPU会使用虚拟地址进行内存访问。此时,由于虚拟地址空间和物理地址空间不同,需要通过页表来进行地址转换。
这种方式使得每个进程可以拥有自己独立的地址空间,互相之间的内存访问不会相互干扰。同时,页表的机制还有助于实现内存保护、内存共享和内存分配等功能。
未完待续......