xv6:a simple,Unix-like teaching operating system

原文地址:
book-riscv-rev1.pdf


Chapter 1 Operating system interfaces

  • 操作系统的工作是在多个程序之间共享一台计算机,并提供一组比硬件单独支持的更有用的服务。操作系统管理和抽象低级硬件,因此,例如,字处理器不必关心正在使用哪种类型的磁盘硬件。操作系统在多个程序之间共享硬件,以便它们同时运行(或看似运行)。最后,操作系统为程序交互提供受控方式,以便它们可以共享数据或协同工作。

  • 操作系统通过接口向用户程序提供服务。设计一个好的界面很难。一方面,我们希望界面简单而狭窄,因为这样可以更轻松地实现。另一方面,我们可能会想为应用程序提供许多复杂的功能。解决这种紧张关系的诀窍是设计依赖于一些机制的接口,这些机制可以组合起来提供更多的通用性。

  • 本书以单一的操作系统为具体例子来说明操作系统的概念。该操作系统xv6提供了肯·汤普森和丹尼斯·里奇的Unix操作系统 [15] 引入的基本接口,以及模仿Unix的内部设计。Unix提供了一个狭窄的接口,其机制结合得很好,提供了令人惊讶的通用性。这个界面非常成功,以至于现代操作系统 – BSD、Linux、macOS、Solaris,甚至在较小的程度上,还有Microsoft Windows – 都有类似Unix的界面。了解xv6是了解这些系统和许多其他系统的良好开端。

  • 如图1.1所示,xv6采用了内核的传统形式,这是一种为运行中的程序提供服务的特殊程序。每个正在运行的程序称为进程,其内存中包含指令、数据和堆栈。这些指令实现了程序的计算。数据是计算所依据的变量。堆栈组织程序的过程调用。给定的计算机通常有许多进程,但只有一个内核。
    A kernel and two user processes

  • 当进程需要调用内核服务时,它会调用系统调用,这是操作系统接口中的调用之一。系统调用进入内核;内核执行服务并返回。因此,一个进程在用户空间和内核空间中交替执行。

  • 内核使用CPU1提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己的内存。内核执行时具有实现这些保护所需的硬件特权;用户程序在没有这些特权的情况下执行。当用户程序调用系统调用时,硬件提高特权级别,并开始执行内核中预先安排的功能。

  • 内核提供的系统调用集合是用户程序看到的接口。 xv6 内核提供了 Unix 内核传统提供的服务和系统调用的一个子集。 图 1.2 列出了所有 xv6 的系统调用。
    在这里插入图片描述

  • 本章的其余部分概述了 xv6 的服务——进程、内存、文件描述符、管道和文件系统——并通过代码片段和关于 shell(Unix 的命令行用户界面)如何使用它们的讨论来说明它们。

  • Shell 对系统调用的使用说明了它们是如何精心设计的。 shell 是一个普通程序,它从用户那里读取命令并执行它们。 shell 是一个用户程序,而不是内核的一部分,这一事实说明了系统调用接口的强大功能:shell 没有什么特别之处。 这也意味着外壳易于更换; 因此,现代 Unix 系统有多种 shell 可供选择,每种都有自己的用户界面和脚本功能。 xv6 shell 是 Unix Bourne shell 本质的简单实现。 它的实现可以在 (user/sh.c:1) 找到。

1.1 Processes and memory

  • xv6 进程由用户空间内存(指令、数据和堆栈)和内核私有的每个进程状态组成。 Xv6 分时进程:它透明地在等待执行的进程集中切换可用的 CPU。当一个进程没有执行时,xv6 会保存它的 CPU 寄存器,并在下一次运行该进程时恢复它们。内核将进程标识符或 PID 与每个进程相关联。
  • 一个进程可以使用 fork 系统调用创建一个新进程。 Fork 为新进程提供与调用进程完全相同的内存内容(指令和数据)。 Fork 在原始进程和新进程中都返回。在原进程中,fork 返回新进程的 PID。在新进程中,fork 返回零。原始进程和新进程通常称为父进程和子进程。
  • 例如,考虑以下用 C 编程语言 [6] 编写的程序片段:
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的地址; 如果父进程的子进程都没有退出,则等待一个退出。如果父进程没有子进程,则等待立即返回-1。如果父进程不关心子进程的退出状态,它可以将wait置零。
//上方程序的输出
parent: child=1234
child: exiting

