目录

第1章 操作系统接口

1.1过程和内存 

1.2 I/O和文件描述符  

1.3管道

1.4文件系统

1.5真实世界(Real world)

1.6练习

第2章 操作系统组织机构        

2.1抽象的物理资源

2.2用户模式、监控模式和系统调用

2.3内核组织

2.4代码:xv6组织

2.5工艺概述


第1章 操作系统接口


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

        操作系统通过接口向用户程序提供服务,设计出一个好的界面原来是很困难的。一方面,我们希望接口是简单和狭窄,因为这使得它更容易获得正确的实现。另一方面,我们可能会想为应用程序提供许多复杂的特性,解决这种张力的技巧是设计出依赖于一些机制的接口,这些机制可以组合起来提供许多通用性。

        这本书使用了一个单一的操作系统作为一个具体的例子来说明操作系统的概念,该操作系统xv6提供了由 Ken Thompson and Dennis Ritchie’s的Unix操作系统[17]引入的基本界面,同时也模仿了Unix的内部设计。Unix提供了一个狭窄的接口,其机制结合得很好,提供了一种令人惊讶的通用性。Unix提供了一个狭窄的接口,其机制结合得很好,提供了一种令人惊讶的通用性。这个界面非常成功,以至于现代操作系统——bsd、Linux、macOS、Solaris,甚至是在较小程度上的微软视窗——都有类似于unix的界面。理解xv6是理解这些系统和许多其他系统的一个很好的开始。

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

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

        如后续章节中详细所述,内核使用CPU(本文通常指的是使用术语CPU来执行计算的硬件元素,它是一个首字母缩写)提供的硬件保护机制,以确保每个进程在用户空间中执行只能访问它自己的内存,内核以实现这些保护所需的硬件特权执行;用户程序执行时没有这些特权。当用户程序调用系统调用时,硬件会提高特权级别,并开始在内核中执行预先安排的函数。

内核提供的系统调用的集合是用户程序所看到的界面。xv6内核提供了Unix内核传统上提供的服务和系统调用的子集。图1.2列出了xv6的所有系统调用。

        本章的其余部分概述了xv6的服务——进程、内存、文件描述符、管道和文件系统——并通过代码片段和unilx命令行用户界面如何使用它们的讨论来说明它们。外壳对系统调用的使用说明了它们的设计是多么仔细。

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

1.1过程和内存 

        xv6进程由用户空间内存(指令、数据和堆栈)和内核私有的每个进程状态组成。Xv6分时进程:它在等待执行的一组进程之间透明地切换可用的cpu。当进程不执行时,xv6保存进程的CPU寄存器,并在下次运行该进程时恢复它们。内核将一个进程标识符或PID与每个进程关联联。        

一个进程可以使用fork系统调用创建一个新的进程。fork给新进程一个调用进程内存的精确副本:它将调用进程的指令、数据和堆栈复制到新进程的内存中。fork在原始进程和新进程中都有返回。在原始进程中,fork返回新进程的PID。在新的进程中,fork返回零。原始进程和新进程通常被称为父进程和子进程。       

例如,考虑以下用C语言编程语言[7]编写的程序片段:

exit()系统调用会导致调用进程停止执行,并释放诸如内存和打开文件等资源。退出采用一个整数状态参数,通常以0表示成功,以1表示失败,wait()系统调用返回当前进程的退出(或已终止的)子节点的PID,并将子进程的退出状态复制到传递等待的地址;if none of the caller’s children has exited, wait waits for one to do so. If the caller has no children, wait immediately returns -1. If the parent doesn’t care about the exit status of a child, it can pass a 0 address to wait

在本示例中,输出行:

parent: child=1234

child: exiting

可能以两种顺序出现(甚至是混合的)出现,这取决于父级或子级是否先进行printf调用。子级退出后,父级的等待返回,导致父级打印

parent: child 1234 is done   

虽然子节点最初具有与父节点相同的内存内容,但父节点和子节点正在使用单独的内存和单独的寄存器执行,在其中一个变量中更改一个变量不会影响另一个变量。例如,当wait的返回值存储在父进程中的pid中时,它不会改变子进程中的变量pid。子代中的pid值仍然为零。

