冯诺依曼的的体系结构:
冯诺依曼体系结构是由5部分组成的:输入设备、输出设备、内存、运算器、控制器
其中运算器主要的功能是:算术运算、逻辑运算
运算器和控制器一般就集成在我们的CPU当中。
输入设备:键盘、网卡、硬盘、话筒、摄像头
输出设备:显示器、音响、网卡、硬盘
理解:
硬件角度上、数据层面上,cpu只和内存交互。输入设备无法直接联系到cpu计算,cpu无法直接给显示器输出运算结果
为什么?因为cpu的计算能力是很快的,而我们输入与cpu相比是非常慢的。那整体速度却是我们输入和输出来决定的,
我们的数据要进行处理,必须要从外设预装载到内存中,cpu需要直接和内存进行交互。这时候cpu要你的一行代码,那我直接给内存预装载100行,cpu就会从第一行开始从头往后读取数据,因为这里用了统计学上的局部性原理:从统计学角度,代码执行的时候,按顺序从上到下执行的改率是90%左右,也就是说,cpu要执行100行,那么瞬间要执行101行的概率非常的大,所以预装载的时候,把附近的代码全部进行了预加载在内存上。代码的命中率非常之高。
这些操作都是由操作系统完成的。
一个程序要执行,必须提前加载到内存中,为社么呢?CPU所需要的数据和代码必须要从内存中拿,所以一并采用了预装原理,可执行程序的本质就是一个存在在硬盘中的文件,硬盘就是一个外设,无法和cpu直接进行数据交互,cpu运行程序的前提就是要硬盘先把数据加载到内存中。这样有很大的成本优势和效率优势。
成本优势:最少的成本,最高的性能。
寄存器不仅仅存在于CPU中,任何一个外设都会有寄存器
输入:我们按键盘,数据存到键盘的寄存器中,进而写到内存的寄存器中,内存根据电路功能,把数据写到内存中
读取数据:从内存读取到cpu寄存器,然后处理、返回到内存,再从内存中输出到输出设备的寄存器中。
各个硬件单元进行交互的时候都是传递的电脉冲信号,通道叫做总线。
总线:
外设和内存的总线:IO总线
内存和CPU的总线:系统总线
以上谈到的都是数据的传输,除了数据的传输,还有硬件控制信号的传输。
我们上文提到,外设不可以和cpu进行直接通讯,指的是再数据层面。
外设是可以和cpu进行控制信号的直接通讯的,,比如常见的中断信号等
理解:
我们通过键盘输入一个数据,cpu如何知道,有数据传来了要计算了?
我们的外设通过电信号的方式给cpu发信号,之后cpu控制硬件,使内存接收数据。
简单了解了之后,我们来进行一下模拟更加复杂的场景
我们发送文件的过程:
你用键盘发一个信息,存在键盘寄存器中,并给cpu发送控制信号。cpu使内存接收键盘寄存器中的数据,存在内存的寄存器中。
cpu接收到内存中的数据进行计算,打包程序等处理,之后返还给内存结果,内存把结果给显示器和网卡的寄存器中,然后通过刷新再显示器上,网卡通过网络把信息发送给朋友的网卡。网卡得到信息并给cpu发信号,cpu控制内存接收数据,并接收内存的数据进行处理解析。然后返还给内存,内存把数据写给显示器的内存中,并等待刷新显示。
上文提到很多硬件,我们还需要有软件,我们进入我们的主题:操作系统 。操作系统就是用来管理我们硬件的软件。
os需要向各个硬件发送指令,并控制他们。
当然os不会自己进行操作,那样成本太高了,试想一下,100个键盘需要100种操作系统吗?所以再os和硬件之间还有一套软件层,驱动,它才真正进行控制底层硬件。
当然了,驱动一般是由硬件厂商提供的,也有可能是操作系统的开发方。当我们使用新鼠标,他应该会进行驱动的安装吧,他家都见过。
驱动的作用除了让os更好更方便的控制硬件,还可以让os和硬件解耦,让他们失去直接的关系,操作系统要操作硬件必须从驱动中穿过,这样既保证了操作系统的安全又保证了硬件的安全。
当然了,咱我们的操作系统和用户层之间也需要解耦,不然用户随意改动操作系统,那遇到高手还好,起码不会崩溃,遇到菜鸡这系统直接凉了。所以操作系统只给了一些安全的常用的接口给用户使用,这就是在os和用户层之间的:系统调用接口。
linux就是用c语言写的,所谓的系统调用接口,其实就是c的一个一个函数。但是这些系统调用接口的使用成本还是很大的,你得对操作系统有很深的理解才能去更好的玩弄系统调用函数,所以我们使用的更多的其实是一个一个的库,比如print的libc库等等。
库其实就是封装了系统调用接口。
封装工作是库的开发者做的,也就是语言的发明者。
最后才有了我们使用的printf和scanf这样基于库和系统调用接口的库函数。我们可以用这些函数接口去写helloworld。
当然还有很多的命令行,也都是对于系统调用接口的封装。
我们通过上文的理解可以得出:
操作系统是一款软件:进行软硬件资源管理的软件
操作系统由两大块组成:
对下:与硬件进行交互。管理硬件软件资源。
试想一下,如果你换一个硬件,就要有新的os来操作它,这很明显不是有给好的设计方案。
正确操作方案:软硬件解耦,通过驱动层去交互。
对上:为用户提供良好的使用环境。因为我们不擅长与硬件打交道。操作系统最终的目的就是让用户使用,给用户提供稳定和安全的执行环境。
我们需要深刻的理解什么是管理
管理者通常是做什么事情的?
管理者通常管理的是数据信息资源,比如通过学生的成绩信息,选择优秀者。
那么这些信息是如何得来的?
一般通过执行者的反馈。管理者下达指令,并通过执行者对信息的反馈,来完善或者改变决策。比如虽然我们选了年级前十,获得国家奖学金。但是在观察阶段,辅导员发现一名学生生活作风完全不合格,那么辅导员会把信息反馈给管理者。管理者则通过此信息,取消该生资格。
那么管理者依靠什么做这样的决策呢?难道就是因为它官大权力大吗?他说我就想开除一个学生,他不尊重我。怎么可能是这样,管理者开除一名学生,依靠的是学校出台的相关文件,某生触犯了哪一条,才进行这样的决策。
当管理者需要管理很多的信息的时候,那就一定要进行信息的描述与组织,也就是信息的分类。这样我们把所有成绩放在一起,把所有的综合评分放在一起,把所有的健康信息放在一起,这样才更方便管理者进行数据的管理。
讲了这么一大堆,那我们来告诉大家:
操作系统就是我们的管理者,或者说管理者+执行者。而我们管理的对象就是我们的软硬件资源。操作系统如何进行数据的分类呢?因为我们linux是用C语言写的,那么linux对信息数据分类可能就是一个链表,一个队列等这样的数据结构。每个结点我们把它放在struct中。也就是我们常说的面向对象。
什么叫做进程:把程序加载到内存里就叫做进程。
比如:我们在硬盘中一个程序,当我们运行它的时候,它的代码和数据会被放在内存中,这时候这个程序就叫做运行起来了。就称之为进程。
那么一个一个进程就是我们操作系统需要去管理的对象。也就是进程管理。
再开机时,os作为一款软件进程,也会运行于内存中。
那么os是如何进行进程管理的呢?
先描述,再组织
先描述:把其它的进程进行描述,不是直接跑到别的进程中去管理,而是通过PCB进行管理。
PCB:进程控制块
PCB是一个结构体或者说一个数据结构用来描述进程的属性信息。当一个程序被加载到内存中变为一个进程的时候,我们的操作系统会同时的生成一个PCB结构体,用来描述进程的信息,存在于os的区域内。当我们os要去管理进程的时候,只要通过管理PCB就可以实现了。如果有1000个进程,那么操作系统会把这1000个PCB进行数据结构的处理,比如把一个一个PCB组织成链表、队列、哈希等结构,进行快速管理。总不能一个一个遍历去管理相应的进程吧。
也就是说操作系统在程序运行的时候有两个作用:
1、把进程加载到内存上
2、给进程生成对应的PCB,存在操作系统区域里
这里我们就可以得到一个结论:
进程>可执行程序
进程=可执行程序的数据+代码+PCB
再组织
在linux中,会把所有的PCB用双链表的形式连接起来,每个pcb代表一个进程。我们的os只要找到链表的头,就可以管理所有的进程。
创建一个进程(运行一个程序):加载到内存 + 创建PCB
删除一个内存:删除链表的结点 + 清理PCB对应的代码和程序的内存
linux中,pcb对应的结构体叫做:struct task _struct
其中这里的结构体struct task _struct 就是进程控制块PCB中的一种。
它会被装载到ram中,里面包含着进程的各种信息。
我们下来看看linux的PCB:struct task _struct的内容:
我们来对其进行理解:
我们先来学习一下如何查看进程相关的信息。
ps aux | grep xxx
结束进程:
kill -9 pid
我们可以看到我们pid和ppid都在这类显示,当然我们除了调出进程的状态属性。我们还可以使用函数。
函数名是getpid () ,所要包含的头文件是< sys / types.h>
其中 pid_t 其实和我们 size_t 是一样的。无符号整形。
当我们具体的查看一个进程的时候,可以这样
ps ajx | head -1 && ps axj | grep 20066 |grep -v grep
这里pid 和ppid很清楚的显示了出来。这里的-bash,就是我们shell的一个子进程,用来控制我们的进程。程序运行的时候
都会自动创建一个bash来管理进程。
我们可以到看 这里的Ss S+ 就是进程的状态。
S代表休眠状态
休眠状态:
我们查看的进程信息,只是一个瞬间的信息。而且99%的情况下,我们的进程都会处于S状态。因为我们程序用了
printf 等函数。我们都知道 printf是一个库函数,用来管理我们的外设,我们还知道外设的速度是很慢的,况且还有刷新速度,而cpu是很快的。所以我们操作系统在调用它的时候,大部分时间都在休眠。主要的矛盾不是cpu处理导致的运行状态。而是外设刷新的很慢。
那我们想要一个运行状态该怎么办呢?这里可以什么都不要写,只写一个while(1)死循环。这样我们的进程就是运行状态了。
这里的R就表示运行状态。
然而细心的朋友会发现R后面的+,他代表什么呢?
这里这个进程代表 前台进程。而linux只允许一个前台进程。
这时候我们不管输入什么都没有用,只能先ctrl +c 把这个前台进程停下来。才能继续输入命令。
那我们如何把一个前台进程变为后台进程呢?
只需要在运行的时候 加上 & 就可以了。
这个时候我们再次看一下进程。
这个程序就变味了后台进程了。后台进程可以很多个,所以这时候我们可以继续输入命令等。
优先级:获得资源的先后顺序,一般我们通过排队来实现。比如排队就医、排队抢票。
那么我们为什么要有优先级呢?就是因为资源有限!
CPU资源是有限的,而进程却很多很多。所以进程必须要进程排队组织,进行优先级设置。
其中进程排队指:pcb进行排队。头部优先,尾部优先级低。
这里有PC指针,也就是内存指针,为了找到我们之前的代码 EIP指令寄存器(空间概念):CPU从进程pcb中拿到指令,分析指令,执行指令完成之后,会再次循环进行取指令,分析指令,执行指令的操作。而此时cpu内部的eip寄存器会存储最近正在执行指令的下一条指令的地址。
所谓函数的跳转,分支判断,循环等结构,都是通过修改eip来完成的。
操作系统记录程序运行的位置,切换后,下次回来还会从这里开始执行。保存的数据就存在程序计数器中
cpu操作过程:1、从内存中取出指令(需要程序计数器、上下文信息等)2、分析 3、 执行
这三个过程不断的循环。
操作系统的基于时间片的轮转算法
每个运行进程都有自己的时间片,操作系统在进行进程调度的时候都是以时间片为单位来进行调度的。时间到了哪怕是死循环也会被拉下来执行下一个进程。cpu的物理属性导致其在同一时间内只能处理一个进程。时间片的存在,致使死循环并不会使其他进程崩溃。
当我们的在硬盘中的程序被我们运行起来的时候,操作系统将其代码和数据写入到内存中,同时开辟task_struct来管理这个进程。此时CPU通过取指令、分析指令、执行指令这样的循环来对代码和数据进行运算处理。
比如我们要执行i++这个代码,cpu通过pcb中的指针找到数据,比如为10,cpu取指令得到10存在寄存器中(此时的数据都是临时拷贝),运算后需要把结果11写回内存中。此时时间片时间到了,cpu需要去找下一个进程。但是因为这个结果是一份临时信息且cpu内的寄存器是有限的,所以此时i++这个进程的pcb内会形成一份上下文信息来保存当前的进度。因为进程被切换,可能发生在任意时刻(时间片到了+优先级更高的抢占等),而且cpu内寄存器信息会被覆盖,所以当我们下次切换回i++时,需要这个进程的上下文信息来确保程序的正常处理,上文中的程序计数器也是上下文信息的一种。
这就是上下文信息和进程调度的详细信息。
fork函数:创建一个子进程,并给父进程返回子进程的pid,给子进程返回 0
可以简单的看一下下面的这个函数
我们父进程的pid为12534,通过fork创建的子进程pid为12535
父进程的父进程为9575,这里是bash 进程。
fork创建的子进程和其父进程有什么关系呢?
fork创建子进程之后,开始执行两个流
操作系统给子进程创建一个pcb并与其父进程相关联,其中子进程的代码和数据均来自于父进程pcb内的指针。
同时fork之后,我们会产生两个进程,他们谁会先被调度呢?这个是不确定的,是由操作系统的调度算法实现的。
我们可以通过这样的代码来理解两个进程流之间的关系:
我们得知 fork函数有两个返回值:对于父进程返回一个子进程的pid,对于子进程 返回0.
那么我们就可以通过if条件来对父进程和子进程的代码块进行控制。从而达到目的:一份代码。执行两个不同的分支。试想一下,仅仅在c语言中写一个if条件判断,是不可以达到同时满足两个分支的。
这里用if进行分流,使两个分支在父子进程中同时进行(单核cpu) – 协作
一个进程的状态有上图中这么几个,我们主要以linux进行举例
进程的状态是一种数据,并保存在pcb中
我们先来看一下linux源代码中的task_struct 中 对于进程状态的控制
接下来我们会讲解其中重要的状态。
R状态:允许被调度 (并非在运行中的状态)
同时,我们需要介绍一个概念:调度队列
我们知道每一个进程在内存中都被操作系统描述为一个PCB,其中在linux中就是task_struct这个结构体,然后通过优先级把每一个结构体用链表的方式连接起来。
这一个一个进程中,可能有各种各样状态的进程。当然也会存在n个R状态的进程。
此时操作系统会把这些R状态的进程通过优先级在用一个队列描述起来,那么此时这写可被调度的进程就会有序的被管理和执行。当然这里的调度队列并不是对原pcb进行拷贝,而是用指针链接成新的关系。
队列也满足了FIFO的优先级概念
这里的睡眠状态对应在操作系统中就是 休眠 – 挂起 --阻塞等待等等概念,而在linux中 我们称为 浅度睡眠
浅度睡眠:随时可以被唤醒 被kill --通常用来等待某种事件的发生
也称作可中断睡眠。
要很好的理解浅度睡眠,我们可以先了解一下深度睡眠
D状态–深度睡眠也叫做磁盘睡眠 --没有人能够kill掉D状态进程,除非操作系统重启 或是 自我唤醒
我们知道有大量的进程在内存中等待cpu的处理,如果此时一个进程需要在磁盘中存取一些东西,但是磁盘的速度很闷,就造成了此进程要白白浪费时间和内存来等待磁盘的操作,那此时这个进程依旧会在内存中,被不会因为影响了cpu进度或是内存空间就被kill掉。只有自己从磁盘中取得数据,才会自我唤醒并离开。
所以我们在回到浅度睡眠,只要操作系统认为,一个进程的操作使效率变慢,操作系统就是联系cpu内存等硬件对这个进程进行挂起等浅度睡眠操作。把优先级让给其他更重要的事情,这也是浅度睡眠进程需要等待的原因。
T状态:暂停进程
再讲这个进程前,我们先插入两个知识点:
我们谈僵尸进程前,先来谈谈僵尸状态:
当一个进程退出的时候,会存在返回值、退出码等大量退出信息(显然是存在于task_struct中)。
比如我们用 echo $? 这条命令,来查看进程的退出码。
这些信息只有被父进程提取到,才能确定进程的结束。否者一个没有被父进程拿走退出信息的进程,是永远不会被系统释放。这是操作系统为了保护数据信息所做的操作。
此时,等待父进程读取自己推出信息的进程,就叫做僵尸进程。这种状态,就叫做将是状态。
这里的读取退出信息,我们会在之后的wait中讲。
僵尸进程的危害:
而孤儿进程是指父进程提前结束了,子进程无父亲的进程
但不会真正的无父亲,只是它原来的父亲没了,操作系统立即派遣一号进程init 对其进行领养,否则没人领养就失去了控制,内存就泄露了。
所以最终也是由一号进程来对孤儿进程进行回收领养。
这里我们主要谈一下他们的父子关系,来理解这个
当父进程fork一个子进程的时候,此时存在两个流。那势必会存在这样的问题,父进程结束了,子进程还没有结束。相反也会存在。我们为了理解,可以用sleep函数对父子进程时间进行控制。
我们可以通过这份代码查看僵尸进程和孤儿进程。
父进程退出,子进程还在运行 – 孤儿进程
父进程运行,子进程退出后不被回收 – 僵尸进程
一句话:当一个僵尸进程被读取完退出信息后,就是一个X状态的进程,下一秒这个进程就消失了。
进程优先级这个概念很好理解,cpu的资源很有限,进程有那么多,所以操作系统分配进程优先级使系统高效处理问题。
linux优先级是如何计算的呢?
就是通过PRI+NI
那么我们该如何修改进程的优先级呢?在这之前我们先说说,其实修改优先级就是修改我们的偏移量NI值,然后与PRI求合,当然这里有一个需要注意的事情是,每次NI+PRI中的PRI一定是唯一的就是80,而不是上一次修改后的PRI,我们回到正题
调整nice的方法:1.top任务管理 2. renice命令 3.系统调用接口
我们着重推荐第一种方法top。
top (r)就是我们linux中的任务管理器,我们输入r 命令,然后在输入进程的pid就可以修改nice值了,很简单实用
当然这里需要注意的是,仅当系统内进程特别多的时候,进程切换的成本才会很高。当一个进程由于时间片到了,或是被优先级高的进程抢占了,被切换下去,进程很多就意味着下一次被调度的周期会变的很长。从而导致整体运行效率的降低。
环境变量 就是 由 系统提供的变量 ,也是系统级别的全局变量
环境变量的理解:
当一个命令被执行,比如ls,我们不需要任何操作就可以直接运行这个可执行程序,反观,当我们自己写了一个可执行程序的时候,总需要加 ./ 这样的地址信息,来确保系统能够找到我们的程序信息。
这里的差异,本质上就是环境变量的影响。
./的含义是告诉编译器我们的程序文件都在当前目录,这样的地址信息。
ls之所以不带就是因为操作系统执行这条可执行命令的时候,是可以找到这个程序的地址的。
这就是环境变量 —PATH
我们可以看一看环境变量的内容:echo $PATH
操作系统会依次在以下目录中寻找ls命令。
上文我们提到系统的命令可以不需要加任何地址信息,直接运行,那我也想把我们自己写的程序放入PATH中,这样以后运行就很方便了 。
yum安装windows安装,都是自动将程序cp到了系统目录下
方法一是我们不推荐的,这样会污染别人写好的系统工具集、命令池
方法二:将程序的路径,导入到PATH中 –添加环境变量
我们再来介绍一下与环境变量有关的命令:
这里设计了本地变量和环境变量,我们上文提到了环境变量是系统级别的全局变量,也就是可以被当前环境所有进程使用。而本地变量是指,仅在本地当前一个进程使用。我们可以使用export命令,将我们的本地变量升级为环境变量。
我们通过这个小程序来理解一下main函数的三个参数分别是什么意思:
argc命令行参数个数,那么什么是命令行参数:简单来说,就是 ls -a -l 中,a和 l 就是我们所谓的命令行参数,此时的argc就是2(假设分别为a、l)
argv就是指我们具体的命令行参数分别是什么。其中他是一个输出型参数,我们通过外部使用命令行参数,通过argv传送的main函数中,在进行处理。
我们大致了解了以下argc和argv这两个参数,那么我们再了解一下它的底层实现:
我们可以看到 argv是一个指针数组,存放的是一个个char* 指针,其中argv[0] 永远是指向我们的可执行程序的开头,之后每一个指针都指向一个命令行参数,我们这里输入了四个命令行参数,之后在程序中,我们把它打印了出来 ,这就叫做输出型参数。其中argv这个数组的最后一个位置一定为NULL。
既然是这样的输出型参数,那么我们就可以通过控制输入的命令行参数,来达到不同的功能。
说到这里就很好理解了吧!其中为什么最后一个-cccc 的argc是2呢?
我们再来讲讲envp这个参数,以environ这个变量为例,我们可以通过这个参数,以代码的形式获取系统中的环境变量。
envp这个参数的实现与上面的environ变量的实现方式是大致相同的,指针数组指向一个一个环境变量。
每一个程序都会收到一个环境表,环境表就是上图中的指针数组,每一个指针指向一个以’\0’结尾的环境字符串
除了以上main函数的参数进行获取环境变量,还有一个方法就是通过environ 这个变量来获取
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所有在使用时需要使用extern声明。
还可以通过什么样的方式进行系统调用呢?–通过系统调用接口或是设置环境变量,我们先简单聊聊,后期会出现在新的一篇博客中
–getenv:根据环境变量的名字获取环境变量
我们通过一个简单的例子,在理解虚拟地址的问题:
我们实验通过使用fork创建子进程,并使子进程修改全局变量g_val来观察地址的差别
现象:g_val被子进程修改,同时g_val的地址竟然是相同的
结论一:这里打印出来的地址一定非物理内存中的地址–而是虚拟地址
结论二:父子进程的虚拟地址相同,通过映射关系,指向了不同的物理内存地址。
这里转化的工作是由os完成的。
语言层面的地址,都是虚拟地址,虚拟地址是指向同一块空间,但映射到物理内存中就是两个地址
我们通过这张图来深度理解一下底层是如何工作的
父进程的地址空间由PCB指定,此全为虚拟地址。同时用户使用的虚拟地址区域,都有一个页表,将这些虚拟地址映射到物理地址中。
当我们创建一个全局变量g_val,由于子进程是将父进程作为模板创建的,所以变量的虚拟地址都是相同的。但是他们的页表是不同的。所以当我们修改一个变量时,出现了上面实验的情况。