OSTEP [虚拟] Chapt. 03-06 - 虚拟化入门

分类:Operating System, 发布于:2019-04-15 22:14:54, 更新于:2019-04-19 00:09:40。 评论

虚拟化:每个程序都以为自己独占CPU、整个内存空间。

为了实现CPU的虚拟化,OS需要一些低级的机械功能(low-level machinery)和一些高级的智能(intelligence)。

我们称低级功能为机能(mechanisms),指一些实现所需功能的方法或协议。例如,我们需要上下文切换来让CPU切换执行的任务,这是一种时间共享(time-sharing)的机制。

另一方面,OS中的智能通过规则(policies)展现,这些规则是OS做出决定的算法。例如决定运行哪个程序、利用历史数据制定调度规则等。

TIP: Seperate Policy and Mechanism

可以将machinism理解为how,将policy理解为which。将这两个概念区分开来可以实现模块化,以助于设计OS。

4.1 The Abstraction: A Process

OS提供的对运行程序的抽象叫做进程。

要理解什么组成一个进程,首先需要理解进程的机器状态:内存(地址空间)、寄存器(PC、IP、栈指针、帧指针等)、IO信息等。

4.2 Process API

下面是任何操作系统都必须拥有的对进程的API,在所有的现代操作系统中都有出现:

  • 新建(create);
  • 销毁(destroy):主动退出或者被杀死;
  • 等待(wait):等待进程直到运行结束;
  • 混合操作(miscellaneous control):睡眠、恢复等;
  • 状态(status):获得进程状态。

4.3 Process Creation: A Little More Detail

运行程序的第一件事是将其代码和静态数据加载(load)到内存(进程的地址空间)中。程序一开始位于磁盘(disk)中,以可执行文件格式存储。OS读取数据并放置到内存中。

早期的系统中,这个加载过程是eager loading,即一次性加载全部内容。而现代OS使用lazy loading,借助分页(paging)和交换(swapping)技术,只加载当前执行所需要的部分。

一旦程序被加载到内存中之后,OS还需要分配一块内存作为进程的栈区,用来存储静态分配的对象、函数参数、返回值,并初始化部分内容(如main函数的参数)。

同时,OS也可能会分配内存作为进程的堆区,存储动态分配对象。

接着,OS还会执行一些初始化任务,如在Unix系统中,会打开0,1,2三个文件描述符,提供I/O操作。

最后,OS开始执行程序,从程序加载点开始运行代码,将CPU控制权转移给新进程,进程开始运行。

4.4 Process States

简单来说,进程可能处于以下三个状态之一:

  • Running:进程在处理器上执行指令;
  • Ready:进程准备执行,但由于某些原因OS决定现在不让它运行;
  • Blocked:进程此前执行了某些操作,让它在其他事件发生前停止执行。

决定当前执行什么程序是系统调度的任务,为书本后面几章的内容。

4.5 Data Structures

上图为xv6中进程对应的数据结构,需要保存寄存器内容、进程状态等内容来实现现场恢复、上下文切换。

有时,一个进程还可以处于初始(initial)状态,代表已经被创建;以及最终(final/zombie)状态,代表进程已经结束但还没有被OS清除。最终状态对于父进程检查子进程的返回值很有用,如Unix系统中成功返回0,否则返回非0值)。父进程会执行一次调用来等待子进程执行结束,并告知OS可以清除子进程和相关数据了。

5.1 The fork() System Call

fork()系统调用可用于创建新进程。每个进程会被分得一个编号,叫做process identifier (PID)

fork()调用会创建一个与调用者进程 完 全 一 致 的进程(exact copy of the calling process)。在OS看来,就像是两个进程的拷贝在运行,两个都即将从fork()调用中返回。父进程通过返回值获得了子进程的PID,而子进程获得0,由此可以区分父子进程。

但是,多进程的运行结果是不确定的。父进程或子进程都有可能先于对方继续执行。而具体是谁先执行由CPU调度器决定。

5.2 The wait() System Call

wait()系统调用会阻塞当前进程,直到等待对象状态改变/结束为止,然后返回当前进程继续执行。

5.3 The exec() System Call

fork()调用只能拷贝当前程序,而exec()系统调用可以运行一个其他的程序。exec()也会拷贝一份进程,然后从给定的可执行文件读取代码,覆盖本身的代码段、重新初始化堆栈,然后执行新的程序。

