一、冯诺依曼系统
输入设备: 键盘、磁盘、网卡、显卡、写字板、摄像头等
输出设备: 显示器、磁盘、网卡、显卡等
存储器: 内存
运算器和控制器: CPU
我们可以看到输入设备和输出设备并不是完全独立的。比如我们以前的文件操作是从磁盘中读取
为什么能直接把外设的数据加载到CPU中?
因为CPU的速度远大于输入设备,如果直接把外设的数据加载进来,那么决定程序的效率就取决于外设。现在CPU直接和内存打交道,所以在没有软件优化的情况下,硬件体系结构的效率由内存决定,内存是体系结构的核心设备。
现在大部分的体系结构都是冯诺依曼体系,比如我们的电脑和使用的服务器。
二、操作系统
首先要知道,启动的操作系统才有意义,就是只有把操作系统加载到内存中才有意义。
2.1 OS层次图
2.2 操作系统的意义
如果没有操作系统,我们就要和一堆的硬件直接接触,需要了解各种硬件的特性,使用成本太高。
由此就有了操作系统:
像这种任何计算机包含的一种最基本的能够进行软硬交互的一款软件,我们把它叫做操作系统(英文缩写OS)
OS包括:
内核:进程管理,内存管理,文件管理,驱动管理:这是OS用于管理硬件的
其它程序:函数库,shell程序等,这些程序能帮助用户更方便的使用OS
操作系统是什么?
是一款专门针对软硬件资源管理工作的软件。
操作系统的功能:
对下管理好软硬件资源
对上为用户提供稳定、高效、安全的运行环境
那OS怎么对用户提供各种功能?
首先要知道OS不信任任何用户,所以我们也只能通过接口来与OS打交道,也就是OS层次图中的系统调用接口。
2.2.1 系统调用与库函数的区别
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,一些大佬对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
2.3 管理的理解
既然是管理,那么就有管理者和被管理者。
我们可以想象在学校中,校长就是管理者,但是我们从来没见过校长。
管理者和被管理者不直接接触,管理者的决策交给执行者(老师、辅导员),通过他们管理被管理者(学生)。
按照上面OS的层次图,OS就是校长,驱动程序就是辅导员,底层硬件就是我们学生。
通过例子我们可以得出以下的结论:
1️⃣ 管理者和被管理者并不直接打交道
2️⃣ 如何对被管理者进行管理?先进行一些决策,再把这些决策施行
而管理者的决策需要有依据,依据就是数据。
而我们的数据怎么被管理者知道呢?
如果一个学校人很少,校长有可能能知道每个人的数据,但是如果人很多呢?该如何聚合这些数据呢?
在C语言中,我们可以使用结构体,在C++中,就使用类。
这个过程,我们通常叫做数据的描述
而如何将这些聚合数据产生关联呢?
可以采用一些数据结构存储,由此变成数据结构的增删查改。
这个过程,我们通常叫做数据的组织
由此我们得出重要结论:
计算机执行管理时,先把管理对象描述起来,再把管理对象组织起来
以上讲的都是对硬件的管理,那么对软件呢?
三、进程
3.1 进程的概念
进程=程序对应的文件内容(struct)+相关数据结构(比如优先级队列)
在Windows中任务管理器下的程序就叫进程。
上面我们说过操作系统的本质是管理,而管理是先描述再组织。
所以在进程形成的时候,操作系统会对该进程创建PCB(进程控制块)。
3.2 描述进程-PCB
PCB其实是一个结构体,包含进程的所有属性信息,在linux中,PCB就是struct task_struct{};
而这两个的关系可以类比类和对象之间的关系。
task_ struct内容分类
标示符(pid): 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
这些在下面的PCB内容会说到
操作系统要找找到进程,不是找进程加载到内存的代码数据,是直接找PCB。
有了进程控制块,所有的进程管理与进程对应的程序毫无关系,与进程对应的PCB强相关。
3.3 进程和程序
我们写好了程序,当程序被加载到内存中时,操作系统会创建一个task_struct来描述程序,我们把task_struct 和加载到内存的可执行程序一起叫做进程。
也就是上面说的:进程=程序对应的文件内容(struct)+ 相关数据结构(比如优先级队列)
3.4 PCB内容
3.4.1 查看进程
查看进程
先写一个死循环的程序:
查看进程指令:
有两种指令:
ps axj | grep “mytest”
ps -l
查看运行中进程的详细信息
运行后打开新的对话框:
另一种查看进程的方法:
ls /proc
proc是linux默认查看进程的目录,而我们创建的的进程的信息也会再proc中创建一个目录。
pid在后面会讲
3.4.2 标识符
查看进程的标识符:
getpid()
运行后:
查看父进程的标识符:
getppid()
3.4.3 状态
退出信号:
我们写的程序末尾会有个return 0
,0就是退出码
查看最近一次的退出码:
echo $?
3.4.4 程序计数器
永远指向将被执行的下一行指令的地址。
3.4.5 记账信息
OS有一个调度模块,可以较为均匀的调度每个进程,因为进程要获得CPU的资源才能进行,而进程又有很多。他会记录每个进程所使用的时间总和以确保“公平”。
3.4.6 上下文信息❗️❗️
假设有三个进程正在运行:
为了保证公平,操作系统规定了进程单次运行的时间片(单次运行的最长时间),我们以为CPU在同时运行多个进程,其实是CPU的快速切换完成的。
但是进程很有可能没运行完,此时PCB1就拍到了末尾。
进程运行会产生大量的临时数据
但是这样就会引出一个问题:
当PCB1运行完一次时间片后这些临时数据下次还要用,如果不管,PCB2就会覆盖掉PCB1的临时数据。
所以我们需要保护上下文信息和恢复上下文信息,当PCB1运行完一个时间片时,PCB1带走自己的临时数据,PCB回来的时候,再把临时数据给寄存器,接着上次运行。
四、fork系统调用创建进程
4.1 fork的理解
那么如何理解fork创建子进程呢?
首先要知道创建进程的三种方式:
1️⃣ 运行程序
2️⃣ 执行命令
3️⃣ fork
在操作系统的角度,这三种方式没有差别
fork的本质是创建一个进程,而这个进程就是task_struct + 进程的代码和数据,task_struct是一创建进程就有的,但是进程的代码和数据怎么办?
默认情况下会继承父进程的代码,而且task_struct也会以父进程为模板,初始化子进程的task_struct(上下文信息也会继承,所以子进程fork前的代码不会运行),要注意代码是不可修改的(只读)。
而数据默认情况下也是共享的,但是要考虑修改的情况,如果一个进程发生修改,就会发生写时拷贝。
写时拷贝:
进程具有独立性,当一个进程的数据发生修改,为了不让另一个进程也被改变,所以会拷贝一份数据再修改。
好处:提高效率(不修改就共享)
4.2 fork的返回值
经过上述描述,我们会想到一个问题:
如果只掉用fork,父子就做同一件事,这有意义吗?
答案是没有意义,所以我们需要让他们做不一样的事情。
那么怎么让他们做不一样的事情呢?
答案时依靠返回值
fork的返回值:
失败:返回
< 0
成功:
1️⃣ 给父进程返回子进程的pid
2️⃣ 给子进程返回0
如何理解有两个返回值?
pid_t fork()
{
……
return ……
}
当fork的时候,就会分流,而当一个进程return的时候就会写入数据,发生写时拷贝,导致两个不同的返回值。
为什么要如此设置两个返回值?
父进程有很多子进程,子进程只有一个父进程,所以父进程要找到子进程就需要子进程的pid,子进程就不需要。
补充一点:
fork后父子进程执行顺序不确定。
五、进程状态
首先想想为什么要有进程状态?
进程状态可以方便OS快速判断进程的状态,方便使其完成各种功能,本质是一种分类
linux中的进程一般有以下状态:
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列
里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠
(interruptible sleep))。
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的
进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态
5.1 运行状态
前面我们讲过进程在运行状态不一定占有CPU,而是运行一个时间片后让下一个进程获得CPU资源。
只要在运行队列中,所有的状态都叫做R状态,随时被CPU调用。
5.2 睡眠状态和休眠状态
如果一个程序需要的资源没有准备好,那么程序将会睡眠,等待资源准备就绪了它才会再次运行
假如当程序需要读取磁盘时,此时已经已经有多个进程在等待,这是就会形成等待队列,就是S(睡眠)或者D(休眠)状态。
总结一下:
等CPU资源的队列叫运行队列
等外设资源的队列叫等待队列
当PCB1一旦获取磁盘资源,就会把PCB1的状态改成R(被唤醒),放入运行队列中,等到他获取CPU资源的时候,再读取磁盘。
从运行队列到等待队列叫做挂起等待(阻塞),从等待队列到运行队列叫唤醒进程
一个进程可能因为运行的需要,在不同的队列里,每个队列代表不同的运行状态。
那么睡眠状态(S)和休眠状态(D)有什么区别呢?
S是浅度睡眠,是可以中断的
D是深度睡眠,不可中断
为什么要有两种“睡眠”状态?
当一个进程想要往磁盘写入数据,写入磁盘需要时间,此时该进程就在内存等待结果(处于休眠状态),如果此时可用资源比较少,该进程就会被OS杀死,那么磁盘写入结果的返回就无法送达,可能引发问题,但是如果是D状态,OS也无法杀死。
为什么数据刷新这么快,还是S状态?
上面说过了写入磁盘需要时间,而CPU的速度太快了(比IO快得多),所以我们以为他一直在执行,其实它大部分时间在休眠。
5.3 停止状态和死亡状态
停止状态跟S状态很像,都可以挂起进程,那么他们有什么区别呢?
S状态虽然什么事都没干,但会更新核心数据,比方说我们写了个
sleep(10);
到了10秒就会唤醒该进程。
而T状态就是彻底暂停,不会更新数据。
kill -19 可以暂停进程
kill -18 可以继续进程
死亡状态(X):
我们在创建进程的时候会有PCB和代码和数据,当进程死亡的时候,也要把这些资源回收回去。
kill -9 可以杀死进程
5.3.1 前台和后台进程
我们看到他们的状态有的后面带+号有的不带,这表示什么呢?
后面带+号表示前台进程
后面不带+号表示后台进程
区别就是
前台进程运行时我们无法输入任何指令,但可以「Ctrl」+ c干掉进程。
而后台进程运行的时候可以执行命令,但是「Ctrl」+ c不能干掉进程,想要杀死就用kill -9。
5.4 僵尸进程
上面我们说了进程需要退出时,系统会回收这个进程的资源,然后进程就进入了死亡。
但是当进程退出的时候,进程的所有资源不会立即回收,而是进入僵尸状态(Z),把数据暂时保存(要写入退出信息),目的是为了判断退出死亡的原因。
资源是由父进程进行回收。
验证:我们可以让父进程休息,然后杀死子进程就可以看到僵尸状态。
僵尸进程我们需要尽量避免,因为会导致过度占用空间和内存泄漏
5.5 孤儿进程
字面意思,父子进程同时运行,父进程被杀掉,子进程就变成了孤儿进程。
孤儿进程将会被1号进程(OS本身)领养
六、进程优先级
首先要知道为什么有优先级,本质是因为资源过少。
优先级也是PCB中的一个数据。
它决定了程序获得CPU资源的顺序。
6.1 查看优先级
ps
指令
ps指令主要用来显示linux进程信息
选项:
-a 显示所有进程
PRI :代表这个进程可被执行的优先级,其值越小越早被执行,默认值为80
NI :优先级修正数据,取值范围(-20 ~ 19)
PRI的最终值也取决于NI值(PRI=80+NI)
6.2 调整优先级
调整优先级其实就是调整NI值。
输入top后按r->输入进程pid->输入nice
我们知道NI的范围只能是-20 ~ 19,那么为什么不让范围更大呢?
优先级不管怎么调也只是相对的优先,不能出现绝对的优先,不然会让一些进程一直得不到资源,形成饥饿问题。
6.3 并行与并发
并行:
多个进程在多个CPU下分别,同时进行运行,就是在任意时刻都有两个以上的进程在运行,这称之为并行
并发:
多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
七、环境变量
为什么我们运行自己的程序的时候要加上./
,./
就是指明当前路径,要找到程序才能运行,而我们运行指令的时候却不用加呢?
因为环境变量
7.1 环境变量的定义
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性(这就是我们使用某些程序不需要./的原因)
要注意他也是OS在内存中开辟的空间用来保存数据。
常见的环境变量:
PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL : 当前Shell,它的值通常是/bin/bash。
7.2 环境变量的操作
env命令可以查看系统所有变量
我们也可以把我们自己的程序路径加到环境变量中
使用export指令:
export PATH=
此时我们就可以像运行指令一样运行我们自己的程序:
但此时我们发现我们使用不了正常的指令了
再查看环境变量:
可以看到原来的被覆盖了。
所以我们添加环境变量不覆盖应该使用:
export PATH=$PATH:
7.3 命令行参数
main函数可以携带两个参数:
int main(int argc, char* argv[])
argc就是参数的个数
第二个参数就是我们传进去的参数,在linux中,通常以每个程序后面的附加选项实现,例如ls -a, ls -l
所以我们知道argv的图:
从这里我们也就知道命令行后面附加的参数是如何起作用的了。
7.4 获取环境变量
上面说过ls -al
这种命令因为有环境变量可以不带路径执行,那么他是怎么得到环境变量的呢?
main函数可以携带第三个参数:char* env[]
。 它可以从父进程中得到。
上面讲的argv指向的是一个个的参数,而env指向的是一个个的环境变量。
而且我们也可以把它打印出来:
可以参考上面的env命令,发现他们一摸一样。
当然我们也可以通过系统调用获得环境变量:
getenv()
7.5 环境变量的全局属性
环境变量通常具有全局属性,可以被子进程继承下去
创建一个本地变量:
导出环境变量:
八、程序地址空间❗️❗️
8.1 物理地址or虚拟地址?
在我们以前学到的内存布局应该是这样的:
但是这是真实的物理内存吗?
现在我们可以编写一段代码验证一下:
这里因为父子进程公用代码和数据,结果正常。
但是当父进程发生写时拷贝时:
我们发现他们的地址没有变化
前面我们讲过如果发生写时拷贝,就会另外开一份空间来存储数据,那么地址肯定会变化。
这只能说明打印的地址不是真实的物理地址。
我们把这个叫做虚拟地址,我们在语言层面下看到的地址,其实全部都是虚拟地址!
8.2 进程地址空间
OS中有物理地址,而每个进程都有自己的虚拟地址。
每个进程都会认为自己是独享整个物理地址,它们都会以统一的方式划分自己的内存。
而每个进程都有自己的地址空间,而操作系统需要管理这些地址空间。
进程地址空间本质是内核的数据类型(struct mm_struct),类似于我们前面讲的PCB。
结构体的模拟如下:
struct mm_struct
{
unsigned int stack_begin;
unsigned int stack_end;
//栈区的界限
unsigned int heap_begin;
unsigned int heap_end;
//堆区的界限
//其他区域也类似
}
通过这些区域划分出不同的界限。每个进程都认为自己的mm_struct代表整个内存(地址从0x000000…000 ~ 0xFFFFFF…FFF),也就是每个进程都认为自己拥有4GB的空间。
但是光有虚拟地址是不够用的,必须要把数据存储在物理内存中。
8.3 页表+MMU
OS通过页表+MMU(内置在CPU中的一个硬件)来对齐进行映射
页表负责把虚拟地址映射到物理地址
那么为什么需要页表映射,不能直接让进程地址空间访问物理内存吗?
如果我们允许进程直接访问物理内存,就很有可能访问到不属于自己的空间,所以我们需要页表来管理,如果是非法的OS就直接可以拒绝访问。
举个例子const char* p = "abcd"
,我们不能通过指针p修改"abcd",因为它在字符常量区。本质是OS给我们的权限只有读权限(通过页表的权限管理)
8.4 地址空间的好处
我们想象一种场景:我们直接申请一块大空间,操作系统会直接全部给我了吗?如果我们没有用那么多,其他的空间不是被浪费了吗?
所以当我们在堆区申请一块空间的时候,OS会把地址空间中的heap_end加上我们申请的大小,但是却没给我们物理内存,当我们需要读取的时候,才会使用页表进行映射,得到物理空间。
假设没有地址空间,那么CPU是不是只能遍历一遍物理空间才能找到该进程的起始位置?
所以我们可以把main函数的地址放入每个进程的地址空间(同一位置),然后CPU就访问地址空间的固定位置然后通过页表映射找到物理内存存放代码数据的位置。
总结一下:
为什么要有地址空间?
1️⃣ 通过添加一层软件层,完成对进程操作的风险管理(权限管理),保护了物理内存以及各个进程的数据安全。
2️⃣ 将内存申请和内存使用的概念在时间层面上划分清楚,通过地址空间完成屏蔽底层申请物理空间的过程。达到进程读写内存和OS进行内存管理操作,在软件层面上分离(你不知OS给你的内存是富裕或者是快满状态的空间)。
3️⃣ 站在CPU和应用层的角度,每个进程可以看作统一使用4GB空间,而且每个空间区域的相对位置是比较确定的。
不管代码数据在内存如何存放,在地址空间中一定是连续存放的,大大减小了内存管理的负担。
通过以上的学习,我们再来谈程序和进程的区别:
进程是需要PCB、进程地址空间、页表和代码数据结合起来才叫进程。