1.1 Processes and memory

操作系统的任务是在多个程序之间共享一台计算机,并提供一组比硬件单独支持的更有用的服务。操作系统管理和抽象低级硬件,从而例如字处理器不需要关心正在使用哪种类型的盘硬件。操作系统在多个程序之间共享硬件,以便它们同时运行(或看起来像在运行)。最后,操作系统为程序提供了可控的交互方式,以便它们可以共享数据或协同工作。
image.png
当一个进程暂时不使用 cpu 时,xv6 会保存它的 CPU 寄存器,在下次运行该进程时恢复它们。

Fork 创建的新进程,称为子进程,其内存内容与调用的进程完全相同,原进程被称为父进程。在父进程和子进程中,fork 都会返回。在父进程中,fork 返回子进程的 PID;在子进程中,fork 返回 0。
exit 系统调用退出调用进程,并释放资源,如内存和打开的文件。exit 需要一个整数状态参数,通常 0 表示成功,1 表示失败。
wait 系统调用返回当前进程的一个已退出(或被杀死)的子进程的 PID,并将该子进程的退出状态码复制到一个地址,该地址由 wait 参数提供;如果调用者的子进程都没有退出,则 wait 等待一个子进程退出。如果调用者没有子进程,wait 立即返回-1。如果父进程不关心子进程的退出状态,可以传递一个 0 地址给 wait。

你可能会奇怪为什么 fork 和 exec 没有结合在一次调用中,我们后面会看到 shell 在实现 I/O 重定向时利用了这种分离的特性。为了避免创建相同进程并立即替换它(使用 exec)所带来的浪费,内核通过使用虚拟内存技术(如 copy-on-write)来优化这种用例的 fork 实现(见 4.6 节)。

1.2 I/O and File descriptors

文件描述符是一个小整数,代表一个可由进程读取或写入的内核管理对象。一个进程可以通过打开一个文件、目录、设备,或者通过创建一个管道,或者通过复制一个现有的描述符来获得一个文件描述符。

在内部,xv6 内核为每一个进程单独维护一个以文件描述符为索引的表,因此每个进程都有一个从 0 开始的文件描述符私有空间。
按照约定,一个进程从文件描述符 0(标准输入)读取数据,向文件描述符 1(标准输出)写入输出,向文件描述符 2(标准错误)写入错误信息。

read/write 系统调用可以从文件描述符指向的文件读写数据。每个引用文件的文件描述符都有一个与之相关的偏移量。读取从当前文件偏移量中读取数据,然后按读取的字节数推进偏移量,随后的读取将返回上次读取之后的数据。当没有更多的字节可读时,读返回零,表示文件的结束。写入的文件偏移量类似。

新分配的文件描述符总是当前进程中最小的未使用描述符。

文件描述符和 fork 相互作用,使 I/O 重定向易于实现。Fork 将父进程的文件描述符表和它的内存一起复制,这样子进程开始时打开的文件和父进程完全一样。系统调用 exec 替换调用进程的内存,但会保留文件描述符表。这种行为允许 shell 通过 fork 实现 I/O 重定向,在子进程中重新打开所选的文件描述符,然后调用 exec 运行新程序。下面是 shell 运行 cat < input.txt 命令的简化版代码。

  1. char *argv[2];
  2. argv[0] = "cat";
  3. argv[1] = 0;
  4. if (fork() == 0)
  5. {
  6. close(0); // 释放标准输入的文件描述符
  7. open("input.txt", O_RDONLY); // 这时 input.txt 的文件描述符为 0
  8. // 即标准输入为 input.txt
  9. exec("cat", argv); // cat 从 0 读取,并输出到 1
  10. }

现在应该清楚为什么 fork 和 exec 是分开调用的:在这两个调用之间,shell 有机会重定向子进程的 I/O,而不干扰父进程的 I/O 设置。

虽然 fork 复制了文件描述符表,但每个底层文件的偏移量都是父子共享的。

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 表示 2 是 1 的复制品(dup(1)),即重定向错误信息到标准输出,已存在文件的名称和不存在文件的错误信息都会显示在文件 tmp1 中。

1.3 Pipes

管道是一个小的内核缓冲区,作为一对文件描述符暴露给进程,一个用于读,一个用于写。将数据写入管道的一端就可以从管道的另一端读取数据。管道为进程提供了一种通信方式。下面的示例代码运行程序 wc,标准输入连接到管道的读取端。

