目录
程序地址空间
感知虚拟地址空间的存在
进程地址空间
分页 & 虚拟地址空间
Linux2.6内核进程调度队列
程序地址空间
我们在学习C语言的时候了解过程序地址空间的分布:
- 需要注意的是:程序地址空间不是内存。我们在linux操作系统中通过代码来验证该布局。
1、🚩验证程序地址空间的分布:
#include <stdio.h> #include <stdlib.h> #include <string.h> int un_g_val; int g_val=100; int main(int argc,char*argv[],char*env[]) { printf("code addr :%p\n",main); //代码区 printf("init global addr :%p\n",&g_val); //已初始化全局数据区地址 printf("init global addr :%p\n",&un_g_val); //未初始化全局数据区地址 char* m1=(char*)malloc(100); printf("head addr :%p\n",m1); //堆区 printf("stack addr :%p\n",&m1); //堆区 int i=0; for(i=0;i<argc;i++) { printf("argv addr :%p\n",argv[i]); } for(i=0;env[i];i++) { printf("env addr :%p\n",env[i]); } return 0; }
运行此程序:
- 通过我们这段代码的运行结果来看,从上到下地址是在逐渐增大的,且栈区和堆区之间有一块非常大的地址镂空,同时这也证实了我们的程序地址空间的布局是按照上图所示的地址空间的布局。
2、🚩验证堆和栈增长方向的问题:
- 堆:
我们用如下代码进行测试:
运行结果如下:
从运行结果来看,堆区的地址方向是向上增长的。
- 栈:
我们用如下代码进行测试:
从运行结果来看,栈区的地址方向是向下减少的。
总结:
- 堆区向地址增大方向增长
- 栈区向地址减少方向增长
- 堆、栈相对而生。
- 我们一般在C函数中定义的变量,通常在栈上保存,那么先定义的一定是地址比较高的(变量先定义先入栈,后定义后入栈)。
3、🚩理解static变量:
- 我们知道对于被static修饰的变量,其作用域不变,依旧在函数内有效,但是其声明周期会随着程序一直存在,这是为什么呢?
首先我们来看一下正常定义的变量:
正常定义的变量s符合先前栈的地址分布规则:后定义的变量在地址较低处,下面我们来看一下static定义的变量:
- 根据图示我们发现变量s一旦被static修饰,尽管s是在代码函数里面被定义,但此变量已经不在栈上面了,此时变成了全局变量,这也就是为什么声明周期会一直存在。
⭐结论:函数内定义的变量被static修饰,本质是编译器会把该变量编译进全局数据区内。
感知虚拟地址空间的存在
我们以下面代码为示例:
当父子进程没有人修改全局数据的时候,父子是共享该数据的!我们可以通过下面的运行结果看出:
如果我们尝试写入数据呢?
我们看运行结果:
- 这里我们可以看到父子进程读取的是同一个变量(地址一样),但是后续在没有人修改的情况下父子进程读取到的内容却不一样!!!明明子进程和父进程对全局变量的地址是一样的,但为什么输出的内容却不一样呢???
⭐结论:我们在C/C++中使用的地址绝对不是物理地址。因为如果是物理地址,那么上述现象是绝对不会产生的!!!这种地址我们称为虚拟地址、线性地址、逻辑地址。
🤔为什么操作系统不让我们直接看到物理内存呢??
- 因为这并不安全,内存是一个硬件,不能够阻拦你访问!只有被动的进行读取和写入。所以为了防止系统故障的发生,我们不能够直接访问。
进程地址空间
其实我们之前说"程序的地址空间"是不准确的,准确的说应该是进程地址空间。
- 每一个进程在启动的时候,都会让操作系统给它创建一个地址空间,该地址空间就是进程地址空间。每一个进程都会有一个自己的进程地址空间。操作系统需要管理这些进程地址空间,依旧是先描述,再组织。所谓的进程地址空间,其实是内核的一个数据结构(struct mm_struct )
- 在前面我们提到过进程是具有独立性的,体现在相关的数据结构是独立的,进程的代码和数据是独立的等等......这其实是操作系统给每个进程画的大饼,让每一个进程都认为自己是独占系统中的所有资源的!!!事实是所谓的地址空间其实就是OS通过软件的方式,给进程提供一个软件视角,认为自己会独占系统的所有资源(内存)。
分页 & 虚拟地址空间
在Linux内核中,每个进程都有task_struct结构体,该结构体有个指针指向一个结构mm_struct(程序地址空间),我们假设磁盘的一个程序被加载到物理内存,我们需要将虚拟地址空间和物理内存之间建立映射关系,这种映射关系是通过页表(映射表)的结构来完成的(操作系统会给每一个进程构建一个页表结构)。如下图:
🚩问: 区域是如何划分的?
- mm_struct划分的机制就是和上图一样,此结构体就是按照类似如下的代码方式进行限制的:
struct mm_struct { long code_start; long code_end; long init_start; long init_end; long uninit_start; long uninit_end; //…… }
🚩问: 程序是如何变成进程的?
- 程序被编译出来,没有被加载的时候,程序内部是有地址和区域的。不过这里的地址采用的是相对地址的方式,而区域实际是在磁盘上已经划分好了。加载无非就是按照区域加载到内存。
🚩问:为什么先前修改一个进程时,地址是一样的,但是父子进程访问的内容却是不一样的?
- 父子进程创建都有其各自专属的task_struct和地址空间mm_struct,虚拟地址空间通过页表映射到物理内存:
- 当子进程刚刚创建的时候,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。所以我们先前打印的g_val的值和内容全部一样。而当子进程修改完g_val的数据后,结果就发生了变化。
- 因为操作系统要做到使进程具有独立性,如果子进程把变量g_val修改了,那么就会导致父进程识别此变量的时候出现问题,但是独立性的要求是互不影响,所以此时操作系统会给子进程重新开辟一块内存空间。把先前g_val的值100拷贝下来,重新给此进程建立映射关系,所以子进程的页表就不再指向父进程的数据100了,而是指向新的100,此时把100修改为200,无论怎么修改,变动的永远都是右侧,左侧页表间的关系不变,所以最终读到的结果为子进程是200,父进程是100。但其虚拟地址空间值还一样,所以这也就解释了我们前面出现的现象。
- ⭐结论:当父子进程对数据修改的时候,操作系统会给修改的一方在物理内存中重新开辟一块空间,并且把原始数据拷贝到新空间中,这种行为我们称之为写时拷贝。通过页表,将父子进程的数据就可以通过写时拷贝的方式,进行了分离。从而做到父子进程具有独立性的特点。
🚩问:为什么fork有两个返回值,即对于变量pid_t id怎么有不同的值?
- 一般情况下,pid_t id是属于父进程的栈空间中定义的变量,fork内部,return会被执行两次,return的本质就是通过寄存器将返回值写入到接收返回值的变量中!当id = fork()的时候,谁先返回,谁就要发生写时拷贝,所以,同一个变量,会有不同的内容值,本质是因为大家的虚拟地址是一样的,但是大家对应的物理地址是不一样的。
🚩问:为什么要有虚拟地址空间?
- 保护内存。假设我们写了个非法访问野指针(*p = 111),假设此野指针指向了进程2,甚至直接指向了操作系统,那么当你进行访问的时候,你就会直接修改其它进程的数据,所以直接让进程访问物理内存的方式是不安全的。如果我们假设虚拟地址空间就不会出现这种现象了,因为遇到野指针,页表就不会给你建立映射关系,那么就不会给你访问到物理内存的机会。综上,有了虚拟地址空间,相当于在访问内存的时候添加了一层软硬件层,可以对转化过程进行审核,非法的请求就可以直接拦截了。
- 可以把Linux内存管理,进程管理通过地址空间进行功能模块的解耦。
- 让进程或者程序可以以一种统一的视角看待内存,方便以统一的方式来编译和加载所有的可执行程序,简化进程本身的设计与实现!
Linux2.6内核进程调度队列
⭐:一个CPU拥有一个runqueue
- 如果有多个CPU就要考虑进程个数的负载均衡问题。
⭐:优先级
- 普通优先级: 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指针的内容,就相当于有具有了一批新的活动进程!
- 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法!