exec()系统调用将用从存储在文件系统中的文件中加载的新内存映像替换调用进程的内存,文件必须具有特定的格式,它指定文件的哪个部分包含指令,哪个部分是数据,在哪个指令上开始等等,Xv6使用了ELF格式,第3章将更详细地讨论该格式。通常,该文件是编译一个程序的源代码的结果。当exec成功时,它不会返回到调用程序;相反,从文件中加载的指令在ELF报头中声明的入口点开始执行。exec接受两个参数:包含可执行文件的文件的名称和一个字符串参数的数组。例如:

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

        xv6 shell使用上述调用来代表用户运行程序。外壳的主要结构很简单;见main(user/sh.c:146)。主循环用getcmd从用户那里读取一行输入数据。然后它调用fork,从而创建一个shell进程的副本。父呼叫wait(),当子程序运行该命令时。例如,如果用户向shell输入“echo hello”,runcmd将以“echo  hello”作为参数。runcmd(user/sh.c:55)运行实际的命令。对于“echohello”,它将调用exec(user/sh.c:79)。如果exec成功,那么子程序将从echo而不是runcmd中执行指令。在某个点上,echo将调用exit,这将导致父节点从主节点(user/sh.c:146)中的等待中返回。

        您可能想知道为什么fork和exec没有合并在一个调用中;稍后我们将看到shell在实现I/O重定向时利用了分离。为了避免创建一个重复的进程,然后立即替换它(用exec),操作内核通过使用虚拟内存技术(见第4.6节)来优化fork的实现。

        Xv6隐式地分配大部分用户空间内存: fork分配子级父内存副本所需的内存,而exec分配足够的内存来保存可执行文件。在运行时需要更多内存的进程(可能对于malloc)可以调用sbrk (n)将其数据内存增加n个字节;sbrk返回新内存的位置。      

1.2 I/O和文件描述符  

        文件描述符是一个小整数,表示进程可以从中读取或写入的内核管理对象。进程可以通过打开文件、目录或设备,或通过创建管道,或通过复制现有的描述符来获取文件描述符。为简单起见,我们通常将文件描述符所指的对象称为“文件”;文件描述符接口抽象化了文件、管道和设备之间的差异,使它们看起来都像字节流。我们将把输入和输出称为I/O。

        在内部,xv6内核使用文件描述符作为每个进程表的索引,因此每个进程都有一个从零开头的文件描述符的私有空间,按照惯例,进程从文件描述符0(标准输入)读取,将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)。正如我们将看到的,shell利用约定来实现I/O重定向和管道。shell确保它总是有三个文件描述符(user/sh.c:152),默认情况下是控制台的文件描述符。

        read and write系统调用读取和写字节来打开由文件描述符命名的文件,read (fd, buf, n)  从文件描述符fd中最多读取n个字节,将它们复制到buf中,并返回读取的字节数,引用文件的每个文件描述符都有与其关联的偏移量,读取从当前文件偏移量读取数据,然后通过读取字节读取的字节数:后续的读取将返回第一次读取返回的字节之后的字节。当没有更多的字节时,读取将返回0以表示文件的结束。

        write (fd, buf, n)  从buf写入n个字节到文件描述符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()系统调用释放一个文件描述符,使其可以被未来打开的管道、管道或dup系统调用重用(见下文)。新分配的文件描述符总是当前进程中编号最少的未使用的描述符。

        文件描述符和fork交互,使I/O重定向易于实现。fork复制父文件的文件描述符表及其内存,以便子文件以与父文件完全相同的打开文件开始。系统调用exec将替换调用进程的内存,但将保留其文件表。这种行为允许shell通过分叉、重新打开子程序中选择的文件描述符,然后调用exec来运行新程序来实现I/O重定向。下面是为cat < input.txt命令运行的shell运行的代码的简化版本:

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使用引用input.txt的文件描述符0(标准输入)来执行。父进程的文件描述符不会被此序列更改,因为它只修改了子进程的描述符。

        xv6 shell中的I/O重定向代码就是这样工作的(user/sh.c:83)。回想一下,在代码的此时,shell已经叉了子shell,runcmd将调用exec来加载新程序。

        用于读取或写入,或同时用于读取和写入,在文件不存在时创建文件,并将文件截断为零长度。要打开的第二个参数由一组表示为位的标志组成,这些标志控制打开的功能。可能的值在文件控制(fcntl)头(kernel/fcntl.h:1-5)中定义: O_RDONLY、O_WRONLY、O_RDWR、O_CREATE和O_TRUNC,它们指示打开以打开文件用于读取或写入,或同时用于读取和写入,在文件不存在时创建文件,并将文件截断为零长度。

        现在应该清楚为什么fork和exec是单独的调用是有用的:在两者之间,shell有机会在不干扰主外壳的I/O设置的情况下重定向子节点的I/O设置。人们可以想象一个假设的组合叉执行系统调用,但使用这样的调用进行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。父文件中的write(由于wait,只有在子文件完成后才运行)选择子文件删除的位置。这种行为有助于从shell命令序列中产生顺序输出,如(回声hello;回声世界)>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现有的文件,不存在的文件,> tmp1 2>&1。2>&1告诉shell给该命令一个文件描述符2,它是描述符1的副本。现有文件的名称和不存在文件的错误消息都将显示在文件tmp1中。xv6 shell不支持对错误文件描述符的I/O重定向,但是现在您知道如何实现它了。

        文件描述符是一个强大的抽象,因为它们隐藏了它们所连接的内容的细节:写入文件描述符1的进程可能是写入文件、写入控制台等设备或写入管道。

