Operating Systems Principles and Practice 2——The Kernel Abstraction

进程的抽象:什么是进程?它和程序之间有什么区别?

程序可以被编译器转化为可执行的映像(就是一个文件,里面有机器指令以及执行这些指令所需要的数据),而操作系统会将可执行映像拷贝到物理内存。操作系统首先开辟一个栈来承载本地变量的相关状态,同时也会开辟一块空间叫做堆来动态的为数据结构和程序开辟空间。一旦一个程序被载入内存,操作系统可以通过设置该程序的栈顶指针并跳转到该程序的第一条指令来执行它。为了运行多个相同指令的拷贝,操作系统可以拷贝多份这样的指令 +栈+堆+静态数据的组合。因此,一个进程是一个程序的实例,就好比一个对象是一个class的实例一样。每个程序都可以有0个,一个或者很多个进程来执行它。操作系统通过一个叫做PCB的数据结构来跟踪每个进程。

The PCB stores all the information the operating system needs about a particular process: where it is stored in memory, where its executable image resides on disk, which user asked it to execute, what privileges the process has, and so forth.

哪些硬件确保了操作系统有效的实现进程这个抽象?

操作系统内核如何阻止一个进程破坏其他进程或者操作系统本身呢?一个简单的猜想是:程序解释器在执行每条指令之前,都要去判断一下这条指令的地址是否越界,是否要跳转到不属于它的代码段,是否要直接访问磁盘?如果违反了相关规则,则直接停止该程序。
但是,大部分指令都是安全的,比如两个数的相加指令等。因此,我们引出 dual-mode operation,处理器的一个比特位用来指示现在是处于用户态还是内核态。当模式位被设为1,表示处于内核态并且可以做任何事情。当模式为被设为0,表示处于用户态并且执行被限制。模式位一般存储在CPU状态寄存器中,在用户态下,我们需要对每条指令进行检查,而在内核态下,操作系统的检查功能则被关闭。
在这里插入图片描述
如上图所示,程序计数器和模式共同控制着处理器的执行 。

那么,什么样的硬件可以确保让操作系统的内核保护应用程序的同时,让用户态直接在处理器上执行程序呢?这个硬件必须支持三件事
1.特权指令(只能在内核态运行的指令)。在用户态下任何有潜在安全风险的指令(清理 内存,设置时钟)应被禁止执行
2.内存保护。在用户态下任何超出了进程有效内存范围的内存空间应被禁止访问
3.计时器中断。不管进程正在做什么,内核必须有某种方法重新取得进程的控制权

1.特权指令

进程隔离要想实现,必须有一种办法来限制程序从用户特权级直接切换到内核特权级(实际上,程序 可以通过 系统调用 间接切换)
因此,应用程序只能执行所有指令集的一个子集(非特权指令),内核态则可以执行全部。当应用程序访问到了受限制的内存或者试图去修改特权级状态,都将引起一个处理器异常

2.内存保护

应用程序和操作系统都存在于内存之中。为了保证内存被安全的共享,操作系统必须提供一种机制来确保每个应用进程只能读或写它拥有的地址空间。操作系统怎样阻止一个用户程序访问不属于它的物理内存呢?我们这里提供一个通用的原则:
在这里插入图片描述

这样一来,一个处理器拥有两个额外的寄存器:基址寄存器和边界寄存器。每次处理器执行指令都需要检查物理地址是否在Base到Base+Bound之间。但是这样做有几个弊端
1.内存无法共享
2.每次装载时,都将指定不同的物理地址
3.内存分配固定,无法动态增长
为了解决这些 问题,大多数的现代操作系统都引出了 虚拟地址 的概念
。采用了虚拟地址后,每个进程内存地址都从0开始,并且每个进程都认为它拥有整个机器。而硬件负责将虚拟地址转换为物理地址。如果分配的内存不够了,操作系统可以重新分配一块内存并保证虚拟地址不发生变化 。而这些扩展对于用户进程来说都是透明的

3.计时器中断

当操作系统开启了一个用户级程序后,这个进程可以自由的访问限制之内的地址,执行限制之内的指令。对于用户程序来说,他好像获得了控制整个硬件的权力(前提是不能越界)
然而,这仅仅是一种幻想。当应用程序进入到了死循环,或者用户没有耐心了,想让操作系统停止这个进程,这时操作系统必须有能够重新获得进程控制权的能力。而在正常的操作中,操作系统依然要具备这种能力,比如它要在不同进程之间来回切换等。因此,几乎所有操作系统都有一个叫做硬件计时器的设备,可以用来在指定延迟后中断处理器。每个计时器只可以中断一个处理器,而重制计时器是特权级操作,只有内核可以做。当定时中断发生后,进程控制权由用户程序转交给内核。除了定时器外,当发生IO中断时同样会发生这样控制权的转交。操作系统如何得知进程陷入了死循环?实际上是无法得知的,操作系统只能等用户或者系统管理员请求。由于该进程对用户的输入没有反应了,因此操作系统需要获取控制权来询问用户是否需要继续等待。

