目录
[1 .什么是进程地址空间](#1 .什么是进程地址空间)
[2 .地址空间和物理内存](#2 .地址空间和物理内存)
[3 . 为什么要有进程地址空间](#3 . 为什么要有进程地址空间)
这段空间中自下而上,地址是增长的,栈是向地址减小的方向增长(先使用高地址)。堆是向地址增长的方向增长(先使用低地址)。堆栈之间的共享区主要是用来加载动态库。
一、进程地址空间
说明:
上面的图可以说明问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
1 .什么是进程地址空间
进程=内核数据结构(task_struct)+代码(只读的)和数据
进程具有独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
进程地址空间就算从进程的视角看到的地址空间,是进程运行时所用到的虚拟地址的集合。程序运行时,操作系统会给每一个进程都创建一个独立的虚拟地址空间,每一个进程的地址空间都是整个内存的大小。这个时候操作系统会让进程觉得整个内存都属于这个进程,就相当于一个boss给员工画大饼子。这样看来,实际上的内存不是全部都给了同一个进程。进程发出超过的内存实际的大小的需求时,操作系统就会拒绝分配内存。
在操作系统中,进程地址空间中的地址通常被称为线性地址,因为他是从全0到全1依次增加,顺序编址,这些地址也被称为虚拟地址,或者逻辑地址。在linux中,他们的意思都是一样的。
知道了虚拟地址就要知道物理地址,内存是一个硬件,他的每一个字节都有自己的地址,这个地址就是物理地址。当然我们不可能看见物理地址,因为父子进程进行继承的时候是地址一起继承的,而各自修改数据,对方的数据都不会改变,但是他们的变量地址是一样的。因此,他们相同的是虚拟地址,不相同的是物理地址,cpu实际运行的时候会去寻找物理地址。
在linux内核中,有一个这样的结构体:struct_mm_struct,这个结构体用来表示进程地址空间的一个一个区域:
struct mm_struct
{
unsigned long code_start;//代码区
unsigned long code_end;
unsigned long init_start;//初始化区
unsigned long init_end;
unsigned long uninit_start;//未初始化区
unsigned long uninit_end;
unsigned long heap_start;//堆区
unsigned long heap_end;
unsigned long stack_start;//栈区
unsigned long stack_end;
//...等等
}
因此:
进程地址空间本质是进程看待内存的方式,抽象出来的一个概念,内核:struct mm_struct,这样的每个进程,都认为自己独占系统内存资源,地址空间区域划分本质:将线性地址空间划分成为一个一个的area,[start,end](代码区,初始化区... ...)。虚拟地址本质,在[start,end]之间的各个地址叫做虚拟地址
2 .地址空间和物理内存
我们写了多个程序,当我们运行这些程序的时候,他们会生成各自的可执行程序,此时系统中就存在多个进程,我们也会有多个 task_struct 结构体。那么对应进程也都有各自的进程地址空间mm_struct。在task_struct中也会有一个指针指向mm_struct结构体。当可执行程序运行起来需要将代码和数据加载到内存当中。
代码数据加载到内存中过程:
进程先将自己的代码和数据首先放在虚拟地址空间的对应区域(堆,栈... ...),这里会有一个新的工具------页表:
页表的主要工作是将虚拟地址转化为物理地址,也就是物理地址到虚拟地址的映射。
经过页表的转换后,最终我们的可执行程序的代码和数据可以加载到物理内存的任意位置。因为最终只需要建立代码和数据与物理内存之间的映射关系,就可以通过虚拟地址找到物理内存中的真实地址。页表的存在也使得随机的物理内存变为有序。
这里有个问题:不同进程的虚拟地址可以完全一样吗?
当然是可以的,因为每一个进程都有自己独立的进程地址空间,也都有自己独立的页表,虽然进程虚拟地址一样,但是他们虚拟地址映射的物理地址可能不一样。通过改变页表对应的物理地址映射就可以变更不同的代码和数据。如果这里物理地址也一样的话,可能就用到了写时拷贝。读写权限也就不一样。
这里又有一个问题:不同进程虚拟地址在页表中映射的物理地址会相同吗?
这里大部分都是不会相同的,但是父子进程继承的话,就有可能会相同,因为子进程会默认继承父子进程的大部分代码和数据(pid,ppid等不会继承)。因为这里用到了写时拷贝技术
写时拷贝:
写时拷贝就是等到修改数据时猜真正分配内存空间,这是对程序的性能优化,可以延迟甚至避免内存的不必要拷贝。
未来一个全局变量,默认是被父子共享的,代码是是共享(只读的)
子进程在创建的时候默认只读式的继承父进程的大部分数据,包括数据,代码,虚拟地址。但是几乎都是只读式的继承,因为大多数数据在实际情况下并不需要更改,遇到需要更改的数据,我们就采用写时拷贝技术对数据进行开空间拷贝就行了。
总结:
虚拟地址和物理地址之间是用页表映射的方式进行关联的
3 . 为什么要有进程地址空间
问题:为什么进程不直接访问物理内存?
保护物理内存不受到任何进程内地址的直接访问,在虚拟地址到物理地址的转换过程中方便进行合法性的校验。
如果进程直接访问物理内存,那么看到的地址就是物理地址,而语言中有指针,如果指针越界了,一个进程的指针指向了另一个进程的代码和数据,那么进程的独立性,便无法保证。因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,即使操作系统不让改,也可以读取。
问题:虚拟地址如何避免上述问题?
一个进程有它的task_struct,有地址空间,有页表,页表当中有虚拟地址和物理内存的映射关系,有了页表的存在,虚拟地址到物理地址的一个转化,由操作系统来完成的,同时也可以帮系统进行合法性检测
问题:指针越界怎么检查?
越界可能他还是在自己的合法区域。比如他本来指向的是栈区,越界后它依然指向栈区,编译器的检查机制认为这是合法的。
第一种检查:当你指针本来指向数据区,结果指针后来指向了字符常量区,编译器就会根据mm_struct里面的start,end区间来判断你有没有越界。此时发现你越界了就会报错了。
第二种检查为:页表因为将每个虚拟地址的区域映射到了物理内存,其实页表也有一种权限管理,当你对数据区进行映射时,数据区是可以读写的,相应的在页表中的映射关系中的权限就是可读可写,但是当你对代码区和字符常量区进行映射时,因为这两个区域是只读的,相应的在页表中的映射关系中的权限就是只读,如果你对这段区域进行了写,通过页表当中的权限管理,操作系统就直接就将这个进程干掉。
所以进程地址的存在也使得可以通过start和end以及页表的权限来判断指针是否合法访问。从而达到了将内存管理和进程管理进行解耦。
操作系统内部有4种核心管理;
1.进程管理
2.内存管理
3.驱动管理
4.文件管理
在实际过程中:
如果没有进程地址空间,进程直接访问物理内存。
当进程退出时,内存管理需要尽快将该进程回收,在这个过程当中必须得保证内存管理得知道某个进程退出了。并且内存管理也得知道某个进程开始了,这样才能给他们及时的分配资源和回收资源。
这就意味着内存管理和进程管理模块是强耦合的,也就是说内存管理和进程管理关系比较大。
有了进程地址空间,当一个进程需要资源的时候,通过页表映射去要就可以了。
内存管理就只需要知道哪些内存区域(配置)是无效的,哪些是有效的(被页表映射的就是有效的,没有被页表映射的就是无效的),当一个进程退出时,它的映射关系也就没了。
此时没有了映射关系,物理内存这里就将该进程的数据设置为无效。所以第二个好处就是将内存管理和进程管理进行解耦。
内存管理是怎么知道有效还是无效的呢?比如说在一块物理内存区域设置一个计数器count,当页表中有映射到这块区域时,count就++,当一个映射去掉时,就将count--。内存管理只需要检测这个count是不是0,如果为0,说明它是没人用的。
内存管理怎么加载一些大型数据到物理内存?
内存管理是通过延迟加载的方式加载到物理内存。也就是说,内存管理会首先给你加载一小部分给你使用,当你用完时,将进程变为睡眠状态,再加载另一部分。当需要用到前面的进程的时候,将进程唤醒,进程再继续使用即可。当然这样可能会让我们感觉手机运行变慢了。
总结:
进程地址空间+页表还有一个作用是将无序的物理地址变有序。让进程,以统一(线性的)的视角(虚拟地址)看待物理内存,以及自己运行的各个区域。
进程管理和内存管理模块进行解耦
拦截非法请求
进程和程序有什么区别?
提到进程需要知道三个东西:task_struct,mm_struct,页表。进程是加载进内存的程序,由进程常见的数据结构(struct task_struct(控制块) && struct mm_struct(地址空间))和代码数据组成
解释一下fork后为什么会返回两次:return之前就完成了子进程的创建,子进程会默认继承父进程的代码和数据,这里的fork里面会有return语句,这里的return语句也是被继承的数据。创建好子进程后,父子进程各自返回自己的值
二、Linux调度
一个CPU拥有一个****runqueue
在linux中,有一个叫 runqueue 的结构体,就是运行队列结构体。
优先级
普通优先级 : 100 ~ 139 (我们都是普通的优先级,想想 nice 值的取值范围,可与之对应!)
实 时优先级 : 0 ~ 99 (实时操作系统使用,不关心)
在这个结构体里面我们可以看见有几个类似的结构:
这是两个不同的调度队列:
活动队列:
时间片还没有结束的所有进程都按照优先级放在该队列
nr_active: 总共有多少个运行状态的进程
queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照 FIFO 规则进行排队调度 , 所以,数组下标就是优先级!
从该结构中,选择一个最合适的进程,过程是怎么的呢?
- 从 0 下表开始遍历 queue[140]
- 找到第一个非空队列,该队列必定为优先级最高的队列
- 拿到选中队列的第一个进程,开始运行,调度完成!
- 遍历 queue[140] 时间复杂度是常数!但还是太低效了!
bitmap[5]: 一共 140 个优先级,一共 140 个进程队列,为了提高查找非空队列的效率,就可以用 5*32 个比特位表示队列是否为空,这样,便可以大大提高查找效率!
过期队列:
过期队列和活动队列结构一模一样
过期队列上放置的进程,都是时间片耗尽的进程
当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
active 指针和 expired 指针
active 指针永远指向活动队列
expired 指针永远指向过期队列
可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。
没关系,在合适的时候,只要能够 交换active指针和expired指针的内容 ,就相当于有具有了一批新的活动进程!