写在前面:
本文基于mit 6.828 操作系统课程材料——《book riscv rev2》,仅用作学习交流,任何组织和个人未经允许不得将其用于商业用途,包括但不限于出版、授课等。
文中包含对原文的部分翻译以及作者个人的理解。欢迎讨论交流。
chapter 1 操作系统接口
操作系统的基本作用:
- 操作系统对底层硬件提供了抽象和管理;
- 操作系统为用户程序提供了受控制的交互方式,支持用户程序共享数据或者协同工作;
操作系统通过接口为用户程序提供服务,我们希望操作系统的接口包括以下设计要点:
- 简单——因此易于实现;
- 灵活——可以为应用程序提供复杂的特性支持。
看起来这两种设计要点存在冲突,那么解决这个问题的诀窍是将接口的设计尽可能减少依赖(降低耦合),同时可以组合起来形成高级功能的接口(尽可能将复杂功能分离成多个步骤)。
本书通过一个简单的操作系统解释操作系统的相关概念。xv6操作系统提供了Unix的基本接口,同时模仿了Unix的内部设计。理解xv6的设计对理解类Unix操作系统很有帮助。
xv6采用内核空间与用户空间分离的架构,kernel可以理解成一个为运行的用户进程提供支持的特殊的程序;每个运行在用户空间的程序都占用内存资源,包括指令区,数据区以及栈内存区。一个机器只包含一个kernel,但是可以有多个运行的用户进程。
用户程序通过系统调用陷入内核态。内核通过CPU提供的硬件保护机制保证各个进程只能访问自己占有的内存,内核需要特权级别来完成该机制,相对的,用户进程在一般情况下不拥有这些特权,但是当用户进程执行系统调用陷入内核时,硬件则会相应的提高权限级别,并在内核中执行相关的函数。内核提供的系统调用对用户程序可见,xv6内核提供了部分Unix内核的系统调用。
系统调用 | 描述 |
---|---|
int fork() | create a process, return child’s PID. |
int exit(int status) | terminate the current process; status reported to wait(). No return. |
int wait(int *status) | wait for a child to exit; exit status in *status; returns child PID. |
int kill(int pid) | Terminate process PID. Returns 0, or -1 for error. |
int getpid() | return the current process’s PID |
int sleep(int n) | Pause for n clock ticks. |
int exec(char *file, char *argv[] | Load a file and execute it with arguments; only returns if error. |
char *sbrk(int n) | Grow process’s memory by n bytes. Returns start of new memory. |
int open(char *file, int flags) | Open a file; flags indicate read/write; returns an fd (file descriptor). |
int write(int fd, char *buf, int n) | Write n bytes from buf to file descriptor fd; returns n. |
int read(int fd, char *buf, int n) | Read n bytes into buf; returns number read; or 0 if end of file. |
int close(int fd) | Release open file fd. |
int dup(int fd) | Return a new file descriptor referring to the same file as fd. |
int pipe(int p[]) | Create a pipe, put read/write file descriptors in p[0] and p[1]. |
int chdir(char *dir) | Change the current directory. |
int mkdir(char *dir) | Create a new directory. |
int mknod(char *file, int, int) | Create a device file. |
int fstat(int fd, struct stat *st) | Place info about an open file into *st. |
int stat(char *file, struct stat *st) | Place info about a named file into *st. |
int link(char *file1, char *file2) | Create another name (file2) for the file file1. |
int unlink(char *file) | Remove a file. |
接下来,将介绍xv6提供的基本功能:进程、内存、文件描述符、管道以及文件系统等。通过部分代码片段以及如何在shell中使用来解释这些功能的基本信息。
shell是一个读取用户输入并执行的用户程序,需要注意的是shell并不是一个内核程序,shell的实现在xv6源码的(user/sh.c:1
)中。
1.1 进程与内存
一个xv6用户进程占有用户空间内存(指令,数据,栈)以及每个进程独有的状态。Xv6的用户进程在可用的CPU中透明切换,当一个进程不在执行时,xv6 os保存它的CPU寄存器,并让出当前CPU,内核通过进程标识符也叫PID识别每个运行的进程。
进程的创建一般可以通过fork
系统调用,创建出的子进程与父进程共享指令和数据内存,这个系统调用返回两次,在父进程中返回子进程的PID,在子进程中返回0。下面是一个例子:
int pid = fork();
if (pid > 0) {
printf("parent: child = %d\n", pid);pid = wait((int*)0);printf("child %d is done.\n", pid);
} else if (pid == 0) {
printf("child: exiting.\n");exit(0);
} else {
printf("fork error.\n");
}
会看到以下内容:
parent: child = </d+>
child: exiting
parent: child </d+> is done.
虽然在一开始子进程与父进程共享内存(指令部分和数据部分),但是一旦涉及到具体的修改,子进程会把父进程的数据拷贝一份,父子进程在各自的内存上进行独立的修改。
exec
系统调用用一个新的内存image(存储在文件系统中)代替当前进程的内存,新的内存image必须有特定的格式,例如哪部分保存指令,哪部分保存数据,指令从哪里开始等等。xv6选择ELF格式,将在第三章中详细介绍。exec
系统调用成功后不返回任何信息,会直接从加载的指令开始执行。
在xv6中,就使用上述的系统调用实现shell。主进程(user/sh.c:145)使用getcmd
读取用户输入,之后调用fork
,父进程wait
子进程完成,子进程中通过runcmd
(user/sh.c:58)完成实际指令的执行,比如用户输入echo hello
,那么此时runcmd的参数就是"echo和hello",对于echo hello来说,会调用exec
(user/sh.c:78),如果exec成功执行那么此时子进程的将会通过echo执行,之后echo会调用exit,此时子进程退出,父进程wait到子进程的退出,返回父进程(main)。
那么为什么fork和exec不组合起来使用呢?后文会提到,IO重定向就利用了fork和exec分离的特性。同时为了避免fork之后立刻exec的情况(这样会导致内存数据复制无用),内核通过写时复制(copy-on-write)优化了fork的实现。关于写时复制技术详见后文(4.6)。
Xv6隐式分配用户空间内存:fork分配子进程需要的父进程内存大小,exec为可执行文件分配足够大的内存,如果进程在运行过程中需要更多的内存,则使用sbrk动态分配,sbrk返回新分配的内存的起始地址。
1.2 IO以及文件描述符
文件描述符是一个整数,代表有内核管理的,进程可读写的对象,这个对象一般称为“文件(file)”。一个进程可以通过打开一个文件、目录或者设备,创建管道或者复制一个存在的文件描述符等方式获取一个文件描述符。文件描述符对文件,管道,设备等概念进行抽象,将他们统一视为一串字节流,我们将输入输出称为IO。
xv6内核使用文件描述符作为每个进程表的索引,以便每个进程都有一个从零开始的文件描述符的私有空间。一般来说,标准输入的文件描述符是0,标准输出的文件描述符是1,标准错误的是2,shell通过这些描述符实现IO重定向和管道。shell默认打开这三个描述符(user/sh.c:151)。
read和write系统调用从指定的文件描述符读取或者写入指定大小的字节。关于read和write的知识可以参考其他书籍,这里不赘述。
这里可以看一下cat命令的部分源码:
char buf[512];
int n;
for (;;) {
n = read(0, buf, sizeof buf);if (n == 0) break;if (n < 0) {
fprintf(2, "read error.\n");exit(1);}if (write(1, buf, n) != n) {
fprintf(2, "write error.\n");exit(1);}
}
需要注意的是cat命令只对012这三个文件描述符进行操作,并不知道这些文件描述符到底是文件,管道还是终端。
close系统调用回收文件描述符,以便open,pipe或者dup等系统调用的后续使用。新分配的文件描述符是未使用的文件描述符表中索引值最小的那个。
文件描述符和fork系统调用合作可以轻松实现IO重定向,fork会复制父进程中的文件描述符表,因此子进程相当于和父进程打开相同的文件,拥有相同的管道等,exec系统调用会替换子进程中使用的内存但是会保留它的文件描述符表,具体的流程是:fork之后,子进程中重新打开指定的文件描述符,通过exec执行新的应用程序。以下是一个cat < input.txt的简化实现:
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
close(0);open("input.txt", O_RDONLY);exec("/bin/cat", argv);
}
勘误:原文中写的是exec("cat", argv);
子进程中关闭了描述符0,再打开input.txt时绑定的文件描述符就是0,因此cat将标准输入与input.txt进行关联,需要注意的是此时父进程中的文件描述符并没有发生改变。
因此,将fork
和exec
分开的原因之一就是可以在子进程中修改IO而不必修改父进程的IO,这样更灵活。如果出现forkexec
这样的系统调用,理论上可行,但是如果子进程与父进程之间的IO不同,处理起来非常麻烦。要么在主进程中修改IO(之后再撤销),要么将IO重定向作为参数传递给子进程,要么在每个命令中实现重定向(工作量大且复杂)。
虽然在fork
会复制父进程的文件描述符表给子进程,但是文件中的文件偏移是在父子进程中共享的。
if(fork() == 0) {
write(1, "hello ", 6);exit(0);
} else {
wait(0);write(1, "world\n", 6);
}
上例会输出hello world
。
dup
系统调用会复制一个存在的文件描述符表示当前文件描述符表示的文件,并返回一个新的文件描述符。两个文件描述符表示同一个文件,同时也共享文件描述符。
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
也会输出hello world
。
以上就是文件描述符共享文件偏移量的两种情况(fork & dup
),其他情况下文件偏移量并不共享,即使是对同一个文件的多次open操作。
int fd1 = open("output.txt", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
int fd2 = open("output.txt", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
write(fd1, "111111\n", 7);
write(fd2, "222222\n", 7);
上例中output.txt中最终会写入222222
。
一个shell中常见的命令是2>&1
(不能有空格),意思是文件描述符2是文件描述符1的一个拷贝(by dup
)。xv6 shell中并不支持对于error文件描述符进行重定向,不过应该知道如何实现。
总之,文件描述符的抽象非常重要,因为这个抽象省略了他们所表示的对象的细节,可以是一个文件,一个终端设备,或者是一个管道。
1.3 管道
管道是一个小型的内核buffer,表现为两个文件描述符,一个用来读一个用来写,管道提供了一种进程间通信的方式。
int p[2];char *argv[2];
argv[0] = "wc";
argv[1] = 0;pipe(p);
if(fork() == 0) {
close(0);dup(p[0]);close(p[0]);close(p[1]);exec("/bin/wc", argv);
} else {
close(p[0]);write(p[1], "hello world\n", 12);close(p[1]);
}
管道的read端等待数据可用,或者等待全部的写端关闭,全部写端关闭时,read会返回0。需要注意的是,子进程中的write end必须关闭,否则管道的读端会一直阻塞(一直等待写端数据可用)。
xv6中实现了类似grep fork sh.c | wc -l
的管道操作,user/sh.c中具体的代码如下:
// in function runcmd
case PIPE:pcmd = (struct pipecmd*)cmd;if(pipe(p) < 0)panic("pipe");if(fork1() == 0){
close(1);dup(p[1]);close(p[0]);close(p[1]);runcmd(pcmd->left);}if(fork1() == 0){
close(0);dup(p[0]);close(p[0]);close(p[1]);runcmd(pcmd->right);}close(p[0]);close(p[1]);wait(0);wait(0);break;
不难发现,pcmd->left
的部分是管道的write端(close(1), dup(p[1]);
),相对的pcmd->right
的部分是管道的read端(close(0), dup(p[0]);
),掌握这个原则,可以分析a|b|c
等更复杂指令的顺序。
我们发现以上的实现使用了两个fork
,一个直接的想法是能不能减少一次fork
,答案是不能。如果把|左边的指令放在父进程中执行,就是下面的形式:
if (fork1() == 0) {
close(0), dup(p[0]);close(p[0]), close(p[1]);runcmd(pcmd->right);exit(0);
} else {
// left in parent processclose(1), dup(p[1]);close(p[0]), close(p[1]);runcmd(pcmd->left);wait(0);exit(0);
}
break;
涉及的修改如下:
runcmd
不能在switch
外面直接exit
,因为这样会导致父进程执行完runcmd(pcmd->left)
之后直接退出;- 这样就意味着
runcmd
需要知道当前进程是否在内部进程中。
右面也是同理,参考sleep 10 | echo a
这个例子。
以上的内容是原书中的例子,其实一个最简单的反例就是类似grep | grep
。从理论上来说,在父进程中执行命令会导致资源粘连,而且很可能会改变父进程中的一些状态(比如在父进程中关掉文件描述符1用于管道,那么同时标准输出就被关闭了,输出结果就无法在shell上看到),因此这种fork
一次的做法不正确。
临时文件:echo helo world > /tmp/xyz ; wc < /tmp/xyz
管道:echo hello world | wc
对比临时文件,管道的优点包括以下几点:
- 管道随着进程的关闭自动清理自己,而临时文件需要手动删除;
- 管道可以传输长字节流的数据,而临时文件需要足够的磁盘空间存储数据;
- 管道允许并行操作,临时文件则是串行的;
- 管道的阻塞读写形式对于进程内部通信有优势。
1.4 文件系统
xv6文件系统支持数据文件(包含未解释的字节数组)和目录(包含对数据文件和其他目录的命名引用)。以下是一些创建新文件和新目录的系统调用:
mkdir("/dir");
fd = open("/dir/file", O_CREATE | O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
表示创建一个指向设备的特殊文件,mknod通过主设备号(major number)和次设备号(minor device)唯一标识一个设备文件。如果之后进程打开了一个设备文件,那么内核会将read
和write
系统调用换成内核设备文件的实现,而不是文件系统的实现。
文件名与文件本身不同;同一个底层文件(称为inode)可以有多个名称(称为links)。每个link包含一个目录的入口,这个入口包含文件名和inode的引用。inode中保存文件的元数据,包括类型,大小,位置,以及link数等等。
fstat
系统调用检索一个文件描述符链接的inode的信息,struct stat
如下所示:
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // File system’s disk deviceuint ino; // Inode numbershort type; // Type of fileshort nlink; // Number of links to fileuint64 size; // Size of file in bytes
};
link
系统调用创建一个指向已存在文件的inode的链接文件(与已存在文件不同名)。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
每个inode的唯一标识是struct stat
中的ino
,因此查看a
和b
的stat
结构,其ino
是一致的且nlink=2
。
可以使用unlink
解除链接,表现为一个文件名在文件系统中消失。
如果我们想创建一个没有名字的临时Inode,可以使用以下惯用方法:
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
Unix中在用户态提供了多种文件操作的系统调用,比如mkdir, ln, rm
等,同时期的操作系统则把这些系统调用放在shell中实现,并将shell放在内核中实现。但是一个例外是cd
系统调用,他在shell中实现,因为如果新fork
一个进程实现,cd
会改变子进程的工作目录,而父进程的工作目录不受影响。
1.5 现实世界
Unix系统调用结合了现在看起来标准化的概念:文件描述符,管道,方便的shell语义操作等,目前Unix系统调用在Linux,MacOS以及BSD上广泛采用。
Unix系统调用已经按照POSIX(Portable Operating System Interface standard)标准化,Xv6不符合POSIX标准(因为缺少很多的系统调用,以及很多系统调用的语义跟POSIX标准不同),但是Xv6的类Unix系统调用对教学而言非常容易上手。
Unix使用文件描述符和文件抽象的方法影响深远。
Unix中文件系统和文件描述符的抽象非常有效,Unix的前辈Multics则提供了完全不同的抽象,将文件存储看做内存,实现相当复杂,这种复杂的实现直接影响了Unix的设计。
Xv6并未进行用户管理,从Unix的视角来看,所有的用户都是root权限。
本书探讨了xv6如何实现其类似Unix的接口,但这些思想和概念不仅仅适用于Unix。任何操作系统都必须将进程多路复用到底层硬件上,将进程彼此隔离,并提供受控进程间通信的机制。在学习了xv6之后,您应该能够了解其他更复杂的操作系统,并了解这些系统中xv6的基本概念。
1.6 练习题
编写一个程序,使用UNIX系统调用通过一对管道在两个进程之间“pingpong”一个字节,每个方向一个。测量程序的性能,单位为每秒交换数。
个人答案,仅供参考:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
#include <sys/time.h>
#include <time.h>#define NS 1000000000
#define TIMES 500000int main()
{
int p[2], q[2];pipe(p), pipe(q);int pid = fork();if (pid > 0) {
close(p[0]);close(q[1]);char x[1] = {
' '};struct timespec start;struct timespec end;clock_gettime(CLOCK_MONOTONIC, &start);for (int i = 0 ; i < TIMES ; i ++) {
write(p[1], "b", 1);while(read(q[0], x, 1) == 0);}clock_gettime(CLOCK_MONOTONIC, &end);double delta_nsec = (end.tv_sec - start.tv_sec) * NS + end.tv_nsec - start.tv_nsec;double ops = TIMES * (NS / delta_nsec);printf("nsec: %lf, times: %ld, ops: %lf\n", delta_nsec, TIMES, ops);close(p[1]);close(q[0]);wait(0);exit(0);} else {
close(p[1]);close(q[0]);char x[1] = {
' '};for (int i = 0 ; i < TIMES; i ++) {
while(read(p[0], x, 1) == 0);write(q[1], x, 1);}exit(0);}return 0;
}
5次测量值分别为:
nsec: 5463755347.000000, times: 500000, ops: 91512.150205
nsec: 5754410295.000000, times: 500000, ops: 86889.876524
nsec: 5696879357.000000, times: 500000, ops: 87767.349222
nsec: 5728725275.000000, times: 500000, ops: 87279.451536
nsec: 6091463894.000000, times: 500000, ops: 82082.075623
一开始的思路是fork
两个子进程,代码如下,但是时间特别慢,原因暂时不清楚,蹲个大佬~
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
#include <sys/time.h>
#include <time.h>#define NS 1000000000
#define TIMES 500000int main()
{
int p[2], q[2];pipe(p), pipe(q);if (fork() == 0) {
close(p[0]);close(q[1]);char x[1] = {
'x'};struct timespec start;struct timespec end;clock_gettime(CLOCK_MONOTONIC, &start);for (int i = 0 ; i < TIMES ; i ++) {
write(p[1], "b", 1);while(read(q[0], x, 1) == 0);}clock_gettime(CLOCK_MONOTONIC, &end);double delta_nsec = (end.tv_sec - start.tv_sec) * NS + end.tv_nsec - start.tv_nsec;double ops = TIMES * (NS / delta_nsec);printf("nsec: %lf, times: %d, ops: %lf\n", delta_nsec, TIMES, ops);close(p[1]);close(q[0]);exit(0);} if (fork() == 0) {
close(p[1]);close(q[0]);char x[1] = {
'x'};for (int i = 0 ; i < TIMES; i ++) {
while(read(p[0], x, 1) == 0);write(q[1], x, 1);}close(p[0]);close(q[1]);exit(0);}close(p[0]);close(p[1]);close(q[0]);close(q[1]);wait(0);wait(0);return 0;
}
测量结果(慢到我以为我代码写错了。。。):
nsec: 34313626292.000000, times: 500000, ops: 14571.470696
nsec: 34521143653.000000, times: 500000, ops: 14483.877041
nsec: 33957943975.000000, times: 500000, ops: 14724.095203
nsec: 30615538331.000000, times: 500000, ops: 16331.576293
nsec: 35181211527.000000, times: 500000, ops: 14212.131371