1.3管道

        管道(pipe)是作为一对文件描述符公开给进程的一个小的内核缓冲区,一个用于读取,另一个用于写入。将数据写入管道的一端,使该数据可以从管道的另一端读取。管道为流程提供了一种通信的方式。

        下面的示例代码使用连接到管道的读端的标准输入运行程序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(),使文件描述符零引用管道的读端,关闭p中的文件描述符,并调用exec以运行wc。当wc从其标准输入中读取时,它将从管道中读取。父端关闭管道的读取侧,写入管道,然后关闭写入侧。

        如果没有可用数据,则管道上的读取等待写入数据或关闭引用写入端的所有文件描述符;在后一种情况下,读取将返回0,就像已到达数据文件的末尾一样。读块,直到不可能为新数据到达是一个原因,重要的孩子关闭写的管道之前执行上面wc:如果wc的文件描述符称为写的管道,wc永远不会看到文件结束。

        xv6 shell以类似于上述代码(user/sh.c:101)的方式实现了诸如grepforcksh.c|wc-l这样的管道。子进程将创建一个管道,以将管道的左端与右端连接起来。然后,它在管道的左端调用fork和runcmd,在右端调用fork和runcmd,并等待两者都完成。管道的右端可能是一个命令,它本身包括一个管道(例如,一个| b | c),它本身分叉两个新的子进程(一个用于b,一个用于c)。因此,shell可以创建一个进程树。这棵树的叶子是命令,内部节点是等待左右子节点完成的过程。

        管道可能看起来并不比临时文件更强大:管道

   echo hello world | wc

         可以在没有管道实现

echo hello world >/tmp/xyz; wc </tmp/xyz

         在这种情况下,管道比临时文件至少有三个优势。首先,管道自动清理自己;通过文件重定向,shell必须小心删除/tmp/xyz。其次,管道可以通过任意长的数据流,而文件重定向需要磁盘上足够的可用空间来存储所有数据。第三,管道允许并行执行管道阶段,而文件方法要求第一个程序在第二个程序启动之前完成。    

1.4文件系统

        xv6文件系统提供了数据文件,其中包含未解释的字节数组,以及包含对数据文件和其他目录的命名引用的目录。这些目录形成了一个树,从一个称为root的特殊目录开始。 类似于/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,可以有多个名称,称为链接(links)。每个链接都由一个目录中的一个条目组成;该条目包含一个文件名和一个对inode的引用。inode保存有关文件的元数据,包括它的类型(文件或目录或设备)、它的长度、文件内容在磁盘上的位置以及到文件的链接数量。

        fstat()系统调用从文件描述符引用的inode中检索信息。它填充一个结构体stat,在stat.h(内核/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
};

        链接(link())系统调用创建另一个文件系统名,引用与现有文件相同的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");

        到最后一个代码序列,留下的inode和文件内容可访问为b。此外

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

是创建一个没有名称的临时inode,当进程关闭fd或退出时将被清理;

        Unix提供了可从shell中调用的文件实用程序,例如mkdir、ln和rm。这种设计允许任何人通过添加新的用户级程序来扩展命令行接口。事后看来,这个计划似乎是显而易见的,但是在Unix时设计的其他系统经常将这样的命令构建到shell中(并将shell构建到内核中)。

        一个例外是cd,它被内置在shell(user/sh.c:161)中。光盘必须更改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的系统调用接口。为了运行基本的Unix程序,一些人通过一些系统调用扩展了xv6和一个基本的Unix库。然而,与xv6相比,现代内核提供了更多的系统调用和更多类型的内核服务。例如,它们支持网络、窗口系统、用户级线程、许多设备的驱动程序等等。现代内核不断而快速地发展,并提供了POSIX之外的许多特性。

        Unix使用一组文件名和文件描述符接口,实现了对多种类型的资源(文件、目录和设备)的统一访问。这种想法可以扩展到更多种类的资源;一个很好的例子是Plan 9 [16],它将“资源是文件”的概念应用到网络、图形等方面。然而,大多数由unix派生的操作系统都没有遵循这条路线。

        文件系统和文件描述符一直是功能强大的抽象结构。即便如此,也还有其他针对操作系统接口的模型。Multics,Unix的前身,抽象了文件存储,使其看起来像内存,产生了一种非常不同的接口风格。多学设计的复杂性对Unix的设计者产生了直接影响,他们的目标是构建更简单的东西。

        Xv6没有提供用户或保护一个用户免受另一个用户侵害的概念;在Unix术语中,所有Xv6进程都以根用户的身份运行。

        这本书研究了xv6如何实现它的类似于Unix的界面,但这些思想和概念不仅仅适用于Unix。任何操作系统都必须将进程多路复用到底层硬件上,将进程彼此隔离,并为受控的进程间通信提供机制。在学习了xv6之后,您应该能够查看其他更复杂的操作系统,并查看这些系统中xv6背后的概念。

