1. 冯诺依曼体系结构
目前市面上,几乎所有计算机的硬件构成都遵循冯诺依曼体系结构
与最原始的【输入设备->CPU->输出设备】这样简单的结构相比,冯诺依曼体系结构有何好处?
每次我们在键盘输入数据,经过CPU处理,再输出到显示器上,这一整个过程中,数据从键盘流向了CPU,又流向了显示器,由此可以得知,数据是在计算机体系结构中不断流动的,而数据从一个硬件流向另一个硬件,其本质是数据的拷贝,既然有数据的拷贝,就不得不考虑效率的问题
我们都知道,CPU由于其功能的重要性,发展十分迅速,也就导致其运算速度很快,输入/输出设备相较于CPU,速度就比较缓慢,这就导致CPU处理完数据后需要等待输入设备给它数据,因此整台计算机的效率就比较低下;这就好比有两个计划盖一所房子,一个人负责用砖头盖房子,另一个人负责递砖头给他,那盖整所房子的效率实际是取决于递砖头的那个人,因为就算你盖房子的效率再高,没有砖头你怎么盖呢?所以CPU和设备之间的关系也是这样,这种传统的计算机体系结构效率过于低下
为了解决效率低下的问题,冯诺依曼在CPU和设备之间添加了存储器,也就是内存,由内存去获取数据,交给CPU处理,CPU处理完后再返回给内存,再由内存把数据给输入设备
由此我们可以得出结论:
- 外设的数据没有直接交给CPU处理,而是先放到内存中
- CPU不和输入/输出设备打交道,它只和内存打交道
那么,这样做有什么好处呢?一方面,内存可以预先获取一部分数据作为缓存,当CPU需要数据时,直接交给CPU处理;另一方面,数据在外设与内存之间的拷贝效率要高于外设与CPU的,从而大大提升了计算机的整体效率
计算机中,存储数据有很多硬件,寄存器,内存,硬盘…对于这些硬件,距离CPU越近,效率就越高,但成本也就越大
刚才说到想要提高计算机的效率,既然这样,那为什么不在外设上装满寄存器,这样效率不就能达到最大了吗?
按照目前的科技水平,确实是能造出这样的计算机,但问题是,这样的计算机,哪个平名百姓能用得起?这样的计算机成本太大,太贵了,也就很难普及
因此,冯诺依曼体系结构的意义不仅仅是提高的计算机的整体效率的问题,更重要的意义是它在提升计算机效率的同时,又将计算机的成本控制在了一个合理的范围内,让普通老百姓能接受,这大大提高了计算机的普及,从而让越来越多的人能够用得起计算机,互联网也才会发展的这么迅速,我们才有如今的万物联网!
有了上面的知识,我们也可以理解为什么我们平常使用的程序在运行前都要先加载到内存中
我们平常写的程序是二进制代码文件,既然是代码,它就是数据;而我们知道,这些数据是要被CPU访问的
但CPU只和内存之间传输数据,因此数据首先要加载到内存中
同时,我们也可以思考这样的场景:
你在QQ上,向你的朋友发送了一条信息,解释这条信息在计算机中的整个流动过程
- 想发送数据,首先得登录QQ,于是QQ这个程序被加载到了内存中
- 从键盘获取数据–>加载到内存–>被QQ捕获–>CPU加密处理–>返回给内存–>网卡,网卡通过网络交给你朋友的网卡上,加载到内存–>被QQ捕获–>CPU解密处理–>返回给内存–>输出到你朋友的显示器上
2. 操作系统
2.1 概念
操作系统是一款软硬件资源管理的软件
狭义上,我们所知的操作系统由操作系统内核+操作系统外壳程序(图形化界面/shell外壳程序)构成
2.2 结构示意图
我们先简单点看
可以看到,最底层是基础硬件,这些硬件根据冯诺依曼体系结构,通过主板连接在一起,而光有这些硬件远远不够,需要有人对他们进行管理,这便是操作系统的工作
在操作系统和硬件之间,有个驱动层;操作系统需要管理硬件,同时它本身也应当稳定,不能因为硬件的改变就影响到它自身;如果操作系统直接去管理硬件,那么硬件改变了,操作系统也要随之改变,因此,在操作系统和硬件之间添加了驱动层,每个硬件在驱动层都对应一个驱动程序,这些驱动程序由厂商提供
那么,操作系统为什么要管理好底层的这些硬件呢?
正常来讲,这些硬件当然需要被管理好,不然我的电脑怎么运行呢?事实上也确实如此,但需要知道,这并不是人类创造操作系统真正的目的;操作系统它总归只是一个工具,所有的工具其最总的目的都是为了人类的方便,操作系统也是如此,它一定是为用户提供帮助的
操作系统通过管理好底层的这些硬件(手段),进而为用户提供一个稳定的,高效的,安全的环境(目的)
2.2 理解操作系统
前面我们说过,操作系统是一款软硬件资源管理的软件,重在管理二字,因此我们理解操作系统要从操作系统如何管理出发
生活中,处处都有管理,学校里,校长作为一校之主,自然需要将每个学生管理好;一开学,分配好宿舍,上课时间、地点…给你安排的明明白白的,但奇怪的是,我好像从未和校长见过面,更没有与他讨论过这些东西,它怎么把我的事情全部处理好了?校长是怎么做到的?
我们假设校长是一个程序员,最开始想管理好你们自然需要跟你们交谈,但是随着学校规模的增大,学生人数越来越多,校长每天跑动跑西,感到越来越力不从心,于是他想了个法子,写了一个可以记录学生数据的程序,派辅导员收集各班的学生信息,辅导员又派班长收集学生信息,最总所有的学生信息都统计在了程序中,这样校长就能足不出户,动动电脑就能管理好学生
在上述过程中,校长不需要与学生见面,只要有学生的数据,就能管理好学生;也就是说,管理者不需要直接和被管理者直接见面,数据才是管理的关键,管理的本质是对数据进行管理
那么,面对这么多的学生,校长如何清楚的知道每项数据对应哪个学生呢?校长本身是一个程序员,他做了一个通讯录的程序,在通讯录中,每个学生都有基本属性,知道了一个学生的基本属性就相当于知道了该学生,同时,他又懂一些数据结构,将每个学生通过一定的结构拼接在一起,就完成了整个通讯录
通讯录中记录每个学生的基本属性可以称为描述一个学生,将每个学生通过一定结构拼接在一起称为组织学生
因此,任何管理最总归结为6个字:先描述,再组织
用同样的思想来再理解我们的操作系统,想要管理好底层的这些硬件,不需要操作系统直接去访问这些硬件,只要有这些硬件的数据即可,先要对这些硬件描述,再将它们组织起来,这样操作系统只要管理好这些数据,就相当于管理好了硬件
理解了操作系统对下层的管理,我们再来聊聊操作系统对上层有哪些作用;前面说过,操作系统的最总目的是为了用户的方便,但我们知道,用户有时需要访问底层硬件,比如打开文件,这些文件都在磁盘中,本质是打开了磁盘中的文件,这个过程难道是用户直接访问了磁盘吗?当然不是,既然操作系统是硬件的管理者,那么用户想要访问底层硬件必须先告诉操作系统,必须由操作系统去执行;因此,打开文件等访问底层硬件的操作其实是操作系统帮我们完成的
有时,我们也需要访问操作系统本身,比如我想看看操作系统内的数据,难道操作系统就任由你看了?显然不是这样的,操作系统不相信任何人,万一你直接把操作系统搞崩溃了呢?但是你是用户,操作系统必须满足用户的需求,于是,操作系统向用户提供了一些系统接口,用户想访问操作系统,只能通过这些接口访问
但还是存在一些问题,不同的操作系统提供的接口名字和使用方式不同,但功能可能是类似的,难道用户每换一个平台就要把相同功能的接口重新学一下吗?这对用户来说太麻烦了;于是,将系统接口包装成操作接口,提供给用户;这也就是为什么有的语言是跨平台的,因为它有自己的标准库,不同的平台标准库调用的系统接口不同,但我们用户不需要管
3. 进程
3.1 进程的概念
我们都知道,可执行程序被CPU执行前,要先加载到内存中,很多人认为,加载到内存中的代码和数据就是进程,实际上并不是;操作系统想管理好内存中的代码和数据,就要先描述,再组织;如何描述?对于每一个加载到内存中的程序,操作系统会创建结构体pcb(process control block),里面存储着每个程序的属性信息,再通过数据结构组织起来,这样,对进程的管理就变成了对该数据结构的管理;因此,进程 = pcb + 代码和数据
为什么要有PCB?这是因为操作系统需要对进程进行管理,就得先描述,再组织,而PCB是用来描述的
PCB是整个操作系统中的一个的概念,在Linux中,它的PCB叫做task_struct
3.2 进程的pid
Linux中,以./执行一个可执行程序或者执行命令时,它们的本质都是运行一个进程,每个进程都有唯一的标识符,叫做pid;使用【ps ajx】命令可以查看当前的所有进程
如何在进程运行的过程中获取进程的pid呢?由于pid是属于task_struct内部的一个属性,而task_struct又在操作系统的内部,前面说过,用户不能直接访问操作系统,得使用操作系统提供的接口
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<sys/types.h>
4
5 int main()
6 {
7 pid_t id = getpid();
8 pid_t pid = getppid();
9
10 while(1)
11 {
12 printf("I am a process, pid:%d ppid:%d\n",id, pid);
13 sleep(1);
14 }
15 return 0;
16 }
这里pid为6931的进程是bash,由此可以知道,该进程是由bash创建的
使用ctrl+c或者kill -9 pid可以终止一个进程
3.3 进程的创建
同理,用户不可能直接在操作系统内部直接创建一个task_struct,得使用相应的接口
int main()
{
printf("process running,only me,pid:%d\n",getpid());
fork();
printf("hello world!\n");
sleep(1);
return 0;
}
fork()之后,父子代码共享;创建一个进程,本质就是多加了一个task_struct以及代码和数据,加了一个task_struct我们很好理解,但是父进程的代码和数据由磁盘中加载而来,子进程的代码和数据从哪来呢?
默认情况下,子进程的代码和数据继承父进程
为什么要创建进程?是为了让子进程和父进程干不同的工作
29 int main()
30 {
31 printf("process running, only me, pid:%d\n",getpid());
32 pid_t id = fork();
33 if(id == -1) return -1;
34 else if(id == 0)
35 {
36 //child
37 while(1)
38 {
39 printf("I am a child process, pid:%d, ppid:%d\n",getpid(),getppid());
40 sleep(1);
41 }
42 }
43 else
44 {
45 //parent
46 while(1)
47 {
48 printf("I am a parent process, pid:%d, ppid:%d\n",getpid(),getppid());
49 sleep(2);
50 }
51 }
52 return 0;
53 }
对于上述代码,有两个问题?
同一个变量id,怎么会有两个值?
这与虚拟地址空间和父子写时拷贝技术有关
fork()函数怎么有两个返回值?
fork()是一个函数,CPU会去执行fork()函数内部的相关代码;在fork()函数内部,return之前,子进程已经被创建好了,后续的代码被子进程共享,由此fork()函数有两个返回值
父进程和子进程之间的关系是并列的,终止其中一个进程不会影响到另一个,因为进程具有独立性
55 int main()
56 {
57 int count = 5;
58 int i = 0;
59 for(i = 0;i < count;i++)
60 {
61 pid_t id = fork();
62 if(id == 0)
63 {
64 while(1)
65 {
66 printf("I an a child process,pid:%d,ppid:%d\n",getpid(),getppid());
67 sleep(1);
68 }
69 sleep(1);
70 }
71 }
72
73 while(1)
74 {
75 printf("I am a parent process,pid:%d,ppid:%d\n",getpid(),getppid());
76 sleep(1);
77 }
78 return 0;
79 }
//多进程的创建
Linux中,在【/proc】目录下存放着当前所有进程,在进程目录中,有这样两个文件
C语言中,使用fopen打开一个文件时,我们并没有标明文件的具体路径,编译器默认在当前工作路径下寻找,这里的当前工作路径就是cwd
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
chdir("/home/byh/linux");
FILE* pf = fopen("log.txt","w");
(void)pf;
fclose(pf);
while(1)
{
printf("I am a process,pid:%d\n",getpid());
sleep(1);
}
return 0;
}
//会在/home/byh/linux路劲下创建一个log.txt的文件
3.4 进程状态
Linux中,一个进程可能有一下几种状态
- R(running)
- S(sleeping)
- D(disk sleeping)
- T(stopped)
- t(tracing stop)
- X(dead)
- Z(zombie)
对于"S",第一它可以表示进程在等待"资源"就绪;第二它是一种可中断睡眠,我们可以随时杀掉该进程
"S"后面的+号代表着该进程在前端运行,在启动进程前加上&【./xxx &】默认让进程在后端运行,"S"后也就没有了+号
对于”T/t“,它表示让进程暂停,等待进一步被唤醒;代码的调试过程其实就是让进程变成"T"状态
"D"状态是Linux特有的一种状态;有这样的情况,进程需要向磁盘存数据,由于外设速度慢,进程大部分时间处于等待"资源"就绪的状态,因此进程进入"S"状态;但此时的内存严重不足,操作系统需要强制删掉一些进程来维持内存的稳定,于是它找啊找,找到了这个在"S"状态的进程,觉得它也没干什么要紧的事,就把它结束了,向磁盘存放数据也就失败了;假设该进程向磁盘存放的数据十分重要,此时数据弄没了,是谁的责任?为了避免这种情况,进程在这种情况下需要将自身状态改为"D"状态,代表着深度睡眠,不可中断睡眠,进程不可被杀
"Z"代表着僵尸进程,当子进程运行完毕后,它会留下退出信息在task_struct中,等待着父进程来读取;此时子进程已经运行完成,代码和数据已经被释放,只剩下task_struct,这种状态下的进程叫做僵尸进程
僵尸进程的危害:虽然子进程的代码和数据被释放了,但它的task_strcut还在内存中,如果没有父进程去获取task_struct中的退出信息然后释放,那块task_struct的空间就造成了内存泄漏
我们之前启动的很多进程,为什么不去关系它们的内存泄漏问题?因为在命令行直接启动的进程,它们的父进程是bash,进程运行完毕后,bash会自动帮我们回收这些僵尸进程
“孤儿进程”:父进程先比子进程运行完毕,此时子进程就没有了父亲,这种进程叫做孤儿进程;没有了父进程,就代表着当子进程运行完毕后没有人去回收僵尸进程,会造成内存泄漏的问题;Linux中为了避免这种情况,规定父进程比子进程先结束时,这些子进程由1号进程(OS本身)领养
3.5 进程的运行、阻塞和挂起
前面是Linux系统对于进程状态的具体表现,接下来从理论层面,从操作系统的层面理解进程的状态
每一块CPU在内存中都有一个运行队列,真正严格意义上的进程的"R"状态其实是该进程在这个运行队列当中,表示该进程已经准备好了,可以随时被调度
同时,CPU调度进程时,不是一直调度一个进程直到该进程结束的,而是根据每个进程分到的时间片轮转调度的,时间片到了的进程排到运行队列尾
上面所描述的是调度算法的一种,Linux中不是这样调度的
- 让多个进程以切换的方式依次被调度,在一个时间段内得以推进代码的方式,我们把它叫做并发
- 一块CPU不止有一个核心,每个核心都有一个运行队列;任何时刻,真的有多个进程在同时运行的方式,我们把它叫做并行
#include<stdio.h> int main() { int i = 0; scanf("%d",&i); printf("%d\n",i); return 0; } // 运行上述代码,屏幕停在空白处,等待着我们输入数据,此时处于"S"状态 // 从操作系统的层面,可以理解为阻塞状态,在Linux中,可以是"S"或"D"
根据我们对操作系统的理解,操作系统在管理底层硬件时,本质是在管理硬件在内存中的结构体;每个硬件的结构体中都有自身的等待队列
上述代码从操作系统角度可以理解为:该进程原本在CPU的运行队列中,CPU执行过程中遇到了scanf函数,要求从键盘获取数据,于是将该进程的task_struct移动到键盘的wait_queue中,此时进程变成了阻塞状态,该进程是没有被调度的;等到task_struct获取到键盘的数据后,再将task_struct移动到run_queue队列尾
阻塞状态本质是进程等待“资源”的就绪
阻塞和运行状态的变化,伴随着pcb连入到不同的队列当中
在磁盘中,有一个swap分区,用来存放内存中进程的代码和数据
由于操作系统本质上管理的是task_struct,与程序加载到内存中代码和数据无关,当内存十分紧张时,操作系统会将代码和数据临时存放到磁盘中的swap分区,以此来换取内存,缓解内存的紧张,更加合理的使用内存资源;此时该进程的状态就叫做挂起状态;待内存紧张问题解决再唤入数据
挂起状态本质是以时间换取空间的做法,但是注意,swap分区的容量不应设置太大,避免操作系统一有内存紧张问题就向swap分区存放数据,频繁的唤出唤入数据必然导致效率的问题
3.6 进程的切换
CPU根据调度算法依次调度进程时,进程之间是怎样切换的?难道一个进程的时间片到了,下次再运行时就从头运行该进程吗?显然不是这样的,CPU内部有很多寄存器,用来存放临时数据;当一个进程被调度时,各种数据会加载到寄存器当中,一旦进程的时间片到了,寄存器会把该时刻进程的数据存到tack_struct,我们把CPU存放的这些临时数据称为进程的上下文数据;等到下次该进程再次被调度时,会加载这些上下文数据到寄存器当中,这样就不需要重新调度进程,完成了进程的切换
进程在切换的过程中,最重要的是上下文数据的保护和恢复
需要知道,寄存器本是是硬件,CPU内的寄存器只有一套,但它可以有多套进程的上下文数据
3.7 进程的优先级
3.7.1 什么是优先级
进程的优先级是进程的控制块task_struct中的一条属性,它代表着该进程获取某种资源的先后顺序;优先级的值越小,代表着优先级越高
权限规定的是能不能访问某种资源;优先级则是已经能访问了,规定的是访问的顺序
3.7.2 为什么要有优先级
整台计算机中,所有的硬件都只有一个,而进程会有很多,存在多个进程都要使用同一个硬件的情况;也就意味着进程要访问的资源始终是有限的,那就必然要有访问的先后顺序
操作系统对于调度和优先级的原则,它会保证最基本的公平,确保每个进程都能被调度;如果一个进程长时间不被调度,就会造成饥饿问题
3.7.3 Linux中的优先级
NI值是有范围的,[-20,19]
提升一个进程的优先级需要root用户
4. 命令行参数
C语言中的main函数有参数,这些参数写和不写都不影响程序的编译,那如果写了,这些参数是干什么的?
我们发现,当在命令行输入数据时,程序会以空格为分隔符,将命令行中的数据依次打印出来
实际上,argv数组里面存的就是每段字符串的首地址;在命令行输入的数据最终会变成main函数的参数,我们称之为命令行参数
对于上述的现象:有两个问题?
- 是谁将命令行中的数据作为参数传递给main函数的?
- 为什么要这样做?
要解决上面的问题,我们得知道
在命令行直接执行的程序,最总会变成进程,它们其实都是bash的子进程
父进程的数据是能被子进程所看到并访问的
命令行输入的数据默认是输入给bash的,bash根据这些数据绘制了argv数组表,并启动了子进程,而子进程能看到并访问父进程的数据,也就能访问argv数组
那bash为什么要绘制argv数组表呢?如果我们仔细的观察,会发现这种方式跟我们输入指令 + 选项十分相似;我们在指令后加上不同的选项,指令执行的功能也就不同,而命令行参数的作用也在于此,它是为了让程序根据我们指定的选项完成不同的功能
5. 环境变量
5.1 PATH环境变量
Linux中,各种指令和我们自己写的程序,它们的本质都是一个二进制文件,需要bash先找到对应的文件才能运行;但是为什么我们自己的程序需要加上路径才能运行,而指令可以直接运行?
Linux中,存在一些全局的设置,告诉命令行解释器应该去哪些路径下寻找程序
其中的一种设置就是环境变量的设置;而有一个环境变量叫做PATH
当命令行解释器要执行一个程序时,默认会去环境变量PATH的路径下去寻找,如果找到了就执行,找不到就报错
如果我们希望自己写的程序能像指令一样,不加路径直接执行
将程序拷贝到PATH中的任意一个路劲下
将程序的路径添加到PATH当中
最开始的环境变量是在系统的配置文件当中的,当bash启动时,将环境变量加载到bash的进程中
这些配置文件在哪?
每个用户的家目录下,都有隐藏文件
- /etc/bashrc
- /xxx/.bashrc
- /xxx/.bash_profile
如果想永久的保存自己对环境变量PATH的更改,需要对上述的系统文件进行修改
5.2 其他的环境变量
Linux中,还有很多环境变量,使用 env 指令查看所有环境变量,这里选几个介绍一下
当然,系统中的环境变量远不止这些,大家有兴趣自行了解
我们也可以自己导入环境变量
5.3 进一步理解环境变量
上述操作都是使用指令操作环境变量,能不能在代码中操作环境变量
如果仔细观察,发现该代码的逻辑和结果与命令行参数类似
父进程的数据能被子进程访问我们能理解,bash内部这么多的环境变量,它是如何组织的?
根据上述代码的结果,不难得出结论,当系统中的环境变量加载到bash中,bash会给我们创建一张数组表,存放着每个环境变量的地址,同时提供全局的二级指针变量 envrion 给子进程
总结:
bash进程启动时,会默认创建两张表:
- argv[]命令行参数表(通过用户输入的方式获取)
- env[]环境变量表(由系统配置文件加载)
子进程可以通过各种方式访问表中的数据
我们也可以使用 getenv 函数来获取某一个环境变量
有三种方式可以在代码中获取环境变量:
- char** envrion
- char* env[]
- char* getenv(“name”)
当我们使用 export 创建环境变量时,实际上是bash的子进程 export 创建了环境变量;父进程的环境变量具有系统级的全局属性,因为环境变量能被子进程一直继承下去,但子进程的环境变量不应当被父进程访问,因为进程具有独立性,为什么 export 创建的环境变量能够被bash访问?
在Linux中,大部分命令都是bash创建子进程运行的,但也有一些特殊的指令,是由bash亲自运行的;原理是bash代码中直接调用对应的函数,这样的命令我们叫做内建命令
常见的内建命令有:echo export …
知道了内建命令是由bash直接执行的,我们也能理解为什么内建命令能操作本地变量,其他命令不能操作本地变量,因为其他命令是bash的子进程,而本地变量只在bash内部有效
6. 进程地址空间
在正式谈地址空间之前,先来看这样的代码
进程 = task_struct + 代码和数据,因为进程要有独立性,所以父进程和子进程都有自己的task_struct,这个我们能理解;代码是只读的,所以父子进程共享同一份代码不会影响独立性;但是对于数据,父子进程可能需要修改,为了保持独立性,父子进程的数据肯定不会是同一份,上面的代码 g_val 在父子进程中的值不同也验证了这点;但奇怪的是,为什么父子进程中 g_val 的地址指向同一块空间,g_val 的值却不同?说明该地址并不是物理地址,它是一个虚拟地址
6.1 进程地址空间的概念
C/C++的学习过程中,我们可能会见过这样的内存分布表
实际上,在OS内部,每一个进程都对应一个上述的地址空间,该地址空间上面的地址都是虚拟地址,同时还会有一张页表,用来表示虚拟地址映射到物理地址的位置
当该进程创建子进程时,子进程会拥有和父进程一样的地址空间和页表
当子进程/父进程要修改数据时,会在物理内存中将数据拷贝一份,并将子进程/父进程的页表中虚拟地址映射的物理地址重新指向,而虚拟地址不变;从而达到了上面代码中看到的现象
这种只在修改数据时才把数据拷贝一份,叫做写时拷贝,它的本质是按需申请空间,从而达到节省空间的效果
6.2 理解地址空间
为什么要做写时拷贝?能不能直接将父进程的数据全部拷贝一份给子进程?
因为进程具有独立性,父子进程之前不能互相影响,父/字进程修改数据不能影响另一个,那就必须拷贝数据
当然可以直接将父进程的数据全部拷贝给子进程,但并不是所有的数据都会修改,不会修改的数据被拷贝了,不仅浪费了内存空间,还浪费了时间;因此拷贝所有的数据给子进程不是合适的做法
每一个进程对应一个地址空间,有多个进程就有多个地址空间,OS怎么管理这些地址空间?怎么知道哪个地址空间对应哪个进程呢?
OS要进行管理,先描述,再组织;我们看到地址空间中有很多区域,栈,堆…也就是说地址空间需要进行区域划分;在OS内部,地址空间本质是一个内核结构体对象,里面定义了很多 start ,end 的变量,用来表示区域的范围;再将这些结构体以一定的数据结构组织起来,就完成了管理
OS会给每一个进程赋予一个跟物理内存一样大小的地址空间,而进程又根据自身的需求使用地址空间
为什么要有地址空间?
每个程序加载到内存中的代码和数据并不是有顺序的,通常都是东一块,西一块的;如果由 tack_struct 直接指向物理内存中的代码和数据,会产生以下的问题:
- 进程管理太过杂乱
- 内存管理的的同时还需要对进程进行管理,两个模块之间依赖度过高
- 不能对物理内存进行有效的保护
有了地址空间,加上页表的映射
就能让进程以统一的视角看待物理内存以及自己运行的各个区域,将无序变成了有序
内存管理模块和进程管理模块解耦
拦截非法请求,保护了物理内存
当进行非法写/读数据时,比如越界访问/写入数据,页表没有对应的映射关系,则直接报错,防止了对物理内存的随意修改
6.3 进一步理解页表和写时拷贝的作用
实际上,页表比我们了解到的要复杂很多,在映射的物理内存后,还有一些选项,比如表示该数据是否在物理内存中,该数据是否由 rwx 权限
之前我们说过进程的挂起状态,当内存严重不足时,OS会将部分内存暂时唤出到磁盘上的swap分区;此时页表中的物理地址仍然指向那块空间,如果不加修饰,万一其他的数据正好加载到这个位置,你又正好访问了怎么办?因此页表中会有个选项表示该物理内存的数据在不在内存当中,0表示不在,1表示在
char* p = "hello world";
*p = 'H';
如果编译上述代码,编译器会报错,从语言层面,我们知道该字符串是常量字符串,存放在常量区,不可被修改;从操作系统的层面,为什么常量区的数据不可被修改呢?在页表中还有个选项,用来表示该地址的数据是 rwx 的
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<unistd.h>
4
5 int main()
6 {
7 printf("father, pid:%d, ppid:%d\n",getpid(),getppid());
8 pid_t id = fork();
9 if(id == 0)
10 {
11 while(1)
12 {
13 printf("child, pid:%d, ppid:%d\n",getpid(),getppid());
14 sleep(1);
15 }
16 }
17 else
18 {
19 while(1)
20 {
21 printf("parent, pid:%d, ppid:%d\n",getpid(),getppid());
22 sleep(1);
23 }
24 }
25 return 0;
26 }
有了上面的知识,我们也能理解为什么该代码中 id 的值既是 >0 ,又是 == 0,在fork函数内部,会return一个值给id,其本质就是对id进行写入操作,此时会发生写时拷贝,因此父子进程中id的值不同
当进行非法访问数据时,OS识别到错误,进行判断
- 是不是数据不在物理内存中,如果是,发生缺页中断
- 是不是需要写实拷贝,如果是,进行写时拷贝
- 如果不是上述要求,则进行异常处理
7. Linux2.6内核进程调度队列
每个CPU都要有自己的运行队列,下图是Linux2.6内核中进程队列的数据结构
其中,queue数组下标[0,99]的位置不归我们管,[100,139]代表着不同优先级的队列,刚好40个,这也是为什么NI值的调整范围是[-20,19],刚好也是40个数;active,expired指针分别指向arr[0]和arr[1],分别代表着准备调度的进程和调度完的进程
首先,bitmap数组一共是160个bit位,queue中每一个下标按顺序都对应bitmap中的一个bit位;bit位为0表示没有该优先级的进程,为1表示有
当一个进程准备调度时,现根据它的优先级放入队列当中;CPU查找进程运行时,不是去queue中从头开始寻找进程,而是先去bitmap的160个bit位中寻找第一个bit位为1的位置,再去queue中调度该位置的进程
当一个进程调度完,不是直接插入到原本队列当中,而是插入到过期队列;当一个队列的所有进程调度完,将 active 和 expired 指针交换,再去调度 active 指针中的队列,依次往复
也就是说,Linux中调度时,一共有两个队列,一个负责只进不出,另一个负责只出不进,当只进不出的队列运行完,交换这两个队列,达到了轮换的效果
整个过程查找一个进程进行调度的时间复杂度为O(1),也被叫做大O(1)调度算法