6.S081 / Fall 2020 [麻省理工操作系统 - 2020 年秋季]
risc-v版本的xv6 跑在 RISC-V微处理器上,没用x86的指令集
理论上,你可以在一个RISC-V计算机上运行XV6,已经有人这么做了。
但是我们会在一个QEMU模拟器上运行XV6。
Lec01 Introduction and Examples
课程的目标
-
理解操作系统的设计和实现。设计是指整体的结构,实现是指具体的代码长什么样。对于这两者,我们都会花费大量时间讲解。
-
为了深入了解具体的工作原理,你们可以通过一个小的叫做XV6的操作系统,获得实际动手经验。通过研究现有的操作系统,并结合课程配套的实验,你可以获得扩展操作系统,修改并提升操作系统的相关经验,并且能够通过操作系统接口,编写系统软件。
操作系统的共性
- Abstract hardware
抽象硬件——CPU,内存…——一种非常低层级的资源 - Multiplex
多个应用程序之间复用硬件 - Isolation
多个程序之间互不干扰就变得非常重要。所以这里需要隔离性,不同的活动之间不能相互干扰。 - Sharing
不同的活动之间有时又想要相互影响,比如说数据交互,协同完成任务等,在需要的时候实现共享。 - Security or Permission System or Access Control System。
不想要其他人来读取你的文件。所以在共享的同时,也希望在没有必要的时候不共享。 - Performance
操作系统需要至少不阻止应用程序获得高性能,甚至需要帮助应用程序获得高性能。 - Range of uses
支持大量不同类型的应用程序
操作系统结构
一个矩形表示一个计算机
- 将其硬件资源放在矩形的下面,硬件资源包括了CPU,内存,磁盘,网卡。
- 架构的最上层为 称为用户空间(Userspace),运行的程序例如
一个文本编辑器(VI),或许有一个C编译器(CC),作为CLI(命令行界面)存在的Shell等等 - 架构的中间 区别于用户空间程序,有一个特殊的程序总是会在运行,它称为Kernel。
**Kernel是计算机资源的守护者。**当打开计算机时,Kernel总是第一个被启动。Kernel程序只有一个,它维护数据来管理每一个用户空间进程。Kernel同时还维护了大量的数据结构来帮助它管理各种各样的硬件资源,以供用户空间的程序使用。Kernel同时还有大量内置的服务,例如,Kernel通常会有文件系统实现类似文件名,文件内容,目录的东西,并理解如何将文件存储在磁盘中。所以用户空间的程序会与Kernel中的文件系统交互,文件系统再与磁盘交互。
在这门课程中,我们主要关注点在Kernel、连接Kernal和用户空间程序的接口、Kernel内软件的架构。所以,我们会关心Kernel中的服务,**其中一个服务是文件系统,另一个就是进程管理系统。**每一个用户空间程序都被称为一个进程,它们有自己的内存和共享的CPU时间。**同时,Kernel会管理内存的分配。**不同的进程需要不同数量的内存,Kernel会复用内存、划分内存,并为所有的进程分配内存。
Kernel 概览
文件系统通常有一些逻辑分区。目前而言,我们可以认为文件系统的作用是管理文件内容并找出文件具体在磁盘中的哪个位置。文件系统还维护了一个独立的命名空间,其中每个文件都有文件名,并且命名空间中有一个层级的目录,每个目录包含了一些文件。所有这些都被文件系统所管理。
这里还有一些安全的考虑,我们可以称之为Access Control。当一个进程想要使用某些资源时,比如读取磁盘中的数据,使用某些内存,Kernel中的Access Control机制会决定是否允许这样的操作。对于一个分时共享的计算机,例如Athena系统,这里可能会变得很复杂。因为在Athena系统中,每一个进程可能属于不同的用户,因此会有不同Access规则来约定哪些资源可以被访问。
在一个真实的完备的操作系统中,会有很多很多其他的服务,比如在不同进程之间通信的进程间通信服务,比如一大票与网络关联的软件(TCP/IP协议栈),比如支持声卡的软件,比如支持数百种不同磁盘,不同网卡的驱动。所以在一个完备的系统中,Kernel会包含大量的内容,数百万行代码。
System Call
我们同时也对应用程序是如何与Kernel交互,它们之间的接口长什么样感兴趣。这里通常成为Kernel的API,它决定了应用程序如何访问Kernel。通常来说,这里是通过所谓的系统调用(System Call)来完成。**系统调用与程序中的函数调用看起来是一样的,但区别是系统调用会实际运行到系统内核中,并执行内核中对于系统调用的实现。**在这门课程的后面,我会详细介绍系统调用。现在,我只会介绍一些系统调用在应用程序中是长什么样的。
fd = open("out", 1);
write(fd, "hello\n", 6);
pid = fork();
open
第一个例子是,如果应用程序需要打开一个文件,它会调用名为open的系统调用,并且把文件名作为参数传给open。假设现在要打开一个名为“out”的文件,那么会将文件名“out”作为参数传入。同时我们还希望写入数据,那么还会有一个额外的参数,在这里这个参数的值是1,表明我想要写文件
这里看起来像是个函数调用,但是open是一个系统调用,它会跳到Kernel,Kernel可以获取到open的参数,执行一些实现了open的Kernel代码,或许会与磁盘有一些交互,最后返回一个文件描述符对象。上图中的fd全称就是file descriptor。之后,应用程序可以使用这个文件描述符作为handle,来表示相应打开的文件。
write
如果你想要向文件写入数据,相应的系统调用是write。你需要向write传递一个由open返回的文件描述符作为参数。你还需要向write传递一个指向要写入数据的指针(数据通常是char型序列),在C语言中,可以简单传递一个双引号表示的字符串(下图中的\n表示是换行)。第三个参数是你想要写入字符的数量。
第二个参数的指针,实际上是内存中的地址。所以这里实际上告诉内核,将内存中这个地址起始的6个字节数据写入到fd对应的文件中。
fork
另一个你可能会用到的,更有意思的系统调用是fork。fork是一个这样的系统调用,它创建了一个与调用进程一模一样的新的进程,并返回新进程的process ID/pid。这里实际上会复杂的多,我们后面会有更多的介绍。
所以对吧?这些系统调用看起来就跟普通的函数调用一样。系统调用不同的地方是,它最终会跳到系统内核中。
这里只是浅尝辄止,我们后面会介绍更多。所以这些是一些快速预览
Hard and Interesting
Hard
-
内核的编程环境比较困难。当你在编写、修改,扩展内核,或者写一个新的操作系统内核时,你实际上在提供一个基础设施让别人来运行他们的程序
-
设计一个操作系统时,你需要满足一些列矛盾的需求
efficient —— abstract 底层到高层
powerful —— simple
flexble —— secure- 其中一个是,你想要你的操作系统既高效又易用。**高效通常意味着操作系统需要在离硬件近的low-level进行操作,而易用则要求操作系统为应用程序提供抽象的high-level可移植接口。**所以,提供一个简单可移植,同时又高效的抽象接口需要一定的技巧。
- 另一个矛盾的点是,我们想要提供一个非常强大的操作系统服务,这样操作系统才能分担运行应用程序的负担,所以我们需要强大的操作系统服务。但同时,我们也想要有简单的接口。我们不想程序员看到数量巨多,复杂且难以理解的的内核接口。因为,如果他们不理解这些接口,他们就会很难使用这些接口。所以,我们也想要简单的API。**实际上是有可能提供既简单,同时又包含强大功能的接口。**所以,这里要提供一个简单的接口,同时又包含了强大的功能。
- 最后一个矛盾点是所有的操作系统需要满足的。**你希望给与应用程序尽可能多的灵活性,你不会想要限制应用程序,所以你需要内核具备灵活的接口。但是另一方面,你的确需要在某种程度上限制应用程序,因为你会想要安全性。**我们希望给程序员完全的自由,但是实际上又不能是真正的完全自由,因为我们不想要程序员能直接访问到硬件,干扰到其他的应用程序,或者干扰操作系统的行为。
-
另一件使得操作系统的设计难且有趣的点是:操作系统提供了大量的特性和大量的服务,但是它们趋向于相互交互
问题
如果一个应用程序通过open系统调用得到了一个文件描述符fd。之后这个应用程序调用了fork系统调用。fork的语义是创建一个当前进程的拷贝进程。而对于一个真正的拷贝进程,父进程中的文件描述符也必须存在且可用。所以在这里,一个通过open获得的文件描述符,与fork以这种有趣的方式进行交互。当然,你需要想明白,子进程是否能够访问到在fork之前创建的文件描述符fd。在我们要研究的操作系统中答案是,Yes,需要能够访问。
Interesting
- 操作系统需要能够满足广泛的使用场景。相同的操作系统需要既给数据库服务器使用,又给智能手机使用。随着时间的推移,你的计算机所使用的硬件也在变化,或许你有了超级快的SSD存储而不是机械的硬盘。大概15年前,多核CPU计算机还极其稀有,而现在变得极其的流行。最近,我们又看到了网速以指数级增长。所有的这些都需要时不时的重新思考,操作系统是如何被设计的。
课程结构和资源
有几节课会专注于学习XV6中的代码,XV6是我们的一个小的用于教学的操作系统,我们会介绍它是如何工作,查看它的代码,并在课程中演示代码的运行。在每一节课程之前都会有作业,作业会要求你们阅读介绍XV6的书籍,书籍的内容是XV6如何运行以及设计思想。所以你应该在课程之前完成相应的阅读,这样你才能理解课程的讨论内容。有几节课会专注于帮助你完成实验内容,例如解释C语言是如何工作的,例如介绍RISC-V是如何工作的,这是我们将要使用的一个微处理器。这些内容对于你们完成实验是有帮助的。在课程的结束部分,我们会花几节课时间来阅读一些操作系统相关的论文,包括一些研究论文和一些经典论文。我们会要求你在课程之前阅读这些论文,我们也会在课堂上讨论这些论文。
这门课程的下一大部分是lab,几乎每周都会有一些编程实验。实验的意义在于帮助你获得一些使用和实现操作系统的实际动手经验。比如说,下周截止的实验实际上是写一些应用程序代码来执行我们之前谈到的系统调用,之后的大部分实验则要求你要么实现基本的操作系统功能或者扩展XV6操作系统。最后一个lab会要求你添加一个网络协议栈和一个网络驱动,这样操作系统才能连接到网络上。
Shell简介
Shell是一种对于Unix系统管理来说非常有用的接口,它提供了很多工具来管理文件,编写程序,编写脚本。你之前看过我演示一些Shell的功能,通常来说,当你输入内容时,你是在告诉Shell运行相应的程序。所以当我输入ls时,实际的意义是我要求Shell运行名为ls的程序,文件系统中会有一个文件名为ls,这个文件中包含了一些计算机指令,所以实际上,当我输入ls时,我是在要求Shell运行位于文件ls内的这些计算机指令。
系统调用代码演示
read, write, exit
#include "kernel/types.h"
#include "user/user.h"int main()
{
char buf[64];while(1){
int n = read(0, buf,sizeof(buf));if (n <= 0)break;write(1, buf, n);}exit(0);
}
open
#include "kernel/types.h"
#include “user/user.h"
#include "kernel/fcntl.h"int main()
{
int fd = open("output.txt". O_WRONLY | O_CREATE);write(fd, "ooo\n", 4);exit(0);
}
文件描述符本质上对应了内核中的一个表单数据。内核维护了每个运行进程的状态,内核会为每一个运行进程保存一个表单,表单的key是文件描述符。这个表单让内核知道,每个文件描述符对应的实际内容是什么。这里比较关键的点是,每个进程都有自己独立的文件描述符空间,所以如果运行了两个不同的程序,对应两个不同的进程,如果它们都打开一个文件,它们或许可以得到相同数字的文件描述符,但是因为内核为每个进程都维护了一个独立的文件描述符空间,这里相同数字的文件描述符可能会对应到不同的文件。
fork
#include "kernel/types.h"
#include "uesr/user.h"int main() {
int pid;pid = fork();printf("fork() returned %d\n", pid);if (pid == 0) {
printf("child\n");} else {
printf("parent\n");}exit(0);
}
在第12行,我们调用了fork。fork会拷贝当前进程的内存,并创建一个新的进程,这里的内存包含了进程的指令和数据。之后,我们就有了两个拥有完全一样内存的进程。**fork系统调用在两个进程中都会返回,在原始的进程中,fork系统调用会返回大于0的整数,这个是新创建进程的ID。而在新创建的进程中,fork系统调用会返回0。**所以即使两个进程的内存是完全一样的,我们还是可以通过fork的返回值区分旧进程和新进程。
在第16行,你可以看到代码检查pid。如果pid等于0,那么这必然是子进程。在我们的例子中,调用进程通常称为父进程,父进程看到的pid必然大于0。所以父进程会打印“parent”,子进程会打印“child”。之后两个进程都会退出。接下来我运行这个程序:
fork系统调用之后,两个进程都在同时运行,QEMU实际上是在模拟多核处理器,所以这两个进程实际上就是同时在运行。所以当这两个进程在输出的时候,它们会同时一个字节一个字节的输出,两个进程的输出交织在一起,所以你可以看到两个f,两个o等等。
在第一行最后,你可以看到0,这是子进程的输出。我猜父进程返回了19,作为子进程的进程ID。通常来说,这意味着这是操作系统启动之后的第19个进程。之后一个进程输出了child,一个进程输出了parent,这两个输出交织在一起。虽然这只是对于fork的一个简单应用,但是我们可以清晰的从输出看到这里创建了两个运行的进程,其中一个进程打印了child,另一个打印了parent。所以,fork(在子父进程中)返回不同的值是比较重要的。
exec
#include "kernel/types.h"
#include "user/user.h"int main() {
char *argc[] = {
"echo", "this", "is", "echo", 0};exec("echo", argc);printf("exec failed!\n");exit(0);
}
代码会执行exec系统调用,这个系统调用会从指定的文件中读取并加载指令,并替代当前调用进程的指令。从某种程度上来说,这样相当于丢弃了调用进程的内存,并开始执行新加载的指令。所以第12行的系统调用exec会有这样的效果:操作系统从名为echo的文件中加载指令到当前的进程中,并替换了当前进程的内存,之后开始执行这些新加载的指令。同时,你可以传入命令行参数,exec允许你传入一个命令行参数的数组,这里就是一个C语言中的指针数组,在上面代码的第10行设置好了一个字符指针的数组,这里的字符指针本质就是一个字符串(string)。
所以这里等价于运行echo命令,并带上“this is echo” 这三个参数。所以当我运行exec文件,
有关exec系统调用,有一些重要的事情,
- exec系统调用会保留当前的文件描述符表单。所以任何在exec系统调用之前的文件描述符,例如0,1,2等。它们在新的程序中表示相同的东西。
- 通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。
所以,exec系统调用从文件中读取指令,执行这些指令,然后就没有然后了。**exec系统调用只会当出错时才会返回,因为某些错误会阻止操作系统为你运行文件中的指令,例如程序文件根本不存在,因为exec系统调用不能找到文件,exec会返回-1来表示:出错了,我找不到文件。**所以通常来说exec系统调用不会返回,它只会在kernel不能运行相应的文件时返回。
linux下的代码
/*************************************************************************> File Name: echo.c> Author: > Mail: > Created Time: Sat 04 Sep 2021 08:19:47 PM CST************************************************************************/#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {
char *argc[] = {
"echo", "this", "is", "echo", 0 };execlp("echo", argc[0], argc[1], argc[2], argc[3], argc[4]);printf("exec failed!\n");exit(0);}
wait
fork/exec程序
#include "user/user.h"// forkexec.c: fork then execint main() {
int pid, status;pid = fork();if (pid == 0) {
char *argv[] = {
"echo", "THIS", "IS", "ECHO", 0};exec("echo", argv);printf("exec failed\n");exit(1);} else {
printf("parent waiting\n");wait(&status);printf("the child exited with status %d\n");}exit(0);
}
Unix提供了一个wait系统调用,如第20行所示。wait会等待之前创建的子进程退出。当我在命令行执行一个指令时,我们一般会希望Shell等待指令执行完成。所以wait系统调用,使得父进程可以等待任何一个子进程返回。这里wait的参数status,是一种让退出的子进程以一个整数(32bit的数据)的格式与等待的父进程通信方式。所以在第17行,exit的参数是1,操作系统会将1从退出的子进程传递到第20行,也就是等待的父进程处。&status,是将status对应的地址传递给内核,内核会向这个地址写入子进程向exit传入的参数。
Unix中的风格是,如果一个程序成功的退出了,那么exit的参数会是0,如果出现了错误,那么就会像第17行一样,会向exit传递1。所以,如果你关心子进程的状态的话,父进程可以读取wait的参数,并决定子进程是否成功的完成了。
这里有一些东西需要注意,实际上我认为你们很多人已经注意到了,这里是一个常用的写法,先调用fork,再在子进程中调用exec。这里实际上有些浪费,fork首先拷贝了整个父进程的,但是之后exec整个将这个拷贝丢弃了,并用你要运行的文件替换了内存的内容。某种程度上来说这里的拷贝操作浪费了,因为所有拷贝的内存都被丢弃并被exec替换。在大型程序中这里的影响会比较明显。如果你运行了一个几G的程序,并且调用fork,那么实际就会拷贝所有的内存,可能会要消耗将近1秒钟来完成拷贝,这可能会是个问题。
在这门课程的后面,你们会实现一些优化,比如说copy-on-write fork,这种方式会消除fork的几乎所有的明显的低效,而只拷贝执行exec所需要的内存,这里需要很多涉及到虚拟内存系统的技巧。你可以构建一个fork,对于内存实行lazy拷贝,通常来说fork之后立刻是exec,这样你就不用实际的拷贝,因为子进程实际上并没有使用大部分的内存。我认为你们会觉得这将是一个有趣的实验。
I/O Redirect
#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"// redirect.c: run a command with output redirectedint main()
{
int pid;pid = fork();if (pid == 0) {
close(1);open("output.txt", O_WRONLY | O_CREATE);char *argv[] = {
"echo", "this", "is", "redireted", "echo", 0};exec("echo", argc);printf("exec failed\n");exit(1);} else {
wait((int *) 0);}exit(0);
}
我们可以看到预期的输出。代码第15行的close(1)的意义是,我们希望文件描述符1指向一个其他的位置。也就是说,在子进程中,我们不想使用原本指向console输出的文件描述符1。
代码第16行的open一定会返回1,因为open会返回当前进程未使用的最小文件描述符序号。因为我们刚刚关闭了文件描述符1,而文件描述符0还对应着console的输入,所以open一定可以返回1。在代码第16行之后,文件描述符1与文件output.txt关联。
之后我们执行exec(echo),echo会输出到文件描述符1,也就是文件output.txt。这里有意思的地方是,echo根本不知道发生了什么,echo也没有必要知道I/O重定向了,它只是将自己的输出写到了文件描述符1。只有Shell知道I/O重定向了。
这个例子同时也演示了分离fork和exec的好处。fork和exec是分开的系统调用,意味着在子进程中有一段时间,fork返回了,但是exec还没有执行,子进程仍然在运行父进程的指令。所以这段时间,尽管指令是运行在子进程中,但是这些指令仍然是父进程的指令,所以父进程仍然可以改变东西,直到代码执行到了第19行。这里fork和exec之间的间隔,提供了Shell修改文件描述符的可能。
pipe1.c
// pipe1.c: communication over a pipe#include "kernel/types.h" #include "user/user.h"int main() { int fds[2];char buf[100];int n;// create a pipe, with two FDs in fds[0], fds[1].pipe(fds);write(fds[1], "this is pipe1\n", 14);n = read(fds[0], buf, sizeof(buf));write(1, buf, n);exit(0); }
shell是如何实现管道的呢
$ ls | grep x
- 文件描述符可以指向“管道”,也可以指向文件
pipe()
系统调用创建两个文件描述符
- 第一个用于读取
- 第二个用于写入
- 内核为每个管道维护一个缓冲区
write()
追加到缓冲区read()
等待数据pipe2.c
#include "kernel/types.h" #include "user/user.h"// pipe2.c: communication between two processesint main() { int n, pid;int fds[2];char buf[100];// create a pipe, with two FDs in fds[0], fds[1].pipe(fds);pid = fork();if (pid == 0) { write(fds[1], "this is pipe2\n", 14);} else { n = read(fds[0], buf, sizeof(buf));write(1, buf, n);}exit(0); }
管道和
fork()
很好地结合在一起来实现
ls | grep x
- shell创建一个管道,
- 然后执行两次
fork
- 然后将
ls
的文件描述符1连接到管道的写文件描述符- 将
grep
的文件描述符0连接到管道的读文件描述符管道是一个单独的抽象,但与fork()结合得很好。
list.c
#include "kernel/types.h" #include "user/user.h"// list.c: list file names in the current directorystruct dirent { ushort inum;char name[14]; };int main() { int fd;struct dirent e;fd = open(".", 0);while(read(fd, &e, sizeof(e)) == sizeof(e)){ if(e.name[0] != '\0'){ printf("%s\n", e.name);}}exit(0); }
ls如何得到一个目录中的文件列表呢?
你可以打开一个目录并读取它->文件名
"
.
"是进程当前目录的伪名称请参阅
ls.c
了解更多细节
总结
-
看了一些Unix I/O和进程的接口和抽象。这里需要记住的是,接口是相对的简单,你只需要传入表示文件描述符的整数,和进程ID作为参数给相应的系统调用。而接口内部的实现逻辑相对来说是复杂的,比如创建一个新的进程,拷贝当前进程。
-
除此之外,我还展示了一些例子。通过这些例子你可以看到,尽管接口本身是简单的,但是可以将多个接口结合起来形成复杂的用例。比如说创建I/O重定向。