目录
一、冯诺依曼体系
二、操作系统(OS)
设计操作系统的目的
核心功能
系统调用
三、进程的概念与基本操作
简介
查看进程
通过系统调用获取进程标识符
通过系统调用创建进程——fork()
四、进程的状态
操作系统中的运行、阻塞和挂起
理解linux内核链表
Linux的进程状态
孤儿进程
五、进程的优先级
查看进程优先级的命令
优先级的极值
进程的竞争、独立和并发
六、进程切换
Linux真实调度算法:O(1)调度算法
一、冯诺依曼体系
我们所知的计算机大部分都遵循冯诺依曼体系,他们都是由一个个硬件组成的
输入单元:包括键盘,鼠标,扫描仪等
中央处理器(CPU):含有运算器和控制器等
输出单元:显示器,打印机
(图1)
理解:
这里的存储器指的是内存。
不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设。
外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
CPU在数据层面只和内存打交道,所有的设备都只能和内存打交道。
知识点:
1、 体系结构规定,软件没有运行的时候在外设磁盘里面,运行的时候再把它加载到内存里面来,CPU再从内存读取数据来执行代码,所以数据流动的本质就是从一个设备“拷贝”到另一个设备,体系结构效率的高低由设备的“拷贝”效率决定。
2、为什么数据不直接从输入设备直接到CPU在从CPU直接到输出设备呢?
因为存储分级的时候,不同的设备对应的“拷贝”效率,越靠近CPU,效率就越高,CPU的速率要远远高于IO设备,CPU都把数据处理完了,而IO设备还在写入或者读取数据,所以就会导致整个体系的效率由IO设备决定,那为什么不把整个体系中需要处理数据的设备都安排存储分级更高的寄存器呢?因为成本太高了,冯诺依曼体系结构的历史意义就是在保证一定的效率前提下使成本减低。
(图2)
3、在数据流层面上理解冯诺依曼体系
如我们用qq发信息的数据流动过程,先把qq加载到内存上,把从键盘上的数据存入到内存中,CPU接收到内存中的数据后,经过计算处理后写回内存,再通过qq流入到输出设备,也就是网卡,通过网络传给另一台计算机的输入设备(网卡),存到内存后通过CPU解码后写回到存储器,再刷新到外设(显示器)上。
二、操作系统(OS)
任何一个计算机体系都包含一个基本的程序集合,称之为操作系统。
包括内核(进程管理,内存管理,文件管理,驱动管理)
及其他程序(例如数据库,shell程序等等)
(图3)
设计操作系统的目的
体系结构:
(图4)
对下 ,与硬件交互,管理所有的软硬件资源。
硬件部分根据冯诺依曼体系结构组成,访问不同的硬件就要不同的驱动程序,这些工作就是由操作系统完成。
对上,为用户提供一个良好的执行环境。
知识点
1、软硬件体系结构都是层状结构。
2、访问操作系统,必须使用系统调用,其实就是由系统提供的函数。
3、我们写的程序只要你判断出它访问了硬件,它必须贯穿整个软硬件体系结构。
4、库在底层封装了系统调用。
核心功能
在整个计算机软硬件架构中,操作系统的定位就是一款纯正的"搞管理"的软件。
管理把它理解为“先描述,再组织”。
描述就是把被管理者的属性罗列出来,放在一个结构体中。
组织就是把结构体用指针或者其他方式连接起来。
操作系统在对软硬件管理的时候其实就是“先描述,再组织”,先把软硬件属性、状态等以结构体的方式罗列出来,再把结构体用队列或者链表等方式组织起来,所以操作系统对软硬件的管理就变成了对队列或者链表的增删改查
系统调用
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口就叫做系统调用
系统调用在使用上,功能比较基础,对用户的要求相对比较高,所以,有心的开发者可以对部分系统调用进行适度的封装,从而形成库,有了库,就更利于上层用户或者开发者进行二次开发,所以库函数和系统调用是一种上下层的关系
系统调用本质上就是操作系统和用户之间进行某种形式的数据交换,如果让用户直接去访问操作系统的系统调用接口成本高,而且不安全。
其实就是保证在整个环境的安全运行环境下给用户提供服务。
三、进程的概念与基本操作
简介
进程=PCB+自己代码和数据
PCB是存放进程的状态,代码地址,数据地址,id,优先级等信息的,里面包含进程的所有属性,通过PCB提供的属性来对辰戌进行管理,在Linux操作系统中PCB是task_struct.
我们历史上执行的所有指令,工具,自己的程序,运行起来,全部都是进程
查看进程
1、可以通过 /proc系统文件查看,如要获取PID为1的进程信息,ls /proc/1
2、大多数进程可以通过top、ps这些用户级工具来查
补充知识点
(图5)
exe是进程对应的可执行文件
cwd是当前工作目录,在使用fopen函数对一个没有创建的文件进行写操作时,可以带路径,也可以不带路径,因为进程启动时会找到cwd,并把新文件创建到cwd下,chdir可以改变cwd
通过系统调用获取进程标识符
gitpid(),获取进程id
gitppid(),获取父进程id
输入的命令、代码运行起来后它们的父进程都是bash,也就是命令行解释器。
OS会给每一个登录用户分配一个bash。
通过系统调用创建进程——fork()
fork()有两个返回值,进程创建成功,会把子进程的pid返回给父进程,0返回给子进程,失败返回-1给父进程。
默认情况下,父进程与子进程共享代码,在子进程创建成功后子进程会把父进程的pcb拷贝下来,再做与其属性对应的修改
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{int ret = fork();if (ret < 0) {perror("fork");return 1;}else if (ret == 0) { //childprintf("I am child : %d!, ret: %d\n", getpid(), ret);}else { //fatherprintf("I am father : %d!, ret: %d\n", getpid(), ret);}sleep(1);return 0;
}
(代码1)
运行结果:
(图6)
知识点:
对于代码1
1、为什么要给父进程和子进程返回不同的值?
一个父进程可能有多个子进程,父进程要通过子进程的pid对他进行管理,所以要返回子进程的pid给父进程
子进程只有一个父进程,他可以通过ppid获取父进程,所有只需要通过返回值来知道它是否被创建成功即可
2、为什么一个函数函数会返回两次
在执行fork函数时,在执行return语句之前子进程已经被创建好了,他和父进程共享代码,所以父子进程都会执行return语句
3、为什么一个变量既大于0,又等于0,代码1的if 和else同时成立
进程具有独立性,他们的PCB不同,默认指向相同的代码和数据,代码是常量不能修改,在数据修改时不管是父子的哪一方进行数据修改,os都会把对修改的数据在底层拷贝一份,让目标进程修改这个拷贝,来保证进程的独立性,这种形式叫做写时拷贝。
四、进程的状态
(图7)
操作系统中的运行、阻塞和挂起
(图8)
运行:只要进程在调度队列中,它的状态就是running。
阻塞:进程在等待某种设备或者资源就绪(如键盘、网卡、磁盘等),比如程序运行到scanf或者cin这条语句,现在进程就是在等到键盘的反应,而cpu对硬件的管理也会进行结构体描述再组织一个队列,这个结构体中就包含了 “等待队列” 这个成员,在os没有收到键盘的反应时,进程就会从调度队列被链入到等待队列,直到os收到键盘的反应后再重新链入到调度队列。
挂起:如果更多的进程被链入到等待队列导致cpu资源紧张的时候,在磁盘中有一个“swap交换分区”,OS会把阻塞状态的进程的代码和数据唤出到这个分区而PCB不动,此时他就处于阻塞挂起状态等收到资源的反应时再把对应的代码和数据唤入回来;如果是调度队列的太多导致资源紧张,os也会按照进程重要性与“swap交换分区”进行唤入和唤出操作,此时进程就属于运行挂起状态。
进程状态的变换表现之一就是要在不同的队列中进行流动。本质上都是对某种数据结构的增删查改。
理解linux内核链表
在task_struct里面还存在一个list_head的结构体,
正是因为有了它,我们的task_struct就可以以这样的方式连接起来:
(图9)
找到首地址的方式(struct task_struct*)(next-&(struct task_struct*)0)。
task_struct中也可以有多个不同的list_head,所以一个PCB可以隶属于多种数据结构。
(图10)
Linux的进程状态
进程状态就是task_struct内的一个整数
R状态对应的就是操作系统中运行状态。
S和D状态对应的就是操作系统中阻塞状态,在linux中叫休眠状态。
其中S状态是可中断休眠(浅睡眠)。
D状态是不可中断休眠(深睡眠),比如有一个进程需要在磁盘中写入数据,在写的过程中进程属于S状态,此时内存的资源紧张,os就把这个进程释放掉了,而磁盘恰好又写入失败了,在CPU中控制这个任务的进程不在了,接收不到成功与否的信息的同时数据也不知道如何管理,就有可能会丢失,而D状态的就可以保证这时候的进程不被“杀死”,规避了数据丢失的风险。
T和t是暂停状态输入Linux操作系统的特有状态,当操作系统觉得当前进程有问题或者其他情况,就会把它暂停,在由用户决定这个进程是否继续。
使用gdb时对语句打断点就会使得程序被debug,此时进程就是 “t” 状态;在键盘中输入ctrl+r就进入T状态
X状态(死亡状态)对应操作系统中的结束状态
Z状态(僵尸状态),在Linux操作系统中所有的进程一定是某一个进程的子进程,创建子进程的目的是为了让子进程执行任务的,任务的结果父进程需要知道,子进程的数据可以清除掉,但是他的PCB必须要维持起来,所以在子进程退出之后,父进程获取退出信息之前,子进程的状态就是Z状态
僵尸状态的模拟:
#include <stdio.h> #include <stdlib.h> int main() {pid_t id = fork();if (id < 0) {perror("fork");return 1;}else if (id > 0) { //parentprintf("parent[%d] is sleeping...\n", getpid());sleep(30);}else {printf("child[%d] is begin Z...\n", getpid());sleep(5);exit(EXIT_SUCCESS);}return 0; }
结果:
(图11)
可以看到子进程变成了Z状态,当时他还有PID,所以如果父进程对子进程的状态一直不管 ,信息一直不回收,那么子进程就会一直处于僵尸状态,PCB就一直需要维护,并会造成内存资源的浪费。
slab机制
当一个进程结束后他的PCB选择可以不释放,而是把他放到一个表中存起来,当再有新进程运行起来的时候,就可以再表里面把PCB拿出来,把它初始化后就可以使用了,节省了一定的效率
孤儿进程
父进程比子进程先退出,子进程就叫做 “孤儿进程”
模拟孤儿进程代码:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() {pid_t id = fork();if (id < 0) {perror("fork");return 1;}else if (id == 0) {//childprintf("I am child, pid : %d\n", getpid());sleep(10);}else {//parentprintf("I am parent, pid: %d\n", getpid());sleep(3);exit(0);}return 0; }
进程的结果:
可以看到, 当父进程退出后,子进程就被一个PID为1的进程“领养”了,对于1号进程暂时可以把它理解为操作系统,如果不被“领养”的话,子进程进入僵尸状态后就会造成资源浪费,被“领养”后子进程就可以被1号进程回收了,被1号进程“领养”后子进程就会自动变成后台进程,ctrl+c就不能关闭它,用 “kill -9 pid”才可以关闭这个进程。
对于1号进程,在前面的 “进程概念” 中提到,当我们登录Linux操作系统时,操作系统会给用户分配一个bash,这个bash就是1号进程分配的;0号进程也是存在的,它在开机的时候就被1号进程给替换掉了。
五、进程的优先级
优先级是进程得到CPU资源的先后顺序。
优先级也是一种数字,是int类型,在task_struct里面。
值越低,优先级越高,反之优先级越低。
计算机的操作系统是基于时间片的分时操作系统,规定了每个进程的调度时间问题,时间片到了就会把进程从CPU上面剥离下来因为要考虑公平性,所以改变优先级的幅度不能太大。
使用ps -l命令查看优先级的时候能看到UID、PRI和NI等属性
UID就是用户名,
PRI是进程的优先级,默认值为80,
NI是进程优先级的修正数据,
进程的真实优先级=PRI的默认值(也就是80)+NI
查看进程优先级的命令
进入top后按“r”,再输入进程PID,再输入nice值。
也可以通过系统调用:
优先级的极值
Linux进程的优先级范围是[60,99],
nice值:[-20,19],
基于时间片的分时操作系统,规定了每个进程的调度时间问题,如果优先级设计的不合理就会导致优先级低的进程长时间的得不到CPU资源,进而导致进程饥饿。
进程的竞争、独立和并发
竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
六、进程切换
引言
1、死循环进程不会打死系统,它不会一直占有CPU,因为存在时间片,规定时间到了就把进程切换了。
2、一个CPU里有很多寄存器,它就是CPU内部的临时空间,可以用来存放正在运行的进程的临时数据,但寄存器并不等于寄存器里面的数据
(图12)
进程切换最核心的就是保存和恢复当前进程的硬件上下文的数据,即CPU内存的内容,
当前进程要把自己的进程硬件上下文数据保存起来,就是保存到task_struct里面:
(图13)
Linux真实调度算法:O(1)调度算法
(图14)
解释:
1、一个CPU拥有一个调度队列
2、queue[140]
一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以数组下标就是优先级,其中[0,99]下标主要用于实时操作系统,应用于制造业、工业等领域,[100,140]就是我们要了解的,进程在进程队列的下标等于:进程优先级-60+(140-40)
3、bitmap[5]
称之为位图,unsing int bitmap[5],它有32x5个比特位,每个比特位里面的数据1/0表示在queue的队列有没有进程,比如101.......111,就表示queue数组中下标为0的数组里面有数据,下标为1的数组里面没有数据,所以调度器调用进程就变成了,遍历比特位中的数据,再看queue数据,使调度的时间复杂度为O(1)
4、*active和*expired
操作系统具有分时的特性,当一个进程在它的调度时间片用完后,就被按优先级放入到了*expired所指向的队列中,*active队列中所有的进程时间片用完后,在把两个队列进行交换swap(&active,&expired),像这样一直循环这个操作
七、环境变量
概念
环境变量一般指在操作系统中用来指定操作系统运行环境的一些参数,
如:在编写c/c++代码的时候,链接过程中,我们不知道所链接的动静态库在哪里,但是照样可以链接成功,输出可执行程序,原因就是有相关的环境变量帮助编译器进行查找
环境变量通常具有某些特殊用途,它在系统中通常具有全局特性
命令行参数
main函数的两个参数argc,argv[ ]作用
(图15)
用上图15的理论来实现自己写的code的不同子功能
//指令选项的原理 //main函数的参数,就是用来实现程序不同子功能的 int main(int argc,char* argv[]) {if (argc != 2){printf("Usage: %s [-a|-b|-c]", argv[0]);return 1;}const char* arg = argv[1];if (strcmp(argv, "-a") == 0)printf("这是功能1\n");else if (strcmp(argv, "-b") == 0)printf("这是功能2\n");else if (strcmp(argv, "-c") == 0)printf("这是功能3\n");elseprintf("Usage: %s [-a|-b|-c]", argv[0]);return 0; }
结果:
所以得出结论进程会拥有一张命令行表,argv表来实现功能选项。
5个问题切入环境变量
问题1:为什么我们自己写的程序需要带路径才能执行,./表示当前路径,而系统自带的不用带路径就可以执行?
答:因为系统自带的配置了环境变量
问题2:根据问题1的回答,现在ls这个指令在"/usr/bin"目录下面,那我们把自己写的code也把它拷贝进入"/urs/bin“目录下面是不是就可以和系统的指令一样,不用带路径了呢?
答:是的,但是不建议
问题3:它为什么会自己去“use/bin”目录下面去查呢?
答:还是因为配置了环境变量:PATH,PATH的作用是告诉系统怎么找文件env:查看所有的环境变量;
echo &+环境变量名:仅查看一个环境变量
问题4:那如果我把我的"code”文件的路径加入到这个PASH环境变量里面,是不是也可以达到上面的效果?
答:是的!方法
问题5:把Xshell关掉,重新进来的时候发现PASH路径又回复原有的样子了,为什么?
答:这要在从存储的角度去理解环境变量。
当我们登录的时候系统给我们创建了一个bash进程,bash必须从系统中读取所有环境变量的
信息,随后在bash进程的内部就形成了一种环境变量表;当我们输入命令时,bash先拿到命令,把命令进行拆分后,放入到命令行参数表,再找需要执行的命令是否存在。
env命令查的就是这张环境变量表,bash进程就有命令行表和环境变量表,
所以要执行一个程序bash要先通过环境变量找到他。
获取环境的方法
操作:
export 变量名=变量值,自己导入环境变量,它是一个内建命令,它在执行时不需要创建子进程,由bash自己执行,不会经手子进程
env:查看所有环境变量
echo &变量名:仅查看一个环境变量
unset 变量名:删除环境变量
代码:
1、main函数的参数共有三个,除了“argc”和“argv”,还有一个“char* env[ ]”(环境变量指针数组),这三个参数都是父进程传给main的。
int main(int argc,char* argv[]) {//env数组的最后一个元素也是NULLfor (int i = 0; env[i]; i++){printf("env[%d] -> %s\n", i, enc[i]);}return 0; }
2、getenv(“变量名”)
环境变量为什么是全局变量可以传给子进程呢?因为它可以让子进程有一些个性化的操作,
比如我想让代码只能由我一个人使用:
int main(int argc, char* argv[]) {const char* who = getenv("USER");if (who == NULL) return 1;if (strcmp(who, "whb") == 0){printf("正常执行");}elseprintf("only whb\n");return 0; }
3、变量environ
//libc中定义的全局变量environ指向环境变量表, //environ没有包含在任何头⽂件中, 所以在使⽤时 要⽤extern声明。 int main(int argc, char* argv[]) {extern char** environ;int i = 0;for (; environ[i]; i++) {printf("%s\n", environ[i]);}return 0; }
八、程序地址空间
引言
学习c语言的时候脑海中会有这样的图:
它就是程序地址空间图,也就是进程地址空间图(虚拟地址空间图),它属于系统层面的概念
其中,字符串是只读的原因就是它被编码成了代码,在代码区只读,
static修饰的变量本质就是全局变量,只是让他只能在某个函数内被访问而已
那程序地址空间是内存吗?
跟随代码:
int gval = 100; int main() {pid_t id = fork();if (id == 0){//子进程创建成功while (1){printf("子:gval:%d,&gval;%p,pid:%d,ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);gval++;}}else{//父进程while (1){printf("父:gval:%d,&gval;%p,pid:%d,ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);}} }
结果:
(图16)
结论:父子进程地址一样,如果这个地址是内存地址的话,同一个地址空间怎么会有两个不同的值呢? 所以这个地址一定不是内存地址,它是虚拟地址,所学的c/c++指针用的地址全都是虚拟地址!
通过一张图了解页表、物理地址和虚拟地址的关系
(图17)
解释:
一个进程就有一个虚拟地址空间和一套页表。
页表当中存了虚拟地址和对应的物理地址,用户是看不到物理地址的,当用户要访问这块数据时,页表就会通过虚拟地址映射到物理地址。
子进程的PCB、虚拟地址、页表全部都是拷贝父进程的,所以在默认情况下。父进程和子进程的代码和数据都是共享的。
对图16的运行结果,子进程在进行gval++时,他会把物理地址的数据拷贝到一块新空间,在对新空间里面的数据进行修改,并且会更新页表,这叫做写时拷贝。
虚拟地址空间是什么?
虚拟地址空间本质就是操作系统给进程创建的一个结构体对象,一部分数据如下:
问题:为什么要有虚拟地址空间呢?为什么不直接去物理空间上找,而是要通过虚拟地址映射找呢?
因为:
1、页表当中除了虚拟地址和物理地址之外,还有对应地址的权限,在
地址转化的过程中,也可以对地址的的操作合法性进行判断,进而保护
内存,所以在对字符常量区的数据写入失败时的原因就是,查找页表的
时候权限拦截了2、将地址从“无序”变为“有序”
物理空间的地址可以不是连续的,因为每一个物理空间地址对应的虚拟空间是有序的
3、节省内存空间,缺页中断,动态加载
4、让进程管理和内存管理进程解耦合
知识点:
1、在创建一个进程时,可以不加载代码和数据,只有task struct,mm struct和页表
因为当虚拟地址想要通过页表映射找物理地址时,没有发现物理地址时进程就会暂停进行缺页中断们进行补全,如果代码和数据非常大就达到可节省内存空间的目的
2、创建进程先有task struct,mm_struct等,再加载代码和数据
3、进程挂起时,进程的唤出换入swap分区操作时,只把代码和数据换入/唤出,保存PCB,这就是解耦合的作用
4、在mm_struct里面还有一个vm_area_struct,它里面细分了每一段地址空间的起始和结束地址,而mm_struct就是包含整体的栈、堆、代码段等空间的起始地址。