1.6练习

1.编写一个使用UNIX系统调用的程序,通过一对管道在两个进程之间“敲击”一个字节,每个方向对应一个。以每秒的交换量来衡量程序的表现。

第2章 操作系统组织机构        


        操作系统的一个关键要求是同时支持多个活动。例如,使用第1章中描述的系统调用接口,一个进程可以使用fork启动新的进程。操作系统必须在这些进程之间分时间共享计算机的资源。例如,即使进程比硬件cpu更多,操作系统也必须确保所有进程都有机会执行,操作系统还必须安排进程之间的隔离。也就是说,如果一个进程有bug和故障,它不应该影响不依赖于错误进程的进程。然而,完全隔离太强了,因为进程应该可以有意地进行交互;管道就是一个例子。因此,一个操作系统必须满足三个要求:多路复用、隔离和交互;

        本章概述了如何组织操作系统以实现这三个要求。事实证明,有很多方法可以做到这一点,但本文主要关注围绕一个单片内核的主流设计,这已被许多Unix操作系统所使用。本章还概述了xv6进程,它是xv6中的隔离单元,以及在xv6启动时创建第一个进程。

        Xv6运行在多核(这个“多核”文本是指多个共享内存但并行执行的cpu,每个cpu都有自己的寄存器集。本文有时使用术语多处理器作为多核的同义词,尽管多处理器也可以更具体地指具有几个不同处理器芯片的计算机。)RISC-V微处理器上,它的大部分低级功能(例如,其进程实现)是特定于RISC-V的。RISC-V是一个64位CPU,Xv6是用“LP64”C编写的,这意味着C编程语言中的长(L)和指针(P)是64位,但int是32位。这本书假设读者已经在某些架构上做了一些机器级的编程,并将在它们出现时介绍risc-v特定的想法,用户级的ISA [2]和特权体系结构的[3]文档是完整的规范。你也可以参考“RISC-V阅读器:一个开放的架构地图集”[15]。

        一个完整的计算机中的CPU被支持硬件包围,其中大部分是I/O接口的形式。Xv6是为由qemu的“-机器virt”选项模拟的支持硬件编写的。这包括RAM、包含引导代码的ROM、到用户键盘/屏幕的串行连接,以及用于存储的磁盘。

2.1抽象的物理资源

        当你遇到一个操作系统时,人们可能会问的第一个问题是,为什么要有它呢?也就是说,可以将图1.2中的系统调用实现为一个库,应用程序与它进行链接。在这个计划中,每个应用程序甚至可以根据自己的需要定制自己的库。应用程序可以直接与硬件资源交互,并以应用程序的最佳方式使用这些资源(例如,以实现高的或可预测的性能)。一些针对嵌入式设备或实时系统的操作系统就是以这种方式组织起来的。

        这种库方法的缺点是,如果有多个应用程序在运行,则这些应用程序必须行为良好。例如,每个应用程序必须定期放弃CPU,以便其他应用程序可以运行。如果所有应用程序相互信任且没有bug,那么这种协作分时方案可能是可以的。更典型的情况是,应用程序之间不信任彼此,并且存在bug,因此通常需要比合作方案提供的更强的隔离;

        为了实现强隔离,禁止应用程序直接访问敏感硬件资源而将资源抽象为服务是有帮助的。.例如,Unix应用程序仅通过文件系统的打开、读、写和关闭(open, read, write, and close)系统调用与存储器进行交互,而不是直接读取和写磁盘。这为应用程序提供了路径名的便利性,并允许操作系统(作为接口的实现者)来管理磁盘。即使隔离不是一个问题,有意交互(或只是希望避开彼此)的程序也可能为文件系统找到比直接使用磁盘更方便的抽象。

        类似地,Unix可以透明地在进程之间切换硬件cpu,并根据需要保存和恢复寄存器状态,这样应用程序就不必知道分时了。这种透明度允许操作系统共享cpu,即使某些应用程序处于无限循环中。

        另一个例子是,Unix进程使用exec来构建它们的内存映像,而不是直接与物理内存交互。这使得操作系统可以决定将进程放在内存中的位置;如果内存不足,操作系统甚至可能将进程的一些数据存储在磁盘上。exec还为用户提供了一个文件系统来存储可执行程序图像的便利。

        Unix进程之间的许多形式的交互都是通过文件描述符发生的。文件描述符不仅抽象化了许多细节(例如,管道或文件中的数据存储的位置),而且还以一种简化交互的方式定义了它们。例如,如果管道中的一个应用程序失败,内核将为管道中的下一个进程生成一个文件结束信号。

        图1.2中的系统调用接口经过精心设计,既提供了程序员的方便性和强隔离的可能性。Unix接口并不是抽象资源的唯一方法,但它已经被证明是一个很好的方法。

