接前文:
Linux系统 —— 进程系列 - 进程优先级与进程切换-CSDN博客https://blog.csdn.net/hedhjd/article/details/144404639?spm=1001.2014.3001.5502
目录
前言
1. 虚拟地址空间和进程地址空间
1.1 什么是虚拟地址空间?
结论
1.2 虚拟地址空间的结构体里有哪些属性(如何实现)
1.3 虚拟内存管理
2. 为什么要有虚拟地址空间
问题一:安全风险
问题二:地址不确定
问题三:效率低下
前言
我们都知道,我们的内存空间划分为几个区域,我们在调试代码窗口的时候, 看到的地址其实并不是真实内存的地址,而是虚拟地址空间
虚拟地址空间并不是物理内存,两者之间有一点关系,但是不多
一个进程有一个虚拟地址空间,我们前面学习的时候只知道创建一个进程就需要有一个对应的task_struct来描述对应的进程,而每一个task_struct都要对应一个虚拟地址空间
在我们的操作系统里面,一个进程会构建一个页表,我们页表左侧存储的是我们的虚拟地址,右侧存储的是物理地址
页表是用来做虚拟地址到物理地址映射的:所有的数据包括代码本身全部都有地址,所以每一个元素对应的地址都是由每一个虚拟地址加载到物理内存然后经过页面映射找到物理内存
我们都知道,进程有两个进程一个是父进程,另一个是子进程,而我们上面的就是父进程,我们下面这张图片就是子进程,子进程所有数据都是从父进程哪里拷贝下来的(包括:task_struct,页表,数据 ... )
而一旦拷贝就意味着在我们的子进程初始化全局数据区里面也会同样存在着全局变量,也叫做g_val的虚拟地址,而这种页表关系就类似于哈希表,当我们拿到了它的虚拟地址再找到了它对应的物理地址,页表就会把父进程的页表拷贝到子进程的页表里面,这种概率就是发生了简单的浅拷贝,其实就相当于它们都指向了同一个物理内存,所以父进程和子进程的代码和数据都是共享的
这个时候我们就有一个问题:子进程要对变量进行修改怎么办?
答案是:当子进程要对变量进行修改时,这个时候我们的OS就会介入,OS直接给我们开辟一段新的地址空间,然后把我们之前的内容拷贝进新开辟的空间里,那么这个时候我们就会得到一个新的物理地址,然后OS再重新填写页表,构建新的映射关系
那么这个时候,我们对应的同一个虚拟地址并没有变化,但是它的物理内存已经指向了一个新的物理起始空间,这种机制我们称之为:写实拷贝
1. 虚拟地址空间和进程地址空间
1.1 什么是虚拟地址空间?
举个例子:比如,在国外有一个大富翁,非常有钱,有100个亿的资产,但是大富翁的私生活非常混乱,有四个私生子,有一天,这个大富翁找到了他的第一个私生子说:“儿子,只要你好好学习,爸爸的这100个亿就都是你的”,第一个私生子就非常高兴,从此就好好学习,过了一段时间,这个大富翁又找到了他的第二个私生子,也开始给第二个私生子画饼......
这里的大富翁就是我们的操作系统OS,100个亿就是物理内存,私生子就是进程,画饼就是虚拟地址空间
这个是我们就有一个问题了:OS要把进程管理起来,那么要不要把饼也管理起来呢?答案是:要 那么我们应该怎么管理饼呢?答案就是:先描述,再组织
我们的虚拟地址空间(画饼)本质上其实就是一个数据结构,叫做:mm_struct
总结:虚拟地址空间其实就是一个在内核当中,在操作系统内部给进程创建结构体对象,这就叫做虚拟地址空间
我们来段代码感受⼀下
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{pid_t id = fork();if (id < 0) {perror("fork");return 0;}else if (id == 0) { //childprintf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else { //parentprintf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出:
//与环境相关,观察现象即可
parent[2995]: 0 : 0x80497d8
child[2996] : 0 : 0x80497d8
我们发现,输出出来的变量值和地址是⼀模⼀样的,很好理解呀,因为⼦进程按照⽗进程为模版,父子并没有对变量进⾏进⾏任何修改。可是将代码稍加改动:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{pid_t id = fork();if (id < 0) {perror("fork");return 0;}else if (id == 0) { //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程再读取g_val = 100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else { //parentsleep(3);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出结果:
//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
parent[3045] : 0 : 0x80497e8
结论
我们发现,父子进程,输出地址是⼀致的,但是变量内容不⼀样!能得出如下结论:
1. 变量内容不⼀样,所以⽗⼦进程输出的变量绝对不是同⼀个变量
2. 但地址值是⼀样的,说明,该地址绝对不是物理地址
3. 在Linux地址下,这种地址叫做 虚拟地址
4. 我们在⽤C/C++语⾔所看到的地址,全部都是虚拟地址!物理地址,⽤⼾⼀概看不到,由OS统⼀管理
OS必须负责将 虚拟地址 转化成 物理地址
1.2 虚拟地址空间的结构体里有哪些属性(如何实现)
我们有一个问题:什么叫做区域划分呢?
举个例子:我们正在学校上课,但是有一天我们和同桌吵架了,这个时候我们的同桌画了一条38线,并警告我们不许越界,不然就打我们
这个时候,这个38线其实就是我们的区域划分
结合上图,我们的区域划分在只需要定好区域的开始和结束就可以了,而我们想要使用对应的空间就可以以刻度来访问,所以刻度就是地址,刻度是线性且连续的,地址其实就是整数0~100的数字,所以我们可以所以整数Int类型来保存
所以,我们虚拟地址空间的结构体里就包含这些属性:
结构体里的属性都是每个区域的开始和结束
mm_struct:
展开:
1.3 虚拟内存管理
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的结构
struct task_struct
{struct mm_struct/** mm; 对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL*/struct mm_struct* active_mm; /*该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间*/}
可以说,mm_struct结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况
定位mm_struct⽂件所在位置和task_struct所在路径是⼀样的,不过他们所在⽂件是不⼀样的,mm_struct所在的⽂件是mm_types.h
struct mm_struct
{struct vm_area_struct* mmap;/* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb;/* red_black树 */unsigned long task_size;/*具有该结构体的进程的虚拟地址空间的⼤⼩*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;
}
那既然每⼀个进程都会有⾃⼰独⽴的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织⽅式有两种:
1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表
2. 当虚拟区间多时采取红⿊树进⾏管理,由mm_rb指向这棵树
linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进程快速访问
struct vm_area_struct {unsigned long vm_start; //虚存区起始unsigned long vm_end;//虚存区结束struct vm_area_struct* vm_next, * vm_prev;//前后指针struct rb_node vm_rb;//红⿊树中的位置unsigned long rb_subtree_gap;struct mm_struct* vm_mm;//所属的 mm_structpgprot_t vm_page_prot;unsigned long vm_flags;//标志位struct {struct rb_node rb;unsigned long rb_subtree_last;} shared;struct list_head anon_vma_chain;struct anon_vma* anon_vma;const struct vm_operations_struct* vm_ops; //vma对应的实际操作unsigned long vm_pgoff;//⽂件映射偏移量struct file* vm_file;//映射的⽂件void* vm_private_data;//私有数据atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMUstruct vm_region* vm_region;/* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMAstruct mempolicy* vm_policy;/* NUMA policy for the VMA */
#endifstruct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
化为图片就是:
2. 为什么要有虚拟地址空间
也可以说是:如果程序直接可以操作物理内存会造成什么问题?
在早期的计算机中,要运⾏⼀个程序,会把这些程序全都装⼊内存,程序都是直接运⾏在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运⾏多个程序时,必须保证这些程序⽤到的内存总量要⼩于计算机实际物理内存的⼤⼩
那当程序同时运⾏多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存⼤⼩是128M,现在同时运⾏两个程序A和B,A需占⽤内存10M,B需占⽤内存110。计算机在给程序分配内存时会采取这样的⽅法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B
这种分配⽅法可以保证程序A和程序B都能运⾏,但是这种简单的内存分配策略问题很多
问题一:安全风险
每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内
存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪
问题二:地址不确定
众所周知,编译完成后的程序是存放在硬盘上的,当运⾏的时候,需要将程序搬到内存当中
去运⾏,如果直接使⽤物理地址的话,我们⽆法确定内存现在使⽤到哪⾥了,也就是说拷⻉
的实际内存地址每⼀次运⾏都是不确定的,⽐如:第⼀次执⾏a.out时候,内存当中⼀个进程
都没有运⾏,所以搬移到内存地址是0x00000000,但是第⼆次的时候,内存已经有10个进程在运⾏了,那执⾏a.out的时候,内存地址就不⼀定了
问题三:效率低下
如果直接使⽤物理内存的话,⼀个进程就是作为⼀个整体(内存块)操作的,如果出现物理
内存不够⽤的时候,我们⼀般的办法是将不常⽤的进程拷⻉到磁盘的交换分区中,好腾出内
存,但是如果是物理地址的话,就需要将整个进程⼀起拷⾛,这样,在内存和磁盘之间拷⻉
时间太⻓,效率较低
那么,我们有没有解决方法呢?答案是有的
1. 地址空间和⻚表是OS创建并维护的!是不是也就意味着,凡是想使⽤地址空间和⻚表进⾏映射,也⼀定要在OS的监管之下来进⾏访问!!也顺便保护了物理内存中的所有的合法数据
,包括各个进程以及内核的相关有效数据
2. 因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合
因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址
空间上申请的,物理内存可以甚⾄⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问
的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这
是由操作系统⾃动完成,⽤⼾包括进程完全0感知
3. 因为⻚表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进⾏映射,在进程视⻆所有的内存分布都可以是有序的
进程概念系列就先到此为止啦~