什么导致处理器从用户态切换到内核态?

用户态如何切换到内核态?

有三种原因使得内核取得用户进程的控制权:
1.中断
2.处理器异常
3.系统调用
中断是异步的:它由外部事件引起的,使得从用户态转变到内核态
而处理器异常和系统调用是同步的,他们均由进程的指令引起。我们将这种同步的、从用户态转到内核态的行为称作 陷阱

中断
每种不同类型的中断都需要与之对应的中断处理器来响应。
1.定时器中断
定时器中断的处理器会去关注进程是否可以响应用户的输入输出(防止进入死循环),定时器中断的处理器也会判断是否应该使当前执行序列在不同进程之间来回切换。如果没有要改变的,就会跳转到中断发生之前的指令,对用户来说是完全透明的
2.IO中断
当IO请求完成的时候,需要以中断的方式去通知内核。实际上,每个IO设备:网卡 ,WiFi,硬盘,U盘,键盘,鼠标等,都会在输入到来、请求完成时产生一个中断。

中断可以被polling替代:内核不断地轮询每个IO设备看是否有需要处理的事件发生。显然,内核如果正在执行polling是没有空闲去执行用户态 代码的。
3.处理器之间的中断

一个处理器可以向其他任意一个处理器发送中断。内核使用这种中断来协调多处理器之间的动作。比如 ,为了停止一个并行运行的程序,操作系统可以停止所以运行这个程序的处理器

处理器异常

处理器异常是由用户程序的行为引起的。
比如,当一个进程试图执行特权级别指令时,或者访问超出它范围的内存时。其他处理器异常也可以被除零引起,或者试图在一个只读内存写入数据引起等等。在这种情况下,操作系统会停止进程并返回给用户一个错误码。
处理器异常也可以由温柔一些的程序事件引起。比如我们在某行打一个断点的时候,内核会用一个特别的指令去替代打上断点的这条指令,这个特别的指令会引起陷阱。当程序执行到断点处的时候,硬件会切换到内核态,恢复这条指令,并将控制权转交给debugger,debugger可以检查程序变量,设置新断点等等。

系统调用

系统调用是那些由内核提供的,可以被用户级别程序调用的过程。

随着一个中断或者处理器异常,陷阱指令改变了处理器的mode,使其从用户态转变为内核态,然后开始执行在内核中的事先定义好的处理器。

操作系统为我们提供了很多数量的系统调用:比如与一个web服务器建立连接,发送或接受在网络中交换的包,或者创建删除文件,在文件中读写数据,创建一个新的用户线程。对于用户程序来说,系统调用就好像一个接口,由内核执行具体的检查,拷贝参数,执行程序,最后把返回值拷贝回进程内存。

由内核态切换至用户态
创建新进程

当我们创建新进程时,内核会将程序拷贝进内存,设置新的程序计数器到进程第一条指令,设置栈顶指针在用户栈底,然后切换到用户态执行。

从系统调用,中断,处理器异常中恢复

进程切换
当计时器中断发生时,又可能造成进程切换。这种切换会使得从内核态转变为用户态。

User-level upcall
//TDOO

实现安全的模式切换

切入到内核态必须是受限制的:我们不能随意跳转到内核的任意地址

进程状态的切换必须是原子的:在用户态下,栈顶指针,程序计数器位于用户进程,内存保护使得不同进程的数据不可以随意访问。在内核态下,栈顶指针,程序计数器位于内核,内存保护失效,也就是说内核既可以改变自己的数据,也可以改变用户进程的数据。因此在这两种模式之下的切换必须是原子的:要么全部完成,要么全部不完成。
透明的、可恢复运转的:从用户态切换到内核态后,处理器保存当前的数据到内存,并且暂时的屏蔽其他事件引起的中断,跳转到对应的中断或者异常处理逻辑执行。当执行完毕后,重新回退到之前的执行序列,而原来的进程对此浑然不知。

中断向量表

当一个中断发生时,操作系统如何知道该中断是由除零异常引起的,还是文件读取系统调用引起的,或者时计时器中断引起的呢?只有知道中断原因,才可以去执行与之对应的代码逻辑响应这个中断。

在这里插入图片描述

正如上图所示,处理器有一个特殊的寄存器,它指向存在内存中的一个叫做中断向量表的东西。中断向量表是一个指针组成的数组,数组中的每个指针都指向了对应中断处理的第一条代码指令。

中断栈