2.2用户模式、监控模式和系统调用

        强隔离要求应用程序和操作系统之间有一个硬的边界。如果应用程序犯了错误,我们不希望操作系统失败或其他应用程序失败,相反,操作系统应该能够清理失败的应用程序并继续运行其他应用程序。为了实现强隔离,操作系统必须安排应用程序不能修改(甚至读取)操作系统的数据结构和指令,并且应用程序不能访问其他进程的内存;

        cpu为强隔离提供了硬件支持。例如,RISC-V有三种模式,其中CPU可以执行指令:机器模式、监控模式和用户模式(machine mode, supervisor mode, and user mode),在机器模式下执行的指令具有完全的权限;CPU以机器模式启动。机器模式主要用于在引导期间设置计算机。Xv6在机器模式下执行几行,然后更改为监控模式。

        在监控模式下,允许CPU执行特权指令:例如,启用和禁用中断,读取和写入保存页表地址的寄存器等。如果用户模式下的应用程序试图执行特权指令,那么CPU不会执行指令,而是切换到监督模式,以便监督模式代码可以终止应用程序,因为它做了它不应该做的事情。第1章中的图1.1说明了这个组织。一个应用程序只能执行用户模式(user space)的指令(例如,添加数字等)。据说在用户空间中运行,而在监督模式下的软件也可以执行特权指令,据说在内核空间(kernel space)中运行。在内核空间(或监控模式)中运行的软件称为内核。

        想要调用内核函数的应用程序(例如,xv6中的读取系统调用)必须转换到内核;应用程序不能直接调用内核函数。CPU提供了一个特殊的指令,它将CPU从用户模式切换到监控模式,并在内核指定的入口点进入内核。(RISC-V为此目的提供了所有的指令。)一旦CPU切换到监控模式,内核就可以验证系统调用的参数(例如,检查传递给系统调用的地址是否是应用程序内存的一部分),决定是否允许应用程序执行请求的操作(例如,检查应用程序是否允许写入指定的文件),然后拒绝或执行它。内核控制过渡到监督模式的入口点很重要;如果应用程序可以决定内核入口点,例如,恶意应用程序可以在跳过参数验证的点上输入内核。

2.3内核组织

        一个关键的设计问题是,操作系统的哪些部分应该在监控模式下运行。一种可能性是,整个操作系统都驻留在内核中,因此所有系统调用的实现都以监控模式运行。这个组织被称为单片内核;

        在这个组织中,整个操作系统由一个具有完整硬件特权的单个程序组成。这个组织很方便,因为操作系统设计者不需要决定操作系统的哪些部分不需要完整的硬件特权,此外,操作系统的不同部分也更容易进行合作。例如,操作系统可能具有一个可以由文件系统和虚拟内存系统共享的缓冲区缓存。

        整体组织的一个缺点是,操作系统的不同部分之间的交互通常是复杂的(正如我们将在本文的其余部分中看到的),因此它是操作系统开发人员很容易犯错误。在单片内核中,错误是致命的,因为在监督模式下的错误通常会导致内核失败。如果内核失败,计算机将停止工作,因此所有应用程序也会失败。计算机必须重新启动才能重新启动。

        为了减少内核中出错的风险,操作系统设计人员可以最小化在监控模式下运行的操作系统代码的数量,并在用户模式下执行大部分操作系统。这种内核组织被称为微内核。

        图2.1说明了这个微内核的设计。在图中,文件系统作为用户级进程运行。作为进程运行的操作系统服务称为服务器。为了允许应用程序与文件服务器交互,内核提供了一种进程间通信机制,将消息从一个用户模式进程到另一个进程发送消息。例如,如果像shell这样的应用程序想要读取或写入一个文件,它会向文件服务器发送一条消息并等待响应;

        在微内核中,内核接口由一些低级功能组成,可用于启动应用程序、发送消息、访问设备硬件等。这种组织允许内核相对简单,因为大多数操作系统都驻留在用户级服务器中。

        在现实世界中,单片内核和微内核都很受欢迎。许多Unix内核都是单片的。例如,Linux有一个单片内核,尽管一些操作系统功能运行作为用户级服务器(例如,window系统)。Linux为操作系统密集型的应用程序提供了高性能,部分原因是内核的子系统可以紧密集成。

        诸如Minix、L4和QNX等操作系统被组织成一个带有服务器的微内核,并已在嵌入式设置中广泛部署。L4的一个变体,seL4,是足够小的,它已经被验证为内存安全和其他安全属性[8]。

        在操作系统的开发人员中,关于哪种组织更好有很多争论,而且也没有确凿的证据,此外,它在很大程度上取决于“更好”的含义:更快的性能、更小的代码大小、内核的可靠性、完整的操作系统(包括用户级服务)的可靠性等等。

        还有一些实际的考虑,可能比哪个组织的问题更重要。一些操作系统有一个微内核,但出于性能原因,在内核空间中运行一些用户级服务,一些操作系统有单片内核,因为它们就是这样开始的,而且几乎没有动力转移到纯微内核组织,因为新特性可能比重写现有的操作系统以适应微内核设计更重要。

        从这本书的角度来看,微内核和单片操作系统有许多关键的想法。他们实现系统调用,他们使用页表,他们处理中断,他们支持进程,它们使用锁进行并发控制,它们实现文件系统等等。这本书着重于这些核心思想。

        与大多数Unix操作系统一样,Xv6被实现为一个单片内核。因此,xv6内核接口对应于操作系统接口,并且内核实现了完整的操作系统。由于xv6不提供很多服务,所以它的内核比一些微内核要小,但在概念上讲,xv6是单片的。

