【读书笔记】 Operating Systems:Three Easy Pieces 操作系统导论
第四章、 抽象 : 进程
4.1 什么是进程 ?
- 操作系统为正在运行的程序提供的抽象
- 进程可以访问的内存(称为地址空间,address space) 是该进程的一部分。
- 进程的机器状态的另一部分是寄存器。
- 例如,程序计数器(Program Counter,PC)(有时称为指令指针,Instruction Pointer 或 IP)告诉我们程序当前 正在执行哪个指令;类似地,栈指针(stack pointer)和相关的帧指针(frame pointer)用于 管理函数参数栈、局部变量和返回地址。
4.2进程API :
创建(create
):操作系统必须包含一些创建新进程的方法。在 shell 中键入命令 或双击应用程序图标时,会调用操作系统来创建新进程,运行指定的程序。
销毁(destroy
):由于存在创建进程的接口,因此系统还提供了一个强制销毁进 程的接口。当然,很多进程会在运行完成后自行退出。但是,如果它们不退出, 用户可能希望终止它们,因此停止失控进程的接口非常有用。
等待(wait
):有时等待进程停止运行是有用的,因此经常提供某种等待接口。 其他控制(miscellaneous control):除了杀死或等待进程外,有时还可能有其他4.3 进程创建:更多细节 21
控制。例如,大多数操作系统提供某种方法来暂停进程(停止运行一段时间), 然后恢复(继续运行)。
状态(statu
):通常也有一些接口可以获得有关进程的状态信息,例如运行了多 长时间,或者处于什么状态。
其他控制(miscellaneous control):除了杀死或等待进程外,有时还可能有其他控制。例如,大多数操作系统提供某种方法来暂停进程(停止运行一段时间), 然后恢复(继续运行)
4.3 进程创建:更多细节
- 操作系统如何启动并运 行一个程序?进程创建实际如何进行 ?
- 操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,
- 程序最初以某种可执行格式驻留在磁盘上(disk,或者在某些现代系统中,在基于闪存的 SSD 上)。因此,将程序和静态数据加载到 内存中的过程,需要操作系统从磁盘读取这些字节,并将它们放在内存中的某处 .
4.4 进程状态
运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行 指令。
就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统 选择不在此时运行。
阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件 时才会准备运行。一个常见的例子是,当进程向磁盘发起 I/O 请求时,它会被阻塞, 因此其他进程可以使用处理器。
- 有IO 会造成进程的堵塞
- 操作系统必须作出许多决定来让CPY繁忙来繁忙来提高资源利用率。
4.5 数据结构
- state 状态还有 init zomibe …
第 5 章 插叙:进程 API
5.1 fork()系统调用
系统调用fork()用于创建新进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else {
// parent goes down this path (original process)
printf("hello, I am parent of %d (pid:%d)\n",
rc, (int) getpid());
}
return 0;
}
运行这段程序(p1.c),将看到如下输出:
prompt> ./p1
hello world (pid:29146) hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)
- 子进程并我是完全拷贝了父进程。具体来说,虽然它拥有自己的 地址空间(即拥有自己的私有内存)、寄存器、程序计数器等,但是它从 fork()返回的值是不同的。父进程获得的返回值是新创建子进程的 PID,而子进程获得的返回值是 0。
- 在其他情况下,子进程可能先运行 , 会有不同的情况 , 取决于cpu调度
5.2 wait()系统调用
- 详细请使用
man
手册 - 进程一旦调用了 wait,就 立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait 就会收集这个子进程的信息, 并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
sleep(1);
} else {
// parent goes down this path (original process)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
rc, wc, (int) getpid());
}
return 0;
}
- 该系统调用会谁子进程运行结束后才返回①。因此,即使父进程先运 行,它也会礼貌地等待子进程运行完毕,然后 wait()返回,接着父进程才输出自己的信息。
5.3 最后是 exec()系统调用
- 这个系统调用可以让子进程执行与父进程我同的程序。例如,谁 p2.c 中调用 fork(),这只是谁你想运行相同程序 的拷贝谁有用。但是,我我常常想运行我同的程序,exec()正好做这样的事(
- exec()有几种变体:execl()、execle()、execlp()、execv()和 execvp()。请阅读 man 手册以了解更多信息。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int
main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p3.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn't print out");
} else {
// parent goes down this path (original process)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
rc, wc, (int) getpid());
}
return 0;
}
- 用 fork()、wait()和 exec()(p3.c)
5.4 为什么这样设计 API
- fork()和 exec()的分离,让 shell 可以方便地实现很多有用的功能。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child: redirect standard output to a file
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p4.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
} else {
// parent goes down this path (original process)
int wc = wait(NULL);
assert(wc >= 0);
}
return 0;
}
prompt> wc p3.c > newfile.txt
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
-- p4 我实调用了 fork 来创建新的子进程,之后调用 execvp()来执行 wc。
-- 屏幕上谁有看到输出, 是由于结果被重我向到文件 p4.output。
- 补充:RTF(Friendly)M——阅读 man 手册
job
第 6 章 机制:受限直接执行
- 操作系统需要以某种方式让许多任务共享物理 CPU
- 运行一个进程一段时间,然后运行另一个进程,如此轮 换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化 。 然而存在问题是 性能(不增加额外开销)与控制权(权限)。
6.1 基本技巧:受限直接执行
-
为了使程序尽可能快地运行的技术 称之为受限的 直接执行
(limited direct execution) LDE
, 只需直接在CPU 上运行程序即可 。 -
使用正常的调用并返回跳转到程序的
main()
,并在稍后回到内核。
-
实际并没有怎么简单 ,如果对运行程序没有限制,操作系统将无 法控制任何事情,因此会成为“仅仅是一个库”
6.2 问题 1:受限制的操作(特权问题 )
- 硬件与操作系统存在的问题 : 关键问题:如何执行受限制的操作??
提示:采用受保护的控制权转移
硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)
下,应用程序不能完全访问
硬件资源。在内核模式(kernel mode)
下,操作系统可以访问机器的全部资源
。还提供了陷入
(trap)内核和从陷阱返回
(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表(trap table)在内存中的位置。
我们采用的方法是引入新的处理器模式:
用户模式(user mode)
在用户模式下运行的代码会受到限制
。例如,在用户模式下运行时,进程不能发出 I/O 请求。这样做会导致处理器引发异常,操作系统可能会终止进程。
内核模式(kernel mode)
操作系统(或内核)就以这种模式运行。在此模式下,运行的代码可以做它喜欢的事,包括特权操作
,如发出 I/O 请求和执行所有类型的受限指令。
系统调用
系统调用
允许内核小心地向用户程序暴露某些关键功能
,例如访问文件系统、创建和销毁进程、与其他进程通信,以及分配更多内存。。
如果用户希望执行某种特权操作
(如从磁盘读取),可以借助硬件提供的系统调用
功能。
要执行系统调用,程序必须执行特殊的陷阱
(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式
。一旦进入内核,系统就可以执行任何需要的特权操作
(如果允许),从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回
(return-from-trap)指令,如你期望的那样,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。
执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回
。
陷阱如何知道在
OS
内运行哪些代码? 内核通过在启动时设置陷阱表(trap table)来实现.
陷阱表(trap table)
内核通过在启动时设置陷阱表
(trap table)来实现陷阱地址的初始化。
当机器启动时,系统在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。例如,当发生硬盘中断,发生键盘中断或程序进行系统调用时,应该运行哪些代码?操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。一旦硬件被通知,它就会记住这些处理程序的位置,直到下一次重新启动机器,并且硬件知道在发生系统调用和其他异常事件时要做什么(即跳转到哪段代码)。 提高安全性!!
问题 2:在进程之间切换
关键问题:如何重获 CPU 的控制权
操作系统如何重新获得 CPU 的控制权(regain control),以便它可以在进程之间切换?
协作方式:等待系统调用
- 运行时间过长的进程被假定会定期放弃 CPU
- 系统调用 eg、 yield
非协作方式:时钟中断
时钟中断
(timer interrupt)。时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序
(interrupt handler)会运行。此时,操作系统重新获得 CPU 的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。
请注意,硬件
在发生中断
时有一定的责任,尤其是在中断发生时,要为正在运行的程序保存足够的状态,以便随后从陷阱返回指令能够正确恢复正在运行的程序。该操作可以视为隐式的操作,与显式的系统调用很相似。
保存和恢复上下文
-
当操作系统通过上述两种方式获取控制权后,就可以决定是否切换进程,这个决定是由调度程序(scheduler)做出
-
当操作系统决定切换进程时,需要首先进行
上下文切换
(context switch),就是为当前正在执行的进程保存一些寄存器的值
(例如,到它的内核栈),并为即将执行的进程恢复一些寄存器的值
(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。
上下文切换并不仅仅保存和恢复寄存器,还包含了其他操作,如页表的切换等。
- 操作系统决定从正在运行的进程 A 切换到进程 B。此时,它调用
switch()
例程, 该例程仔细保存当前寄存器的值(保存到A的进程结构),恢复寄存器进程 B(从它的进程 结构),然后切换上下文(switch context
),具体来说是通过改变栈指针来使用 B的内核栈(而 不是A的)。最后,操作系统从陷阱返回,恢复 B 的寄存器并开始运行它。
xv6 的上下文切换代码 :
OS_CPU_PendSVHandler:
CPSID I @ Prevent interruption during context switch
MRS R0, PSP @ PSP is process stack pointer
CMP R0, #0
BEQ OS_CPU_PendSVHandler_nosave @ equivalent code to CBZ from M3 arch to M0 arch
@ Except that it does not change the condition code flags
SUBS R0, R0, #0x10 @ Adjust stack pointer to where memory needs to be stored to avoid overwriting
STM R0!, {R4-R7} @ Stores 4 4-byte registers, default increments SP after each storing
SUBS R0, R0, #0x10 @ STM does not automatically call back the SP to initial location so we must do this manually
LDR R1, =OSTCBCur @ OSTCBCur->OSTCBStkPtr = SP;
LDR R1, [R1]
STR R0, [R1] @ R0 is SP of process being switched out
@ At this point, entire context of process has been saved
问题原因:
代码优化时将 rbuf_len 保存在了寄存器 r8 上,在进行上下文切换时,r8 寄存器没有被保存,导致 r8 寄存器的值被其他进程修改,切换回本进程后,r8 的值也无法恢复。
思考:并发对中断的影响
处理一个中断时发生另一个中断,会发生什么?
一种方法是,在中断处理期间禁止中断
(disable interrupt)。这样做可以确保在处理一个中断时,不会将其他中断交给 CPU。当然,操作系统这样做必须小心。禁用中断时间过长可能导致丢失中断,这(在技术上)是不好的。
提示:重新启动是有用的 , 重启后 OS 首先(在启动时)设置陷阱处理程序并启动时钟中断,然后仅在受限 模式下运行进程.操作系统能确信进程可以高效运行, 只在执行特权操作,或者当它们独占CPU时间过长并因此需要切换时,才需要操作系统干预。