int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if (fork() == 0)
{
    close(0); // 释放文件描述符 0
    dup(p[0]); // 复制一个 p[0](管道读端),此时文件描述符 0(标准输入)也引用管道读端,故改变了标准输入。
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv); // wc 从标准输入读取数据,并写入到参数中的每
                            // 一个文件
}
else
{
    close(p[0]);
    write(p[1], "hello world\n", 12);
    close(p[1]);
}

当 wc 从其标准输入端读取时,它将从管道中读取。父进程关闭管道的读端,向管道写入,然后关闭写端。
如果没有数据可用,管道上的 read 会等待数据被写入,或者等待所有指向写端的文件描述符被关闭;在后一种情况下,读将返回 0,就像数据文件的结束一样。事实上,如果没有数据写入,读会无限阻塞,直到新数据不可能到达为止(写端被关闭),这也是子进程在执行上面的 wc 之前关闭管道的写端很重要的一个原因:如果 wc 的一个文件描述符仍然引用了管道的写端,那么 wc 将永远看不到文件的关闭(被自己阻塞)。

xv6 的 shell 实现了管道,如 grep fork sh.c | wc -l,shell 的实现类似于上面的代码(user/sh.c:100)。执行 shell 的子进程创建一个管道来连接管道的左端和右端(去看源码,不看难懂)。然后,它在管道左端(写入端)调用 fork 和 runcmd,在右端(读取端)调用 fork 和 runcmd,并等待两者的完成。管道的右端(读取端)可以是一个命令,也可以是包含管道的多个命令(例如,a | b | c),它又会分叉为两个新的子进程(一个是 b,一个是 c)。因此,shell 可以创建一棵进程树。这棵树的叶子是命令,内部(非叶子)节点是等待左右子进程完成的进程。

读取端会因为管道无数据且输入端未关闭而阻塞,即只能等待左边的命令执行完才能执行右边的命令,写入端需要将自己的输出写入到管道,或者关闭管道,右边的命令读取管道并将其作为自己的输入(可以没有参数)

管道似乎没有比临时文件拥有更多的功能:
echo hello world | wc
不使用管道:
echo hello world >/tmp/xyz; wc </tmp/xyz
在这种情况下,管道比临时文件至少有四个优势。首先,管道会自动清理自己;如果是文件重定向,shell 在完成后必须小心翼翼地删除/tmp/xyz。第二,管道可以传递任意长的数据流,而文件重定向则需要磁盘上有足够的空闲空间来存储所有数据。第三,管道可以分阶段的并行执行,而文件方式则需要在第二个程序开始之前完成第一个程序。第四,如果你要实现进程间的通信,管道阻塞读写比文件的非阻塞语义更有效率。

1.4 File system

像/a/b/c 这样的路径指的是根目录/中的 a 目录中的 b 目录中的名为 c 的文件或目录。不以/开头的路径是相对于调用进程的当前目录进行计算其绝对位置的,可以通过 chdir 系统调用来改变进程的当前目录。下面两个 open 打开了同一个文件(假设所有涉及的目录都存在)。

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 的引用。inode 保存着一个文件的 metadata(元数据),包括它的类型(文件或目录或设备),它的长度,文件内容在磁盘上的位置,以及文件的链接数量。

Inode 是 linux 和类 unix 操作系统用来储存除了文件名和实际数据的数据结构,它是用来连接实际数据和文件名的。

fstat 系统调用从文件描述符引用的 inode 中检索信息。它定义在 stat.h (kernel/stat.h) 的 stat 结构中:

#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
};

link 系统调用创建了一个引用了同一个 inode 的文件(文件名)。下面的片段创建了引用了同一个 inode 的两个文件 a 和 b。

open("a", O_CREATE | O_WRONLY);
link("a", "b");

读写 a 与读写 b 是一样的,每个 inode 都有一个唯一的 inode 号来标识。经过上面的代码序列后,可以通过检查 fstat 的结果来确定 a 和 b 指的是同一个底层内容:两者将返回相同的 inode 号(ino),并且 nlink 计数为 2。

unlink 系统调用会从文件系统中删除一个文件名。只有当文件的链接数为零且没有文件描述符引用它时,文件的 inode 和存放其内容的磁盘空间才会被释放。
unlink("a");
上面这行代码会删除 a,此时只有 b 会引用 inode。

fd = open("/tmp/xyz", O_CREATE | O_RDWR);
unlink("/tmp/xyz");

这段代码是创建一个临时文件的一种惯用方式,它创建了一个无名称 inode,故会在进程关闭 fd 或者退出时删除文件。