2.4代码:xv6组织

        xv6内核源代码在内核/子目录中。按照模块化的粗略概念,将源代码划分为文件;图2.2列出了这些文件。模块间接口的定义是在defs.h (kernel/defs.h)

    File                   Description

bio.c             Disk block cache for the file system.文件系统的磁盘块高速缓存。
console.c         Connect to the user keyboard and screen.连接到用户的键盘和屏幕
entry.S           Very first boot instructions.  very先启动指令。
exec.c            exec() system call.
file.c            File descriptor support.文件描述符支持。
fs.c              File system.文件系统。
kalloc.c          Physical page allocator.  物理页面分配器
kernelvec.S       Handle traps from kernel.  处理内核中的陷阱。
log.c             File system logging and crash recovery.文件系统日志记录和崩溃恢复。
main.c            Control initialization of other modules during boot.控制引导过程中其他模块的初始化。
pipe.c            Pipes.
plic.c            RISC-V interrupt controller.RISC-V中断控制器。
printf.c          Formatted output to the console.格式化输出到控制台
proc.c            Processes and scheduling. 过程和调度。
sleeplock.c       Locks that yield the CPU.产生CPU的锁
spinlock.c        Locks that don’t yield the CPU.不产生CPU的锁。
start.c           Early machine-mode boot code.早期机器模式引导代码。
string.c          C string and byte-array library.C字符串和字节数组库
swtch.S           Thread switching.线程切换。
syscall.c         Dispatch system calls to handling function.调度系统调用到处理功能
sysfile.c         File-related system calls.与文件相关的系统调用。
sysproc.c         Process-related system calls.与进程相关的系统调用。
trampoline.S      Assembly code to switch between user and kernel.在用户和内核之间切换的汇 
                                                                   编代码。
trap.c            C code to handle and return from traps and interrupts.C代码来处理和返回
                                                                        从陷阱和中断
uart.c            Serial-port console device driver.串口控制台设备驱动程序。
virtio_disk.c     Disk device driver.磁盘设备驱动程序
vm.c              Manage page tables and address spaces.管理页表和地址空间

        Figure 2.2: Xv6 kernel source files.图2.2:Xv6内核源文件