5.4 Why? Motivating The API

为什么区分fork()exec()

fork()exec()的区分对于构建一个Unix外壳(shell)非常重要,因为OS允许shell在fork()之后到exec()之前的中间部分执行代码。这段代码可以改变即将运行的程序的环境,提供更多的特性支持。

TIP: Getting It Right

Sometimes, you just have to do the right thing, and when you do, it is way better than the alternatives. There are lots of ways to design APIs for process creation; however, the combination of fork() and exec() are simple and immensely powerful. Here, the UNIX designers simply got it right.

shell是一个工具人用户程序,给你展示一个提示符,等待你向它输入指令。shell接着找出你要来执行的程序在哪里,然后调用fork()来创建一个子进程,再调用exec()的各种变种来执行程序。最后,shell通过wait()等待程序完成退出,此时shell从系统调用中返回,打印新的提示,等待用户的下一个输入。

因此,区分fork()exec()系统调用允许shell简单地实现一些有趣的东西。比如

prompt> wc p3.c > newfile.txt

在上面的例子中,wc的输出被重定向到了文件newfile.txt。shell通过在exec()执行前关闭标准输出(stdout),重定向到文件来修改即将运行的程序,实现这个功能。

ASIDE: RTFM - Read The Man Pages

大家都懂了,STFW(上网冲浪)也不错。

5.5 Process Control And Users

在大多数Unix shell中,按下特定的按键组合会向运行的进程发送指定的信号。例如,Control+C会发送SIGINT(interrupt)信号给进程(正常的结束运行);Control+Z会发送SIGTSTP(stop)信号,中途暂停进程的执行(之后可以用fg等指令恢复执行)。

信号系统提供了外部给进程发送事件的丰富体系,包括进程接收、处理信号,以及发送给单个或集体进程的功能。为了使用这种通信方式,进程需要使用signal()系统调用来捕捉各种信号,来阻止默认处理程序的执行,转而调用自己定义的处理函数。

问题来了:谁可以给进程发信号,谁又不可以给进程发信号?通常来说,如果多用户系统中一个用户可以任意地给别的程序发送SIGINT,那么整个系统的可用性和安全性就会受到威胁(比如ICS中的某用户直接关机)。因此,现代操作系统包含了关于对用户的强定义(strong conception of the notion of a user)。用户在输入密码,建立信任(establish credential)后就可以登入系统,享受访问某些资源的特权。用户可以创建进程,享有对这些进程的完整控制;但一般用户只能控制自己拥有的进程。OS的任务之一是把进程打包好(parcel out),送给各个用户来满足系统的需求。

ASIDE: The Superuser (Root)

(太长了,又太精彩了我就直接复制整段了)

A system generally needs a user who can administer the system, and is not limited in the way most users are. Such a user should be able to kill an arbitrary process (e.g., if it is abusing the system in some way), even though that process was not started by this user. Such a user should also be able to run powerful commands such as shutdown (which, unsurprisingly, shuts down the system). In UNIX-based systems, these special abilities are given to the superuser (sometimes called root). While most users can't kill other users processes, the superuser can. Being root is much like being Spider-Man: with great power comes great responsibility. Thus, to increase security (and avoid costly mistakes), it's usually better to be a regular user; if you do need to be root, tread carefully, as all of the destructive powers of the computing world are now at your fingertips.

5.6 Useful Tools

  • ps
  • top(现在用htop更多);
  • kill(user friendly killall);
  • 各种各样的CPU性能表(CPU meters);

题外话:ps + grep/sed + kill组合技用来覆盖掉已存在的进程不要太香。

6.1 Basic Technique: Limited Direct Execution

要让程序跑得快,最直接的方法就是direct execution:直接让程序在CPU上跑。只需要加载程序代码,创建堆栈,保留OS的上下文,然后跳转到程序加载点即可。

但此方法存在一定的问题:OS如何保证程序不干坏事?操作系统如何切换程序来实现分时共享?

6.2 Problem #1: Restricted Operations

(此部分很多ICS的内容)

解决方案:创建一个新的处理器模式:用户模式(user mode)。程序能够执行的代码受到限制,不能够发起I/O请求,否则会造成CPU报错,OS终止程序继续执行。

