第一章 简介
操作系统的任务是在多个程序之间共享计算机资源,并提供比硬件本身支持的更有用的服务集合。操作系统管理和抽象低级硬件,因此,例如,文字处理器无需关心正在使用的磁盘硬件类型。操作系统在多个程序之间共享硬件,使它们能够同时运行(或看似同时运行)。最后,操作系统提供了程序之间交互的受控方式,以便它们可以共享数据或协同工作。
本次的xv6提供了 Ken Thompson 和 Dennis Ritchie 的 Unix 操作系统引入的基本接口,并模仿了 Unix 的内部设计。本章的其余部分概述了 xv6 的服务——进程、内存、文件描述符、管道和文件系统——并用代码片段和讨论说明了如何使用这些服务,特别是 Unix 的命令行用户界面——shell。shell 对系统调用的使用说明了它们是如何精心设计的。
1.1程序和内存
xv6中进程由用户空间内存(指令,数据,栈)以及内核空间中的进程的状态构成。并且xv6采用时间片轮转调度(其他更为复杂的系统则为了更好的性能表现采取更为复杂的调度方式),才进程不可执行或者发生陷阱(中断等)时,xv6会保存其cpu寄存器,以便下次运行时读取之前的进程状态。并且内核会为其分配一个专有的进程号。
fork系统调用:在一个进程中调用fork时会
创建一个新进程,称为子进程,其内存内容与调用进程(称为父进程)完全相同。fork
在父进程和子进程中都会返回。
在父进程中,fork
返回子进程的 PID;
在子进程中,fork
返回零。
int pid = fork();
if (pid > 0) //因为大于零则代表其为父进程
{
printf("parent: child=%d\n", pid);
pid = wait((int *)0); //wait(int * n),若n不为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 =1234
child:exiting
child:exiting
child 1234 is done
导致不同只是因为其父进程或者子进程进入printf调用的时间先后不同。
尽管子进程最初与父进程具有相同的内存内容,但父进程和子进程是使用不同的内存和不同的寄存器执行的:在一个进程中更改变量不会影响另一个进程。例如,当 wait
的返回值被存储到父进程中的变量 pid
时,它不会改变子进程中的变量 pid
。子进程中的 pid
值仍将为零。
exec系统调用:会将目前进程的内存替换为新指定的内存。带有两个参数,分别是包含可执行文件的文件名和一个字符串参数数组。文件必须具有特定的格式,该格式指定了文件的哪一部分包含指令、哪一部分是数据、从哪条指令开始等。Xv6 使用 ELF 格式,将在第 3 章将更详细地讨论这种格式。
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
,而子进程运行命令。
xv6系统调用
如果未另行说明,这些调用在没有错误时返回 0,出错时返回 -1。
-
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)
:打开文件;标志表示读/写;返回文件描述符(fd)。 -
int write(int fd, char *buf, int n)
:从缓冲区buf
写入 n 字节到文件描述符fd
;返回写入的字节数。 -
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[])
:创建管道,将读/写文件描述符存入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)
:将打开文件的信息存入*st
。 -
int stat(char *file, struct stat *st)
:将命名文件的信息存入*st
。 -
int link(char *file1, char *file2)
:为文件file1
创建另一个名称(file2
)。 -
int unlink(char *file)
:删除文件。
1.2 文件描述符
文件描述符是一个小整数,代表一个进程可以从中读取或写入的内核管理对象。进程可以通过打开文件、目录或设备,也就是说将文件,目录或设备的概念模糊化,将其读写操作糅合在一起创造了一个新概念---文件描述符。文件描述符可以通过创建管道,或者通过复制现有描述符来获得。为了简化,我们通常会将文件描述符所指的对象称为“文件”。
在内部,Xv6 内核使用文件描述符作为每个进程表的索引,以便每个进程都有一个从零开始的私有文件描述符空间。按照惯例,进程从文件描述符 0(标准输入)读取,将输出写入文件描述符 1(标准输出),并将错误消息写入文件描述符 2(标准错误)。正如我们将看到的,shell 利用这一惯例来实现 I/O 重定向和管道。Shell 确保它始终有三个文件描述符打开(user/sh.c:151
),默认情况下是控制台的文件描述符。
io重定向:文件描述符和 fork
相互作用,使得 I/O 重定向易于实现。fork
复制父进程的文件描述符表及其内存,因此子进程从父进程开始时具有完全相同的打开文件。系统调用 exec
替换调用进程的内存,但保留其文件表。这种行为使得 shell 可以通过 fork
、在子进程中重新打开选定的文件描述符,然后调用 exec
来运行新程序,从而实现 I/O 重定向。
以下是一个简化版本的代码,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 后,打开操作保证会使用该文件描述符为新打开的 input.txt
:0 将是可用的最小文件描述符。然后 cat
执行时,文件描述符 0(标准输入)指向 input.txt
。父进程的文件描述符不会因这一序列而改变,因为它只修改子进程的描述符。
现在应该清楚为什么 fork
和 exec
是分开的调用是有帮助的:在这两者之间,shell 有机会在不干扰主 shell 的 I/O 设置的情况下重定向子进程的 I/O。人们也可以想象一个假设的组合 fork exec
系统调用,但使用这样的调用进行 I/O 重定向的选项似乎很笨拙。Shell 可以在调用 fork exec
之前修改其自己的 I/O 设置(然后撤销这些修改);或者 fork exec
可以将 I/O 重定向的指令作为参数;或者(最不可取)每个像 cat
这样的程序都可以学会自己进行 I/O 重定向。
尽管 fork
复制了文件描述符表,但每个底层文件偏移量在父进程和子进程之间是共享的。考虑这个例子:
if(fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
这段代码在父进程中调用wait(0)来防止出现写入错误,并且由于是fork产生的子进程,两个进程会有共同的偏移量(针对1这个文件描述符),所以父进程不会将子进程的写入覆盖。复制系统调用复制一个现有的文件描述符,返回一个指向同一个底层 I/O 对象的新描述符。两个文件描述符共享一个偏移量,就像 fork
复制的文件描述符一样。这是另一种将 hello world
写入文件的方法:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
如果两个文件描述符是由一系列 fork
和 dup
调用从同一个原始文件描述符派生出来的,那么它们共享一个偏移量。否则,即使它们是针对同一个文件的打开调用的结果,文件描述符也不会共享偏移量。
Xv6 shell 不支持错误文件描述符的 I/O 重定向,但是允许 shell 实现像这样的命令:
ls existing-file non-existing-file > tmp1 2>&1
。2>&1
告诉 shell 给命令一个文件描述符 2,它是描述符 1 的副本。现有文件的名称和不存在文件的错误消息都将出现在文件 tmp1
中。
文件描述符是一种强大的抽象,因为它们隐藏了它们所连接的细节:向文件描述符 1 写入的进程可能是在写入文件、像控制台这样的设备,或者管道。
1.3 管道
管道是一个小的内核缓冲区,以一对文件描述符的形式暴露给进程,一个用于读取,一个用于写入。向管道的一端写入数据会使这些数据在管道的另一端可供读取。管道提供了一种进程间通信的方式。
以下示例代码运行程序 wc
,其标准输入连接到管道的读取端。
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]);
}
程序调用 pipe
,创建一个新的管道,并将读取和写入文件描述符记录在数组 p
中。fork
之后,父子进程都有指向管道的文件描述符。子进程调用 close
和 dup
,使文件描述符 0 指向管道的读取端,关闭 p
中的文件描述符,并调用 exec
来运行 wc
。当 wc
从其标准输入读取时,它从管道读取。父进程关闭管道的读取端,向管道写入内容,然后关闭写入端。
并且读取时如果父进程还没有完成写入,那么子进程会等待数据被写入,或者所有写入的文件描述符被关闭。如果所有的文件描述符被关闭,那么读取将返回0,并且会等待所有的写入文件描述符关闭,所以需要在exec时,需要关闭wc的写入文件描述符。如果不关闭就会一直等待。
Xv6 的 shell 使用管道实现命令链,是因为管道提供了一种高效、灵活且通用的进程间通信机制。它不仅提高了命令链的执行效率,还简化了 shell 的实现,使得用户可以轻松地组合命令来完成复杂的任务。(详细的构建较为复杂,所以在此就做一个引入,在后面的章节再详细介绍)
1.4文件系统
1.4.1 打开目录
Xv6 文件系统提供了数据文件(包含未解释的字节数组)和目录(包含对数据文件和其他目录的命名引用)。目录形成一个树形结构,从一个特殊的目录——根目录开始。路径 /a/b/c
指向根目录 /
中名为 a
的目录中名为 b
的目录中名为 c
的文件或目录。不以 /
开头的路径是相对于调用进程的当前目录进行评估的,当前目录可以通过 chdir
系统调用进行更改。以下两个代码片段打开同一个文件(假设所有涉及的目录都存在)
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
第一个片段将进程的当前目录更改为 /a/b
;第二个片段既不引用也不更改进程的当前目录。
1.4.2创建目录
有三方式可以创建目录:
1.mkdir 创建一个目录
2.open(……,0_CREATE......) 带有O_CREATE的open系统调用
3.mknod 创建一个新设备文件
以下为示例:
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
创建一个引用设备的特殊文件。与设备文件相关联的是主设备号和次设备号(mknod
的两个参数),它们唯一标识一个内核设备。当进程稍后打开设备文件时,内核会将读取和写入系统调用重定向到内核设备实现,而不是传递给文件系统。
1.4.3 文件与文件名
文件的名称与文件本身是分开的;同一个底层文件(称为 inode)可以有多个名称,称为链接。每个链接由目录中的一个条目组成;该条目包含文件名和对 inode 的引用。Inode 包含有关文件的元数据,包括其类型(文件、目录或设备)、长度、文件内容在磁盘上的位置以及指向文件的链接数量。
fstat
系统调用从文件描述符所引用的 inode 中检索信息。它填充了一个 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
的新文件。也就类似于创建一个软连接,和win系统中创建桌面图标的方法和原理类似。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
从 a
读取或写入与从 b
读取或写入相同。每个 inode 都由一个唯一的 inode 编号标识。在上述代码序列之后,可以通过检查 fstat
的结果来确定 a
和 b
指向相同的底层内容:两者都将返回相同的 inode 编号(ino
),并且 nlink
计数将设置为 2。
unlink
系统调用从文件系统中删除一个名称。只有当文件的链接计数为零且没有文件描述符引用它时,文件的 inode 和磁盘空间才会被释放。
以下代码是一种创建没有名称的临时 inode 的惯用方法,该 inode 将在进程关闭 fd
或退出时被清理:
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
1.4.4 文件工具
Unix 提供了可从 shell 作为用户级程序调用的文件工具,例如 mkdir
、ln
和 rm
。这种设计允许任何人通过添加新的用户级程序来扩展命令行界面。事后看来,这个计划似乎显而易见,但在 Unix 设计时,其他系统通常将这样的命令内置于 shell 中(并将 shell 内置于内核中)。
一个例外是 cd
,它内置于 shell 中(user/sh.c:160
)。cd
必须更改 shell 自身的当前工作目录。如果 cd
作为常规命令运行,那么 shell 将分叉一个子进程,子进程将运行 cd
,cd
将更改子进程的工作目录。父进程(即 shell 的)工作目录不会更改。
也就是说xv6并不支持在shell中直接使用大多数的unix命令,只支持cd命令。
1.5现实世界
Unix 的“标准”文件描述符、管道以及对它们操作的方便的 shell 语法的结合,是编写通用可重用程序的一个重大进步。这一理念激发了一种“软件工具”的文化,这种文化是 Unix 强大和流行的主要原因之一,而 shell 也成为了第一种所谓的“脚本语言”。Unix 系统调用接口至今仍然存在于像 BSD、Linux 和 Mac OS X 这样的系统中。
Unix 系统调用接口已经通过可移植操作系统接口(POSIX)标准进行了标准化。Xv6 并不符合 POSIX 标准:它缺少许多系统调用(包括基本的 lseek
),并且它提供的许多系统调用与标准不同。我们对 Xv6 的主要目标是简单性和清晰性,同时提供一个简单的类 Unix 系统调用接口。一些人已经通过添加一些更多的系统调用和一个简单的 C 库来扩展 Xv6,以便运行基本的 Unix 程序。然而,现代内核提供了比 Xv6 更多的系统调用,以及更多种类的内核服务。例如,它们支持网络、窗口系统、用户级线程、许多设备的驱动程序等。现代内核不断发展和快速演变,提供了许多超出 POSIX 的功能。
Unix 通过单一的文件名和文件描述符接口统一了对多种资源(文件、目录和设备)的访问。这一理念可以扩展到更多种类的资源;一个很好的例子是 Plan 9,它将“资源是文件”的概念应用于网络、图形等。然而,大多数基于 Unix 的操作系统并没有走这条路。
文件系统和文件描述符一直是强大的抽象概念。即便如此,操作系统接口还有其他模型。Multics,Unix 的前身,以一种使其看起来像内存的方式抽象文件存储,产生了一种非常不同的接口风格。Multics 设计的复杂性直接影响了 Unix 的设计者,他们试图构建一个更简单的东西。
Xv6 没有提供用户的概念,也没有保护一个用户免受另一个用户的影响;用 Unix 的术语来说,所有 Xv6 进程都以 root 用户身份运行。
本书考察了 Xv6 如何实现其类 Unix 接口,但这些理念和概念并不仅适用于 Unix。任何操作系统都必须将进程多路复用到底层硬件上,隔离进程,以及提供进程间受控通信的机制。在学习了 Xv6 之后,你应该能够查看其他更复杂的操作系统,并在这些系统中看到 Xv6 的底层概念。
1.6 练习及解答
编写一个程序,使用 UNIX 系统调用在两个进程之间通过一对管道来回传递一个字节(每个方向一个管道)。测量程序的性
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
#define PING 1 // 定义 PING 数据
#define PONG 2 // 定义 PONG 数据
// 父进程的 ping 函数
void ping(int pipe1[2], int pipe2[2], int *count) {
char data = PING; // 数据内容为 PING
clock_t start = clock(); // 记录开始时间
while (1) {
// 写入数据到子进程
write(pipe1[1], &data, sizeof(data)); // 通过 pipe1 的写端发送数据
// 从子进程读取数据
read(pipe2[0], &data, sizeof(data)); // 通过 pipe2 的读端接收数据
(*count)++; // 增加交换次数计数
// 如果超过 1 秒,退出循环
if (((double)(clock() - start)) / CLOCKS_PER_SEC >= 1.0) {
break;
}
}
// 关闭管道
close(pipe1[1]); // 关闭 pipe1 的写端
close(pipe2[0]); // 关闭 pipe2 的读端
}
// 子进程的 pong 函数
void pong(int pipe1[2], int pipe2[2], int *count) {
char data;
while (1) {
// 从父进程读取数据
read(pipe1[0], &data, sizeof(data)); // 通过 pipe1 的读端接收数据
// 写入数据到父进程
write(pipe2[1], &data, sizeof(data)); // 通过 pipe2 的写端发送数据
(*count)++; // 增加交换次数计数
}
// 关闭管道
close(pipe1[0]); // 关闭 pipe1 的读端
close(pipe2[1]); // 关闭 pipe2 的写端
}
int main() {
int pipe1[2], pipe2[2]; // 两对管道
int count = 0; // 交换次数计数
pid_t pid;
// 创建两个管道
if (pipe(pipe1) == -1 || pipe(pipe2) == -1) {
perror("pipe"); // 如果创建管道失败,打印错误信息
exit(EXIT_FAILURE);
}
// 创建子进程
pid = fork();
if (pid == -1) {
perror("fork"); // 如果创建子进程失败,打印错误信息
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 子进程:执行 pong 操作
pong(pipe1, pipe2, &count);
} else {
// 父进程:执行 ping 操作
ping(pipe1, pipe2, &count);
// 等待子进程结束
wait(NULL);
}
// 计算每秒交换次数
double duration = 1.0; // 测试时间为 1 秒
double rate = count / duration; // 计算交换速率
printf("Ping-pong rate: %.2f exchanges per second\n", rate); // 打印结果
return 0;
}
能,以每秒的交换次数为单位。