6.S081 / Fall 2020 [麻省理工操作系统 - 2020 年秋季]
risc-v版本的xv6 跑在 RISC-V微处理器上,没用x86的指令集
理论上,你可以在一个RISC-V计算机上运行XV6,已经有人这么做了。
但是我们会在一个QEMU模拟器上运行XV6。
操作系统结构
一个矩形表示一个计算机
- 将其硬件资源放在矩形的下面,硬件资源包括了CPU,内存,磁盘,网卡。
- 架构的最上层为称为用户空间(Userspace),运行的程序例如一个文本编辑器(VI),或许有一个C编译器(CC),作为CLI(命令行界面)存在的Shell等等
- 架构的中间区别于用户空间程序,有一个特殊的程序总是会在运行,它称为Kernel。
Kernel是计算机资源的守护者
当打开计算机时,Kernel总是第一个被启动。Kernel程序只有一个,它维护 数据来管理每一个用户空间进程。Kernel同时还维护了大量的数据结构来帮助它管理各种各样的硬件资源,以供用户空间的程序使用。Kernel同时还有大量内置的服务,例如,Kernel通常会有文件系统实现类似文件名,文件内容,目录的东西,并理解如何将文件存储在磁盘中。所以用户空间的程序会与Kernel中的文件系统交互,文件系统再与磁盘交互。
我们主要关心Kernel中的服务,其中一个服务是文件系统,另一个就是进程管理系统。每一个用户空间程序都被称为一个进程,它们有自己的内存和共享的CPU时间。同时,Kernel会管理内存的分配。不同的进程需要不同数量的内存,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,来表示相应打开的文件。
文件描述符本质上对应了内核中的一个表单数据。内核维护了每个运行进程的状态,内核会为每一个运行进程保存一个表单,表单的key是文件描述符。这个表单让内核知道,每个文件描述符对应的实际内容是什么。这里比较关键的点是,每个进程都有自己独立的文件描述符空间,所以如果运行了两个不同的程序,对应两个不同的进程,如果它们都打开一个文件,它们或许可以得到相同数字的文件描述符,但是因为内核为每个进程都维护了一个独立的文件描述符空间,这里相同数字的文件描述符可能会对应到不同的文件。
write
如果你想要向文件写入数据,相应的系统调用是write。你需要向write传递一个由open返回的文件描述符作为参数。你还需要向write传递一个指向要写入数据的指针(数据通常是char型序列),在C语言中,可以简单传递一个双引号表示的字符串(下图中的\n表示是换行)。第三个参数是你想要写入字符的数量。
第二个参数的指针,实际上是内存中的地址。所以这里实际上告诉内核,将内存中这个地址起始的6个字节数据写入到fd对应的文件中。
fork
另一个你可能会用到的,更有意思的系统调用是fork。fork是一个这样的系统调用,它创建了一个与调用进程一模一样的新的进程,并返回新进程的process ID/pid。这里实际上会复杂的多,我们后面会有更多的介绍。
所以对吧?这些系统调用看起来就跟普通的函数调用一样。系统调用不同的地方是,它最终会跳到系统内核中。
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的执行过程中会保持打开状态,且在新程序中仍然有效,这一特性确保了新执行的进程无需再次打开文件,十分有用
-
通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。
exec系统调用只会当出错时才会返回,因为某些错误会阻止操作系统为你运行文件中的指令,例如程序文件根本不存在,因为exec系统调用不能找到文件,exec会返回-1来表示:出错了,我找不到文件。所以通常来说exec系统调用不会返回,它只会在kernel不能运行相应的文件时返回。
在运行shell时,我们不希望系统调用替代了Shell进程,实际上,Shell会执行fork,这是一个非常常见的Unix程序调用风格。对于那些想要运行程序,但是还希望能拿回控制权的场景,可以先执行fork系统调用,然后在子进程中调用exec。
wait
#include "user/user.h"
// forkexec.c: fork then exec
int 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的参数,并决定子进程是否成功的完成了。
I/O Redirect
#include "kernel/types.h"
#include "user/user.h"
#include "kernel/fcntl.h"
// redirect.c: run a command with output redirected
int main()
{
int pid;
pid = fork();
if (pid == 0) {
close(1);
open("output.txt", O_WRONLY | O_CREATE); //打开文件 "output.txt" 并将其作为新的标准输出文件。
char *argv[] = { "echo", "this", "is", "redireted", "echo", 0};
exec("echo", argc); //exec("echo", argv);:使用 exec 系统调用执行命令 "echo",并将输出重定向到 "output.txt"。
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重定向了。