相对的,另一种处理器模式叫做内核模式(kernel mode),程序可以执行任意代码,包括特权操作,如I/O请求、执行其他特权指令。

TIP: Use Protected Control Transfer

The hardware assists the OS by providing different modes of execution. In user mode, applications do not have full access to hardware resources. In kernel mode, the OS has access to the full resources of the machine. Special instructions to trap into the kernel and return-from-trap back to user-mode programs are also provided, as well as instructions that allow the OS to tell the hardware where the trap table resides in memory.

现代硬件提供用户程序系统调用(system call)的机会来执行特权指令。系统调用允许内核小心的暴露部分核心功能给用户程序,例如访问文件系统、与其他进程通信、分配更多内存等。

为了执行系统调用,程序必须执行特殊的自陷(trap)指令来切换到内核态。结束后,系统会执行特殊的从自陷状态返回的指令,回到用户态。

同时,系统中应该存有一张跳转表,而不是固定每个跳转的地址。否则程序就可以知道目标,直接跳转,获得内核权限。开机时,系统处于内核态,此时OS告知硬件中断处理函数的位置,此后即可进行中断跳转。

要区分到底进行了什么系统调用,每个系统调用都被分配了一个编号(system-call number)。用户代码将调用编号放在一个指定的寄存器或者栈区位置,然后OS的处理函数在中断处理时分析这个编号,找到对应的系统调用。这样的中转起到了保护的作用,用户无法直接访问内核操作,必须向OS用一个编号请求某个特定的服务。

最后,设置、查询中断跳转的函数是一个特权函数,用户使用时,硬件不会回应用户的操作,(会直接灭了他)。

在有限的直接运行(LDE)协议中有两个阶段:

  • 内核初始化跳转表,CPU记住其位置并处理后续中断;
  • 内核创建一块内存,执行中断返回函数来真正进入用户态执行程序;程序期望进行系统调用时,自陷并切换到内核态,由OS处理调用;程序运行结束时,执行结束时的调用(如exit()),最后陷入内核态,OS清场,结束运行。

6.3 Problem #2: Switching Between Processes

第二个问题:OS如何重新获得对CPU的控制来进行程序切换?

合作方案:等待系统调用

大多数进程都会频繁地进行系统调用,将控制权交回给OS;通常系统也包括一个显式自陷(yield)系统调用,让程序主动放弃CPU。

同时,当程序出现违规操作(除0等)时控制权会被转移到OS(OS可能会终止违例程序)。

TIP: Dealing With Application Misbehavior

Operating systems often have to deal with misbehaving processes, those that either through design (maliciousness) or accident (bugs) attempt to do something that they shouldn't. In modern systems, the way the OS tries to handle such malfeasance is to simply terminate the offender. One strike and you're out! Perhaps brutal, but what else should the OS do when you try to access memory illegally or execute an illegal instruction?

非合作方案:OS接管

在主动方案中,如果程序陷入死循环,只有重启系统能够让OS重新接管。因此,需要一种能够让OS主动夺回控制权的方法。

OS可以利用时钟中断,当时钟中断发生,硬件开始执行特定的处理函数,OS重新获得控制权。

因此,OS在开机时必须告诉硬件中断处理函数的位置,一旦时钟开始工作,OS可以相信自己最终能够重新获得控制权。但作为一种并发功能,时钟和中断也可以被关闭(特权行为)。

TIP: Reboot Is Useful

Reboot is useful because it moves software back to a known and likely more tested state. Reboots also reclaim stale or leaked resources (e.g., memory) which may otherwise be hard to handle. Finally, reboots are easy to automate.

Thus, next time you reboot, you are not just enacting some ugly hack. Rather, you are using a time-tested approach to improving the behavior of a computer system. Well done!

保存和恢复上下文

OS重新获得控制权后,调度者(scheduler)会决定是否要切换当前运行的程序。如果决定要切换,OS会执行一段低级代码来切换上下文(context switch)。

坑死人:IP保存的是下一条指令的地址(xv6里面是直接把返回地址塞进去了);压入的大致顺序是:SP、EFlags、CSIP、ERRNO、TrapNO、段寄存器pusha

在进程切换和中断处理过程中,OS会关闭中断,所以OS在此过程中不会被干扰。

评论