可能会以任一顺序出现(甚至混合出现),具体取决于父级或子级是否首先到达其 printf 调用。

//child 退出后,parent 的等待返回,导致 parent 打印 
parent:child 1234 done
  • 尽管孩子最初具有与父母相同的内存内容,但父母和孩子使用不同的内存和不同的寄存器执行: 在一个变量中更改变量不会影响另一个变量。例如,当wait的返回值存储到父进程中的pid中时,它不会更改子进程中的变量pid。子级中pid的值仍将为零。
  • exec 系统调用将调用进程的内存替换为从存储在文件系统中的文件加载的新内存映像。该文件必须具有特定的格式,它指定文件的哪一部分包含指令,哪一部分是数据,从哪条指令开始等等。Xv6 使用 ELF 格式,第 3 章将对此进行更详细的讨论。 exec成功后,不返回调用程序;相反,从文件加载的指令在 ELF 标头中声明的入口点开始执行。 Exec 有两个参数:包含可执行文件的文件名和字符串参数数组。例如:
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

此片段将调用程序替换为程序 /bin/echo 的实例,该程序使用参数列表 echo hello 运行。 大多数程序忽略参数数组的第一个元素,通常是程序的名称。

  • xv6 shell 使用上述调用来代表用户运行程序。外壳主体结构简单;见(user/sh.c:145)。主循环使用 getcmd 从用户那里读取一行输入。然后它调用 fork,它会创建一个 shell 进程的副本。父进程调用等待,而子进程运行命令。例如,如果用户在 shell 中输入了“echo hello”,runcmd 就会以“echo hello”作为参数被调用。 runcmd (user/sh.c:58) 运行实际命令。对于“echo hello”,它会调用 exec (user/sh.c:78)。如果 exec 成功,则子进程将执行来自 echo 而不是 runcmd 的指令。在某些时候,echo 会调用 exit,这将导致父进程从 main (user/sh.c:145) 中的等待返回。
  • 您可能想知道为什么 fork 和 exec 没有组合在一个调用中;稍后我们将看到,shell 在实现 I/O 重定向时利用了这种分离。为了避免创建重复进程然后立即替换它(使用 exec)的浪费,操作系统内核通过使用虚拟内存技术(copy-on-write)优化了 fork 的实现(参见第 4.6 节)。
  • Xv6 隐式分配大部分用户空间内存:fork 分配父内存的子副本所需的内存,而 exec 分配足够的内存来保存可执行文件。在运行时需要更多内存的进程(可能是 malloc)可以调用 sbrk(n) 将其数据内存增加 n 字节; sbrk 返回新内存的位置。

