目录
一、堆、栈、环境参数所在位置
二、进程地址空间底层实现原理
编辑
三、什么是地址空间
四、为什么要有进程地址空间
五、细谈写实拷贝的实现及意义
在C/C++学习中,都学习过如上图所示的一套存储结构,我们大致知道一般存储空间分为堆区,栈区,静态区等,可我们对其构成和实现原理并不理解,而今天博主将通过Linux基于xshell平台来对其进行深度刨析。
一、堆、栈、环境参数所在位置
我们可以通过一段代码来验证上图所划分区域的准确性进行验证:使用makefile编译myenv然后运行
在Linux环境下,我们可以很清晰的看到不同类型的数据的存储位置,而在VS下却不一定遵守此套规则,因为在Windows环境下,考虑到安全性会对地址进行随机化,而且每次打印出的地址都不一样,为了防止代码和数据被固定编译到某个地方。
二、进程地址空间底层实现原理
父子进程内容不一样,地址却一样?从物理层面上来讲这是不可能的,同一块地址却有两个值,此时只能说明,此地址不是物理地址而是虚拟地址。所以我们日常编写代码过程中使用的地址也都是虚拟地址!!
所以上图展示出的所谓的内存分布结构,也不是物理层面的结构,而是由虚拟地址所构建出的结构,而它正确的叫法叫做进程地址空间。
通过之前的了解我们可以知道,在进行fork创建子进程时,子进程会继承父进程的代码 数据和属性信息,所以子进程和父进程中都会存在一个名为g_val的一个全局变量,而在子进程对其进行修改之前父子进程中的g_val在进程地址空间中都是同一块空间即他们的虚拟地址都是一样的,而此时每个进程PCB即task_struct中都会有一张表(页表)来将代码中数据的虚拟地址通过该表来映射到真实的物理地址,可以理解为一个数组,g_val的虚拟地址为数组下标,而下标所对应的元素中存的值就是真实的物理地址。
而子进程在创建时也会继承父进程中的这张表,在子进程要对g_val的值进程更改时,遵循进程之间相互独立互不影响的原则,此时就会进行写时拷贝,操作系统会在真实的物理内存中新开辟一块空间然后将原本在哈希表中g_val下标所存的地址所指向的物理内存中的值进行拷贝然后修改为200,然后再将g_val虚拟地址为下标的元素中的值更换为这个新开辟的物理内存的地址,此时就完美实现了父子进程中同一变量却存着不一样的值的效果!
所以父子进程对g_val进行访问时通过不同的映射关系找到不同的物理内存。
所以这也是fork指令后,父子进程明明是同一段代码,却能使用getpid赋值给同一个变量不同值的原因。这也使得我们可以getpid后通过获取到的id值来对父子进程进行分流从而时父子进程实现不同的功能。以此为切入点,我们详细刨析地址空间。
三、什么是地址空间
每一个进程都会存在一个进程地址空间。操作系统要对其进行管理依旧是遵循先描述再组织的原则。
所以进程地址空间的本质是数据结构,具体到进程中,就是特定数据结构的对象。
所以操作系统每创建一个进程就创建一个地址空间,内部用next指针链接起来,所以对进程地址空间的管理就变成了对链表的增删查改。
每个进程创建的时候都会存在一个struct task_struct,而每个task_struct中都存在一个指针指向对应的进程地址空间。所以进程和地址空间的关系就是数据结构之间的关系。
所以在设计进程的地址空间时,如何将地址空间进行区域划分呢?
直接上源码:
可以很清晰的看到,在linux内核中存在一个struct,里面存放着各个区域在内存中开始和结束的位置 ,即大量的start/end。所以对应的地址空间本质上是进程的一种数据结构。
所以当操作系统创建进程时,下图的数据结构就会被进程创建出来,然后将里面的字段进行初始化。所以每个进程都有自己的mm_struct.。里面有着该进程的各种信息。
而空间划分本质就是区域内的各个地址都可以使用。而结构体中的地址空间是虚拟的,本身并不具有代码和数据保存能力,代码和数据必须存放在物理内存中,所以就必须将虚拟地址(线性地址)转化成为物理内存中,这时就需要用到我们上文中所说的那张表来进行映射,这张表就叫做页表。
一个进程在启动时,先创建PCB再创建mm_struct即进程地址空间,然后将磁盘中的可执行程序(代码和数据)加载到物理内存当。随后mm_struct中的正文代码数据等都会通过页表转换到物理地址。
而完成页表映射,并去页表进行查找的工作都是由CPU完成的,在CPU中有个mmu集成硬件专门用来转换工作,其中有一个名为CR3的寄存器,寄存器指向页表的起始位置,当CPU执行代码需要去内存中查找变量或者存储变量时,mmu会通过CR3会去页表中进行查找,然后找到真实物理地址,注意:CR3中保存的地址就是页表的物理地址,因为虚拟地址需要CR3来进行页表查找,它内部存的如果是页表的虚拟地址,就无法转化出页表的物理地址,经典的蛋生鸡,鸡生蛋的问题。
虚拟地址是给进程的/给用户的!
经过页表的映射,就成功的将进程的管理和内存的管理成功的分离开,这样操作系统在对进程进行管理时,就直接给予虚拟地址所构建出的上图的数据结构进行管理就可以,而对物理内存,也不需要担心存储不连续导致的一系列问题,即使一串代码分开存储,在页表中页可以通过映射来将它们放到一起映射到同一虚拟地址中。
再回头看父子进程创建的整个过程就显得透明清晰了。
四、为什么要有进程地址空间
1.将物理内存从无序变有序,让进程以统一的视角,看待内存
2.将进程和内存管理进行解耦合
3地址空间+页表是保护内存安全的重要手段,如果访问内存的请求合理就通过页表去访问物理内存,如果访问请求非法就及时拦截。
所以在日常我们越界访问或者出现野指针时,就会出现报错,但这种非法访问并不会导致操作系统和程序崩溃,因为拦截此此次访问操作的是地址空间。以此保护了内存。
4.地址空间的存在可以优化内存申请分配的方式
操作系统,一定要为效率和资源使用率负责。
比如我们在new或者malloc的时候,操作系统并不会直接去物理内存开辟新的空间,而是在进程的虚拟地址空间中申请,所以,申请内存的本质是在地址空间中申请
因为申请空间并不代表要马上使用空间,当空间申请时,操作系统先在虚拟地址空间中分配对应大小的空间,当要实际去进行存储和使用时,再去通过页表建立映射关系在物理内存中开辟空间。开辟虚拟地址空间时去建立映射和使用时再去建立映射本质上没有太大差别,使用时再去建立映射反而有以下两点好处:
1、充分保证内存的使用率
2、提升new或malloc的速度
五、细谈写实拷贝的实现及意义
同样遵循上述规律,子进程在刚开始被创建时,虽然有自己的页表mm_struct和pcb,但在物理内存上并没有创建新的内存空间来拷贝父进程的数据而是和父进程指向相同的空间,当子进程要去修改数据时再进行写实拷贝。
而写实拷贝的应用场景不止是对数据进行更改,还有可能进行增删查等工作。
而页表也并不只有虚拟地址和物理地址两个部分,还有一个权限位,所以对以上代码对str进行更改时就需要去页表进行映射,而“hello linux”作为常量字符串具有常性,不能被修改,而编译器是怎么知道不能修改的呢?不加const的问题也存在与此,加了const以后编译时直接会在编译时报错,而不加是在运行时报错,加了const相当于将运行时的报错进行提前,而运行时的报错藏的是比较深的有时甚至不会被发现。所以加cosnt属于防御性编程。
所以根据示例可以知道,页表也是有权限的,当加const时权限位就变成只读权限。
所以如上图,我们fork创建子进程时,默认的代码段和数据段都是r只读权限,所以在读的时候操作系统就不会触发任何错误,直接通过页表来对内存进行读取,而当我们尝试修改时,因为此时页表中的权限位是r权限,此时就会引发操作系统来进行处理,而我们也将此情况称为缺页中断。
此时引发缺页中断时,操作系统作为管理者就会进行写实拷贝。