2.5工艺概述

        xv6中的隔离单元(与在其他Unix操作系统中一样)是一个过程。进程抽象可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。它还可以防止进程破坏内核本身,因此进程就不能破坏内核的隔离机制,内核必须小心地实现进程抽象,因为一个有错误或恶意的应用程序可能会欺骗内核或硬件做一些不好的事情(例如,绕过隔离)。内核用于实现进程的机制包括用户/管理员模式标志、地址空间和线程的时间切片。

        为了帮助强制隔离,流程抽象给程序提供了一种认为它有自己的私有机器的错觉。进程为程序提供了专用内存系统或地址空间,其他进程无法读写..一个进程还为程序提供了一个似乎是它自己的CPU来执行程序的指令;

        Xv6使用页表(由硬件实现)给每个进程提供自己的地址空间。RISC-V页表将(或“映射”)虚拟地址(RISC-V指令操作的地址)转换为物理地址(CPU发送到主存的地址)。

        Xv6为每个进程的地址空间维护一个单独的页表,如图2.3所示,地址空间包括从虚拟地址0开始的进程的用户内存。指令首先出现,然后是全局变量,然后是堆栈,最后是一个“堆”区域(对于malloc),malloc可以根据需要进行扩展,有许多因素限制了进程地址空间的最大大小:RISC-V上的指针是64位宽;在页面表中查找虚拟地址时,硬件只使用低39位;xv6只使用这39位中的38位。.因此,最大地址为2的38次方−1=0x3 fffffffff,是MAXVA(kernel/riscv.h:378)。在地址空间的顶部xv6放置一个蹦床页面(4096字节)和一个弹帧页面。Xv6使用这两个页面转换到内核和返回;蹦床页面包含转换进出内核的代码,陷阱帧是内核保存进程的用户注册的地方,正如第4章解释的那样;

        xv6内核为每个进程维护许多状态片段,并将这些状态片段收集到一个结构过程(kernel/proc.h:85)中。进程中最重要的内核状态部分是它的页表、内核堆栈和运行状态。我们将使用符号p->xxx来引用proc结构的元素;例如,p->pagetable是一个指向进程页面表的指针。

        每个进程都有一个控制线程(或简称线程),它保存执行进程所需的状态。在任何给定的时间,线程可能在CPU上执行,或者挂起(不执行,但能够在将来恢复执行)。要在进程之间切换CPU,内核将挂起当前在该CPU上运行的线程并保存其状态,并恢复另一个进程以前挂起的线程的状态。线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的堆栈上。每个进程都有两个堆栈:一个用户堆栈和一个内核堆栈(p->kstack)。当进程正在执行用户指令时,只使用其用户堆栈,并且其内核堆栈为空。当进程进入内核时(对于系统调用或中断),内核代码在进程的内核堆栈上执行;当进程在内核中时,它的用户堆栈仍然包含已保存的数据,但没有被主动使用。进程的线程在主动使用其用户堆栈和内核堆栈之间进行切换。内核堆栈是独立的(并且不受用户代码的保护),因此即使进程破坏了其用户堆栈,内核也可以执行。

        一个进程可以通过执行RISC-V ecall指令来进行系统调用。此指令提高了硬件特权级别,并将程序计数器更改为一个由内核定义的入口点。入口点上的代码切换到进程的内核堆栈,并执行实现系统调用的内核指令。当系统调用完成时,内核切换回用户堆栈,并通过调用sret指令返回到用户空间,这降低了硬件特权级别,并在系统调用指令之后立即恢复执行用户指令。进程的线程可以在内核中“阻塞”以等待I/O,并在I/O完成后恢复到它停止的位置。

        p->state 指示进程是已分配、准备运行、当前在CPU上运行、等待I/O还是退出。

        p->pagetable 以RISC-V硬件所期望的格式保存进程的页面表。Xv6使分页硬件在用户空间中执行分页进程时使用进程的p->pagetable。进程的页面表还用作分配给存储进程内存的物理页面地址的记录。

        总之,一个过程结合了两个设计思想:一个地址空间给一个进程自己的内存的错觉,另一个线程给这个进程自己的CPU的错觉。在xv6中,一个进程由一个地址空间和一个线程组成。在实际的操作系统中,一个进程可能有多个线程来利用多个cpu。