1.2 I/O and File descriptors

  • 文件描述符是一个小整数,表示进程可以读取或写入的内核管理对象。 进程可以通过打开文件、目录或设备,或通过创建管道,或通过复制现有描述符来获得文件描述符。 为简单起见,我们通常将文件描述符所指的对象称为“文件”; 文件描述符接口抽象出文件、管道和设备之间的差异,使它们看起来都像字节流。 我们将输入和输出称为 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返回零以指示文件的末尾。
  • 调用 write(fd, buf, n) 将 n 个字节从 buf 写入文件描述符 fd 并返回写入的字节数。 只有在发生错误时才写入少于 n 个字节。 与读取一样,写入在当前文件偏移处写入数据,然后将该偏移量增加写入的字节数:每次写入都从前一次停止的地方开始。
  • 以下程序片段(构成程序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不知道它是从文件、控制台还是管道中读取。类似地,cat也不知道它是否正在打印到控制台、文件或任何东西。文件描述符的使用以及文件描述符0为输入、文件描述符1为输出的约定,允许简单地实现cat。

  • close 系统调用释放文件描述符,使其可以自由地供将来的 open、pipe 或 dup 系统调用重用(见下文)。新分配的文件描述符始终是当前进程中编号最小的未使用描述符。
  • 文件描述符和 fork 交互使 I/O 重定向易于实现。 Fork 复制父级的文件描述符表及其内存,以便子级以与父级完全相同的打开文件开始。系统调用 exec 替换调用进程的内存,但保留其文件表。这种行为允许 shell 通过分叉、重新打开子中选择的文件描述符,然后调用 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后,open保证为新打开的input.txt使用该文件描述符:0将是最小的可用文件描述符。Cat然后执行,文件描述符0(标准输入)引用输入。父进程的文件描述符不会被这个序列改变,因为它只修改子进程的描述符。

  • xv6 shell中的I/O重定向代码就是这样工作的(user/sh.c:82)。回想一下,在代码的这一点上,shell已经分叉了子shell,runcmd将调用exec来加载新程序。
  • 打开的第二个参数由一组以位表示的 flags组成,它们控制打开的功能。文件控件 (fcntl) 标头 (内核/fcntl.h:1-5) 中定义了可能的值: O_RDONLY, O_WRONLY, O_RDWR, O_CREATE, O_TRUNC,,它们指示open打开文件进行读取或写入,或同时进行读取和写入,创建文件 (如果不存在),并将文件截断到零长度。
  • 现在应该清楚为什么 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外壳不支持错误文件描述符的I/O重定向,但现在你知道如何实现它。
  • 文件描述符是一种强大的抽象,因为它们隐藏了它们所连接的细节:写入文件描述符 1 的进程可能正在写入文件、控制台等设备或管道。

1.3 Pipes

  • 管道是一个小的内核缓冲区,作为一对文件描述符暴露给进程,一个用于读取,一个用于写入。将数据写入管道的一端使该数据可用于从管道的另一端读取。管道为进程提供了一种通信方式。
  • 下面的示例代码运行程序 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 之后,父子节点都有引用管道的文件描述符。 child 调用 close 和 dup 使文件描述符零引用管道的读取端,关闭 p 中的文件描述符,并调用 exec 运行 wc。 当 wc 从其标准输入中读取时,它从管道中读取。 父级关闭管道的读取端,写入管道,然后关闭写入端。

  • 如果没有数据可用,则管道上的读取将等待写入数据或关闭所有引用写入端的文件描述符; 在后一种情况下,read 将返回 0,就像到达数据文件的末尾一样。 read 阻塞直到新数据无法到达这一事实是子进程在执行上述 wc 之前关闭管道的写端很重要的原因之一:如果 wc 的文件描述符之一引用了 管道,wc 永远不会看到文件结尾。
  • xv6 shell 实现了管道,例如 grep fork sh.c | wc -l 的方式类似于上面的代码(user/sh.c:100)。子进程创建一个管道来连接管道的左端和右端。然后它为管道的左端调用 fork 和 runcmd,为右端调用 fork 和 runcmd,并等待两者都完成。管道的右端可能是一个命令,它本身包含一个管道(例如,a | b | c),它本身分叉了两个新的子进程(一个用于 b,一个用于 c)。因此,shell 可以创建进程树。这棵树的叶子是命令,内部节点是等待左右子节点完成的进程。
  • 原则上,可以让内部节点运行在管道的左端,但正确地这样做会使实现复杂化。考虑只进行以下修改:将 sh.c 更改为 not fork for p->left 并在内部进程中运行 runcmd(p->left)。然后,例如 16,echo hi | wc 不会产生输出,因为在 runcmd 中退出 echo hi 时,内部进程退出并且永远不会调用 fork 来运行管道的右端。这种不正确的行为可以通过不在 runcmd 中为内部进程调用 exit 来修复,但是这个修复使代码复杂化:现在 runcmd 需要知道它是否在内部进程中。不为 runcmd(p->right) 分叉时也会出现并发症。例如,仅通过该修改, sleep 10 | echo hi 将立即打印“hi”和一个新的提示,而不是 10 秒后;发生这种情况是因为 echo 立即运行并退出,而不是等待 sleep 完成。由于 sh.c 的目标是尽可能简单,因此它不会试图避免创建内部流程。
  • 管道似乎没有更强大的比临时文件:管道
echo hello world | wc
# could be implemented without pipes as
echo hello world >/tmp/xyz; wc </tmp/xyz

在这种情况下,与临时文件相比,管道至少有四个优点。一是管道自动清理;通过文件重定向,shell 在完成后必须小心删除 /tmp/xyz。其次,管道可以传递任意长的数据流,而文件重定向需要磁盘上有足够的可用空间来存储所有数据。第三,管道允许并行执行管道阶段,而文件方法要求第一个程序在第二个程序开始之前完成。第四,如果你正在实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效。

1.4 File system

  • 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 创建一个新目录,使用 O_CREATE 标志打开创建一个新数据文件,mknod 创建一个新设备文件。 这个例子说明了所有三个:
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);