在大多数处理器中,一个特别的,特权的寄存器指向了内存中的一块区域,这块区域称为中断栈。当中断发生时,硬件会将当前程序正在用到的寄存器中的数据,上下文数据和程序计数器入栈。中断结束后,会弹出栈顶元素,恢复原来寄存器中的值和程序计数器。

每个进程两个栈

每个进程都额外拥有一个内核栈。分配一个内核栈可以使我们在系统调用或者中断过程中很方便的切换线程:只需要将原本的内核栈指针存入PCB,并且切换内核栈就可以了。

在这里插入图片描述

当进程处于运行态并且是用户模式下,内核栈是空的,此时它等待系统调用,中断或处理器异常来使用。
当进程处于运行态并且是内核模式下, 内核栈被使用,保存有那些用户进程的被保留的用来计算的寄存器以及当前内核处理器的状态。
当进程处于可执行状态,内核栈存有进程恢复执行时寄存器的数据和相应的状态
当进程处于等待IO的阻塞态,内核栈存有IO完成后待恢复的暂停计算过程的数据。

中断屏蔽

中断的到来是异步的,处理器不管在内核态还是用户态,中断都随时可能到来。然而,当我们在执行CPU调度,或者是中断处理时,我们不希望中断的发生。为了简化内核的设计,硬件提供了一种特权级指令来暂时的屏蔽中断信号的传递。当中断被屏蔽后,如果有很多中断到来,当从屏蔽状态恢复时,硬件会将这些中断依次传送过去。因此需要设置一个缓存来存储在屏蔽状态时到达的中断信号。由于缓存的大小是有限的,所以中断信号也有可能会被抛弃。

实现UpCalls

当一个进程需要执行被保护的指令或者获取内核中的数据时,它可以陷入内核态通过系统调用来实现。
为了允许程序实现类操作系统的功能,我们需要做的更多。由于种种原因,内核需要基于中断的事件传输。应用程序也从中获益:当某些需要应用程序立即响应的事件发生时,应用程序应该被告知。我们着重强调一个观点:

为了让应用程序表现的更像一个操作系统,我们需要对操作系统的部分功能虚拟化。

我们把虚拟化的中断或异常称为upcalls。在UNIX系统中,它通常被称为信号,在Windows系统中,它通常被称为异步事件。下面是几种使用upcalls进行立即事件传递的例子:
1.用户级线程的抢占
就像操作系统内核在单个处理器上运行多个进程一样,应用程序也可以执行多个任务或线程。用户级线程可以使用定时的upcall作为一个切换任务的触发器,以此来使多个任务更平均的共享处理器或者是停止正在执行的任务。(说明用户级线程自己不可以随意的切换,只能等任务执行完)
2.异步的IO通知
大多数系统调用直到需要完成的操作执行完之前都是阻塞状态。但是如果处理器同时还有其他事情要做怎么办?有一种可行的方法叫做异步IO:系统的调用开始后立即返回,随后,应用程序可以不断的poll内核看IO是否完成,或者IO完成后,内核会通过upcall向应用程序传递一个通知。
3.进程间通信

大多数进程间通信可以通过系统调用来完成:一个进程写数据,其他进程读取数据。如果一个进程想要发出一个需要其他进程立即感知到的信号,那么内核的upcall就是被需要的。

4.用户级的异常处理

之前,我们提到了除零异常等处理器异常都由内核的处理。然而,许多应用程序都有它们自己的异常处理逻辑,例如,应用程序关闭之前应确保文件被保存。为了实现这个,操作系统在处理器异常发生时,需要通知应用程序以便应用程序在运行时,而不是内核中,处理异常。

5.用户级的资源分配

Java的垃圾回收需要操作系统能够通知自己当内存分配发生改变时,因为一些别的进程需要更多或更少的内存

操作系统内核的启动

当电脑开机后,程序计数器的指针被放在内存中一个事先定义好的位置。操作系统使用一块特别的,只读的非易失(ROM)的内存存储单元来存储开机指令。这段开机程序通常被叫做BIOS(Basic Input Output System)。但是,把内核的所有代码存储到ROM中会有许多缺点:最重要的问题是操作系统无法更新了,因为在电脑被生产出来的一瞬间指令都被固定了。与之矛盾的是,操作系统通常需要不断的更新自己随着bug被发现和修复。并且,ROM是昂贵的,访问缓慢的。因此我们只将一小段代码存储的ROM中,而BIOS可以通过这一小段代码,读取磁盘固定位置上的bootloader,一旦BIOS把bootloader拷贝到内存中,就立即跳转到bootloader指令进行执行。bootloader依次把内核的代码加载入内存中并跳转执行。内核代码一旦开始执行,就会开始初始化它所需要的各个数据结构,包括中断向量表等。随后,内核开始执行第一个进程,一般而言是在磁盘中存储的用户登录程序。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值