2.6代码:启动xv6,第一个进程和系统调用

        为了使xv6更加具体,我们将概述内核是如何启动和运行第一个进程的。接下来的章节将更详细地描述在本概述中显示的机制。

        当RISC-V计算机开机时,它会初始化自己并运行一个存储在只读存储器中的引导加载器。引导加载程序将xv6内核加载到内存中。然后,在机器模式下,CPU从_入口开始执行xv6。S:7).RISC-V开始时禁用了分页硬件:虚拟地址直接映射到物理地址。

        加载器将xv6内核加载到物理地址0x80000000的内存中。它将内核放置在0x800000000而不是0x0的原因是因为地址范围0x0:0x80000000包含I/O设备。

        在_entry中的指令设置了一个堆栈,以便xv6可以运行C代码。Xv6在文件start.c(kernel/start.c:11)中声明了初始堆栈、堆栈0的空间。at _entry的代码加载堆栈指针寄存器sp与地址堆栈0,堆栈的顶部+4096,因为RISC-V上的堆栈会向下增长。现在内核有了一个堆栈,_entry在开始时调用C代码(kernel/start.c:15)。

        功能启动start  一些只在机器模式下允许的配置,然后切换到监控模式。要进入监控模式,RISC-V提供指令mret。此指令通常用于从主管模式调用返回到机器模式。要进入监控(enter)模式,RISC-V提供指令mret。此指令通常用于从主管模式调用返回到机器模式。start不是从这样一个调用,但设置好像:它设置前特权模式主管注册m状态,它设置返回地址main通过main的地址写入注册mepc,禁用虚拟地址转换主管模式写入0到页表寄存器satp,并委托所有中断和异常主管模式。

        在跳入监控模式之前,start 执行另一项任务:它为时钟芯片编程,以产生计时器中断,有了这个家务事,通过调用mret开始“returns”到主管模式。这将导致程序计数器更改为main(kernel/main.c:11),即以前存储在mepc中的地址。

        在main(kernel/main.c:11)初始化几个设备和子系统之后,它通过userinit(kernel/proc.c:233)来创建第一个进程。第一个进程执行一个用RISC-V程序集编写的小程序,它在xv6中进行第一个系统调用。initcode.S (user/initcode.S:3)加载exec系统调用的号码,SYS_EXEC(kernel/syscall.h:8),到注册a7,然后调用ecall重新进入内核。

        内核使用在系统共享中注册a7(kernel/syscall.c:132)中的数字来调用所需的系统调用。系统调用表(kernel/syscall.c:107)将SYS_EXEC映射到内核调用的函数SYS_EXEC。正如我们在第1章中所看到的,exec用一个新程序(在本例中是/init)替换了当前进程的内存和寄存器。

        当内核完成exec后,它将返回到/init进程中的用户空间。init(user/init.c:15)会根据需要创建一个新的控制台设备文件,然后将其作为文件描述符0、1和2打开。然后它会在控制台上启动一个外壳。系统启动。

2.7安全模型

        您可能想知道操作系统是如何处理有问题或恶意代码的。因为处理恶意比处理意外的漏洞更困难,所以主要关注提供恶意安全是合理的。下面是一个关于操作系统设计中典型的安全假设和目标的高级视图。

        操作系统必须假定进程的用户级代码将尽力破坏内核或其他进程。用户代码可以尝试取消引用其允许的地址空间之外的指针;它可以尝试执行任何RISC-V指令,甚至是那些不适合用户代码的指令;它可以尝试读取和写任何RISC-V控制寄存器;它可能会尝试直接访问设备硬件;它可能会将聪明的值传递给系统调用,试图欺骗内核崩溃或做一些愚蠢的事情,.内核的目标是限制每个用户进程,以便它所能做的就是读/写/执行它自己的用户内存,使用32个通用的RISC-V寄存器,并以系统调用所允许的方式影响内核和其他进程,内核必须阻止任何其他操作。这通常是内核设计中的一个绝对要求。

        人们对内核自己的代码的期望是完全不同的。内核代码被认为是由善意和谨慎的程序员编写的,内核代码应该是没有漏洞的,当然也不包含任何恶意的内容。这个假设影响了我们分析内核代码的方式。例如,有许多内部内核函数(例如,自旋锁),如果内核代码使用错误,将会导致严重的问题。当检查任何特定的内核代码时,我们都希望说服自己它的行为是正确的。然而,我们假设内核代码通常是正确编写的,并且遵循有关使用内核自己的函数和数据结构的所有规则。在硬件级别,RISC-V CPU、RAM、磁盘等。假定按照文档中所宣传的那样操作,没有硬件错误。

        当然,在现实生活中,事情并不是那么简单。通过消耗受内核保护的资源——磁盘空间、CPU时间、进程表插槽等,很难防止聪明的用户代码使系统无法使用(或导致它陷入恐慌)。通常不可能编写无漏洞的内核代码或设计无漏洞的硬件;如果恶意用户代码的编写者知道内核或硬件错误,他们将利用这些错误。即使在成熟的、广泛使用的内核中,如Linux,人们也会不断发现新的漏洞。值得在内核中设计保护措施,以防止它可能存在bug:断言、类型检查、堆栈保护页面等等。最后,用户代码和内核代码之间的区别有时是模糊的:一些具有特权的用户级进程可能提供基本的服务,并有效地成为操作系统的一部分,并且在一些操作系统中,享有特权的用户代码可以在内核中插入新代码(如Linux的可加载内核模块)

2.8真实世界

        大多数操作系统都采用了流程的概念,而且大多数流程看起来类似于xv6的流程。然而,现代操作系统支持一个进程中的多个线程,以允许单个进程利用多个cpu。在一个进程中支持多个线程涉及到大量xv6没有的机制,通常包括接口更改(例如,Linux的克隆,fork的变体),以控制进程线程共享的哪些方面。

2.9练习1。向xv6添加一个系统调用,返回可用内存量。

第3章 页面表

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