Mknod 创建一个引用设备的特殊文件。 与设备文件相关的是主要和次要设备号(mknod 的两个参数),它们唯一地标识一个内核设备。 当一个进程稍后打开一个设备文件时,内核将读写系统调用转移到内核设备实现,而不是将它们传递给文件系统。

  • 文件名与文件本身不同;同一个底层文件,称为 inode,可以有多个名称,称为链接。每个链接由目录中的一个条目组成;该条目包含文件名和对 inode 的引用。 inode 保存有关文件的元数据,包括文件类型(文件或目录或设备)、文件长度、文件内容在磁盘上的位置以及文件链接的数量。
  • fstat 系统调用从文件描述符引用的 inode 检索信息。它填充了一个结构体 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;
	// File system’s disk device
	uint ino; // Inode number
	short type; // Type of file
	short nlink; // Number of links to file
	uint64 size; // Size of file in bytes
};

链接系统调用创建另一个文件系统名称,该名称引用与现有文件相同的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("a");到最后一个代码序列将 inode 和文件内容作为 b 访问。
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");

是创建一个没有名称的临时 inode 的惯用方法,当进程关闭 fd 或退出时,该 inode 将被清理。

  • Unix提供可从shell作为用户级程序调用的文件实用程序,例如mkdir、ln和rm。这种设计允许任何人通过添加新的userlevel程序来扩展命令行界面。事后看来,这个计划似乎很明显,但是在Unix时设计的其他系统通常会将此类命令构建到shell中 (并将shell构建到内核中)。
  • 一个例外是 cd,它内置于 shell (user/sh.c:160)。 cd 必须更改 shell 本身的当前工作目录。如果 cd 作为常规命令运行,那么 shell 将派生一个子进程,子进程将运行 cd,而 cd 将更改子进程的工作目录。父级(即 shell 的)工作目录不会改变。

1.5 Real world

  • Unix 将“标准”文件描述符、管道和方便的 shell 语法组合在一起,是编写通用可重用程序的一大进步。这个想法引发了一种“软件工具”文化,这种文化是 Unix 强大和流行的主要原因,而 shell 是第一个所谓的“脚本语言”。 Unix 系统调用接口今天仍然存在于 BSD、Linux 和 macOS 等系统中。
  • Unix 系统调用接口已通过可移植操作系统接口 (POSIX) 标准进行了标准化。 Xv6 不符合 POSIX:它缺少许多系统调用(包括 lseek 等基本系统调用),并且它提供的许多系统调用与标准不同。我们对 xv6 的主要目标是简单明了,同时提供一个简单的类 UNIX 系统调用接口。有几个人用更多的系统调用和一个简单的 C 库扩展了 xv6,以便运行基本的 Unix 程序。然而,现代内核提供了比 xv6 更多的系统调用和更多种类的内核服务。例如,它们支持网络、窗口系统、用户级线程、许多设备的驱动程序等等。现代内核持续快速地发展,并提供了许多超越 POSIX 的特性。
  • Unix 通过一组文件名和文件描述符接口统一访问多种类型的资源(文件、目录和设备)。这个想法可以扩展到更多种类的资源;一个很好的例子是 Plan 9 [14],它将“资源即文件”的概念应用于网络、图形等。然而,大多数 Unix 派生的操作系统并没有遵循这条路线。文件系统和文件描述符是强大的抽象。即便如此,操作系统接口还有其他模型。 Multics 是 Unix 的前身,它以一种看起来像内存的方式抽象了文件存储,从而产生了一种截然不同的界面风格。 Multics 设计的复杂性直接影响了 Unix 的设计者,他们试图构建更简单的东西。
  • Xv6 不提供用户或保护一个用户免受另一个用户的概念; 在 Unix 术语中,所有 xv6 进程都以 root 身份运行。 本书探讨了 xv6 如何实现其类 Unix 接口,但这些想法和概念不仅仅适用于 Unix。 任何操作系统都必须将进程多路复用到底层硬件上,将进程彼此隔离,并提供受控进程间通信的机制。 学习 xv6 后,您应该能够了解其他更复杂的操作系统,并了解这些系统中 xv6 的基本概念。

1.6 Exercises

  1. 编写一个程序,使用 UNIX 系统调用通过一对管道在两个进程之间“ping-pong”传输一个字节,一个管道用于每个方向。衡量程序的性能,以每秒交换数为单位。
  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值