MIT 6.S081 教材第一章内容
- 引言
- 第一章 操作系统接口
- 进程和内存
- I/O和文件描述符
- 管道
- 文件系统
- 真实世界
引言
MIT 6.S081 2020 操作系统
本文为MIT 6.S081课程第一章教材内容翻译加整理。
本课程前置知识主要涉及:
- C语言(建议阅读C程序语言设计—第二版)
- RISK-V汇编
- 推荐阅读: 程序员的自我修养-装载,链接与库
第一章 操作系统接口
操作系统的任务是在多个程序之间共享一台计算机,并提供比硬件本身支持的更有用的服务。操作系统管理和抽象底层硬件,例如:
- 文字处理器不需要关心使用哪种类型的磁盘硬件。
- 一个操作系统在多个程序之间共享硬件,这样它们就可以(或者看起来可以)同时运行。
- 最后,操作系统为程序提供了可控的交互方式,这样它们就可以共享数据或者一起工作。
操作系统通过接口向用户程序提供服务。设计良好的接口是很困难的。
- 一方面,我们希望接口简单明了,因为这样更利于正确使用。
- 另一方面,我们可能倾向于为应用程序提供许多复杂的特性。
解决这个问题的诀窍在于设计接口时,依赖一些可结合的机制,以此来提供更好的通用性。
本书使用单一的操作系统作为具体的例子来说明操作系统的概念。xv6这个操作系统提供了Ken Thompson和Dennis Ritchie的Unix 介绍的基本接口,并且模仿了Unix的内部设计。
Unix提供了一个窄接口,其机制表现突出,提供了令人惊讶的通用程度。这个接口非常成功,甚至现代操作系统BSD、Linux、Mac OSX、Solaris,甚至在一定程度上,Microsoft windows都有类Unix的接口。理解xv6是理解这些系统和其他系统的一个良好开端。
如下图所示,xv6采用传统的内核形式(内核是一个特殊的程序,为正在运行的程序提供服务)。每个正在运行的程序,称为进程,都有包含指令、数据和堆栈的内存。指令实现了程序的运算,数据是计算所依赖的变量,堆栈组织程序的过程调用。一台给定的计算机通常有许多进程,但只有一个内核。
当一个进程需要调用一个内核服务时,它会调用一个系统调用,这是操作系统接口中的一个调用。系统调用进入内核;内核执行服务并返回。因此,一个进程在用户空间和内核空间之间交替执行。
内核使用CPU提供的硬件保护机制来确保每个在用户空间执行的进程只能访问它自己的内存。内核程序的执行拥有操控硬件的权限,它需要实现这些保护;而用户程序执行时没有这些特权。当用户程序调用系统调用时,硬件会提升权限级别,并开始执行内核中预先安排好的函数。
内核提供的系统调用集合是用户程序看到的接口。Xv6内核提供了Unix内核传统上提供的服务和系统调用的子集。
下表列出了xv6的所有系统调用。
系统调用 | 描述 |
---|---|
int fork() | 创建一个进程,返回子进程的PID |
int exit(int status) | 终止当前进程,并将状态报告给wait()函数。无返回 |
int wait(int *status) | 等待一个子进程退出; 将退出状态存入*status; 返回子进程PID。 |
int kill(int pid) | 终止对应PID的进程,返回0,或返回-1表示错误 |
int getpid() | 返回当前进程的PID |
int sleep(int n) | 暂停n个时钟节拍 |
int exec(char *file, char *argv[]) | 加载一个文件并使用参数执行它; 只有在出错时才返回 |
char *sbrk(int n) | 按n 字节增长进程的内存。返回新内存的开始 |
int open(char *file, int flags) | 打开一个文件;flags表示read/write;返回一个fd(文件描述符) |
int write(int fd, char *buf, int n) | 从buf 写n 个字节到文件描述符fd; 返回n |
int read(int fd, char *buf, int n) | 将n 个字节读入buf;返回读取的字节数;如果文件结束,返回0 |
int close(int fd) | 释放打开的文件fd |
int dup(int fd) | 返回一个新的文件描述符,指向与fd 相同的文件 |
int pipe(int p[]) | 创建一个管道,把read/write文件描述符放在p[0]和p[1]中 |
int chdir(char *dir) | 改变当前的工作目录 |
int mkdir(char *dir) | 创建一个新目录 |
int mknod(char *file, int, int) | 创建一个设备文件 |
int fstat(int fd, struct stat *st) | 将打开文件fd的信息放入*st |
int stat(char *file, struct stat *st) | 将指定名称的文件信息放入*st |
int link(char *file1, char *file2) | 为文件file1创建另一个名称(file2) |
int unlink(char *file) | 删除一个文件 |
xv6系统调用(除非另外声明,这些系统调用返回0表示无误,返回-1表示出错)
本章的其余部分概述了xv6的服务——进程、内存、文件描述符、管道和文件系统——并用代码片段和关于shell(Unix的命令行用户界面)如何使用它们的讨论来阐释。Shell对系统调用的使用说明了它们是如何被精心设计的。
Shell是一个普通的程序,它从用户那里读取命令并执行它们。Shell是一个用户程序,而不是内核的一部分,这一事实说明了系统调用接口的强大之处:shell没有什么特别之处。这也意味着shell很容易替换;因此,现代Unix系统有多种shell可供选择,每种shell都有自己的用户界面和脚本特性。Xv6 Shell是Unix Bourne shell本质的简单实现。它的实现可以在(user/sh.c:1)
中找到.
进程和内存
Xv6进程由用户空间内存(指令、数据和堆栈)
和对内核私有的每个进程状态组成。Xv6分时进程: 它透明地在等待执行的进程集合中切换可用的CPU。当一个进程没有执行时,xv6保存它的CPU寄存器,并在下一次运行该进程时恢复它们。内核利用进程id或PID标识每个进程。
一个进程可以使用fork系统调用创建一个新的进程。
- Fork创建了一个新的进程,其内存内容与调用进程(称为父进程)完全相同,称其为子进程。
- Fork在父子进程中都返回值。
- 在父进程中,fork返回子类的PID;
- 在子进程中,fork返回零。
例如,考虑下面用C语言编写的程序片段
// 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");
}
exit
系统调用导致调用进程停止执行并释放资源(如内存和打开的文件)。exit
接受一个整数状态参数,通常0表示成功,1表示失败。
wait
系统调用返回当前进程的已退出(或已杀死)子进程的PID,并将子进程的退出状态复制到传递给wait
的地址;- 如果调用方的子进程都没有退出,那么wait等待一个子进程退出。
- 如果调用者没有子级,
wait
立即返回-1。 - 如果父进程不关心子进程的退出状态,它可以传递一个0地址给
wait
。
在这个例子中,输出:
parent: child=1234
child: exiting
可能以任何一种顺序出来,这取决于父或子谁先到达printf
调用。子进程退出后,父进程的wait
返回,导致父进程打印
parent: child 1234 is done
之所以可能以任何一种顺序出来,这是因为父进程中将0作为参数传递给wait()表示父进程希望等待任何子进程退出。这称为非阻塞等待,父进程会继续执行,无论是否有任何子进程已经终止。
尽管最初子进程与父进程有着相同的内存内容,但是二者在运行中拥有不同的内存空间和寄存器:
- 在一个进程中改变变量不会影响到另一个进程。
- 例如:当
wait
的返回值存入父进程的变量pid
中时,并不会影响子进程中的pid
,子进程中pid
仍然为0。
exec
系统调用使用从文件系统中存储的文件所加载的新内存映像替换调用进程的内存。
(百度百科:根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件)
该文件必须有特殊的格式,它指定文件的哪部分存放指令,哪部分是数据,以及哪一条指令用于启动等等。xv6使用ELF格式(将会在第三章详细讨论)。当exec
执行成功,它不向调用进程返回数据,而是使加载自文件的指令在ELF header
中声明的程序入口处开始执行。exec
有两个参数:可执行文件的文件名和字符串参数数组。
程序的加载,链接部分内容可以阅读: 程序员的自我修养之装载,链接与库进行学习
例如:
char* argv[3];
argv[0] = "echo";
argv[1] = "hello";
//标识参数结束
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
这个代码片段将调用程序替换为了参数列表为echo hello
的/bin/echo
程序运行,多数程序忽略参数数组中的第一个元素,它通常是程序名。
xv6的shell使用上述调用为用户运行程序。shell的主要结构很简单,请参见main
(*user/sh.c:145*)。
- 主循环使用
getcmd
函数从用户的输入中读取一行,然后调用fork
创建一个shell进程的副本。 - 父进程调用
wait
,子进程执行命令。- 例如:当用户向shell输入
echo hello
时,runcmd
(*user/sh.c:58*) 将以echo hello
为参数被调用来执行实际命令。- 对于“
echo hello
”,它将调用exec
(*user/sh.c:78*)。如果exec
成功,那么子进程将从echo
而不是runcmd
执行命令,在某刻echo
会调用exit
,这将导致父进程从main
(*user/sh.c:78*)中的wait
返回。
- 对于“
- 例如:当用户向shell输入
你或许想知道为什么exec
和fork
没有组合成为一个系统调用,稍后我们将会看到shell在其I/O重定向的实现中利用了这种分离。为了避免创建一个重复的进程然后立即替换它(使用exec
)的浪费,操作内核通过使用虚拟内存技术(如copy-on-write)优化 fork 。
Xv6 隐式地分配大多数用户空间内存:
fork
分配父内存的子副本所需的内存exec
分配足够的内存来保存可执行文件- 在运行时需要更多内存的进程(可能是
malloc
)可以调用sbrk(n)
将其数据内存增加n个字节;sbrk
返回新内存的位置。
I/O和文件描述符
文件描述符是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象。
- 进程可以通过打开一个文件、目录、设备,或创建一个管道,或复制一个已存在的描述符来获得一个文件描述符。
- 为了简单起见,我们通常将文件描述符所指的对象称为“文件”;
- 文件描述符接口将文件、管道和设备之间的差异抽象出来,使它们看起来都像字节流。
- 我们将输入和输出称为 I/O。
在内部,xv6内核使用文件描述符作为每个进程表的索引,这样每个进程都有一个从零开始的文件描述符的私有空间。
按照惯例,进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)。
- 正如我们将看到的,shell利用这个约定来实现I/O重定向和管道。
- shell确保它始终有三个打开的文件描述符(*user/sh.c*:151),这是控制台的默认文件描述符。
read
和write
系统调用以字节为单位读取或写入已打开的以文件描述符命名的文件。
read(fd,buf,n)
从文件描述符fd读取最多n字节,将它们复制到buf,并返回读取的字节数,引用文件的每个文件描述符都有一个与之关联的偏移量。read
从当前文件偏移量开始读取数据,然后将该偏移量前进所读取的字节数:- (也就是说)后续读取将返回第一次读取返回的字节之后的字节。
- 当没有更多的字节可读时,
read
返回0来表示文件的结束。
- 系统调用
write(fd,buf,n)
将buf中的n字节写入文件描述符,并返回写入的字节数。- 只有发生错误时才会写入小于n字节的数据。
- 与读一样,
write
在当前文件偏移量处写入数据,然后将该偏移量向前推进写入的字节数:- 每个
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
不知道它是从文件、控制台还是管道读取。同样也不知道它是打印到控制台、文件还是其他什么地方。文件描述符的使用以及文件描述符0是输入而文件描述符1是输出的约定允许了cat
的简单实现。
close
系统调用释放一个文件描述符,使其可以被未来使用的open
、pipe
或dup
系统调用重用(见下文)。新分配的文件描述符总是当前进程中编号最小的未使用描述符。
文件描述符和fork
相互作用,使I/O重定向更容易实现。
fork
复制父进程的文件描述符表及其内存,以便子级以与父级在开始时拥有完全相同的打开文件。- 系统调用
exec
替换了调用进程的内存,但保留其文件表。 - 此行为允许shell通过
fork
实现I/O重定向,在子进程中重新打开选定的文件描述符,然后调用exec
来运行新程序。
下面是shell运行命令cat < input.txt
的代码的简化版本:
char* argv[2];
argv[0] = "cat";
argv[1] = 0;
if (fork() == 0) {
close(0);
open("input.txt", O_RDONLY);
exec("cat", argv);
}
在子进程关闭文件描述符0之后,open
保证使用新打开的*input.txt*:0的文件描述符作为最小的可用文件描述符。
cat
然后执行文件描述符0(标准输入),但引用的是*input.txt*。父进程的文件描述符不会被这个序列改变,因为它只修改子进程的描述符。
Xv6shell中的I/O重定向代码就是这样工作的(*user/sh.c*:82)。回想一下,在代码执行到这里时,shell已经fork
出了子shell,runcmd
将调用exec
来加载新程序。
open
的第二个参数由一组标志组成,这些标志以位表示,用于控制打开的操作。可能的值定义在文件控制(fcntl)头文件(*kernel/fcntl.h*:1-5)中
宏定义 | 功能说明 |
---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 可读可写 |
O_CREATE | 如果文件不存在则创建文件 |
O_TRUNC | 将文件截断为零长度 |
现在应该很清楚为什么fork
和exec
分离的用处了:
- 在这两个调用之间,shell有机会对子进程进行I/O重定向,而不会干扰主shell的I/O设置。
- 我们可以想象一个假设的
forkexec
系统调用组合,但是用这样的调用进行I/O重定向是很笨拙的。 - Shell可以在调用
forkexec
之前修改自己的I/O设置(然后撤销这些修改); - 或者
forkexec
可以将I/O重定向的指令作为参数; - 或者(最不吸引人的是)可以让每个程序(如cat)执行自己的I/O重定向。
尽管fork
复制了文件描述符表,但是每个基础文件偏移量在父文件和子文件之间是共享的,比如下面的程序:
if (fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
在这个片段的末尾,附加到文件描述符1的文件将包含数据hello world
。父进程中的写操作(由于等待,只有在子进程完成后才运行)在子进程停止写入的位置进行。这种行为有助于从shell命令序列产生顺序输出,比如(echo hello;echo world) >output.txt
。
dup
系统调用复制一个现有的文件描述符,返回一个引用自同一个底层I/O对象的新文件描述符。两个文件描述符共享一个偏移量,就像fork复制的文件描述符一样。这是另一种将“hello world”写入文件的方法:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
如果两个文件描述符是通过一系列fork
和dup
调用从同一个原始文件描述符派生出来的,那么它们共享一个偏移量。
否则,文件描述符不会共享偏移量,即使它们来自于对同一文件的打开调用。
dup
允许shell执行这样的命令:ls existing-file non-existing-file > tmp1 2>&1
。
2>&1
告诉shell所给命令的文件描述符2是描述符1的副本。- 现有文件的名称和不存在文件的错误信息都会显示在tmp1文件中。
- Xv6 shell不支持错误文件描述符的I/O重定向,但是现在你知道如何实现它了。
- >重定向运算符背后的解析逻辑就是将当前子进程的文件描述符数组的1号位替换为指定文件的描述符。
- 通过 N>&M 的形式,其中 N 和 M 是文件描述符的数字标识符,可以将文件描述符 N 重定向到与文件描述符 M 相同的目标。这样,N 和 M 将引用同一个目标,并共享读写位置和其他属性。
文件描述符是一个强大的抽象,因为它们隐藏了它们所连接的细节:写入文件描述符1的进程可能写入文件、设备(如控制台)或管道。
管道
管道是作为一对文件描述符公开给进程的小型内核缓冲区,一个用于读取,一个用于写入。将数据写入管道的一端使得这些数据可以从管道的另一端读取。管道为进程提供了一种通信方式。
下面的示例代码使用连接到管道读端的标准输入来运行程序wc
。
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
//p数组接收创建得到的管道的输入输出文件描述符号
//p0用于读,p1用于写
//假设父进程此时只打开了0,1,2三个文件描述符,那么p0和p1分别占用3和4号描述符
pipe(p);
if (fork() == 0) {
//子进程关闭默认的0号标准输入
close(0);
//让管道的读端作为新的0号文件描述符关联的文件
dup(p[0]);
//释放子进程的3号文件描述符,即p0读端占用的一个描述符,因为此时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]);
}
程序调用pipe
,创建一个新的管道,并在数组p中记录读写文件描述符。
- 在
fork
之后,父子进程都有指向管道的文件描述符。 - 子进程调用
close
和dup
使文件描述符0指向管道的读取端(前面说过优先分配最小的未使用的描述符),然后关闭p中所存的文件描述符,并调用exec
运行wc
。 - 当
wc
从它的标准输入读取时,就是从管道读取。 - 父进程关闭管道的读取端,写入管道,然后关闭写入端。
如果没有可用的数据,则管道上的read
操作将会进入等待,直到有新数据写入或所有指向写入端的文件描述符都被关闭,在后一种情况下,read
将返回0,就像到达数据文件的末尾一样。
- 事实上,
read
在新数据不可能到达前会一直阻塞,这是子进程在执行上面的wc
之前关闭管道的写入端非常重要的一个原因:- 如果wc的文件描述符之一指向管道的写入端,wc将永远看不到文件的结束。
Xv6 shell以类似于上面代码(*user/sh.c*:100)的方式实现了诸如grep fork sh.c | wc -l
之类的管道。
- 子进程创建一个管道将管道的左端和右端连接起来。
- 然后对管道的左端调用
fork
和runcmd
,对管道的右端调用fork
和runcmd
,并等待两者都完成。 - 管道的右端可能是一个命令,该命令本身包含一个管道(例如,
a | b | c
),该管道本身fork
为两个新的子进程(一个用于b,一个用于c)。 - 因此,shell可以创建一个进程树。这个树的叶子是命令,内部节点是等待左右两个子进程完成的进程。
原则上,可以让内部节点(上面的父进程)在管道的左端运行,但是正确地这样做会使实现复杂化。考虑进行以下修改:
- 将sh.c更改为不对
p->left
进行fork
,并在内部进程中运行runcmd(p->left)
。 - 然后,例如:
echo hi | wc
将不会产生输出,因为当echo hi
在runcmd
中退出时,内部进程将退出,而不会调用fork
来运行管道的右端。- 这个不正确的行为可以通过不调用内部进程的
runcmd
中的exit
来修复,但是这个修复使代码复杂化:- 现在
runcmd
需要知道它是否是一个内部进程。 - 同样的,当没有对
(p->right)
执行fork
时也会更加复杂。 - 例如,只需进行上述的修改,
sleep 10 | echo hi
将立即打印“hi”,而不是在10秒后,因为echo
将立即运行并退出,而不是等待sleep
完成。
- 现在
- 这个不正确的行为可以通过不调用内部进程的
- 因为sh.c的目标是尽可能的简单,所以它不会试图避免创建内部进程。
管道看起来并不比临时文件更强大:
下面的管道命令行:
echo hello world | wc
可以不通过管道实现,如下:
echo hello world > /tmp/xyz; wc < /tmp/xyz
在这种情况下,管道相比临时文件至少有四个优势
- 首先,管道会自动清理自己;在文件重定向时,shell使用完
/tmp/xyz
后必须小心删除 - 其次,管道可以任意传递长的数据流,而文件重定向需要磁盘上足够的空闲空间来存储所有的数据。
- 第三,管道允许并行执行管道阶段,而文件方法要求第一个程序在第二个程序启动之前完成。
- 第四,如果实现进程间通讯,管道的块读写比文件的非块语义更有效率。
文件系统
Xv6文件系统提供数据文件(包含未解释的字节数组)和目录(包含对数据文件和其他目录的命名引用)。
- 这些目录形成一个树,从一个叫做根的特殊目录开始。
- 像
/a/b/c
这样的路径是指在根目录/
下名为a
的目录中名为b
的目录中名为c
的文件或目录。
- 像
- 不以
/
开始的路径相对于调用进程的当前工作目录进行计算,当前工作目录可以通过chdir
系统调用进行更改。
下面两个代码片段打开相同的文件(假设所有相关的目录都存在)
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
上面代码将进程的当前目录更改为/a/b
;下面代码既不引用也不更改进程的当前目录,还有创建新文件和目录的系统调用:
mkdir
创建一个新目录open
中若使用O_CREATE
标志将会创建一个新的数据文件mknod
创建一个新的设备文件
这个例子说明了这三点:
mkdir("/dir");
fd = open("/dir/file", O_CREATE | O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
创建一个引用设备的特殊文件。
- 与设备文件相关联的是主设备号和次设备号(
mknod
的两个参数),它们唯一地标识了一个内核设备。 - 当进程稍后打开设备文件时,内核将使用内核设备实现
read
和write
系统调用,而不是使用文件系统。
一个文件的名字和文件本身是不同的;
- 同一个底层文件(叫做inode,索引结点)可以有多个名字(叫做link,链接)。
- 每个链接都由目录中的一个条目组成;
- 该条目包含一个文件名和一个inode引用。
- Inode保存有关文件的元数据(用于解释或帮助理解信息的数据),包括其类型(文件/目录/设备)、长度、文件内容在磁盘上的位置以及指向文件的链接数。
fstat
系统调用从文件描述符所引用的inode中检索信息。它填充一个stat
类型的结构体,struct stat
在stat.h(kernel/stat.h)
中定义为:
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // 文件系统的磁盘设备
uint ino; // Inode编号
short type; // 文件类型
short nlink; // 指向文件的链接数
uint64 size; // 文件字节数
};
link
系统调用创建另一个文件名,该文件名指向与现有文件相同的inode。下面的代码片段创建了一个名字既为a又为b的新文件:
open("a", O_CREATE | O_WRONLY);
link("a", "b");
从a读取或写入与从b读取或写入是相同的操作。
- 每个inode由唯一的inode编号标识。
- 在上面的代码序列之后,可以通过检查
fstat
的结果来确定a和b引用相同的底层内容:- 两者都将返回相同的inode号(
ino
),并且nlink
计数将被设置为2。
- 两者都将返回相同的inode号(
unlink
系统调用从文件系统中删除一个名称。只有当文件的链接数为零且没有文件描述符引用时,文件的inode和包含其内容的磁盘空间才会被释放,因此添加:
unlink("a");
最后一行代码序列中会使inode和文件内容可以作为b访问。
此外:
fd = open("/tmp/xyz", O_CREATE | O_RDWR);
unlink("/tmp/xyz");
是创建没有名称的临时inode的惯用方法,该临时inode将在进程关闭fd或退出时被清理。
Unix以用户级程序的形式提供了可从shell调用的文件实用程序,例如mkdir
、ln
和rm
。这种设计允许任何人通过添加新的用户级程序来扩展命令行接口。事后看来,这个计划似乎是显而易见的,但是在Unix时代设计的其他系统经常将这样的命令构建到shell中(并将shell构建到内核中)。
在添加新的命令之前,确保将可执行文件放置在适当的位置,并根据需要设置文件的执行权限。这样,当用户在命令行中输入新的命令时,系统就能够找到并执行对应的用户级程序。
一个例外是cd
,它是内置在shell(*user/sh.c*:160)。cd
必须更改shell本身的当前工作目录。如果cd
作为常规命令运行,那么shell将分出一个子进程,子进程将运行cd
,cd
将更改子进程的工作目录。父目录(即shell的)的工作目录不会改变。
真实世界
Unix将“标准”文件描述符、管道和方便的shell语法结合起来进行操作,这是编写通用可重用程序方面的一大进步。这个想法引发了一种“软件工具”的文化,这种文化对Unix的强大和流行做出了卓越贡献,shell是第一个所谓的“脚本语言”。Unix系统调用接口今天仍然存在于BSD、Linux和MacOSx等系统中。
Unix系统调用接口已经通过便携式操作系统接口(POSIX)标准进行了标准化。Xv6与POSIX不兼容:
- 它缺少许多系统调用(包括lseek等基本系统调用),并且它提供的许多系统调用与标准不同。
- 我们xv6的主要目标是简单明了,同时提供一个简单的类unix系统调用接口。
- 为了运行基本的Unix程序,有些人扩展了xv6,增加了一些系统调用和一个简单的c库。
- 然而,现代内核比xv6提供了更多的系统调用和更多种类的内核服务。
- 例如,它们支持网络工作、窗口系统、用户级线程、许多设备的驱动程序等等。
- 现代内核不断快速发展,提供了许多超越POSIX的特性。
Unix通过一组文件名和文件描述符接口统一访问多种类型的资源(文件、目录和设备)。这个想法可以扩展到更多种类的资源;一个很好的例子是Plan9,它将“资源是文件”的概念应用到网络、图形等等。然而,大多数unix衍生的操作系统并没有遵循这条路。
文件系统和文件描述符是强大的抽象。即便如此,还有其他的操作系统接口模型。Multics,Unix的前身,以一种看起来像内存的方式抽象了文件存储,产生了一种非常不同的接口风格。Multics设计的复杂性直接影响了Unix的设计者,他们试图使设计更简单。
Xv6没有提供一个用户概念或者保护一个用户不受另一个用户的伤害;用Unix的术语来说,所有的Xv6进程都作为root运行。
本书研究了xv6如何实现其类Unix接口,但这些思想和概念不仅仅适用于Unix。任何操作系统都必须在底层硬件上复用进程,彼此隔离进程,并提供受控制的进程间通讯机制。在学习了xv6之后,你应该去看看更复杂的操作系统,以及这些系统中与xv6相同的底层基本概念。