Csail.mit 发布的文章

简介

对于最后的项目,你有两个选择:

  • 继续使用你自己的 JOS 内核并做 实验 6,包括实验 6 中的一个挑战问题。(你可以随意地、以任何有趣的方式去扩展实验 6 或者 JOS 的任何部分,当然了,这不是课程规定的。)
  • 在一个、二个或三个人组成的团队中,你选择去做一个涉及了你的 JOS 的项目。这个项目必须是涉及到与实验 6 相同或更大的领域(如果你是团队中的一员)。

目标是为了获得乐趣或探索更高级的 O/S 的话题;你不需要做最新的研究。

如果你做了你自己的项目,我们将根据你的工作量有多少、你的设计有多优雅、你的解释有多高明、以及你的解决方案多么有趣或多有创意来为你打分。我们知道时间有限,因此也不期望你能在本学期结束之前重写 Linux。要确保你的目标是合理的;合理地设定一个绝对可以实现的最小目标(即:控制你的实验 6 的规模),如果进展顺利,可以设定一个更大的目标。

如果你做了实验 6,我们将根据你是否通过了测试和挑战练习来为你打分。

交付期限

11 月 3 日:Piazza 讨论和 1、2、或 3 年级组选择(根据你的最终选择来定)。使用在 Piazza 上的 lab7 标记/目录。在 Piazza 上的文章评论区与其它人计论想法。使用这些文章帮你去找到有类似想法的其它学生一起组建一个小组。课程的教学人员将在 Piazza 上为你的项目想法给出反馈;如果你想得到更详细的反馈,可以与我们单独讨论。

.

11 月 9 日:在 提交网站 上提交一个提议,只需要一到两个段落就可以。提议要包括你的小组成员列表、你的计划、以及明确的设计和实现打算。(如果你做实验 6,就不用做这个了)

.

12 月 7 日:和你的简短报告一起提交源代码。将你的报告放在与名为 “README.pdf” 的文件相同的目录下。由于你只是这个实验任务小组中的一员,你可能需要去使用 git 在小组成员之间共享你的项目代码。因此你需要去决定哪些源代码将作为你的小组项目的共享起始点。一定要为你的最终项目去创建一个分支,并且命名为 lab7。(如果你做了实验 6,就按实验 6 的提交要求做即可。)

.

12 月 11 日这一周:简短的课堂演示。为你的 JOS 项目准备一个简短的课堂演示。为了你的项目演示,我们将提供一个投影仪。根据小组数量和每个小组选择的项目类型,我们可能会限制总的演讲数,并且有些小组可能最终没有机会上台演示。

.

12 月 11 日这一周:助教们验收。向助教演示你的项目,因此我们可能会提问一些问题,去了解你所做的一些细节。

项目想法

如果你不做实验 6,下面是一个启迪你的想法列表。但是,你应该大胆地去实现你自己的想法。其中一些想法只是一个开端,并且本身不在实验 6 的领域内,并且其它的可能是在更大的领域中。

  • 使用 x86 虚拟机支持 去构建一个能够运行多个访客系统(比如,多个 JOS 实例)的虚拟机监视器。
  • 使用 Intel SGX 硬件保护机制做一些有用的事情。这是使用 Intel SGX 的最新的论文
  • 让 JOS 文件系统支持写入、文件创建、为持久性使用日志、等等。或许你可以从 Linux EXT3 上找到一些启示。
  • 软更新WAFL、ZFS、或其它较高级的文件系统上找到一些使用文件系统的想法。
  • 给一个文件系统添加快照功能,以便于用户能够查看过去的多个时间点上的文件系统。为了降低空间使用量,你或许要使用一些写时复制技术。
  • 使用分页去提供实时共享的内存,来构建一个 分布式的共享内存(DSM)系统,以便于你在一个机器集群上运行多线程的共享内存的并行程序。当一个线程尝试去访问位于另外一个机器上的页时,页故障将给 DSM 系统提供一个机会,让它基于网络去从当前存储这个页的任意一台机器上获取这个页。
  • 允许进程在机器之间基于网络进行迁移。你将需要做一些关于一个进程状态的多个片段方面的事情,但是由于在 JOS 中许多状态是在用户空间中,它或许从 Linux 上的进程迁移要容易一些。
  • 在 JOS 中实现 分页 到磁盘,这样那个进程使用的内存就可以大于真实的内存。使用交换空间去扩展你的内存。
  • 为 JOS 实现文件的 mmap()
  • 使用 xfi 将一个进程的代码沙箱化。
  • 支持 x86 的 2MB 或 4MB 的页大小)。
  • 修改 JOS 让内核支持进程内的线程。从查看 课堂上的 uthread 任务 去开始。实现调度器触发将是实现这个项目的一种方式。
  • 在 JOS 的内核中或文件系统中(实现多线程之后),使用细粒度锁或无锁并发。Linux 内核使用 读复制更新 去执行无需上锁的读取操作。通过在 JOS 中实现它来探索 RCU,并使用它去支持无锁读取的名称缓存。
  • 实现 外内核论文 中的想法。例如包过滤器。
  • 使 JOS 拥有软实时行为。用它来辨识一些应用程序时非常有用。
  • 使 JOS 运行在 64 位 CPU 上。这包括重设计虚拟内存让它使用 4 级页表。有关这方面的文档,请查看 参考页
  • 移植 JOS 到一个不同的微处理器。这个 osdev wiki 或许对你有帮助。
  • 为 JOS 系统增加一个“窗口”系统,包括图形驱动和鼠标。有关这方面的文档,请查看 参考页sqrt(x) 就是一个 JOS “窗口” 系统的示例。
  • 在 JOS 中实现 dune,以提供特权硬件指令给用户空间应用程序。
  • 写一个用户级调试器,添加类似跟踪的功能;硬件寄存器概要(即:Oprofile);调用跟踪等等。
  • 为(静态的)Linux 可运行程序做一个二进制仿真。

via: https://pdos.csail.mit.edu/6.828/2018/labs/lab7/

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

简介

这个实验是默认你能够自己完成的最终项目。

现在你已经有了一个文件系统,一个典型的操作系统都应该有一个网络栈。在本实验中,你将继续为一个网卡去写一个驱动程序。这个网卡基于 Intel 82540EM 芯片,也就是众所周知的 E1000 芯片。

预备知识

使用 Git 去提交你的实验 5 的源代码(如果还没有提交的话),获取课程仓库的最新版本,然后创建一个名为 lab6 的本地分支,它跟踪我们的远程分支 origin/lab6

athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'my solution to lab5'
nothing to commit (working directory clean)
athena% git pull
Already up-to-date.
athena% git checkout -b lab6 origin/lab6
Branch lab6 set up to track remote branch refs/remotes/origin/lab6.
Switched to a new branch "lab6"
athena% git merge lab5
Merge made by recursive.
 fs/fs.c |   42 +++++++++++++++++++
 1 files changed, 42 insertions(+), 0 deletions(-)
athena%

然后,仅有网卡驱动程序并不能够让你的操作系统接入互联网。在新的实验 6 的代码中,我们为你提供了网络栈和一个网络服务器。与以前的实验一样,使用 git 去拉取这个实验的代码,合并到你自己的代码中,并去浏览新的 net/ 目录中的内容,以及在 kern/ 中的新文件。

除了写这个驱动程序以外,你还需要去创建一个访问你的驱动程序的系统调用。你将要去实现那些在网络服务器中缺失的代码,以便于在网络栈和你的驱动程序之间传输包。你还需要通过完成一个 web 服务器来将所有的东西连接到一起。你的新 web 服务器还需要你的文件系统来提供所需要的文件。

大部分的内核设备驱动程序代码都需要你自己去从头开始编写。本实验提供的指导比起前面的实验要少一些:没有框架文件、没有现成的系统调用接口、并且很多设计都由你自己决定。因此,我们建议你在开始任何单独练习之前,阅读全部的编写任务。许多学生都反应这个实验比前面的实验都难,因此请根据你的实际情况计划你的时间。

实验要求

与以前一样,你需要做实验中全部的常规练习和至少一个挑战问题。在实验中写出你的详细答案,并将挑战问题的方案描述写入到 answers-lab6.txt 文件中。

QEMU 的虚拟网络

我们将使用 QEMU 的用户模式网络栈,因为它不需要以管理员权限运行。QEMU 的文档的这里有更多关于用户网络的内容。我们更新后的 makefile 启用了 QEMU 的用户模式网络栈和虚拟的 E1000 网卡。

缺省情况下,QEMU 提供一个运行在 IP 地址 10.2.2.2 上的虚拟路由器,它给 JOS 分配的 IP 地址是 10.0.2.15。为了简单起见,我们在 net/ns.h 中将这些缺省值硬编码到网络服务器上。

虽然 QEMU 的虚拟网络允许 JOS 随意连接互联网,但 JOS 的 10.0.2.15 的地址并不能在 QEMU 中的虚拟网络之外使用(也就是说,QEMU 还得做一个 NAT),因此我们并不能直接连接到 JOS 上运行的服务器,即便是从运行 QEMU 的主机上连接也不行。为解决这个问题,我们配置 QEMU 在主机的某些端口上运行一个服务器,这个服务器简单地连接到 JOS 中的一些端口上,并在你的真实主机和虚拟网络之间传递数据。

你将在端口 7(echo)和端口 80(http)上运行 JOS,为避免在共享的 Athena 机器上发生冲突,makefile 将为这些端口基于你的用户 ID 来生成转发端口。你可以运行 make which-ports 去找出是哪个 QEMU 端口转发到你的开发主机上。为方便起见,makefile 也提供 make nc-7make nc-80,它允许你在终端上直接与运行这些端口的服务器去交互。(这些目标仅能连接到一个运行中的 QEMU 实例上;你必须分别去启动它自己的 QEMU)

包检查

makefile 也可以配置 QEMU 的网络栈去记录所有的入站和出站数据包,并将它保存到你的实验目录中的 qemu.pcap 文件中。

使用 tcpdump 命令去获取一个捕获的 hex/ASCII 包转储:

tcpdump -XXnr qemu.pcap

或者,你可以使用 Wireshark 以图形化界面去检查 pcap 文件。Wireshark 也知道如何去解码和检查成百上千的网络协议。如果你在 Athena 上,你可以使用 Wireshark 的前辈:ethereal,它运行在加锁的保密互联网协议网络中。

调试 E1000

我们非常幸运能够去使用仿真硬件。由于 E1000 是在软件中运行的,仿真的 E1000 能够给我们提供一个人类可读格式的报告、它的内部状态以及它遇到的任何问题。通常情况下,对祼机上做驱动程序开发的人来说,这是非常难能可贵的。

E1000 能够产生一些调试输出,因此你可以去打开一个专门的日志通道。其中一些对你有用的通道如下:

标志含义
tx包发送日志
txerr包发送错误日志
rx到 RCTL 的日志通道
rxfilter入站包过滤日志
rxerr接收错误日志
unknown未知寄存器的读写日志
eeprom读取 EEPROM 的日志
interrupt中断和中断寄存器变更日志

例如,你可以使用 make E1000_DEBUG=tx,txerr 去打开 “tx” 和 “txerr” 日志功能。

注意:E1000_DEBUG 标志仅能在打了 6.828 补丁的 QEMU 版本上工作。

你可以使用软件去仿真硬件,来做进一步的调试工作。如果你使用它时卡壳了,不明白为什么 E1000 没有如你预期那样响应你,你可以查看在 hw/e1000.c 中的 QEMU 的 E1000 实现。

网络服务器

从头开始写一个网络栈是很困难的。因此我们将使用 lwIP,它是一个开源的、轻量级 TCP/IP 协议套件,它能做包括一个网络栈在内的很多事情。你能在 这里 找到很多关于 lwIP 的信息。在这个任务中,对我们而言,lwIP 就是一个实现了一个 BSD 套接字接口和拥有一个包输入端口和包输出端口的黑盒子。

一个网络服务器其实就是一个有以下四个环境的混合体:

  • 核心网络服务器环境(包括套接字调用派发器和 lwIP)
  • 输入环境
  • 输出环境
  • 定时器环境

下图展示了各个环境和它们之间的关系。下图展示了包括设备驱动的整个系统,我们将在后面详细讲到它。在本实验中,你将去实现图中绿色高亮的部分。

Network server architecture

核心网络服务器环境

核心网络服务器环境由套接字调用派发器和 lwIP 自身组成的。套接字调用派发器就像一个文件服务器一样。用户环境使用 stubs(可以在 lib/nsipc.c 中找到它)去发送 IPC 消息到核心网络服务器环境。如果你看了 lib/nsipc.c,你就会发现核心网络服务器与我们创建的文件服务器 i386_init 的工作方式是一样的,i386_init 是使用 NSTYPENS 创建的 NS 环境,因此我们检查 envs,去查找这个特殊的环境类型。对于每个用户环境的 IPC,网络服务器中的派发器将调用相应的、由 lwIP 提供的、代表用户的 BSD 套接字接口函数。

普通用户环境不能直接使用 nsipc_* 调用。而是通过在 lib/sockets.c 中的函数来使用它们,这些函数提供了基于文件描述符的套接字 API。以这种方式,用户环境通过文件描述符来引用套接字,就像它们引用磁盘上的文件一样。一些操作(connectaccept 等等)是特定于套接字的,但 readwriteclose 是通过 lib/fd.c 中一般的文件描述符设备派发代码的。就像文件服务器对所有的打开的文件维护唯一的内部 ID 一样,lwIP 也为所有的打开的套接字生成唯一的 ID。不论是文件服务器还是网络服务器,我们都使用存储在 struct Fd 中的信息去映射每个环境的文件描述符到这些唯一的 ID 空间上。

尽管看起来文件服务器的网络服务器的 IPC 派发器行为是一样的,但它们之间还有很重要的差别。BSD 套接字调用(像 acceptrecv)能够无限期阻塞。如果派发器让 lwIP 去执行其中一个调用阻塞,派发器也将被阻塞,并且在整个系统中,同一时间只能有一个未完成的网络调用。由于这种情况是无法接受的,所以网络服务器使用用户级线程以避免阻塞整个服务器环境。对于每个入站 IPC 消息,派发器将创建一个线程,然后在新创建的线程上来处理请求。如果线程被阻塞,那么只有那个线程被置入休眠状态,而其它线程仍然处于运行中。

除了核心网络环境外,还有三个辅助环境。核心网络服务器环境除了接收来自用户应用程序的消息之外,它的派发器也接收来自输入环境和定时器环境的消息。

输出环境

在为用户环境套接字调用提供服务时,lwIP 将为网卡生成用于发送的包。lwIP 将使用 NSREQ_OUTPUT 去发送在 IPC 消息页参数中附加了包的 IPC 消息。输出环境负责接收这些消息,并通过你稍后创建的系统调用接口来转发这些包到设备驱动程序上。

输入环境

网卡接收到的包需要传递到 lwIP 中。输入环境将每个由设备驱动程序接收到的包拉进内核空间(使用你将要实现的内核系统调用),并使用 NSREQ_INPUT IPC 消息将这些包发送到核心网络服务器环境。

包输入功能是独立于核心网络环境的,因为在 JOS 上同时实现接收 IPC 消息并从设备驱动程序中查询或等待包有点困难。我们在 JOS 中没有实现 select 系统调用,这是一个允许环境去监视多个输入源以识别准备处理哪个输入的系统调用。

如果你查看了 net/input.cnet/output.c,你将会看到在它们中都需要去实现那个系统调用。这主要是因为实现它要依赖你的系统调用接口。在你实现了驱动程序和系统调用接口之后,你将要为这两个辅助环境写这个代码。

定时器环境

定时器环境周期性发送 NSREQ_TIMER 类型的消息到核心服务器,以提醒它那个定时器已过期。lwIP 使用来自线程的定时器消息来实现各种网络超时。

Part A:初始化和发送包

你的内核还没有一个时间概念,因此我们需要去添加它。这里有一个由硬件产生的每 10 ms 一次的时钟中断。每收到一个时钟中断,我们将增加一个变量值,以表示时间已过去 10 ms。它在 kern/time.c 中已实现,但还没有完全集成到你的内核中。

练习 1、为 kern/trap.c 中的每个时钟中断增加一个到 time_tick 的调用。实现 sys_time_msec 并增加到 kern/syscall.c 中的 syscall,以便于用户空间能够访问时间。

使用 make INIT_CFLAGS=-DTEST_NO_NS run-testtime 去测试你的代码。你应该会看到环境计数从 5 开始以 1 秒为间隔减少。-DTEST_NO_NS 参数禁止在网络服务器环境上启动,因为在当前它将导致 JOS 崩溃。

网卡

写驱动程序要求你必须深入了解硬件和软件中的接口。本实验将给你提供一个如何使用 E1000 接口的高度概括的文档,但是你在写驱动程序时还需要大量去查询 Intel 的手册。

练习 2、为开发 E1000 驱动,去浏览 Intel 的 软件开发者手册。这个手册涵盖了几个与以太网控制器紧密相关的东西。QEMU 仿真了 82540EM。

现在,你应该去浏览第 2 章,以对设备获得一个整体概念。写驱动程序时,你需要熟悉第 3 到 14 章,以及 4.1(不包括 4.1 的子节)。你也应该去参考第 13 章。其它章涵盖了 E1000 的组件,你的驱动程序并不与这些组件去交互。现在你不用担心过多细节的东西;只需要了解文档的整体结构,以便于你后面需要时容易查找。

在阅读手册时,记住,E1000 是一个拥有很多高级特性的很复杂的设备,一个能让 E1000 工作的驱动程序仅需要它一小部分的特性和 NIC 提供的接口即可。仔细考虑一下,如何使用最简单的方式去使用网卡的接口。我们强烈推荐你在使用高级特性之前,只去写一个基本的、能够让网卡工作的驱动程序即可。

PCI 接口

E1000 是一个 PCI 设备,也就是说它是插到主板的 PCI 总线插槽上的。PCI 总线有地址、数据、和中断线,并且 PCI 总线允许 CPU 与 PCI 设备通讯,以及 PCI 设备去读取和写入内存。一个 PCI 设备在它能够被使用之前,需要先发现它并进行初始化。发现 PCI 设备是 PCI 总线查找已安装设备的过程。初始化是分配 I/O 和内存空间、以及协商设备所使用的 IRQ 线的过程。

我们在 kern/pci.c 中已经为你提供了使用 PCI 的代码。PCI 初始化是在引导期间执行的,PCI 代码遍历PCI 总线来查找设备。当它找到一个设备时,它读取它的供应商 ID 和设备 ID,然后使用这两个值作为关键字去搜索 pci_attach_vendor 数组。这个数组是由像下面这样的 struct pci_driver 条目组成:

struct pci_driver {
    uint32_t key1, key2;
    int (*attachfn) (struct pci_func *pcif);
};

如果发现的设备的供应商 ID 和设备 ID 与数组中条目匹配,那么 PCI 代码将调用那个条目的 attachfn 去执行设备初始化。(设备也可以按类别识别,那是通过 kern/pci.c 中其它的驱动程序表来实现的。)

绑定函数是传递一个 PCI 函数 去初始化。一个 PCI 卡能够发布多个函数,虽然这个 E1000 仅发布了一个。下面是在 JOS 中如何去表示一个 PCI 函数:

struct pci_func {
    struct pci_bus *bus;

    uint32_t dev;
    uint32_t func;

    uint32_t dev_id;
    uint32_t dev_class;

    uint32_t reg_base[6];
    uint32_t reg_size[6];
    uint8_t irq_line;
};

上面的结构反映了在 Intel 开发者手册里第 4.1 节的表 4-1 中找到的一些条目。struct pci_func 的最后三个条目我们特别感兴趣的,因为它们将记录这个设备协商的内存、I/O、以及中断资源。reg_basereg_size 数组包含最多六个基址寄存器或 BAR。reg_base 为映射到内存中的 I/O 区域(对于 I/O 端口而言是基 I/O 端口)保存了内存的基地址,reg_size 包含了以字节表示的大小或来自 reg_base 的相关基值的 I/O 端口号,而 irq_line 包含了为中断分配给设备的 IRQ 线。在表 4-2 的后半部分给出了 E1000 BAR 的具体涵义。

当设备调用了绑定函数后,设备已经被发现,但没有被启用。这意味着 PCI 代码还没有确定分配给设备的资源,比如地址空间和 IRQ 线,也就是说,struct pci_func 结构的最后三个元素还没有被填入。绑定函数将调用 pci_func_enable,它将去启用设备、协商这些资源、并在结构 struct pci_func 中填入它。

练习 3、实现一个绑定函数去初始化 E1000。添加一个条目到 kern/pci.c 中的数组 pci_attach_vendor 上,如果找到一个匹配的 PCI 设备就去触发你的函数(确保一定要把它放在表末尾的 {0, 0, 0} 条目之前)。你在 5.2 节中能找到 QEMU 仿真的 82540EM 的供应商 ID 和设备 ID。在引导期间,当 JOS 扫描 PCI 总线时,你也可以看到列出来的这些信息。

到目前为止,我们通过 pci_func_enable 启用了 E1000 设备。通过本实验我们将添加更多的初始化。

我们已经为你提供了 kern/e1000.ckern/e1000.h 文件,这样你就不会把构建系统搞糊涂了。不过它们现在都是空的;你需要在本练习中去填充它们。你还可能在内核的其它地方包含这个 e1000.h 文件。

当你引导你的内核时,你应该会看到它输出的信息显示 E1000 的 PCI 函数已经启用。这时你的代码已经能够通过 make gradepci attach 测试了。

内存映射的 I/O

软件与 E1000 通过内存映射的 I/O(MMIO)来沟通。你在 JOS 的前面部分可能看到过 MMIO 两次:CGA 控制台和 LAPIC 都是通过写入和读取“内存”来控制和查询设备的。但这些读取和写入不是去往内存芯片的,而是直接到这些设备的。

pci_func_enable 为 E1000 协调一个 MMIO 区域,来存储它在 BAR 0 的基址和大小(也就是 reg_base[0]reg_size[0]),这是一个分配给设备的一段物理内存地址,也就是说你可以通过虚拟地址访问它来做一些事情。由于 MMIO 区域一般分配高位物理地址(一般是 3GB 以上的位置),因此你不能使用 KADDR 去访问它们,因为 JOS 被限制为最大使用 256MB。因此,你可以去创建一个新的内存映射。我们将使用 MMIOBASE(从实验 4 开始,你的 mmio_map_region 区域应该确保不能被 LAPIC 使用的映射所覆盖)以上的部分。由于在 JOS 创建用户环境之前,PCI 设备就已经初始化了,因此你可以在 kern_pgdir 处创建映射,并且让它始终可用。

练习 4、在你的绑定函数中,通过调用 mmio_map_region(它就是你在实验 4 中写的,是为了支持 LAPIC 内存映射)为 E1000 的 BAR 0 创建一个虚拟地址映射。

你将希望在一个变量中记录这个映射的位置,以便于后面访问你映射的寄存器。去看一下 kern/lapic.c 中的 lapic 变量,它就是一个这样的例子。如果你使用一个指针指向设备寄存器映射,一定要声明它为 volatile;否则,编译器将允许缓存它的值,并可以在内存中再次访问它。

为测试你的映射,尝试去输出设备状态寄存器(第 12.4.2 节)。这是一个在寄存器空间中以字节 8 开头的 4 字节寄存器。你应该会得到 0x80080783,它表示以 1000 MB/s 的速度启用一个全双工的链路,以及其它信息。

提示:你将需要一些常数,像寄存器位置和掩码位数。如果从开发者手册中复制这些东西很容易出错,并且导致调试过程很痛苦。我们建议你使用 QEMU 的 e1000\_hw.h 头文件做为基准。我们不建议完全照抄它,因为它定义的值远超过你所需要,并且定义的东西也不见得就是你所需要的,但它仍是一个很好的参考。

DMA

你可能会认为是从 E1000 的寄存器中通过写入和读取来传送和接收数据包的,其实这样做会非常慢,并且还要求 E1000 在其中去缓存数据包。相反,E1000 使用直接内存访问(DMA)从内存中直接读取和写入数据包,而且不需要 CPU 参与其中。驱动程序负责为发送和接收队列分配内存、设置 DMA 描述符、以及配置 E1000 使用的队列位置,而在这些设置完成之后的其它工作都是异步方式进行的。发送包的时候,驱动程序复制它到发送队列的下一个 DMA 描述符中,并且通知 E1000 下一个发送包已就绪;当轮到这个包发送时,E1000 将从描述符中复制出数据。同样,当 E1000 接收一个包时,它从接收队列中将它复制到下一个 DMA 描述符中,驱动程序将能在下一次读取到它。

总体来看,接收队列和发送队列非常相似。它们都是由一系列的描述符组成。虽然这些描述符的结构细节有所不同,但每个描述符都包含一些标志和包含了包数据的一个缓存的物理地址(发送到网卡的数据包,或网卡将接收到的数据包写入到由操作系统分配的缓存中)。

队列被实现为一个环形数组,意味着当网卡或驱动到达数组末端时,它将重新回到开始位置。它有一个头指针和尾指针,队列的内容就是这两个指针之间的描述符。硬件就是从头开始移动头指针去消费描述符,在这期间驱动程序不停地添加描述符到尾部,并移动尾指针到最后一个描述符上。发送队列中的描述符表示等待发送的包(因此,在平静状态下,发送队列是空的)。对于接收队列,队列中的描述符是表示网卡能够接收包的空描述符(因此,在平静状态下,接收队列是由所有的可用接收描述符组成的)。正确的更新尾指针寄存器而不让 E1000 产生混乱是很有难度的;要小心!

指向到这些数组及描述符中的包缓存地址的指针都必须是物理地址,因为硬件是直接在物理内存中且不通过 MMU 来执行 DMA 的读写操作的。

发送包

E1000 中的发送和接收功能本质上是独立的,因此我们可以同时进行发送接收。我们首先去攻克简单的数据包发送,因为我们在没有先去发送一个 “I’m here!” 包之前是无法测试接收包功能的。

首先,你需要初始化网卡以准备发送,详细步骤查看 14.5 节(不必着急看子节)。发送初始化的第一步是设置发送队列。队列的详细结构在 3.4 节中,描述符的结构在 3.3.3 节中。我们先不要使用 E1000 的 TCP offload 特性,因此你只需专注于 “传统的发送描述符格式” 即可。你应该现在就去阅读这些章节,并要熟悉这些结构。

C 结构

你可以用 C struct 很方便地描述 E1000 的结构。正如你在 struct Trapframe 中所看到的结构那样,C struct 可以让你很方便地在内存中描述准确的数据布局。C 可以在字段中插入数据,但是 E1000 的结构就是这样布局的,这样就不会是个问题。如果你遇到字段对齐问题,进入 GCC 查看它的 “packed” 属性。

查看手册中表 3-8 所给出的一个传统的发送描述符,将它复制到这里作为一个示例:

  63            48 47   40 39   32 31   24 23   16 15             0
  +---------------------------------------------------------------+
  |                         Buffer address                        |
  +---------------|-------|-------|-------|-------|---------------+
  |    Special    |  CSS  | Status|  Cmd  |  CSO  |    Length     |
  +---------------|-------|-------|-------|-------|---------------+

从结构右上角第一个字节开始,我们将它转变成一个 C 结构,从上到下,从右到左读取。如果你从右往左看,你将看到所有的字段,都非常适合一个标准大小的类型:

struct tx_desc
{
    uint64_t addr;
    uint16_t length;
    uint8_t cso;
    uint8_t cmd;
    uint8_t status;
    uint8_t css;
    uint16_t special;
};

你的驱动程序将为发送描述符数组去保留内存,并由发送描述符指向到包缓冲区。有几种方式可以做到,从动态分配页到在全局变量中简单地声明它们。无论你如何选择,记住,E1000 是直接访问物理内存的,意味着它能访问的任何缓存区在物理内存中必须是连续的。

处理包缓存也有几种方式。我们推荐从最简单的开始,那就是在驱动程序初始化期间,为每个描述符保留包缓存空间,并简单地将包数据复制进预留的缓冲区中或从其中复制出来。一个以太网包最大的尺寸是 1518 字节,这就限制了这些缓存区的大小。主流的成熟驱动程序都能够动态分配包缓存区(即:当网络使用率很低时,减少内存使用量),或甚至跳过缓存区,直接由用户空间提供(就是“零复制”技术),但我们还是从简单开始为好。

练习 5、执行一个 14.5 节中的初始化步骤(它的子节除外)。对于寄存器的初始化过程使用 13 节作为参考,对发送描述符和发送描述符数组参考 3.3.3 节和 3.4 节。

要记住,在发送描述符数组中要求对齐,并且数组长度上有限制。因为 TDLEN 必须是 128 字节对齐的,而每个发送描述符是 16 字节,你的发送描述符数组必须是 8 个发送描述符的倍数。并且不能使用超过 64 个描述符,以及不能在我们的发送环形缓存测试中溢出。

对于 TCTL.COLD,你可以假设为全双工操作。对于 TIPG、IEEE 802.3 标准的 IPG(不要使用 14.5 节中表上的值),参考在 13.4.34 节中表 13-77 中描述的缺省值。

尝试运行 make E1000_DEBUG=TXERR,TX qemu。如果你使用的是打了 6.828 补丁的 QEMU,当你设置 TDT(发送描述符尾部)寄存器时你应该会看到一个 “e1000: tx disabled” 的信息,并且不会有更多 “e1000” 信息了。

现在,发送初始化已经完成,你可以写一些代码去发送一个数据包,并且通过一个系统调用使它可以访问用户空间。你可以将要发送的数据包添加到发送队列的尾部,也就是说复制数据包到下一个包缓冲区中,然后更新 TDT 寄存器去通知网卡在发送队列中有另外的数据包。(注意,TDT 是一个进入发送描述符数组的索引,不是一个字节偏移量;关于这一点文档中说明的不是很清楚。)

但是,发送队列只有这么大。如果网卡在发送数据包时卡住或发送队列填满时会发生什么状况?为了检测这种情况,你需要一些来自 E1000 的反馈。不幸的是,你不能只使用 TDH(发送描述符头)寄存器;文档上明确说明,从软件上读取这个寄存器是不可靠的。但是,如果你在发送描述符的命令字段中设置 RS 位,那么,当网卡去发送在那个描述符中的数据包时,网卡将设置描述符中状态字段的 DD 位,如果一个描述符中的 DD 位被设置,你就应该知道那个描述符可以安全地回收,并且可以用它去发送其它数据包。

如果用户调用你的发送系统调用,但是下一个描述符的 DD 位没有设置,表示那个发送队列已满,该怎么办?在这种情况下,你该去决定怎么办了。你可以简单地丢弃数据包。网络协议对这种情况的处理很灵活,但如果你丢弃大量的突发数据包,协议可能不会去重新获得它们。可能需要你替代网络协议告诉用户环境让它重传,就像你在 sys_ipc_try_send 中做的那样。在环境上回推产生的数据是有好处的。

练习 6、写一个函数去发送一个数据包,它需要检查下一个描述符是否空闲、复制包数据到下一个描述符并更新 TDT。确保你处理的发送队列是满的。

现在,应该去测试你的包发送代码了。通过从内核中直接调用你的发送函数来尝试发送几个包。在测试时,你不需要去创建符合任何特定网络协议的数据包。运行 make E1000_DEBUG=TXERR,TX qemu 去测试你的代码。你应该看到类似下面的信息:

e1000: index 0: 0x271f00 : 9000002a 0
...

在你发送包时,每行都给出了在发送数组中的序号、那个发送的描述符的缓存地址、cmd/CSO/length 字段、以及 special/CSS/status 字段。如果 QEMU 没有从你的发送描述符中输出你预期的值,检查你的描述符中是否有合适的值和你配置的正确的 TDBAL 和 TDBAH。如果你收到的是 “e1000: TDH wraparound @0, TDT x, TDLEN y” 的信息,意味着 E1000 的发送队列持续不断地运行(如果 QEMU 不去检查它,它将是一个无限循环),这意味着你没有正确地维护 TDT。如果你收到了许多 “e1000: tx disabled” 的信息,那么意味着你没有正确设置发送控制寄存器。

一旦 QEMU 运行,你就可以运行 tcpdump -XXnr qemu.pcap 去查看你发送的包数据。如果从 QEMU 中看到预期的 “e1000: index” 信息,但你捕获的包是空的,再次检查你发送的描述符,是否填充了每个必需的字段和位。(E1000 或许已经遍历了你的发送描述符,但它认为不需要去发送)

练习 7、添加一个系统调用,让你从用户空间中发送数据包。详细的接口由你来决定。但是不要忘了检查从用户空间传递给内核的所有指针。

发送包:网络服务器

现在,你已经有一个系统调用接口可以发送包到你的设备驱动程序端了。输出辅助环境的目标是在一个循环中做下面的事情:从核心网络服务器中接收 NSREQ_OUTPUT IPC 消息,并使用你在上面增加的系统调用去发送伴随这些 IPC 消息的数据包。这个 NSREQ_OUTPUT IPC 是通过 net/lwip/jos/jif/jif.c 中的 low_level_output 函数来发送的。它集成 lwIP 栈到 JOS 的网络系统。每个 IPC 将包含一个页,这个页由一个 union Nsipc 和在 struct jif_pkt pkt 字段中的一个包组成(查看 inc/ns.h)。struct jif_pkt 看起来像下面这样:

struct jif_pkt {
    int jp_len;
    char jp_data[0];
};

jp_len 表示包的长度。在 IPC 页上的所有后续字节都是为了包内容。在结构的结尾处使用一个长度为 0 的数组来表示缓存没有一个预先确定的长度(像 jp_data 一样),这是一个常见的 C 技巧(也有人说这是一个令人讨厌的做法)。因为 C 并不做数组边界的检查,只要你确保结构后面有足够的未使用内存即可,你可以把 jp_data 作为一个任意大小的数组来使用。

当设备驱动程序的发送队列中没有足够的空间时,一定要注意在设备驱动程序、输出环境和核心网络服务器之间的交互。核心网络服务器使用 IPC 发送包到输出环境。如果输出环境在由于一个发送包的系统调用而挂起,导致驱动程序没有足够的缓存去容纳新数据包,这时核心网络服务器将阻塞以等待输出服务器去接收 IPC 调用。

练习 8、实现 net/output.c

你可以使用 net/testoutput.c 去测试你的输出代码而无需整个网络服务器参与。尝试运行 make E1000_DEBUG=TXERR,TX run-net_testoutput。你将看到如下的输出:

Transmitting packet 0
e1000: index 0: 0x271f00 : 9000009 0
Transmitting packet 1
e1000: index 1: 0x2724ee : 9000009 0
...

运行 tcpdump -XXnr qemu.pcap 将输出:

reading from file qemu.pcap, link-type EN10MB (Ethernet)
-5:00:00.600186 [|ether]
    0x0000:  5061 636b 6574 2030 30                   Packet.00
-5:00:00.610080 [|ether]
    0x0000:  5061 636b 6574 2030 31                   Packet.01
...

使用更多的数据包去测试,可以运行 make E1000_DEBUG=TXERR,TX NET_CFLAGS=-DTESTOUTPUT_COUNT=100 run-net_testoutput。如果它导致你的发送队列溢出,再次检查你的 DD 状态位是否正确,以及是否告诉硬件去设置 DD 状态位(使用 RS 命令位)。

你的代码应该会通过 make gradetestoutput 测试。

问题 1、你是如何构造你的发送实现的?在实践中,如果发送缓存区满了,你该如何处理?

Part B:接收包和 web 服务器

接收包

就像你在发送包中做的那样,你将去配置 E1000 去接收数据包,并提供一个接收描述符队列和接收描述符。在 3.2 节中描述了接收包的操作,包括接收队列结构和接收描述符、以及在 14.4 节中描述的详细的初始化过程。

练习 9、阅读 3.2 节。你可以忽略关于中断和 offload 校验和方面的内容(如果在后面你想去使用这些特性,可以再返回去阅读),你现在不需要去考虑阈值的细节和网卡内部缓存是如何工作的。

除了接收队列是由一系列的等待入站数据包去填充的空缓存包以外,接收队列的其它部分与发送队列非常相似。所以,当网络空闲时,发送队列是空的(因为所有的包已经被发送出去了),而接收队列是满的(全部都是空缓存包)。

当 E1000 接收一个包时,它首先与网卡的过滤器进行匹配检查(例如,去检查这个包的目标地址是否为这个 E1000 的 MAC 地址),如果这个包不匹配任何过滤器,它将忽略这个包。否则,E1000 尝试从接收队列头部去检索下一个接收描述符。如果头(RDH)追上了尾(RDT),那么说明接收队列已经没有空闲的描述符了,所以网卡将丢弃这个包。如果有空闲的接收描述符,它将复制这个包的数据到描述符指向的缓存中,设置这个描述符的 DD 和 EOP 状态位,并递增 RDH。

如果 E1000 在一个接收描述符中接收到了一个比包缓存还要大的数据包,它将按需从接收队列中检索尽可能多的描述符以保存数据包的全部内容。为表示发生了这种情况,它将在所有的这些描述符上设置 DD 状态位,但仅在这些描述符的最后一个上设置 EOP 状态位。在你的驱动程序上,你可以去处理这种情况,也可以简单地配置网卡拒绝接收这种”长包“(这种包也被称为”巨帧“),你要确保接收缓存有足够的空间尽可能地去存储最大的标准以太网数据包(1518 字节)。

练习 10、设置接收队列并按 14.4 节中的流程去配置 E1000。你可以不用支持 ”长包“ 或多播。到目前为止,我们不用去配置网卡使用中断;如果你在后面决定去使用接收中断时可以再去改。另外,配置 E1000 去除以太网的 CRC 校验,因为我们的评级脚本要求必须去掉校验。

默认情况下,网卡将过滤掉所有的数据包。你必须使用网卡的 MAC 地址去配置接收地址寄存器(RAL 和 RAH)以接收发送到这个网卡的数据包。你可以简单地硬编码 QEMU 的默认 MAC 地址 52:54:00:12:34:56(我们已经在 lwIP 中硬编码了这个地址,因此这样做不会有问题)。使用字节顺序时要注意;MAC 地址是从低位字节到高位字节的方式来写的,因此 52:54:00:12 是 MAC 地址的低 32 位,而 34:56 是它的高 16 位。

E1000 的接收缓存区大小仅支持几个指定的设置值(在 13.4.22 节中描述的 RCTL.BSIZE 值)。如果你的接收包缓存够大,并且拒绝长包,那你就不用担心跨越多个缓存区的包。另外,要记住的是,和发送一样,接收队列和包缓存必须是连接的物理内存。

你应该使用至少 128 个接收描述符。

现在,你可以做接收功能的基本测试了,甚至都无需写代码去接收包了。运行 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinputtestinput 将发送一个 ARP(地址解析协议)通告包(使用你的包发送的系统调用),而 QEMU 将自动回复它,即便是你的驱动尚不能接收这个回复,你也应该会看到一个 “e1000: unicast match[0]: 52:54:00:12:34:56” 的消息,表示 E1000 接收到一个包,并且匹配了配置的接收过滤器。如果你看到的是一个 “e1000: unicast mismatch: 52:54:00:12:34:56” 消息,表示 E1000 过滤掉了这个包,意味着你的 RAL 和 RAH 的配置不正确。确保你按正确的顺序收到了字节,并不要忘记设置 RAH 中的 “Address Valid” 位。如果你没有收到任何 “e1000” 消息,或许是你没有正确地启用接收功能。

现在,你准备去实现接收数据包。为了接收数据包,你的驱动程序必须持续跟踪希望去保存下一下接收到的包的描述符(提示:按你的设计,这个功能或许已经在 E1000 中的一个寄存器来实现了)。与发送类似,官方文档上表示,RDH 寄存器状态并不能从软件中可靠地读取,因为确定一个包是否被发送到描述符的包缓存中,你需要去读取描述符中的 DD 状态位。如果 DD 位被设置,你就可以从那个描述符的缓存中复制出这个数据包,然后通过更新队列的尾索引 RDT 来告诉网卡那个描述符是空闲的。

如果 DD 位没有被设置,表明没有接收到包。这就与发送队列满的情况一样,这时你可以有几种做法。你可以简单地返回一个 ”重传“ 错误来要求对端重发一次。对于满的发送队列,由于那是个临时状况,这种做法还是很好的,但对于空的接收队列来说就不太合理了,因为接收队列可能会保持好长一段时间的空的状态。第二个方法是挂起调用环境,直到在接收队列中处理了这个包为止。这个策略非常类似于 sys_ipc_recv。就像在 IPC 的案例中,因为我们每个 CPU 仅有一个内核栈,一旦我们离开内核,栈上的状态就会被丢弃。我们需要设置一个标志去表示那个环境由于接收队列下溢被挂起并记录系统调用参数。这种方法的缺点是过于复杂:E1000 必须被指示去产生接收中断,并且驱动程序为了恢复被阻塞等待一个包的环境,必须处理这个中断。

练习 11、写一个函数从 E1000 中接收一个包,然后通过一个系统调用将它发布到用户空间。确保你将接收队列处理成空的。

.

小挑战!如果发送队列是满的或接收队列是空的,环境和你的驱动程序可能会花费大量的 CPU 周期是轮询、等待一个描述符。一旦完成发送或接收描述符,E1000 能够产生一个中断,以避免轮询。修改你的驱动程序,处理发送和接收队列是以中断而不是轮询的方式进行。

注意,一旦确定为中断,它将一直处于中断状态,直到你的驱动程序明确处理完中断为止。在你的中断服务程序中,一旦处理完成要确保清除掉中断状态。如果你不那样做,从你的中断服务程序中返回后,CPU 将再次跳转到你的中断服务程序中。除了在 E1000 网卡上清除中断外,也需要使用 lapic_eoi 在 LAPIC 上清除中断。

接收包:网络服务器

在网络服务器输入环境中,你需要去使用你的新的接收系统调用以接收数据包,并使用 NSREQ_INPUT IPC 消息将它传递到核心网络服务器环境。这些 IPC 输入消息应该会有一个页,这个页上绑定了一个 union Nsipc,它的 struct jif_pkt pkt 字段中有从网络上接收到的包。

练习 12、实现 net/input.c

使用 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-net_testinput 再次运行 testinput,你应该会看到:

Sending ARP announcement...
Waiting for packets...
e1000: index 0: 0x26dea0 : 900002a 0
e1000: unicast match[0]: 52:54:00:12:34:56
input: 0000 5254 0012 3456 5255 0a00 0202 0806 0001
input: 0010 0800 0604 0002 5255 0a00 0202 0a00 0202
input: 0020 5254 0012 3456 0a00 020f 0000 0000 0000
input: 0030 0000 0000 0000 0000 0000 0000 0000 0000

“input:” 打头的行是一个 QEMU 的 ARP 回复的十六进制转储。

你的代码应该会通过 make gradetestinput 测试。注意,在没有发送至少一个包去通知 QEMU 中的 JOS 的 IP 地址上时,是没法去测试包接收的,因此在你的发送代码中的 bug 可能会导致测试失败。

为彻底地测试你的网络代码,我们提供了一个称为 echosrv 的守护程序,它在端口 7 上设置运行 echo 的服务器,它将回显通过 TCP 连接发送给它的任何内容。使用 make E1000_DEBUG=TX,TXERR,RX,RXERR,RXFILTER run-echosrv 在一个终端中启动 echo 服务器,然后在另一个终端中通过 make nc-7 去连接它。你输入的每一行都被这个服务器回显出来。每次在仿真的 E1000 上接收到一个包,QEMU 将在控制台上输出像下面这样的内容:

e1000: unicast match[0]: 52:54:00:12:34:56
e1000: index 2: 0x26ea7c : 9000036 0
e1000: index 3: 0x26f06a : 9000039 0
e1000: unicast match[0]: 52:54:00:12:34:56

做到这一点后,你应该也就能通过 echosrv 的测试了。

问题 2、你如何构造你的接收实现?在实践中,如果接收队列是空的并且一个用户环境要求下一个入站包,你怎么办?

.

小挑战!在开发者手册中阅读关于 EEPROM 的内容,并写出从 EEPROM 中加载 E1000 的 MAC 地址的代码。目前,QEMU 的默认 MAC 地址是硬编码到你的接收初始化代码和 lwIP 中的。修复你的初始化代码,让它能够从 EEPROM 中读取 MAC 地址,和增加一个系统调用去传递 MAC 地址到 lwIP 中,并修改 lwIP 去从网卡上读取 MAC 地址。通过配置 QEMU 使用一个不同的 MAC 地址去测试你的变更。

.

小挑战!修改你的 E1000 驱动程序去使用 零复制 技术。目前,数据包是从用户空间缓存中复制到发送包缓存中,和从接收包缓存中复制回到用户空间缓存中。一个使用 ”零复制“ 技术的驱动程序可以通过直接让用户空间和 E1000 共享包缓存内存来实现。还有许多不同的方法去实现 ”零复制“,包括映射内容分配的结构到用户空间或直接传递用户提供的缓存到 E1000。不论你选择哪种方法,都要注意你如何利用缓存的问题,因为你不能在用户空间代码和 E1000 之间产生争用。

.

小挑战!把 “零复制” 的概念用到 lwIP 中。

一个典型的包是由许多头构成的。用户发送的数据被发送到 lwIP 中的一个缓存中。TCP 层要添加一个 TCP 包头,IP 层要添加一个 IP 包头,而 MAC 层有一个以太网头。甚至还有更多的部分增加到包上,这些部分要正确地连接到一起,以便于设备驱动程序能够发送最终的包。

E1000 的发送描述符设计是非常适合收集分散在内存中的包片段的,像在 lwIP 中创建的包的帧。如果你排队多个发送描述符,但仅设置最后一个描述符的 EOP 命令位,那么 E1000 将在内部把这些描述符串成包缓存,并在它们标记完 EOP 后仅发送串起来的缓存。因此,独立的包片段不需要在内存中把它们连接到一起。

修改你的驱动程序,以使它能够发送由多个缓存且无需复制的片段组成的包,并且修改 lwIP 去避免它合并包片段,因为它现在能够正确处理了。

.

小挑战!增加你的系统调用接口,以便于它能够为多于一个的用户环境提供服务。如果有多个网络栈(和多个网络服务器)并且它们各自都有自己的 IP 地址运行在用户模式中,这将是非常有用的。接收系统调用将决定它需要哪个环境来转发每个入站的包。

注意,当前的接口并不知道两个包之间有何不同,并且如果多个环境去调用包接收的系统调用,各个环境将得到一个入站包的子集,而那个子集可能并不包含调用环境指定的那个包。

这篇 外内核论文的 2.2 节和 3 节中对这个问题做了深度解释,并解释了在内核中(如 JOS)处理它的一个方法。用这个论文中的方法去解决这个问题,你不需要一个像论文中那么复杂的方案。

Web 服务器

一个最简单的 web 服务器类型是发送一个文件的内容到请求的客户端。我们在 user/httpd.c 中提供了一个非常简单的 web 服务器的框架代码。这个框架内码处理入站连接并解析请求头。

练习 13、这个 web 服务器中缺失了发送一个文件的内容到客户端的处理代码。通过实现 send_filesend_data 完成这个 web 服务器。

在你完成了这个 web 服务器后,启动这个 web 服务器(make run-httpd-nox),使用你喜欢的浏览器去浏览 http://host:port/index.html 地址。其中 host 是运行 QEMU 的计算机的名字(如果你在 athena 上运行 QEMU,使用 hostname.mit.edu(其中 hostname 是在 athena 上运行 hostname 命令的输出,或者如果你在运行 QEMU 的机器上运行 web 浏览器的话,直接使用 localhost),而 port 是 web 服务器运行 make which-ports 命令报告的端口号。你应该会看到一个由运行在 JOS 中的 HTTP 服务器提供的一个 web 页面。

到目前为止,你的评级测试得分应该是 105 分(满分为 105)。

小挑战!在 JOS 中添加一个简单的聊天服务器,多个人可以连接到这个服务器上,并且任何用户输入的内容都被发送到其它用户。为实现它,你需要找到一个一次与多个套接字通讯的方法,并且在同一时间能够在同一个套接字上同时实现发送和接收。有多个方法可以达到这个目的。lwIP 为 recv(查看 net/lwip/api/sockets.c 中的 lwip_recvfrom)提供了一个 MSG\_DONTWAIT 标志,以便于你不断地轮询所有打开的套接字。注意,虽然网络服务器的 IPC 支持 recv 标志,但是通过普通的 read 函数并不能访问它们,因此你需要一个方法来传递这个标志。一个更高效的方法是为每个连接去启动一个或多个环境,并且使用 IPC 去协调它们。而且碰巧的是,对于一个套接字,在结构 Fd 中找到的 lwIP 套接字 ID 是全局的(不是每个环境私有的),因此,比如一个 fork 的子环境继承了它的父环境的套接字。或者,一个环境通过构建一个包含了正确套接字 ID 的 Fd 就能够发送到另一个环境的套接字上。

问题 3、由 JOS 的 web 服务器提供的 web 页面显示了什么?

.

问题 4、你做这个实验大约花了多长的时间?

本实验到此结束了。一如既往,不要忘了运行 make grade 并去写下你的答案和挑战问题的解决方案的描述。在你动手之前,使用 git statusgit diff 去检查你的变更,并不要忘了去 git add answers-lab6.txt。当你完成之后,使用 git commit -am 'my solutions to lab 6’ 去提交你的变更,然后 make handin 并关注它的动向。


via: https://pdos.csail.mit.edu/6.828/2018/labs/lab6/

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

简介

在本实验中,你将要去实现 spawn,它是一个加载和运行磁盘上可运行文件的库调用。然后,你接着要去充实你的内核和库,以使操作系统能够在控制台上运行一个 shell。而这些特性需要一个文件系统,本实验将引入一个可读/写的简单文件系统。

预备知识

使用 Git 去获取最新版的课程仓库,然后创建一个命名为 lab5 的本地分支,去跟踪远程的 origin/lab5 分支:

athena% cd ~/6.828/lab
athena% add git
athena% git pull
Already up-to-date.
athena% git checkout -b lab5 origin/lab5
Branch lab5 set up to track remote branch refs/remotes/origin/lab5.
Switched to a new branch "lab5"
athena% git merge lab4
Merge made by recursive.
.....
athena%

在实验中这一部分的主要新组件是文件系统环境,它位于新的 fs 目录下。通过检查这个目录中的所有文件,我们来看一下新的文件都有什么。另外,在 userlib 目录下还有一些文件系统相关的源文件。

  • fs/fs.c 维护文件系统在磁盘上结构的代码
  • fs/bc.c 构建在我们的用户级页故障处理功能之上的一个简单的块缓存
  • fs/ide.c 极简的基于 PIO(非中断驱动的)IDE 驱动程序代码
  • fs/serv.c 使用文件系统 IPC 与客户端环境交互的文件系统服务器
  • lib/fd.c 实现一个常见的类 UNIX 的文件描述符接口的代码
  • lib/file.c 磁盘上文件类型的驱动,实现为一个文件系统 IPC 客户端
  • lib/console.c 控制台输入/输出文件类型的驱动
  • lib/spawn.c spawn 库调用的框架代码

你应该再次去运行 pingpongprimesforktree,测试实验 4 完成后合并到新的实验 5 中的代码能否正确运行。你还需要在 kern/init.c 中注释掉 ENV_CREATE(fs_fs) 行,因为 fs/fs.c 将尝试去做一些 I/O,而 JOS 到目前为止还不具备该功能。同样,在 lib/exit.c 中临时注释掉对 close_all() 的调用;这个函数将调用你在本实验后面部分去实现的子程序,如果现在去调用,它将导致 JOS 内核崩溃。如果你的实验 4 的代码没有任何 bug,将很完美地通过这个测试。在它们都能正常工作之前是不能继续后续实验的。在你开始做练习 1 时,不要忘记去取消这些行上的注释。

如果它们不能正常工作,使用 git diff lab4 去重新评估所有的变更,确保你在实验 4(及以前)所写的代码在本实验中没有丢失。确保实验 4 仍然能正常工作。

实验要求

和以前一样,你需要做本实验中所描述的所有常规练习和至少一个挑战问题。另外,你需要写出你在本实验中问题的详细答案,和你是如何解决挑战问题的一个简短(即:用一到两个段落)的描述。如果你实现了多个挑战问题,你只需要写出其中一个即可,当然,我们欢迎你做的越多越好。在你动手实验之前,将你的问题答案写入到你的 lab5 根目录下的一个名为 answers-lab5.txt 的文件中。

文件系统的雏形

你将要使用的文件系统比起大多数“真正的”文件系统(包括 xv6 UNIX 的文件系统)要简单的多,但它也是很强大的,足够去提供基本的特性:创建、读取、写入和删除组织在层次目录结构中的文件。

到目前为止,我们开发的是一个单用户操作系统,它提供足够的保护并能去捕获 bug,但它还不能在多个不可信用户之间提供保护。因此,我们的文件系统还不支持 UNIX 的所有者或权限的概念。我们的文件系统目前也不支持硬链接、时间戳、或像大多数 UNIX 文件系统实现的那些特殊的设备文件。

磁盘上的文件系统结构

主流的 UNIX 文件系统将可用磁盘空间分为两种主要的区域类型:节点区域和数据区域。UNIX 文件系统在文件系统中为每个文件分配一个节点;一个文件的节点保存了这个文件重要的元数据,比如它的 stat 属性和指向数据块的指针。数据区域被分为更大的(一般是 8 KB 或更大)数据块,它在文件系统中存储文件数据和目录元数据。目录条目包含文件名字和指向到节点的指针;如果文件系统中的多个目录条目指向到那个文件的节点上,则称该文件是硬链接的。由于我们的文件系统不支持硬链接,所以我们不需要这种间接的级别,并且因此可以更方便简化:我们的文件系统将压根就不使用节点,而是简单地将文件的(或子目录的)所有元数据保存在描述那个文件的(唯一的)目录条目中。

文件和目录逻辑上都是由一系列的数据块组成,它或许是很稀疏地分散到磁盘上,就像一个环境的虚拟地址空间上的页,能够稀疏地分散在物理内存中一样。文件系统环境隐藏了块布局的细节,只提供文件中任意偏移位置读写字节序列的接口。作为像文件创建和删除操作的一部分,文件系统环境服务程序在目录内部完成所有的修改。我们的文件系统允许用户环境去直接读取目录元数据(即:使用 read),这意味着用户环境自己就能够执行目录扫描操作(即:实现 ls 程序),而不用另外依赖对文件系统的特定调用。用这种方法做目录扫描的缺点是,(也是大多数现代 UNIX 操作系统变体摒弃它的原因)使得应用程序依赖目录元数据的格式,如果不改变或至少要重编译应用程序的前提下,去改变文件系统的内部布局将变得很困难。

扇区和块

大多数磁盘都不能执行以字节为粒度的读写操作,而是以扇区为单位执行读写。在 JOS 中,每个扇区是 512 字节。文件系统实际上是以块为单位来分配和使用磁盘存储的。要注意这两个术语之间的区别:扇区大小是硬盘硬件的属性,而块大小是使用这个磁盘的操作系统上的术语。一个文件系统的块大小必须是底层磁盘的扇区大小的倍数。

UNIX xv6 文件系统使用 512 字节大小的块,与它底层磁盘的扇区大小一样。而大多数现代文件系统使用更大尺寸的块,因为现在存储空间变得很廉价了,而使用更大的粒度在存储管理上更高效。我们的文件系统将使用 4096 字节的块,以更方便地去匹配处理器上页的大小。

超级块

Disk layout

文件系统一般在磁盘上的“易于查找”的位置(比如磁盘开始或结束的位置)保留一些磁盘块,用于保存描述整个文件系统属性的元数据,比如块大小、磁盘大小、用于查找根目录的任何元数据、文件系统最后一次挂载的时间、文件系统最后一次错误检查的时间等等。这些特定的块被称为超级块。

我们的文件系统只有一个超级块,它固定为磁盘的 1 号块。它的布局定义在 inc/fs.h 文件里的 struct Super 中。而 0 号块一般是保留的,用于去保存引导加载程序和分区表,因此文件系统一般不会去使用磁盘上比较靠前的块。许多“真实的”文件系统都维护多个超级块,并将它们复制到间隔较大的几个区域中,这样即便其中一个超级块坏了或超级块所在的那个区域产生了介质错误,其它的超级块仍然能够被找到并用于去访问文件系统。

文件元数据

File structure

元数据的布局是描述在我们的文件系统中的一个文件中,这个文件就是 inc/fs.h 中的 struct File。元数据包含文件的名字、大小、类型(普通文件还是目录)、指向构成这个文件的块的指针。正如前面所提到的,我们的文件系统中并没有节点,因此元数据是保存在磁盘上的一个目录条目中,而不是像大多数“真正的”文件系统那样保存在节点中。为简单起见,我们将使用 File 这一个结构去表示文件元数据,因为它要同时出现在磁盘上和内存中。

struct File 中的数组 f_direct 包含一个保存文件的前 10 个块(NDIRECT)的块编号的空间,我们称之为文件的直接块。对于最大 10*4096 = 40KB 的小文件,这意味着这个文件的所有块的块编号将全部直接保存在结构 File 中,但是,对于超过 40 KB 大小的文件,我们需要一个地方去保存文件剩余的块编号。所以我们分配一个额外的磁盘块,我们称之为文件的间接块,由它去保存最多 4096/4 = 1024 个额外的块编号。因此,我们的文件系统最多允许有 1034 个块,或者说不能超过 4MB 大小。为支持大文件,“真正的”文件系统一般都支持两个或三个间接块。

目录与普通文件

我们的文件系统中的结构 File 既能够表示一个普通文件,也能够表示一个目录;这两种“文件”类型是由 File 结构中的 type 字段来区分的。除了文件系统根本就不需要解释的、分配给普通文件的数据块的内容之外,它使用完全相同的方式来管理普通文件和目录“文件”,文件系统将目录“文件”的内容解释为包含在目录中的一系列的由 File 结构所描述的文件和子目录。

在我们文件系统中的超级块包含一个结构 File(在 struct Super 中的 root 字段中),它用于保存文件系统的根目录的元数据。这个目录“文件”的内容是一系列的 File 结构所描述的、位于文件系统根目录中的文件和目录。在根目录中的任何子目录转而可以包含更多的 File 结构所表示的子目录,依此类推。

文件系统

本实验的目标并不是让你去实现完整的文件系统,你只需要去实现几个重要的组件即可。实践中,你将负责把块读入到块缓存中,并且刷新脏块到磁盘上;分配磁盘块;映射文件偏移量到磁盘块;以及实现读取、写入、和在 IPC 接口中打开。因为你并不去实现完整的文件系统,熟悉提供给你的代码和各种文件系统接口是非常重要的。

磁盘访问

我们的操作系统的文件系统环境需要能访问磁盘,但是我们在内核中并没有实现任何磁盘访问的功能。与传统的在内核中添加了 IDE 磁盘驱动程序、以及允许文件系统去访问它所必需的系统调用的“大一统”策略不同,我们将 IDE 磁盘驱动实现为用户级文件系统环境的一部分。我们仍然需要对内核做稍微的修改,是为了能够设置一些东西,以便于文件系统环境拥有实现磁盘访问本身所需的权限。

只要我们依赖轮询、基于 “编程的 I/O”(PIO)的磁盘访问,并且不使用磁盘中断,那么在用户空间中实现磁盘访问还是很容易的。也可以去实现由中断驱动的设备驱动程序(比如像 L3 和 L4 内核就是这么做的),但这样做的话,内核必须接收设备中断并将它派发到相应的用户模式环境上,这样实现的难度会更大。

x86 处理器在 EFLAGS 寄存器中使用 IOPL 位去确定保护模式中的代码是否允许执行特定的设备 I/O 指令,比如 INOUT 指令。由于我们需要的所有 IDE 磁盘寄存器都位于 x86 的 I/O 空间中而不是映射在内存中,所以,为了允许文件系统去访问这些寄存器,我们需要做的唯一的事情便是授予文件系统环境“I/O 权限”。实际上,在 EFLAGS 寄存器的 IOPL 位上规定,内核使用一个简单的“要么全都能访问、要么全都不能访问”的方法来控制用户模式中的代码能否访问 I/O 空间。在我们的案例中,我们希望文件系统环境能够去访问 I/O 空间,但我们又希望任何其它的环境完全不能访问 I/O 空间。

练习 1i386_init 通过将类型 ENV_TYPE_FS 传递给你的环境创建函数 env_create 来识别文件系统。修改 env.c 中的 env_create ,以便于它只授予文件系统环境 I/O 的权限,而不授予任何其它环境 I/O 的权限。

确保你能启动这个文件系统环境,而不会产生一般保护故障。你应该要通过在 make grade 中的 fs i/o 测试。

.

问题 1、当你从一个环境切换到另一个环境时,你是否需要做一些操作来确保 I/O 权限设置能被保存和正确地恢复?为什么?

注意本实验中的 GNUmakefile 文件,它用于设置 QEMU 去使用文件 obj/kern/kernel.img 作为磁盘 0 的镜像(一般情况下表示 DOS 或 Windows 中的 “C 盘”),以及使用(新)文件 obj/fs/fs.img 作为磁盘 1 的镜像(”D 盘“)。在本实验中,我们的文件系统应该仅与磁盘 1 有交互;而磁盘 0 仅用于去引导内核。如果你想去恢复其中一个有某些错误的磁盘镜像,你可以通过输入如下的命令,去重置它们到最初的、”崭新的“版本:

$ rm obj/kern/kernel.img obj/fs/fs.img
$ make

或者:

$ make clean
$ make

小挑战!实现中断驱动的 IDE 磁盘访问,既可以使用也可以不使用 DMA 模式。由你来决定是否将设备驱动移植进内核中、还是与文件系统一样保留在用户空间中、甚至是将它移植到一个它自己的的单独的环境中(如果你真的想了解微内核的本质的话)。

块缓存

在我们的文件系统中,我们将在处理器虚拟内存系统的帮助下,实现一个简单的”缓冲区“(实际上就是一个块缓冲区)。块缓存的代码在 fs/bc.c 文件中。

我们的文件系统将被限制为仅能处理 3GB 或更小的磁盘。我们保留一个大的、尺寸固定为 3GB 的文件系统环境的地址空间区域,从 0x10000000(DISKMAP)到 0xD0000000(DISKMAP+DISKMAX)作为一个磁盘的”内存映射版“。比如,磁盘的 0 号块被映射到虚拟地址 0x10000000 处,磁盘的 1 号块被映射到虚拟地址 0x10001000 处,依此类推。在 fs/bc.c 中的 diskaddr 函数实现从磁盘块编号到虚拟地址的转换(以及一些完整性检查)。

由于我们的文件系统环境在系统中有独立于所有其它环境的虚拟地址空间之外的、它自己的虚拟地址空间,并且文件系统环境仅需要做的事情就是实现文件访问,以这种方式去保留大多数文件系统环境的地址空间是很明智的。如果在一台 32 位机器上的”真实的“文件系统上这么做是很不方便的,因为现在的磁盘都远大于 3 GB。而在一台有 64 位地址空间的机器上,这样的缓存管理方法仍然是明智的。

当然,将整个磁盘读入到内存中需要很长时间,因此,我们将它实现成”按需“分页的形式,那样我们只在磁盘映射区域中分配页,并且当在这个区域中产生页故障时,从磁盘读取相关的块去响应这个页故障。通过这种方式,我们能够假装将整个磁盘装进了内存中。

练习 2、在 fs/bc.c 中实现 bc_pgfaultflush_block 函数。bc_pgfault 函数是一个页故障服务程序,就像你在前一个实验中编写的写时复制 fork 一样,只不过它的任务是从磁盘中加载页去响应一个页故障。在你编写它时,记住: (1) addr 可能并不会做边界对齐,并且 (2) 在扇区中的 ide_read 操作并不是以块为单位的。

(如果需要的话)函数 flush_block 应该会将一个块写入到磁盘上。如果在块缓存中没有块(也就是说,页没有映射)或者它不是一个脏块,那么 flush_block 将什么都不做。我们将使用虚拟内存硬件去跟踪,磁盘块自最后一次从磁盘读取或写入到磁盘之后是否被修改过。查看一个块是否需要写入时,我们只需要去查看 uvpt 条目中的 PTE_D 的 ”dirty“ 位即可。(PTE_D 位由处理器设置,用于表示那个页被写入;具体细节可以查看 x386 参考手册的 第 5 章 的 5.2.4.3 节)块被写入到磁盘上之后,flush_block 函数将使用 sys_page_map 去清除 PTE_D 位。

使用 make grade 去测试你的代码。你的代码应该能够通过 check\_bc、check\_super、和 check\_bitmap 的测试。

fs/fs.c 中的函数 fs_init 是块缓存使用的一个很好的示例。在初始化块缓存之后,它简单地在全局变量 super 中保存指针到磁盘映射区。在这之后,如果块在内存中,或我们的页故障服务程序按需将它们从磁盘上读入后,我们就能够简单地从 super 结构中读取块了。

.

小挑战!到现在为止,块缓存还没有清除策略。一旦某个块因为页故障被读入到缓存中之后,它将一直不会被清除,并且永远保留在内存中。给块缓存增加一个清除策略。在页表中使用 PTE_A 的 accessed 位来实现,任何环境访问一个页时,硬件就会设置这个位,你可以通过它来跟踪磁盘块的大致使用情况,而不需要修改访问磁盘映射区域的任何代码。使用脏块要小心。

块位图

fs_init 设置了 bitmap 指针之后,我们可以认为 bitmap 是一个装满比特位的数组,磁盘上的每个块就是数组中的其中一个比特位。比如 block_is_free,它只是简单地在位图中检查给定的块是否被标记为空闲。

练习 3、使用 free_block 作为实现 fs/fs.c 中的 alloc_block 的一个模型,它将在位图中去查找一个空闲的磁盘块,并将它标记为已使用,然后返回块编号。当你分配一个块时,你应该立即使用 flush_block 将已改变的位图块刷新到磁盘上,以确保文件系统的一致性。

使用 make grade 去测试你的代码。现在,你的代码应该要通过 alloc\_block 的测试。

文件操作

fs/fs.c 中,我们提供一系列的函数去实现基本的功能,比如,你将需要去理解和管理结构 File、扫描和管理目录”文件“的条目、 以及从根目录开始遍历文件系统以解析一个绝对路径名。阅读 fs/fs.c 中的所有代码,并在你开始实验之前,确保你理解了每个函数的功能。

练习 4、实现 file_block_walkfile_get_blockfile_block_walk 从一个文件中的块偏移量映射到 struct File 中那个块的指针上或间接块上,它非常类似于 pgdir_walk 在页表上所做的事。file_get_block 将更进一步,将去映射一个真实的磁盘块,如果需要的话,去分配一个新的磁盘块。

使用 make grade 去测试你的代码。你的代码应该要通过 file\_open、filegetblock、以及 fileflush/filetruncated/file rewrite、和 testfile 的测试。

file_block_walkfile_get_block 是文件系统中的”劳动模范“。比如,file_readfile_write 或多或少都在 file_get_block 上做必需的登记工作,然后在分散的块和连续的缓存之间复制字节。

.

小挑战!如果操作在中途实然被打断(比如,突然崩溃或重启),文件系统很可能会产生错误。实现软件更新或日志处理的方式让文件系统的”崩溃可靠性“更好,并且演示一下旧的文件系统可能会崩溃,而你的更新后的文件系统不会崩溃的情况。

文件系统接口

现在,我们已经有了文件系统环境自身所需的功能了,我们必须让其它希望使用文件系统的环境能够访问它。由于其它环境并不能直接调用文件系统环境中的函数,我们必须通过一个远程过程调用或 RPC、构建在 JOS 的 IPC 机制之上的抽象化来暴露对文件系统的访问。如下图所示,下图是对文件系统服务调用(比如:读取)的样子:

      Regular env           FS env
   +---------------+   +---------------+
   |      read     |   |   file_read   |
   |   (lib/fd.c)  |   |   (fs/fs.c)   |
...|.......|.......|...|.......^.......|...............
   |       v       |   |       |       | RPC mechanism
   |  devfile_read |   |  serve_read   |
   |  (lib/file.c) |   |  (fs/serv.c)  |
   |       |       |   |       ^       |
   |       v       |   |       |       |
   |     fsipc     |   |     serve     |
   |  (lib/file.c) |   |  (fs/serv.c)  |
   |       |       |   |       ^       |
   |       v       |   |       |       |
   |   ipc_send    |   |   ipc_recv    |
   |       |       |   |       ^       |
   +-------|-------+   +-------|-------+
           |                   |
           +-------------------+

圆点虚线下面的过程是一个普通的环境对文件系统环境请求进行读取的简单机制。从(我们提供的)在任何文件描述符上的 read 工作开始,并简单地派发到相关的设备读取函数上,在我们的案例中是 devfile_read(我们还有更多的设备类型,比如管道)。devfile_read 实现了对磁盘上文件指定的 read。它和 lib/file.c 中的其它的 devfile_* 函数实现了客户端侧的文件系统操作,并且所有的工作大致都是以相同的方式来完成的,把参数打包进一个请求结构中,调用 fsipc 去发送 IPC 请求以及解包并返回结果。fsipc 函数把发送请求到服务器和接收来自服务器的回复的普通细节做了简化处理。

fs/serv.c 中可以找到文件系统服务器代码。它是一个 serve 函数的循环,无休止地接收基于 IPC 的请求,并派发请求到相关的服务函数,并通过 IPC 来回送结果。在读取示例中,serve 将派发到 serve_read 函数上,它将去处理读取请求的 IPC 细节,比如,解包请求结构并最终调用 file_read 去执行实际的文件读取动作。

回顾一下 JOS 的 IPC 机制,它让一个环境发送一个单个的 32 位数字和可选的共享页。从一个客户端向服务器发送一个请求,我们为请求类型使用 32 位的数字(文件系统服务器 RPC 是有编号的,就像系统调用那样的编号),然后通过 IPC 在共享页上的一个 union Fsipc 中存储请求参数。在客户端侧,我们已经在 fsipcbuf 处共享了页;在服务端,我们在 fsreq0x0ffff000)处映射入站请求页。

服务器也通过 IPC 来发送响应。我们为函数的返回代码使用 32 位的数字。对于大多数 RPC,这已经涵盖了它们全部的返回代码。FSREQ_READFSREQ_STAT 也返回数据,它们只是被简单地写入到客户端发送它的请求时的页上。在 IPC 的响应中并不需要去发送这个页,因为这个页是文件系统服务器和客户端从一开始就共享的页。另外,在它的响应中,FSREQ_OPEN 与客户端共享一个新的 “Fd page”。我们将快捷地返回到文件描述符页上。

练习 5、实现 fs/serv.c 中的 serve_read

serve_read 的重任将由已经在 fs/fs.c 中实现的 file_read 来承担(它实际上不过是对 file_get_block 的一连串调用)。对于文件读取,serve_read 只能提供 RPC 接口。查看 serve_set_size 中的注释和代码,去大体上了解服务器函数的结构。

使用 make grade 去测试你的代码。你的代码通过 serveopen/filestat/file\_close 和 file\_read 的测试后,你得分应该是 70(总分为 150)。

.

练习 6、实现 fs/serv.c 中的 serve_writelib/file.c 中的 devfile_write

使用 make grade 去测试你的代码。你的代码通过 file\_write、fileread after filewrite、open、和 large file 的测试后,得分应该是 90(总分为150)。

进程增殖

我们给你提供了 spawn 的代码(查看 lib/spawn.c 文件),它用于创建一个新环境、从文件系统中加载一个程序镜像并启动子环境来运行这个程序。然后这个父进程独立于子环境持续运行。spawn 函数的行为,在效果上类似于UNIX 中的 fork,然后同时紧跟着 fork 之后在子进程中立即启动执行一个 exec

我们实现的是 spawn,而不是一个类 UNIX 的 exec,因为 spawn 是很容易从用户空间中、以”外内核式“ 实现的,它无需来自内核的特别帮助。为了在用户空间中实现 exec,想一想你应该做什么?确保你理解了它为什么很难。

练习 7spawn 依赖新的系统调用 sys_env_set_trapframe 去初始化新创建的环境的状态。实现 kern/syscall.c 中的 sys_env_set_trapframe。(不要忘记在 syscall() 中派发新系统调用)

运行来自 kern/init.c 中的 user/spawnhello 程序来测试你的代码kern/init.c ,它将尝试从文件系统中增殖 /hello

使用 make grade 去测试你的代码。

.

小挑战!实现 Unix 式的 exec

.

小挑战!实现 mmap 式的文件内存映射,并如果可能的话,修改 spawn 从 ELF 中直接映射页。

跨 fork 和 spawn 共享库状态

UNIX 文件描述符是一个通称的概念,它还包括管道、控制台 I/O 等等。在 JOS 中,每个这类设备都有一个相应的 struct Dev,使用指针去指向到实现读取/写入/等等的函数上。对于那个设备类型,lib/fd.c 在其上实现了类 UNIX 的文件描述符接口。每个 struct Fd 表示它的设备类型,并且大多数 lib/fd.c 中的函数只是简单地派发操作到 struct Dev 中相应函数上。

lib/fd.c 也在每个应用程序环境的地址空间中维护一个文件描述符表区域,开始位置在 FDTABLE 处。这个区域为应该程序能够一次最多打开 MAXFD(当前为 32)个文件描述符而保留页的地址空间值(4KB)。在任意给定的时刻,当且仅当相应的文件描述符处于使用中时,一个特定的文件描述符表才会被映射。在区域的 FILEDATA 处开始的位置,每个文件描述符表也有一个可选的”数据页“,如果它们被选定,相应的设备就能使用它。

我们想跨 forkspawn 共享文件描述符状态,但是文件描述符状态是保存在用户空间的内存中。而现在,在 fork 中,内存是标记为写时复制的,因此状态将被复制而不是共享。(这意味着环境不能在它们自己无法打开的文件中去搜索,并且管道不能跨一个 fork 去工作)在 spawn 上,内存将被保留,压根不会去复制。(事实上,增殖的环境从使用一个不打开的文件描述符去开始。)

我们将要修改 fork,以让它知道某些被”库管理的系统“所使用的、和总是被共享的内存区域。而不是去”硬编码“一个某些区域的列表,我们将在页表条目中设置一个”这些不使用“的位(就像我们在 fork 中使用的 PTE_COW 位一样)。

我们在 inc/lib.h 中定义了一个新的 PTE_SHARE 位,在 Intel 和 AMD 的手册中,这个位是被标记为”软件可使用的“的三个 PTE 位之一。我们将创建一个约定,如果一个页表条目中这个位被设置,那么在 forkspawn 中应该直接从父环境中复制 PTE 到子环境中。注意它与标记为写时复制的差别:正如在第一段中所描述的,我们希望确保能共享页更新。

练习 8、修改 lib/fork.c 中的 duppage,以遵循最新约定。如果页表条目设置了 PTE_SHARE 位,仅直接复制映射。(你应该去使用 PTE_SYSCALL,而不是 0xfff,去从页表条目中掩掉有关的位。0xfff 仅选出可访问的位和脏位。)

同样的,在 lib/spawn.c 中实现 copy_shared_pages。它应该循环遍历当前进程中所有的页表条目(就像 fork 那样),复制任何设置了 PTE_SHARE 位的页映射到子进程中。

使用 make run-testpteshare 去检查你的代码行为是否正确。正确的情况下,你应该会看到像 fork handles PTE_SHARE right 和 ”spawn handles PTE_SHARE right” 这样的输出行。

使用 make run-testfdsharing 去检查文件描述符是否正确共享。正确的情况下,你应该会看到 read in child succeeded 和 “read in parent succeeded” 这样的输出行。

键盘接口

为了能让 shell 工作,我们需要一些方式去输入。QEMU 可以显示输出,我们将它的输出写入到 CGA 显示器上和串行端口上,但到目前为止,我们仅能够在内核监视器中接收输入。在 QEMU 中,我们从图形化窗口中的输入作为从键盘到 JOS 的输入,虽然键入到控制台的输入作为出现在串行端口上的字符的方式显现。在 kern/console.c 中已经包含了由我们自实验 1 以来的内核监视器所使用的键盘和串行端口的驱动程序,但现在你需要去把这些增加到系统中。

练习 9、在你的 kern/trap.c 中,调用 kbd_intr 去处理捕获 IRQ_OFFSET+IRQ_KBDserial_intr,用它们去处理捕获 IRQ_OFFSET+IRQ_SERIAL

lib/console.c 中,我们为你实现了文件的控制台输入/输出。kbd_intrserial_intr 将使用从最新读取到的输入来填充缓冲区,而控制台文件类型去排空缓冲区(默认情况下,控制台文件类型为 stdin/stdout,除非用户重定向它们)。

运行 make run-testkbd 并输入几行来测试你的代码。在你输入完成之后,系统将回显你输入的行。如果控制台和窗口都可以使用的话,尝试在它们上面都做一下测试。

Shell

运行 make run-icodemake run-icode-nox 将运行你的内核并启动 user/icodeicode 又运行 init,它将设置控制台作为文件描述符 0 和 1(即:标准输入 stdin 和标准输出 stdout),然后增殖出环境 sh,就是 shell。之后你应该能够运行如下的命令了:

echo hello world | cat
cat lorem |cat
cat lorem |num
cat lorem |num |num |num |num |num
lsfd

注意用户库常规程序 cprintf 将直接输出到控制台,而不会使用文件描述符代码。这对调试非常有用,但是对管道连接其它程序却很不利。为将输出打印到特定的文件描述符(比如 1,它是标准输出 stdout),需要使用 fprintf(1, "...", ...)printf("...", ...) 是将输出打印到文件描述符 1(标准输出 stdout) 的快捷方式。查看 user/lsfd.c 了解更多示例。

练习 10、这个 shell 不支持 I/O 重定向。如果能够运行 run sh <script 就更完美了,就不用将所有的命令手动去放入一个脚本中,就像上面那样。为 <user/sh.c 中添加重定向的功能。

通过在你的 shell 中输入 sh <script 来测试你实现的重定向功能。

运行 make run-testshell 去测试你的 shell。testshell 只是简单地给 shell ”喂“上面的命令(也可以在 fs/testshell.sh 中找到),然后检查它的输出是否与 fs/testshell.key 一样。

.

小挑战!给你的 shell 添加更多的特性。最好包括以下的特性(其中一些可能会要求修改文件系统):

  • 后台命令 (ls &)
  • 一行中运行多个命令 (ls; echo hi)
  • 命令组 ((ls; echo hi) | cat > out)
  • 扩展环境变量 (echo $hello)
  • 引号 (echo "a | b")
  • 命令行历史和/或编辑功能
  • tab 命令补全
  • 为命令行查找目录、cd 和路径
  • 文件创建
  • 用快捷键 ctl-c 去杀死一个运行中的环境

可做的事情还有很多,并不仅限于以上列表。

到目前为止,你的代码应该要通过所有的测试。和以前一样,你可以使用 make grade 去评级你的提交,并且使用 make handin 上交你的实验。

本实验到此结束。 和以前一样,不要忘了运行 make grade 去做评级测试,并将你的练习答案和挑战问题的解决方案写下来。在动手实验之前,使用 git statusgit diff 去检查你的变更,并不要忘记使用 git add answers-lab5.txt 去提交你的答案。完成之后,使用 git commit -am 'my solutions to lab 5’ 去提交你的变更,然后使用 make handin 去提交你的解决方案。


via: https://pdos.csail.mit.edu/6.828/2018/labs/lab5/

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

简介

在本实验中,你将在多个同时活动的用户模式环境之间实现抢占式多任务处理。

在 Part A 中,你将在 JOS 中添加对多处理器的支持,以实现循环调度。并且添加基本的环境管理方面的系统调用(创建和销毁环境的系统调用、以及分配/映射内存)。

在 Part B 中,你将要实现一个类 Unix 的 fork(),它将允许一个用户模式中的环境去创建一个它自已的副本。

最后,在 Part C 中,你将在 JOS 中添加对进程间通讯(IPC)的支持,以允许不同用户模式环境之间进行显式通讯和同步。你也将要去添加对硬件时钟中断和优先权的支持。

预备知识

使用 git 去提交你的实验 3 的源代码,并获取课程仓库的最新版本,然后创建一个名为 lab4 的本地分支,它跟踪我们的名为 origin/lab4 的远程 lab4 分支:

    athena% cd ~/6.828/lab
    athena% add git
    athena% git pull
    Already up-to-date.
    athena% git checkout -b lab4 origin/lab4
    Branch lab4 set up to track remote branch refs/remotes/origin/lab4.
    Switched to a new branch "lab4"
    athena% git merge lab3
    Merge made by recursive.
    ...
    athena%

实验 4 包含了一些新的源文件,在开始之前你应该去浏览一遍:

kern/cpu.h       Kernel-private definitions for multiprocessor support
kern/mpconfig.c  Code to read the multiprocessor configuration 
kern/lapic.c     Kernel code driving the local APIC unit in each processor
kern/mpentry.S   Assembly-language entry code for non-boot CPUs
kern/spinlock.h  Kernel-private definitions for spin locks, including the big kernel lock 
kern/spinlock.c  Kernel code implementing spin locks
kern/sched.c     Code skeleton of the scheduler that you are about to implement

实验要求

本实验分为三部分:Part A、Part B 和 Part C。我们计划为每个部分分配一周的时间。

和以前一样,你需要完成实验中出现的、所有常规练习和至少一个挑战问题。(不是每个部分做一个挑战问题,是整个实验做一个挑战问题即可。)另外,你还要写出你实现的挑战问题的详细描述。如果你实现了多个挑战问题,你只需写出其中一个即可,虽然我们的课程欢迎你完成越多的挑战越好。在动手实验之前,请将你的挑战问题的答案写在一个名为 answers-lab4.txt 的文件中,并把它放在你的 lab 目录的根下。

Part A:多处理器支持和协调多任务处理

在本实验的第一部分,将去扩展你的 JOS 内核,以便于它能够在一个多处理器的系统上运行,并且要在 JOS 内核中实现一些新的系统调用,以便于它允许用户级环境创建附加的新环境。你也要去实现协调的循环调度,在当前的环境自愿放弃 CPU(或退出)时,允许内核将一个环境切换到另一个环境。稍后在 Part C 中,你将要实现抢占调度,它允许内核在环境占有 CPU 一段时间后,从这个环境上重新取回对 CPU 的控制,那怕是在那个环境不配合的情况下。

多处理器支持

我们继续去让 JOS 支持 “对称多处理器”(SMP),在一个多处理器的模型中,所有 CPU 们都有平等访问系统资源(如内存和 I/O 总线)的权力。虽然在 SMP 中所有 CPU 们都有相同的功能,但是在引导进程的过程中,它们被分成两种类型:引导程序处理器(BSP)负责初始化系统和引导操作系统;而在操作系统启动并正常运行后,应用程序处理器(AP)将被 BSP 激活。哪个处理器做 BSP 是由硬件和 BIOS 来决定的。到目前为止,你所有的已存在的 JOS 代码都是运行在 BSP 上的。

在一个 SMP 系统上,每个 CPU 都伴有一个本地 APIC(LAPIC)单元。这个 LAPIC 单元负责传递系统中的中断。LAPIC 还为它所连接的 CPU 提供一个唯一的标识符。在本实验中,我们将使用 LAPIC 单元(它在 kern/lapic.c 中)中的下列基本功能:

  • 读取 LAPIC 标识符(APIC ID),去告诉那个 CPU 现在我们的代码正在它上面运行(查看 cpunum())。
  • 从 BSP 到 AP 之间发送处理器间中断(IPI) STARTUP,以启动其它 CPU(查看 lapic_startap())。
  • 在 Part C 中,我们设置 LAPIC 的内置定时器去触发时钟中断,以便于支持抢占式多任务处理(查看 apic_init())。

一个处理器使用内存映射的 I/O(MMIO)来访问它的 LAPIC。在 MMIO 中,一部分物理内存是硬编码到一些 I/O 设备的寄存器中,因此,访问内存时一般可以使用相同的 load/store 指令去访问设备的寄存器。正如你所看到的,在物理地址 0xA0000 处就是一个 IO 入口(就是我们写入 VGA 缓冲区的入口)。LAPIC 就在那里,它从物理地址 0xFE000000 处(4GB 减去 32MB 处)开始,这个地址对于我们在 KERNBASE 处使用直接映射访问来说太高了。JOS 虚拟内存映射在 MMIOBASE 处,留下一个 4MB 的空隙,以便于我们有一个地方,能像这样去映射设备。由于在后面的实验中,我们将介绍更多的 MMIO 区域,你将要写一个简单的函数,从这个区域中去分配空间,并将设备的内存映射到那里。

练习 1、实现 kern/pmap.c 中的 mmio_map_region。去看一下它是如何使用的,从 kern/lapic.c 中的 lapic_init 开始看起。在 mmio_map_region 的测试运行之前,你还要做下一个练习。
引导应用程序处理器

在引导应用程序处理器之前,引导程序处理器应该会首先去收集关于多处理器系统的信息,比如总的 CPU 数、它们的 APIC ID 以及 LAPIC 单元的 MMIO 地址。在 kern/mpconfig.c 中的 mp_init() 函数,通过读取内存中位于 BIOS 区域里的 MP 配置表来获得这些信息。

boot_aps() 函数(在 kern/init.c 中)驱动 AP 的引导过程。AP 们在实模式中开始,与 boot/boot.S 中启动引导加载程序非常相似。因此,boot_aps() 将 AP 入口代码(kern/mpentry.S)复制到实模式中的那个可寻址内存地址上。不像使用引导加载程序那样,我们可以控制 AP 将从哪里开始运行代码;我们复制入口代码到 0x7000MPENTRY_PADDR)处,但是复制到任何低于 640KB 的、未使用的、页对齐的物理地址上都是可以运行的。

在那之后,通过发送 IPI STARTUP 到相关 AP 的 LAPIC 单元,以及一个初始的 CS:IP 地址(AP 将从那儿开始运行它的入口代码,在我们的案例中是 MPENTRY_PADDR ),boot_aps() 将一个接一个地激活 AP。在 kern/mpentry.S 中的入口代码非常类似于 boot/boot.S。在一些简短的设置之后,它启用分页,使 AP 进入保护模式,然后调用 C 设置程序 mp_main()(它也在 kern/init.c 中)。在继续唤醒下一个 AP 之前, boot_aps() 将等待这个 AP 去传递一个 CPU_STARTED 标志到它的 struct CpuInfo 中的 cpu_status 字段中。

练习 2、阅读 kern/init.c 中的 boot_aps()mp_main(),以及在 kern/mpentry.S 中的汇编代码。确保你理解了在 AP 引导过程中的控制流转移。然后修改在 kern/pmap.c 中的、你自己的 page_init(),实现避免在 MPENTRY_PADDR 处添加页到空闲列表上,以便于我们能够在物理地址上安全地复制和运行 AP 引导程序代码。你的代码应该会通过更新后的 check_page_free_list() 的测试(但可能会在更新后的 check_kern_pgdir() 上测试失败,我们在后面会修复它)。

.

问题 1、比较 kern/mpentry.Sboot/boot.S。记住,那个 kern/mpentry.S 是编译和链接后的,运行在 KERNBASE 上面的,就像内核中的其它程序一样,宏 MPBOOTPHYS 的作用是什么?为什么它需要在 kern/mpentry.S 中,而不是在 boot/boot.S 中?换句话说,如果在 kern/mpentry.S 中删掉它,会发生什么错误? 提示:回顾链接地址和加载地址的区别,我们在实验 1 中讨论过它们。
每个 CPU 的状态和初始化

当写一个多处理器操作系统时,区分每个 CPU 的状态是非常重要的,而每个 CPU 的状态对其它处理器是不公开的,而全局状态是整个系统共享的。kern/cpu.h 定义了大部分每个 CPU 的状态,包括 struct CpuInfo,它保存了每个 CPU 的变量。cpunum() 总是返回调用它的那个 CPU 的 ID,它可以被用作是数组的索引,比如 cpus。或者,宏 thiscpu 是当前 CPU 的 struct CpuInfo 缩略表示。

下面是你应该知道的每个 CPU 的状态:

  • 每个 CPU 的内核栈

因为内核能够同时捕获多个 CPU,因此,我们需要为每个 CPU 准备一个单独的内核栈,以防止它们运行的程序之间产生相互干扰。数组 percpu_kstacks[NCPU][KSTKSIZE] 为 NCPU 的内核栈资产保留了空间。

在实验 2 中,你映射的 bootstack 所引用的物理内存,就作为 KSTACKTOP 以下的 BSP 的内核栈。同样,在本实验中,你将每个 CPU 的内核栈映射到这个区域,而使用保护页做为它们之间的缓冲区。CPU 0 的栈将从 KSTACKTOP 处向下增长;CPU 1 的栈将从 CPU 0 的栈底部的 KSTKGAP 字节处开始,依次类推。在 inc/memlayout.h 中展示了这个映射布局。

  • 每个 CPU 的 TSS 和 TSS 描述符

为了指定每个 CPU 的内核栈在哪里,也需要有一个每个 CPU 的任务状态描述符(TSS)。CPU i 的任务状态描述符是保存在 cpus[i].cpu_ts 中,而对应的 TSS 描述符是定义在 GDT 条目 gdt[(GD_TSS0 >> 3) + i] 中。在 kern/trap.c 中定义的全局变量 ts 将不再被使用。

  • 每个 CPU 当前的环境指针

由于每个 CPU 都能同时运行不同的用户进程,所以我们重新定义了符号 curenv,让它指向到 cpus[cpunum()].cpu_env(或 thiscpu->cpu_env),它指向到当前 CPU(代码正在运行的那个 CPU)上当前正在运行的环境上。

  • 每个 CPU 的系统寄存器

所有的寄存器,包括系统寄存器,都是一个 CPU 私有的。所以,初始化这些寄存器的指令,比如 lcr3()ltr()lgdt()lidt()、等待,必须在每个 CPU 上运行一次。函数 env_init_percpu()trap_init_percpu() 就是为此目的而定义的。

练习 3、修改 mem_init_mp()(在 kern/pmap.c 中)去映射每个 CPU 的栈从 KSTACKTOP 处开始,就像在 inc/memlayout.h 中展示的那样。每个栈的大小是 KSTKSIZE 字节加上未映射的保护页 KSTKGAP 的字节。你的代码应该会通过在 check_kern_pgdir() 中的新的检查。

.

练习 4、在 trap_init_percpu()(在 kern/trap.c 文件中)的代码为 BSP 初始化 TSS 和 TSS 描述符。在实验 3 中它就运行过,但是当它运行在其它的 CPU 上就会出错。修改这些代码以便它能在所有 CPU 上都正常运行。(注意:你的新代码应该还不能使用全局变量 ts

在你完成上述练习后,在 QEMU 中使用 4 个 CPU(使用 make qemu CPUS=4make qemu-nox CPUS=4)来运行 JOS,你应该看到类似下面的输出:

    ...
    Physical memory: 66556K available, base = 640K, extended = 65532K
    check_page_alloc() succeeded!
    check_page() succeeded!
    check_kern_pgdir() succeeded!
    check_page_installed_pgdir() succeeded!
    SMP: CPU 0 found 4 CPU(s)
    enabled interrupts: 1 2
    SMP: CPU 1 starting
    SMP: CPU 2 starting
    SMP: CPU 3 starting
锁定

mp_main() 中初始化 AP 后我们的代码快速运行起来。在你更进一步增强 AP 之前,我们需要首先去处理多个 CPU 同时运行内核代码的争用状况。达到这一目标的最简单的方法是使用大内核锁。大内核锁是一个单个的全局锁,当一个环境进入内核模式时,它将被加锁,而这个环境返回到用户模式时它将释放锁。在这种模型中,在用户模式中运行的环境可以同时运行在任何可用的 CPU 上,但是只有一个环境能够运行在内核模式中;而任何尝试进入内核模式的其它环境都被强制等待。

kern/spinlock.h 中声明大内核锁,即 kernel_lock。它也提供 lock_kernel()unlock_kernel(),快捷地去获取/释放锁。你应该在以下的四个位置应用大内核锁:

  • i386_init() 时,在 BSP 唤醒其它 CPU 之前获取锁。
  • mp_main() 时,在初始化 AP 之后获取锁,然后调用 sched_yield() 在这个 AP 上开始运行环境。
  • trap() 时,当从用户模式中捕获一个 陷阱 trap 时获取锁。在检查 tf_cs 的低位比特,以确定一个陷阱是发生在用户模式还是内核模式时。
  • env_run() 中,在切换到用户模式之前释放锁。不能太早也不能太晚,否则你将可能会产生争用或死锁。

练习 5、在上面所描述的情况中,通过在合适的位置调用 lock_kernel()unlock_kernel() 应用大内核锁。

如果你的锁定是正确的,如何去测试它?实际上,到目前为止,还无法测试!但是在下一个练习中,你实现了调度之后,就可以测试了。

.

问题 2、看上去使用一个大内核锁,可以保证在一个时间中只有一个 CPU 能够运行内核代码。为什么每个 CPU 仍然需要单独的内核栈?描述一下使用一个共享内核栈出现错误的场景,即便是在它使用了大内核锁保护的情况下。

小挑战!大内核锁很简单,也易于使用。尽管如此,它消除了内核模式的所有并发。大多数现代操作系统使用不同的锁,一种称之为细粒度锁定的方法,去保护它们的共享的栈的不同部分。细粒度锁能够大幅提升性能,但是实现起来更困难并且易出错。如果你有足够的勇气,在 JOS 中删除大内核锁,去拥抱并发吧!

由你来决定锁的粒度(一个锁保护的数据量)。给你一个提示,你可以考虑在 JOS 内核中使用一个自旋锁去确保你独占访问这些共享的组件:

  • 页分配器
  • 控制台驱动
  • 调度器
  • 你将在 Part C 中实现的进程间通讯(IPC)的状态

循环调度

本实验中,你的下一个任务是去修改 JOS 内核,以使它能够在多个环境之间以“循环”的方式去交替。JOS 中的循环调度工作方式如下:

  • 在新的 kern/sched.c 中的 sched_yield() 函数负责去选择一个新环境来运行。它按顺序以循环的方式在数组 envs[] 中进行搜索,在前一个运行的环境之后开始(或如果之前没有运行的环境,就从数组起点开始),选择状态为 ENV_RUNNABLE 的第一个环境(查看 inc/env.h),并调用 env_run() 去跳转到那个环境。
  • sched_yield() 必须做到,同一个时间在两个 CPU 上绝对不能运行相同的环境。它可以判断出一个环境正运行在一些 CPU(可能是当前 CPU)上,因为,那个正在运行的环境的状态将是 ENV_RUNNING
  • 我们已经为你实现了一个新的系统调用 sys_yield(),用户环境调用它去调用内核的 sched_yield() 函数,并因此将自愿把对 CPU 的控制禅让给另外的一个环境。

练习 6、像上面描述的那样,在 sched_yield() 中实现循环调度。不要忘了去修改 syscall() 以派发 sys_yield()

确保在 mp_main 中调用了 sched_yield()

修改 kern/init.c 去创建三个(或更多个!)运行程序 user/yield.c的环境。

运行 make qemu。在它终止之前,你应该会看到像下面这样,在环境之间来回切换了五次。

也可以使用几个 CPU 来测试:make qemu CPUS=2

...
Hello, I am environment 00001000.
Hello, I am environment 00001001.
Hello, I am environment 00001002.
Back in environment 00001000, iteration 0.
Back in environment 00001001, iteration 0.
Back in environment 00001002, iteration 0.
Back in environment 00001000, iteration 1.
Back in environment 00001001, iteration 1.
Back in environment 00001002, iteration 1.
...

在程序 yield 退出之后,系统中将没有可运行的环境,调度器应该会调用 JOS 内核监视器。如果它什么也没有发生,那么你应该在继续之前修复你的代码。

问题 3、在你实现的 env_run() 中,你应该会调用 lcr3()。在调用 lcr3() 的之前和之后,你的代码引用(至少它应该会)变量 e,它是 env_run 的参数。在加载 %cr3 寄存器时,MMU 使用的地址上下文将马上被改变。但一个虚拟地址(即 e)相对一个给定的地址上下文是有意义的 —— 地址上下文指定了物理地址到那个虚拟地址的映射。为什么指针 e 在地址切换之前和之后被解除引用?

.

问题 4、无论何时,内核从一个环境切换到另一个环境,它必须要确保旧环境的寄存器内容已经被保存,以便于它们稍后能够正确地还原。为什么?这种事件发生在什么地方?

.

小挑战!给内核添加一个小小的调度策略,比如一个固定优先级的调度器,它将会给每个环境分配一个优先级,并且在执行中,较高优先级的环境总是比低优先级的环境优先被选定。如果你想去冒险一下,尝试实现一个类 Unix 的、优先级可调整的调度器,或者甚至是一个彩票调度器或跨步调度器。(可以在 Google 中查找“彩票调度”和“跨步调度”的相关资料)

写一个或两个测试程序,去测试你的调度算法是否工作正常(即,正确的算法能够按正确的次序运行)。如果你实现了本实验的 Part B 和 Part C 部分的 fork() 和 IPC,写这些测试程序可能会更容易。

.

小挑战!目前的 JOS 内核还不能应用到使用了 x87 协处理器、MMX 指令集、或流式 SIMD 扩展(SSE)的 x86 处理器上。扩展数据结构 Env 去提供一个能够保存处理器的浮点状态的地方,并且扩展上下文切换代码,当从一个环境切换到另一个环境时,能够保存和还原正确的状态。FXSAVEFXRSTOR 指令或许对你有帮助,但是需要注意的是,这些指令在旧的 x86 用户手册上没有,因为它是在较新的处理器上引入的。写一个用户级的测试程序,让它使用浮点做一些很酷的事情。

创建环境的系统调用

虽然你的内核现在已经有了在多个用户级环境之间切换的功能,但是由于内核初始化设置的原因,它在运行环境时仍然是受限的。现在,你需要去实现必需的 JOS 系统调用,以允许用户环境去创建和启动其它的新用户环境。

Unix 提供了 fork() 系统调用作为它的进程创建原语。Unix 的 fork() 通过复制调用进程(父进程)的整个地址空间去创建一个新进程(子进程)。从用户空间中能够观察到它们之间的仅有的两个差别是,它们的进程 ID 和父进程 ID(由 getpidgetppid 返回)。在父进程中,fork() 返回子进程 ID,而在子进程中,fork() 返回 0。默认情况下,每个进程得到它自己的私有地址空间,一个进程对内存的修改对另一个进程都是不可见的。

为创建一个用户模式下的新的环境,你将要提供一个不同的、更原始的 JOS 系统调用集。使用这些系统调用,除了其它类型的环境创建之外,你可以在用户空间中实现一个完整的类 Unix 的 fork()。你将要为 JOS 编写的新的系统调用如下:

  • sys_exofork

这个系统调用创建一个新的空白的环境:在它的地址空间的用户部分什么都没有映射,并且它也不能运行。这个新的环境与 sys_exofork 调用时创建它的父环境的寄存器状态完全相同。在父进程中,sys_exofork 将返回新创建进程的 envid_t(如果环境分配失败的话,返回的是一个负的错误代码)。在子进程中,它将返回 0。(因为子进程从一开始就被标记为不可运行,在子进程中,sys_exofork 将并不真的返回,直到它的父进程使用 …. 显式地将子进程标记为可运行之前。)

  • sys_env_set_status

设置指定的环境状态为 ENV_RUNNABLEENV_NOT_RUNNABLE。这个系统调用一般是在,一个新环境的地址空间和寄存器状态已经完全初始化完成之后,用于去标记一个准备去运行的新环境。

  • sys_page_alloc

分配一个物理内存页,并映射它到一个给定的环境地址空间中、给定的一个虚拟地址上。

  • sys_page_map

从一个环境的地址空间中复制一个页映射(不是页内容!)到另一个环境的地址空间中,保持一个内存共享,以便于新的和旧的映射共同指向到同一个物理内存页。

  • sys_page_unmap

在一个给定的环境中,取消映射一个给定的已映射的虚拟地址。

上面所有的系统调用都接受环境 ID 作为参数,JOS 内核支持一个约定,那就是用值 “0” 来表示“当前环境”。这个约定在 kern/env.c 中的 envid2env() 中实现的。

在我们的 user/dumbfork.c 中的测试程序里,提供了一个类 Unix 的 fork() 的非常原始的实现。这个测试程序使用了上面的系统调用,去创建和运行一个复制了它自己地址空间的子环境。然后,这两个环境像前面的练习那样使用 sys_yield 来回切换,父进程在迭代 10 次后退出,而子进程在迭代 20 次后退出。

练习 7、在 kern/syscall.c 中实现上面描述的系统调用,并确保 syscall() 能调用它们。你将需要使用 kern/pmap.ckern/env.c 中的多个函数,尤其是要用到 envid2env()。目前,每当你调用 envid2env() 时,在 checkperm 中传递参数 1。你务必要做检查任何无效的系统调用参数,在那个案例中,就返回了 -E_INVAL。使用 user/dumbfork 测试你的 JOS 内核,并在继续之前确保它运行正常。

.

小挑战!添加另外的系统调用,必须能够读取已存在的、所有的、环境的重要状态,以及设置它们。然后实现一个能够 fork 出子环境的用户模式程序,运行它一小会(即,迭代几次 sys_yield()),然后取得几张屏幕截图或子环境的检查点,然后运行子环境一段时间,然后还原子环境到检查点时的状态,然后从这里继续开始。这样,你就可以有效地从一个中间状态“回放”了子环境的运行。确保子环境与用户使用 sys_cgetc()readline() 执行了一些交互,这样,那个用户就能够查看和突变它的内部状态,并且你可以通过给子环境给定一个选择性遗忘的状况,来验证你的检查点/重启动的有效性,使它“遗忘”了在某些点之前发生的事情。

到此为止,已经完成了本实验的 Part A 部分;在你运行 make grade 之前确保它通过了所有的 Part A 的测试,并且和以往一样,使用 make handin 去提交它。如果你想尝试找出为什么一些特定的测试是失败的,可以运行 run ./grade-lab4 -v,它将向你展示内核构建的输出,和测试失败时的 QEMU 运行情况。当测试失败时,这个脚本将停止运行,然后你可以去检查 jos.out 的内容,去查看内核真实的输出内容。

Part B:写时复制 Fork

正如在前面提到过的,Unix 提供 fork() 系统调用作为它主要的进程创建原语。fork() 系统调用通过复制调用进程(父进程)的地址空间来创建一个新进程(子进程)。

xv6 Unix 的 fork() 从父进程的页上复制所有数据,然后将它分配到子进程的新页上。从本质上看,它与 dumbfork() 所采取的方法是相同的。复制父进程的地址空间到子进程,是 fork() 操作中代价最高的部分。

但是,一个对 fork() 的调用后,经常是紧接着几乎立即在子进程中有一个到 exec() 的调用,它使用一个新程序来替换子进程的内存。这是 shell 默认去做的事,在这种情况下,在复制父进程地址空间上花费的时间是非常浪费的,因为在调用 exec() 之前,子进程使用的内存非常少。

基于这个原因,Unix 的最新版本利用了虚拟内存硬件的优势,允许父进程和子进程去共享映射到它们各自地址空间上的内存,直到其中一个进程真实地修改了它们为止。这个技术就是众所周知的“写时复制”。为实现这一点,在 fork() 时,内核将复制从父进程到子进程的地址空间的映射,而不是所映射的页的内容,并且同时设置正在共享中的页为只读。当两个进程中的其中一个尝试去写入到它们共享的页上时,进程将产生一个页故障。在这时,Unix 内核才意识到那个页实际上是“虚拟的”或“写时复制”的副本,然后它生成一个新的、私有的、那个发生页故障的进程可写的、页的副本。在这种方式中,个人的页的内容并不进行真实地复制,直到它们真正进行写入时才进行复制。这种优化使得一个fork() 后在子进程中跟随一个 exec() 变得代价很低了:子进程在调用 exec() 时或许仅需要复制一个页(它的栈的当前页)。

在本实验的下一段中,你将实现一个带有“写时复制”的“真正的”类 Unix 的 fork(),来作为一个常规的用户空间库。在用户空间中实现 fork() 和写时复制有一个好处就是,让内核始终保持简单,并且因此更不易出错。它也让个别的用户模式程序在 fork() 上定义了它们自己的语义。一个有略微不同实现的程序(例如,代价昂贵的、总是复制的 dumbfork() 版本,或父子进程真实共享内存的后面的那一个),它自己可以很容易提供。

用户级页故障处理

一个用户级写时复制 fork() 需要知道关于在写保护页上的页故障相关的信息,因此,这是你首先需要去实现的东西。对用户级页故障处理来说,写时复制仅是众多可能的用途之一。

它通常是配置一个地址空间,因此在一些动作需要时,那个页故障将指示去处。例如,主流的 Unix 内核在一个新进程的栈区域中,初始的映射仅是单个页,并且在后面“按需”分配和映射额外的栈页,因此,进程的栈消费是逐渐增加的,并因此导致在尚未映射的栈地址上发生页故障。在每个进程空间的区域上发生一个页故障时,一个典型的 Unix 内核必须对它的动作保持跟踪。例如,在栈区域中的一个页故障,一般情况下将分配和映射新的物理内存页。一个在程序的 BSS 区域中的页故障,一般情况下将分配一个新页,然后用 0 填充它并映射它。在一个按需分页的系统上的一个可执行文件中,在文本区域中的页故障将从磁盘上读取相应的二进制页并映射它。

内核跟踪有大量的信息,与传统的 Unix 方法不同,你将决定在每个用户空间中关于每个页故障应该做的事。用户空间中的 bug 危害都较小。这种设计带来了额外的好处,那就是允许程序员在定义它们的内存区域时,会有很好的灵活性;对于映射和访问基于磁盘文件系统上的文件时,你应该使用后面的用户级页故障处理。

设置页故障服务程序

为了处理它自己的页故障,一个用户环境将需要在 JOS 内核上注册一个页故障服务程序入口。用户环境通过新的 sys_env_set_pgfault_upcall 系统调用来注册它的页故障入口。我们给结构 Env 增加了一个新的成员 env_pgfault_upcall,让它去记录这个信息。

练习 8、实现 sys_env_set_pgfault_upcall 系统调用。当查找目标环境的环境 ID 时,一定要确认启用了权限检查,因为这是一个“危险的”系统调用。 “`
在用户环境中的正常和异常栈

在正常运行期间,JOS 中的一个用户环境运行在正常的用户栈上:它的 ESP 寄存器开始指向到 USTACKTOP,而它所推送的栈数据将驻留在 USTACKTOP-PGSIZEUSTACKTOP-1(含)之间的页上。但是,当在用户模式中发生页故障时,内核将在一个不同的栈上重新启动用户环境,运行一个用户级页故障指定的服务程序,即用户异常栈。其它,我们将让 JOS 内核为用户环境实现自动的“栈切换”,当从用户模式转换到内核模式时,x86 处理器就以大致相同的方式为 JOS 实现了栈切换。

JOS 用户异常栈也是一个页的大小,并且它的顶部被定义在虚拟地址 UXSTACKTOP 处,因此用户异常栈的有效字节数是从 UXSTACKTOP-PGSIZEUXSTACKTOP-1(含)。尽管运行在异常栈上,用户页故障服务程序能够使用 JOS 的普通系统调用去映射新页或调整映射,以便于去修复最初导致页故障发生的各种问题。然后用户级页故障服务程序通过汇编语言 stub 返回到原始栈上的故障代码。

每个想去支持用户级页故障处理的用户环境,都需要为它自己的异常栈使用在 Part A 中介绍的 sys_page_alloc() 系统调用去分配内存。

调用用户页故障服务程序

现在,你需要去修改 kern/trap.c 中的页故障处理代码,以能够处理接下来在用户模式中发生的页故障。我们将故障发生时用户环境的状态称之为捕获时状态。

如果这里没有注册页故障服务程序,JOS 内核将像前面那样,使用一个消息来销毁用户环境。否则,内核将在异常栈上设置一个陷阱帧,它看起来就像是来自 inc/trap.h 文件中的一个 struct UTrapframe 一样:

                      <-- UXSTACKTOP
    trap-time esp
    trap-time eflags
    trap-time eip
    trap-time eax     start of struct PushRegs
    trap-time ecx
    trap-time edx
    trap-time ebx
    trap-time esp
    trap-time ebp
    trap-time esi
    trap-time edi      end of struct PushRegs
    tf_err (error code)
    fault_va           <-- %esp when handler is run

然后,内核安排这个用户环境重新运行,使用这个栈帧在异常栈上运行页故障服务程序;你必须搞清楚为什么发生这种情况。fault_va 是引发页故障的虚拟地址。

如果在一个异常发生时,用户环境已经在用户异常栈上运行,那么页故障服务程序自身将会失败。在这种情况下,你应该在当前的 tf->tf_esp 下,而不是在 UXSTACKTOP 下启动一个新的栈帧。

去测试 tf->tf_esp 是否已经在用户异常栈上准备好,可以去检查它是否在 UXSTACKTOP-PGSIZEUXSTACKTOP-1(含)的范围内。

练习 9、实现在 kern/trap.c 中的 page_fault_handler 的代码,要求派发页故障到用户模式故障服务程序上。在写入到异常栈时,一定要采取适当的预防措施。(如果用户环境运行时溢出了异常栈,会发生什么事情?)
用户模式页故障入口点

接下来,你需要去实现汇编程序,它将调用 C 页故障服务程序,并在原始的故障指令处恢复程序运行。这个汇编程序是一个故障服务程序,它由内核使用 sys_env_set_pgfault_upcall() 来注册。

练习 10、实现在 lib/pfentry.S 中的 _pgfault_upcall 程序。最有趣的部分是返回到用户代码中产生页故障的原始位置。你将要直接返回到那里,不能通过内核返回。最难的部分是同时切换栈和重新加载 EIP。

最后,你需要去实现用户级页故障处理机制的 C 用户库。

练习 11、完成 lib/pgfault.c 中的 set_pgfault_handler()。 ”`
测试

运行 user/faultread(make run-faultread)你应该会看到:

    ...
    [00000000] new env 00001000
    [00001000] user fault va 00000000 ip 0080003a
    TRAP frame ...
    [00001000] free env 00001000

运行 user/faultdie 你应该会看到:

    ...
    [00000000] new env 00001000
    i faulted at va deadbeef, err 6
    [00001000] exiting gracefully
    [00001000] free env 00001000

运行 user/faultalloc 你应该会看到:

    ...
    [00000000] new env 00001000
    fault deadbeef
    this string was faulted in at deadbeef
    fault cafebffe
    fault cafec000
    this string was faulted in at cafebffe
    [00001000] exiting gracefully
    [00001000] free env 00001000

如果你只看到第一个 “this string” 行,意味着你没有正确地处理递归页故障。

运行 user/faultallocbad 你应该会看到:

    ...
    [00000000] new env 00001000
    [00001000] user_mem_check assertion failure for va deadbeef
    [00001000] free env 00001000

确保你理解了为什么 user/faultallocuser/faultallocbad 的行为是不一样的。

小挑战!扩展你的内核,让它不仅是页故障,而是在用户空间中运行的代码能够产生的所有类型的处理器异常,都能够被重定向到一个用户模式中的异常服务程序上。写出用户模式测试程序,去测试各种各样的用户模式异常处理,比如除零错误、一般保护故障、以及非法操作码。

实现写时复制 Fork

现在,你有个内核功能要去实现,那就是在用户空间中完整地实现写时复制 fork()

我们在 lib/fork.c 中为你的 fork() 提供了一个框架。像 dumbfork()fork() 应该会创建一个新环境,然后通过扫描父环境的整个地址空间,并在子环境中设置相关的页映射。重要的差别在于,dumbfork() 复制了页,而 fork() 开始只是复制了页映射。fork() 仅当在其中一个环境尝试去写入它时才复制每个页。

fork() 的基本控制流如下:

  1. 父环境使用你在上面实现的 set_pgfault_handler() 函数,安装 pgfault() 作为 C 级页故障服务程序。
  2. 父环境调用 sys_exofork() 去创建一个子环境。
  3. 在它的地址空间中,低于 UTOP 位置的、每个可写入页、或写时复制页上,父环境调用 duppage 后,它应该会映射页写时复制到子环境的地址空间中,然后在它自己的地址空间中重新映射页写时复制。[ 注意:这里的顺序很重要(即,在父环境中标记之前,先在子环境中标记该页为 COW)!你能明白是为什么吗?尝试去想一个具体的案例,将顺序颠倒一下会发生什么样的问题。] duppage 把两个 PTE 都设置了,致使那个页不可写入,并且在 “avail” 字段中通过包含 PTE_COW 来从真正的只读页中区分写时复制页。

然而异常栈是不能通过这种方式重映射的。对于异常栈,你需要在子环境中分配一个新页。因为页故障服务程序不能做真实的复制,并且页故障服务程序是运行在异常栈上的,异常栈不能进行写时复制:那么谁来复制它呢?

fork() 也需要去处理存在的页,但不能写入或写时复制。

  1. 父环境为子环境设置了用户页故障入口点,让它看起来像它自己的一样。
  2. 现在,子环境准备去运行,所以父环境标记它为可运行。

每次其中一个环境写一个还没有写入的写时复制页时,它将产生一个页故障。下面是用户页故障服务程序的控制流:

  1. 内核传递页故障到 _pgfault_upcall,它调用 fork()pgfault() 服务程序。
  2. pgfault() 检测到那个故障是一个写入(在错误代码中检查 FEC_WR),然后将那个页的 PTE 标记为 PTE_COW。如果不是一个写入,则崩溃。
  3. pgfault() 在一个临时位置分配一个映射的新页,并将故障页的内容复制进去。然后,故障服务程序以读取/写入权限映射新页到合适的地址,替换旧的只读映射。

对于上面的几个操作,用户级 lib/fork.c 代码必须查询环境的页表(即,那个页的 PTE 是否标记为 PET_COW)。为此,内核在 UVPT 位置精确地映射环境的页表。它使用一个 聪明的映射技巧 去标记它,以使用户代码查找 PTE 时更容易。lib/entry.S 设置 uvptuvpd,以便于你能够在 lib/fork.c 中轻松查找页表信息。

练习 12、在 lib/fork.c 中实现 forkduppagepgfault

使用 forktree 程序测试你的代码。它应该会产生下列的信息,在信息中会有 ‘new env'、'free env'、和 'exiting gracefully’ 这样的字眼。信息可能不是按如下的顺序出现的,并且环境 ID 也可能不一样。

        1000: I am ''
        1001: I am '0'
        2000: I am '00'
        2001: I am '000'
        1002: I am '1'
        3000: I am '11'
        3001: I am '10'
        4000: I am '100'
        1003: I am '01'
        5000: I am '010'
        4001: I am '011'
        2002: I am '110'
        1004: I am '001'
        1005: I am '111'
        1006: I am '101'

.

小挑战!实现一个名为 sfork() 的共享内存的 fork()。这个版本的 sfork() 中,父子环境共享所有的内存页(因此,一个环境中对内存写入,就会改变另一个环境数据),除了在栈区域中的页以外,它应该使用写时复制来处理这些页。修改 user/forktree.c 去使用 sfork() 而是不常见的 fork()。另外,你在 Part C 中实现了 IPC 之后,使用你的 sfork() 去运行 user/pingpongs。你将找到提供全局指针 thisenv 功能的一个新方式。

.

小挑战!你实现的 fork 将产生大量的系统调用。在 x86 上,使用中断切换到内核模式将产生较高的代价。增加系统调用接口,以便于它能够一次发送批量的系统调用。然后修改 fork 去使用这个接口。

你的新的 fork 有多快?

你可以用一个分析来论证,批量提交对你的 fork 的性能改变,以它来(粗略地)回答这个问题:使用一个 int 0x30 指令的代价有多高?在你的 fork 中运行了多少次 int 0x30 指令?访问 TSS 栈切换的代价高吗?等待 …

或者,你可以在真实的硬件上引导你的内核,并且真实地对你的代码做基准测试。查看 RDTSC(读取时间戳计数器)指令,它的定义在 IA32 手册中,它计数自上一次处理器重置以来流逝的时钟周期数。QEMU 并不能真实地模拟这个指令(它能够计数运行的虚拟指令数量,或使用主机的 TSC,但是这两种方式都不能反映真实的 CPU 周期数)。

到此为止,Part B 部分结束了。在你运行 make grade 之前,确保你通过了所有的 Part B 部分的测试。和以前一样,你可以使用 make handin 去提交你的实验。

Part C:抢占式多任务处理和进程间通讯(IPC)

在实验 4 的最后部分,你将修改内核去抢占不配合的环境,并允许环境之间显式地传递消息。

时钟中断和抢占

运行测试程序 user/spin。这个测试程序 fork 出一个子环境,它控制了 CPU 之后,就永不停歇地运转起来。无论是父环境还是内核都不能回收对 CPU 的控制。从用户模式环境中保护系统免受 bug 或恶意代码攻击的角度来看,这显然不是个理想的状态,因为任何用户模式环境都能够通过简单的无限循环,并永不归还 CPU 控制权的方式,让整个系统处于暂停状态。为了允许内核去抢占一个运行中的环境,从其中夺回对 CPU 的控制权,我们必须去扩展 JOS 内核,以支持来自硬件时钟的外部硬件中断。

中断规则

外部中断(即:设备中断)被称为 IRQ。现在有 16 个可能出现的 IRQ,编号 0 到 15。从 IRQ 号到 IDT 条目的映射是不固定的。在 picirq.c 中的 pic_init 映射 IRQ 0 - 15 到 IDT 条目 IRQ_OFFSETIRQ_OFFSET+15

inc/trap.h 中,IRQ_OFFSET 被定义为十进制的 32。所以,IDT 条目 32 - 47 对应 IRQ 0 - 15。例如,时钟中断是 IRQ 0,所以 IDT[IRQ\_OFFSET+0](即:IDT[32])包含了内核中时钟中断服务程序的地址。这里选择 IRQ_OFFSET 是为了处理器异常不会覆盖设备中断,因为它会引起显而易见的混淆。(事实上,在早期运行 MS-DOS 的 PC 上, IRQ_OFFSET 事实上是 0,它确实导致了硬件中断服务程序和处理器异常处理之间的混淆!)

在 JOS 中,相比 xv6 Unix 我们做了一个重要的简化。当处于内核模式时,外部设备中断总是被关闭(并且,像 xv6 一样,当处于用户空间时,再打开外部设备的中断)。外部中断由 %eflags 寄存器的 FL_IF 标志位来控制(查看 inc/mmu.h)。当这个标志位被设置时,外部中断被打开。虽然这个标志位可以使用几种方式来修改,但是为了简化,我们只通过进程所保存和恢复的 %eflags 寄存器值,作为我们进入和离开用户模式的方法。

处于用户环境中时,你将要确保 FL_IF 标志被设置,以便于出现一个中断时,它能够通过处理器来传递,让你的中断代码来处理。否则,中断将被屏蔽或忽略,直到中断被重新打开后。我们使用引导加载程序的第一个指令去屏蔽中断,并且到目前为止,还没有去重新打开它们。

练习 13、修改 kern/trapentry.Skern/trap.c 去初始化 IDT 中的相关条目,并为 IRQ 0 到 15 提供服务程序。然后修改 kern/env.c 中的 env_alloc() 的代码,以确保在用户环境中,中断总是打开的。

另外,在 sched_halt() 中取消注释 sti 指令,以便于空闲的 CPU 取消屏蔽中断。

当调用一个硬件中断服务程序时,处理器不会推送一个错误代码。在这个时候,你可能需要重新阅读 80386 参考手册 的 9.2 节,或 IA-32 Intel 架构软件开发者手册 卷 3 的 5.8 节。

在完成这个练习后,如果你在你的内核上使用任意的测试程序去持续运行(即:spin),你应该会看到内核输出中捕获的硬件中断的捕获帧。虽然在处理器上已经打开了中断,但是 JOS 并不能处理它们,因此,你应该会看到在当前运行的用户环境中每个中断的错误属性并被销毁,最终环境会被销毁并进入到监视器中。

处理时钟中断

user/spin 程序中,子环境首先运行之后,它只是进入一个高速循环中,并且内核再无法取得 CPU 控制权。我们需要对硬件编程,定期产生时钟中断,它将强制将 CPU 控制权返还给内核,在内核中,我们就能够将控制权切换到另外的用户环境中。

我们已经为你写好了对 lapic_initpic_init(来自 init.c 中的 i386_init)的调用,它将设置时钟和中断控制器去产生中断。现在,你需要去写代码来处理这些中断。

练习 14、修改内核的 trap_dispatch() 函数,以便于在时钟中断发生时,它能够调用 sched_yield() 去查找和运行一个另外的环境。

现在,你应该能够用 user/spin 去做测试了:父环境应该会 fork 出子环境,sys_yield() 到它许多次,但每次切换之后,将重新获得对 CPU 的控制权,最后杀死子环境后优雅地终止。

这是做回归测试的好机会。确保你没有弄坏本实验的前面部分,确保打开中断能够正常工作(即: forktree)。另外,尝试使用 make CPUS=2 target 在多个 CPU 上运行它。现在,你应该能够通过 stresssched 测试。可以运行 make grade 去确认。现在,你的得分应该是 65 分了(总分为 80)。

进程间通讯(IPC)

(严格来说,在 JOS 中这是“环境间通讯” 或 “IEC”,但所有人都称它为 IPC,因此我们使用标准的术语。)

我们一直专注于操作系统的隔离部分,这就产生了一种错觉,好像每个程序都有一个机器完整地为它服务。一个操作系统的另一个重要服务是,当它们需要时,允许程序之间相互通讯。让程序与其它程序交互可以让它的功能更加强大。Unix 的管道模型就是一个权威的示例。

进程间通讯有许多模型。关于哪个模型最好的争论从来没有停止过。我们不去参与这种争论。相反,我们将要实现一个简单的 IPC 机制,然后尝试使用它。

JOS 中的 IPC

你将要去实现另外几个 JOS 内核的系统调用,由它们共同来提供一个简单的进程间通讯机制。你将要实现两个系统调用,sys_ipc_recvsys_ipc_try_send。然后你将要实现两个库去封装 ipc_recvipc_send

用户环境可以使用 JOS 的 IPC 机制相互之间发送 “消息” 到每个其它环境,这些消息有两部分组成:一个单个的 32 位值,和可选的一个单个页映射。允许环境在消息中传递页映射,提供了一个高效的方式,传输比一个仅适合单个的 32 位整数更多的数据,并且也允许环境去轻松地设置安排共享内存。

发送和接收消息

一个环境通过调用 sys_ipc_recv 去接收消息。这个系统调用将取消对当前环境的调度,并且不会再次去运行它,直到消息被接收为止。当一个环境正在等待接收一个消息时,任何其它环境都能够给它发送一个消息 — 而不仅是一个特定的环境,而且不仅是与接收环境有父子关系的环境。换句话说,你在 Part A 中实现的权限检查将不会应用到 IPC 上,因为 IPC 系统调用是经过慎重设计的,因此可以认为它是“安全的”:一个环境并不能通过给它发送消息导致另一个环境发生故障(除非目标环境也存在 Bug)。

尝试去发送一个值时,一个环境使用接收者的 ID 和要发送的值去调用 sys_ipc_try_send 来发送。如果指定的环境正在接收(它调用了 sys_ipc_recv,但尚未收到值),那么这个环境将去发送消息并返回 0。否则将返回 -E_IPC_NOT_RECV 来表示目标环境当前不希望来接收值。

在用户空间中的一个库函数 ipc_recv 将去调用 sys_ipc_recv,然后,在当前环境的 struct Env 中查找关于接收到的值的相关信息。

同样,一个库函数 ipc_send 将去不停地调用 sys_ipc_try_send 来发送消息,直到发送成功为止。

转移页

当一个环境使用一个有效的 dstva 参数(低于 UTOP)去调用 sys_ipc_recv 时,环境将声明愿意去接收一个页映射。如果发送方发送一个页,那么那个页应该会被映射到接收者地址空间的 dstva 处。如果接收者在 dstva 已经有了一个页映射,那么已存在的那个页映射将被取消映射。

当一个环境使用一个有效的 srcva 参数(低于 UTOP)去调用 sys_ipc_try_send 时,意味着发送方希望使用 perm 权限去发送当前映射在 srcva 处的页给接收方。在 IPC 成功之后,发送方在它的地址空间中,保留了它最初映射到 srcva 位置的页。而接收方也获得了最初由它指定的、在它的地址空间中的 dstva 处的、映射到相同物理页的映射。最后的结果是,这个页成为发送方和接收方共享的页。

如果发送方和接收方都没有表示要转移这个页,那么就不会有页被转移。在任何 IPC 之后,内核将在接收方的 Env 结构上设置新的 env_ipc_perm 字段,以允许接收页,或者将它设置为 0,表示不再接收。

实现 IPC

练习 15、实现 kern/syscall.c 中的 sys_ipc_recvsys_ipc_try_send。在实现它们之前一起阅读它们的注释信息,因为它们要一起工作。当你在这些程序中调用 envid2env 时,你应该去设置 checkperm 的标志为 0,这意味着允许任何环境去发送 IPC 消息到另外的环境,并且内核除了验证目标 envid 是否有效外,不做特别的权限检查。

接着实现 lib/ipc.c 中的 ipc_recvipc_send 函数。

使用 user/pingponguser/primes 函数去测试你的 IPC 机制。user/primes 将为每个质数生成一个新环境,直到 JOS 耗尽环境为止。你可能会发现,阅读 user/primes.c 非常有趣,你将看到所有的 fork 和 IPC 都是在幕后进行。

.

小挑战!为什么 ipc_send 要循环调用?修改系统调用接口,让它不去循环。确保你能处理多个环境尝试同时发送消息到一个环境上的情况。

.

小挑战!质数筛选是在大规模并发程序中传递消息的一个很巧妙的用法。阅读 C. A. R. Hoare 写的 《Communicating Sequential Processes》,Communications of the ACM\_ 21(8) (August 1978), 666-667,并去实现矩阵乘法示例。

.

小挑战!控制消息传递的最令人印象深刻的一个例子是,Doug McIlroy 的幂序列计算器,它在 M. Douglas McIlroy,《Squinting at Power Series》,Software–Practice and Experience, 20(7) (July 1990),661-683 中做了详细描述。实现了它的幂序列计算器,并且计算了 sin ( x + x 3) 的幂序列。

.

小挑战!通过应用 Liedtke 的论文(通过内核设计改善 IPC 性能)中的一些技术、或你可以想到的其它技巧,来让 JOS 的 IPC 机制更高效。为此,你可以随意修改内核的系统调用 API,只要你的代码向后兼容我们的评级脚本就行。

Part C 到此结束了。确保你通过了所有的评级测试,并且不要忘了将你的小挑战的答案写入到 answers-lab4.txt 中。

在动手实验之前, 使用 git statusgit diff 去检查你的更改,并且不要忘了去使用 git add answers-lab4.txt 添加你的小挑战的答案。在你全部完成后,使用 git commit -am 'my solutions to lab 4’ 提交你的更改,然后 make handin 并关注它的动向。


via: https://pdos.csail.mit.edu/6.828/2018/labs/lab4/

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

简介

在本实验中,你将要实现一个基本的内核功能,要求它能够保护运行的用户模式环境(即:进程)。你将去增强这个 JOS 内核,去配置数据结构以便于保持对用户环境的跟踪、创建一个单一用户环境、将程序镜像加载到用户环境中、并将它启动运行。你也要写出一些 JOS 内核的函数,用来处理任何用户环境生成的系统调用,以及处理由用户环境引进的各种异常。

注意: 在本实验中,术语“环境”“进程” 是可互换的 —— 它们都表示同一个抽象概念,那就是允许你去运行的程序。我在介绍中使用术语“环境”而不是使用传统术语“进程”的目的是为了强调一点,那就是 JOS 的环境和 UNIX 的进程提供了不同的接口,并且它们的语义也不相同。

预备知识

使用 Git 去提交你自实验 2 以后的更改(如果有的话),获取课程仓库的最新版本,以及创建一个命名为 lab3 的本地分支,指向到我们的 lab3 分支上 origin/lab3

athena% cd ~/6.828/lab
athena% add git
athena% git commit -am 'changes to lab2 after handin'
Created commit 734fab7: changes to lab2 after handin
 4 files changed, 42 insertions(+), 9 deletions(-)
athena% git pull
Already up-to-date.
athena% git checkout -b lab3 origin/lab3
Branch lab3 set up to track remote branch refs/remotes/origin/lab3.
Switched to a new branch "lab3"
athena% git merge lab2
Merge made by recursive.
 kern/pmap.c |   42 +++++++++++++++++++
 1 files changed, 42 insertions(+), 0 deletions(-)
athena% 

实验 3 包含一些你将探索的新源文件:

inc/    env.h       Public definitions for user-mode environments
        trap.h      Public definitions for trap handling
        syscall.h   Public definitions for system calls from user environments to the kernel
        lib.h       Public definitions for the user-mode support library
kern/   env.h       Kernel-private definitions for user-mode environments
        env.c       Kernel code implementing user-mode environments
        trap.h      Kernel-private trap handling definitions
        trap.c      Trap handling code
        trapentry.S Assembly-language trap handler entry-points
        syscall.h   Kernel-private definitions for system call handling
        syscall.c   System call implementation code
lib/    Makefrag    Makefile fragment to build user-mode library, obj/lib/libjos.a
        entry.S     Assembly-language entry-point for user environments
        libmain.c   User-mode library setup code called from entry.S
        syscall.c   User-mode system call stub functions
        console.c   User-mode implementations of putchar and getchar, providing console I/O
        exit.c      User-mode implementation of exit
        panic.c     User-mode implementation of panic
user/   *           Various test programs to check kernel lab 3 code

另外,一些在实验 2 中的源文件在实验 3 中将被修改。如果想去查看有什么更改,可以运行:

$ git diff lab2

你也可以另外去看一下 实验工具指南,它包含了与本实验有关的调试用户代码方面的信息。

实验要求

本实验分为两部分:Part A 和 Part B。Part A 在本实验完成后一周内提交;你将要提交你的更改和完成的动手实验,在提交之前要确保你的代码通过了 Part A 的所有检查(如果你的代码未通过 Part B 的检查也可以提交)。只需要在第二周提交 Part B 的期限之前代码检查通过即可。

由于在实验 2 中,你需要做实验中描述的所有正则表达式练习,并且至少通过一个挑战(是指整个实验,不是每个部分)。写出详细的问题答案并张贴在实验中,以及一到两个段落的关于你如何解决你选择的挑战问题的详细描述,并将它放在一个名为 answers-lab3.txt 的文件中,并将这个文件放在你的 lab 目标的根目录下。(如果你做了多个问题挑战,你仅需要提交其中一个即可)不要忘记使用 git add answers-lab3.txt 提交这个文件。

行内汇编语言

在本实验中你可能发现使用了 GCC 的行内汇编语言特性,虽然不使用它也可以完成实验。但至少你需要去理解这些行内汇编语言片段,这些汇编语言(asm 语句)片段已经存在于提供给你的源代码中。你可以在课程 参考资料 的页面上找到 GCC 行内汇编语言有关的信息。

Part A:用户环境和异常处理

新文件 inc/env.h 中包含了在 JOS 中关于用户环境的基本定义。现在就去阅读它。内核使用数据结构 Env 去保持对每个用户环境的跟踪。在本实验的开始,你将只创建一个环境,但你需要去设计 JOS 内核支持多环境;实验 4 将带来这个高级特性,允许用户环境去 fork 其它环境。

正如你在 kern/env.c 中所看到的,内核维护了与环境相关的三个全局变量:

struct Env *envs = NULL;            // All environments
struct Env *curenv = NULL;          // The current env
static struct Env *env_free_list;   // Free environment list

一旦 JOS 启动并运行,envs 指针指向到一个数组,即数据结构 Env,它保存了系统中全部的环境。在我们的设计中,JOS 内核将同时支持最大值为 NENV 个的活动的环境,虽然在一般情况下,任何给定时刻运行的环境很少。(NENV 是在 inc/env.h 中用 #define 定义的一个常量)一旦它被分配,对于每个 NENV 可能的环境,envs 数组将包含一个数据结构 Env 的单个实例。

JOS 内核在 env_free_list 上用数据结构 Env 保存了所有不活动的环境。这样的设计使得环境的分配和回收很容易,因为这只不过是添加或删除空闲列表的问题而已。

内核使用符号 curenv 来保持对任意给定时刻的 当前正在运行的环境 进行跟踪。在系统引导期间,在第一个环境运行之前,curenv 被初始化为 NULL

环境状态

数据结构 Env 被定义在文件 inc/env.h 中,内容如下:(在后面的实验中将添加更多的字段):

struct Env {
    struct Trapframe env_tf;   // Saved registers
    struct Env *env_link;      // Next free Env
    envid_t env_id;            // Unique environment identifier
    envid_t env_parent_id;     // env_id of this env's parent
    enum EnvType env_type;     // Indicates special system environments
    unsigned env_status;       // Status of the environment
    uint32_t env_runs;         // Number of times environment has run

    // Address space
    pde_t *env_pgdir;          // Kernel virtual address of page dir
};

以下是数据结构 Env 中的字段简介:

  • env_tf: 这个结构定义在 inc/trap.h 中,它用于在那个环境不运行时保持它保存在寄存器中的值,即:当内核或一个不同的环境在运行时。当从用户模式切换到内核模式时,内核将保存这些东西,以便于那个环境能够在稍后重新运行时回到中断运行的地方。
  • env_link: 这是一个链接,它链接到在 env_free_list 上的下一个 Env 上。env_free_list 指向到列表上第一个空闲的环境。
  • env_id: 内核在数据结构 Env 中保存了一个唯一标识当前环境的值(即:使用数组 envs 中的特定槽位)。在一个用户环境终止之后,内核可能给另外的环境重新分配相同的数据结构 Env —— 但是新的环境将有一个与已终止的旧的环境不同的 env_id,即便是新的环境在数组 envs 中复用了同一个槽位。
  • env_parent_id: 内核使用它来保存创建这个环境的父级环境的 env_id。通过这种方式,环境就可以形成一个“家族树”,这对于做出“哪个环境可以对谁做什么”这样的安全决策非常有用。
  • env_type: 它用于去区分特定的环境。对于大多数环境,它将是 ENV_TYPE_USER 的。在稍后的实验中,针对特定的系统服务环境,我们将引入更多的几种类型。
  • env_status: 这个变量持有以下几个值之一:

    • ENV_FREE: 表示那个 Env 结构是非活动的,并且因此它还在 env_free_list 上。
    • ENV_RUNNABLE: 表示那个 Env 结构所代表的环境正等待被调度到处理器上去运行。
    • ENV_RUNNING: 表示那个 Env 结构所代表的环境当前正在运行中。
    • ENV_NOT_RUNNABLE: 表示那个 Env 结构所代表的是一个当前活动的环境,但不是当前准备去运行的:例如,因为它正在因为一个来自其它环境的进程间通讯(IPC)而处于等待状态。
    • ENV_DYING: 表示那个 Env 结构所表示的是一个僵尸环境。一个僵尸环境将在下一次被内核捕获后被释放。我们在实验 4 之前不会去使用这个标志。
  • env_pgdir: 这个变量持有这个环境的内核虚拟地址的页目录。

就像一个 Unix 进程一样,一个 JOS 环境耦合了“线程”和“地址空间”的概念。线程主要由保存的寄存器来定义(env_tf 字段),而地址空间由页目录和 env_pgdir 所指向的页表所定义。为运行一个环境,内核必须使用保存的寄存器值和相关的地址空间去设置 CPU。

我们的 struct Env 与 xv6 中的 struct proc 类似。它们都在一个 Trapframe 结构中持有环境(即进程)的用户模式寄存器状态。在 JOS 中,单个的环境并不能像 xv6 中的进程那样拥有它们自己的内核栈。在这里,内核中任意时间只能有一个 JOS 环境处于活动中,因此,JOS 仅需要一个单个的内核栈。

为环境分配数组

在实验 2 的 mem_init() 中,你为数组 pages[] 分配了内存,它是内核用于对页面分配与否的状态进行跟踪的一个表。你现在将需要去修改 mem_init(),以便于后面使用它分配一个与结构 Env 类似的数组,这个数组被称为 envs

练习 1、修改在 kern/pmap.c 中的 mem_init(),以用于去分配和映射 envs 数组。这个数组完全由 Env 结构分配的实例 NENV 组成,就像你分配的 pages 数组一样。与 pages 数组一样,由内存支持的数组 envs 也将在 UENVS(它的定义在 inc/memlayout.h 文件中)中映射用户只读的内存,以便于用户进程能够从这个数组中读取。

你应该去运行你的代码,并确保 check_kern_pgdir() 是没有问题的。

创建和运行环境

现在,你将在 kern/env.c 中写一些必需的代码去运行一个用户环境。因为我们并没有做一个文件系统,因此,我们将设置内核去加载一个嵌入到内核中的静态的二进制镜像。JOS 内核以一个 ELF 可运行镜像的方式将这个二进制镜像嵌入到内核中。

在实验 3 中,GNUmakefile 将在 obj/user/ 目录中生成一些二进制镜像。如果你看到 kern/Makefrag,你将注意到一些奇怪的的东西,它们“链接”这些二进制直接进入到内核中运行,就像 .o 文件一样。在链接器命令行上的 -b binary 选项,将因此把它们链接为“原生的”不解析的二进制文件,而不是由编译器产生的普通的 .o 文件。(就链接器而言,这些文件压根就不是 ELF 镜像文件 —— 它们可以是任何东西,比如,一个文本文件或图片!)如果你在内核构建之后查看 obj/kern/kernel.sym ,你将会注意到链接器很奇怪的生成了一些有趣的、命名很费解的符号,比如像 _binary_obj_user_hello_start_binary_obj_user_hello_end、以及 _binary_obj_user_hello_size。链接器通过改编二进制文件的命令来生成这些符号;这种符号为普通内核代码使用一种引入嵌入式二进制文件的方法。

kern/init.ci386_init() 中,你将写一些代码在环境中运行这些二进制镜像中的一种。但是,设置用户环境的关键函数还没有实现;将需要你去完成它们。

练习 2、在文件 env.c 中,写完以下函数的代码:

  • env_init()

初始化 envs 数组中所有的 Env 结构,然后把它们添加到 env_free_list 中。也称为 env_init_percpu,它通过配置硬件,在硬件上为 level 0(内核)权限和 level 3(用户)权限使用单独的段。

  • env_setup_vm()

为一个新环境分配一个页目录,并初始化新环境的地址空间的内核部分。

  • region_alloc()

为一个新环境分配和映射物理内存

  • load_icode()

你将需要去解析一个 ELF 二进制镜像,就像引导加载器那样,然后加载它的内容到一个新环境的用户地址空间中。

  • env_create()

使用 env_alloc 去分配一个环境,并调用 load_icode 去加载一个 ELF 二进制

  • env_run()

在用户模式中开始运行一个给定的环境

在你写这些函数时,你可能会发现新的 cprintf 动词 %e 非常有用 – 它可以输出一个错误代码的相关描述。比如:

    r = -E_NO_MEM;
    panic("env_alloc: %e", r);

中 panic 将输出消息 env\_alloc: out of memory。

下面是用户代码相关的调用图。确保你理解了每一步的用途。

  • start (kern/entry.S)
  • i386_init (kern/init.c)

    • cons_init
    • mem_init
    • env_init
    • trap_init(到目前为止还未完成)
    • env_create
    • env_run

      • env_pop_tf

在完成以上函数后,你应该去编译内核并在 QEMU 下运行它。如果一切正常,你的系统将进入到用户空间并运行二进制的 hello ,直到使用 int 指令生成一个系统调用为止。在那个时刻将存在一个问题,因为 JOS 尚未设置硬件去允许从用户空间到内核空间的各种转换。当 CPU 发现没有系统调用中断的服务程序时,它将生成一个一般保护异常,找到那个异常并去处理它,还将生成一个双重故障异常,同样也找到它并处理它,并且最后会出现所谓的“三重故障异常”。通常情况下,你将随后看到 CPU 复位以及系统重引导。虽然对于传统的应用程序(在 这篇博客文章 中解释了原因)这是重大的问题,但是对于内核开发来说,这是一个痛苦的过程,因此,在打了 6.828 补丁的 QEMU 上,你将可以看到转储的寄存器内容和一个“三重故障”的信息。

我们马上就会去处理这些问题,但是现在,我们可以使用调试器去检查我们是否进入了用户模式。使用 make qemu-gdb 并在 env_pop_tf 处设置一个 GDB 断点,它是你进入用户模式之前到达的最后一个函数。使用 si 单步进入这个函数;处理器将在 iret 指令之后进入用户模式。然后你将会看到在用户环境运行的第一个指令,它将是在 lib/entry.S 中的标签 start 的第一个指令 cmpl。现在,在 hello 中的 sys_cputs()int $0x30 处使用 b *0x...(关于用户空间的地址,请查看 obj/user/hello.asm )设置断点。这个指令 int 是系统调用去显示一个字符到控制台。如果到 int 还没有运行,那么可能在你的地址空间设置或程序加载代码时发生了错误;返回去找到问题并解决后重新运行。

处理中断和异常

到目前为止,在用户空间中的第一个系统调用指令 int $0x30 已正式寿终正寝了:一旦处理器进入用户模式,将无法返回。因此,现在,你需要去实现基本的异常和系统调用服务程序,因为那样才有可能让内核从用户模式代码中恢复对处理器的控制。你所做的第一件事情就是彻底地掌握 x86 的中断和异常机制的使用。

练习 3、如果你对中断和异常机制不熟悉的话,阅读 80386 程序员手册的第 9 章(或 IA-32 开发者手册的第 5 章)。

在这个实验中,对于中断、异常、以其它类似的东西,我们将遵循 Intel 的术语习惯。由于如 异常 exception 陷阱 trap 中断 interrupt 故障 fault 中止 abort 这些术语在不同的架构和操作系统上并没有一个统一的标准,我们经常在特定的架构下(如 x86)并不去考虑它们之间的细微差别。当你在本实验以外的地方看到这些术语时,它们的含义可能有细微的差别。

受保护的控制转移基础

异常和中断都是“受保护的控制转移”,它将导致处理器从用户模式切换到内核模式(CPL=0)而不会让用户模式的代码干扰到内核的其它函数或其它的环境。在 Intel 的术语中,一个中断就是一个“受保护的控制转移”,它是由于处理器以外的外部异步事件所引发的,比如外部设备 I/O 活动通知。而异常正好与之相反,它是由当前正在运行的代码所引发的同步的、受保护的控制转移,比如由于发生了一个除零错误或对无效内存的访问。

为了确保这些受保护的控制转移是真正地受到保护,处理器的中断/异常机制设计是:当中断/异常发生时,当前运行的代码不能随意选择进入内核的位置和方式。而是,处理器在确保内核能够严格控制的条件下才能进入内核。在 x86 上,有两种机制协同来提供这种保护:

  1. 中断描述符表 处理器确保中断和异常仅能够导致内核进入几个特定的、由内核本身定义好的、明确的入口点,而不是去运行中断或异常发生时的代码。

x86 允许最多有 256 个不同的中断或异常入口点去进入内核,每个入口点都使用一个不同的中断向量。一个向量是一个介于 0 和 255 之间的数字。一个中断向量是由中断源确定的:不同的设备、错误条件、以及应用程序去请求内核使用不同的向量生成中断。CPU 使用向量作为进入处理器的中断描述符表(IDT)的索引,它是内核设置的内核私有内存,GDT 也是。从这个表中的适当的条目中,处理器将加载:

* 将值加载到指令指针寄存器(EIP),指向内核代码设计好的,用于处理这种异常的服务程序。
* 将值加载到代码段寄存器(CS),它包含运行权限为 0—1 级别的、要运行的异常服务程序。(在 JOS 中,所有的异常处理程序都运行在内核模式中,运行级别为 0。)
  1. 任务状态描述符表 处理器在中断或异常发生时,需要一个地方去保存旧的处理器状态,比如,处理器在调用异常服务程序之前的 EIPCS 的原始值,这样那个异常服务程序就能够稍后通过还原旧的状态来回到中断发生时的代码位置。但是对于已保存的处理器的旧状态必须被保护起来,不能被无权限的用户模式代码访问;否则代码中的 bug 或恶意用户代码将危及内核。

基于这个原因,当一个 x86 处理器产生一个中断或陷阱时,将导致权限级别的变更,从用户模式转换到内核模式,它也将导致在内核的内存中发生栈切换。有一个被称为 TSS 的任务状态描述符表规定段描述符和这个栈所处的地址。处理器在这个新栈上推送 SSESPEFLAGSCSEIP、以及一个可选的错误代码。然后它从中断描述符上加载 CSEIP 的值,然后设置 ESPSS 去指向新的栈。

虽然 TSS 很大并且默默地为各种用途服务,但是 JOS 仅用它去定义当从用户模式到内核模式的转移发生时,处理器即将切换过去的内核栈。因为在 JOS 中的“内核模式”仅运行在 x86 的运行级别 0 权限上,当进入内核模式时,处理器使用 TSS 上的 ESP0SS0 字段去定义内核栈。JOS 并不去使用 TSS 的任何其它字段。

异常和中断的类型

所有的 x86 处理器上的同步异常都能够产生一个内部使用的、介于 0 到 31 之间的中断向量,因此它映射到 IDT 就是条目 0-31。例如,一个页故障总是通过向量 14 引发一个异常。大于 31 的中断向量仅用于软件中断,它由 int 指令生成,或异步硬件中断,当需要时,它们由外部设备产生。

在这一节中,我们将扩展 JOS 去处理向量为 0-31 之间的、内部产生的 x86 异常。在下一节中,我们将完成 JOS 的 48(0x30)号软件中断向量,JOS 将(随意选择的)使用它作为系统调用中断向量。在实验 4 中,我们将扩展 JOS 去处理外部生成的硬件中断,比如时钟中断。

一个示例

我们把这些片断综合到一起,通过一个示例来巩固一下。我们假设处理器在用户环境下运行代码,遇到一个除零问题。

  1. 处理器去切换到由 TSS 中的 SS0ESP0 定义的栈,在 JOS 中,它们各自保存着值 GD_KDKSTACKTOP
  2. 处理器在内核栈上推入异常参数,起始地址为 KSTACKTOP
+--------------------+ KSTACKTOP             
| 0x00000 | old SS   |     " - 4
|      old ESP       |     " - 8
|     old EFLAGS     |     " - 12
| 0x00000 | old CS   |     " - 16
|      old EIP       |     " - 20 <---- ESP 
+--------------------+
  1. 由于我们要处理一个除零错误,它将在 x86 上产生一个中断向量 0,处理器读取 IDT 的条目 0,然后设置 CS:EIP 去指向由条目描述的处理函数。
  2. 处理服务程序函数将接管控制权并处理异常,例如中止用户环境。

对于某些类型的 x86 异常,除了以上的五个“标准的”寄存器外,处理器还推入另一个包含错误代码的寄存器值到栈中。页故障异常,向量号为 14,就是一个重要的示例。查看 80386 手册去确定哪些异常推入一个错误代码,以及错误代码在那个案例中的意义。当处理器推入一个错误代码后,当从用户模式中进入内核模式,异常处理服务程序开始时的栈看起来应该如下所示:

    +--------------------+ KSTACKTOP             
    | 0x00000 | old SS   |     " - 4
    |      old ESP       |     " - 8
    |     old EFLAGS     |     " - 12
    | 0x00000 | old CS   |     " - 16
    |      old EIP       |     " - 20
    |     error code     |     " - 24 <---- ESP
    +--------------------+             

嵌套的异常和中断

处理器能够处理来自用户和内核模式中的异常和中断。当收到来自用户模式的异常和中断时才会进入内核模式中,而且,在推送它的旧寄存器状态到栈中和通过 IDT 调用相关的异常服务程序之前,x86 处理器会自动切换栈。如果当异常或中断发生时,处理器已经处于内核模式中(CS 寄存器低位两个比特为 0),那么 CPU 只是推入一些值到相同的内核栈中。在这种方式中,内核可以优雅地处理嵌套的异常,嵌套的异常一般由内核本身的代码所引发。在实现保护时,这种功能是非常重要的工具,我们将在稍后的系统调用中看到它。

如果处理器已经处于内核模式中,并且发生了一个嵌套的异常,由于它并不需要切换栈,它也就不需要去保存旧的 SSESP 寄存器。对于不推入错误代码的异常类型,在进入到异常服务程序时,它的内核栈看起来应该如下图:

    +--------------------+ <---- old ESP
    |     old EFLAGS     |     " - 4
    | 0x00000 | old CS   |     " - 8
    |      old EIP       |     " - 12
    +--------------------+             

对于需要推入一个错误代码的异常类型,处理器将在旧的 EIP 之后,立即推入一个错误代码,就和前面一样。

关于处理器的异常嵌套的功能,这里有一个重要的警告。如果处理器正处于内核模式时发生了一个异常,并且不论是什么原因,比如栈空间泄漏,都不会去推送它的旧的状态,那么这时处理器将不能做任何的恢复,它只是简单地重置。毫无疑问,内核应该被设计为禁止发生这种情况。

设置 IDT

到目前为止,你应该有了在 JOS 中为了设置 IDT 和处理异常所需的基本信息。现在,我们去设置 IDT 以处理中断向量 0-31(处理器异常)。我们将在本实验的稍后部分处理系统调用,然后在后面的实验中增加中断 32-47(设备 IRQ)。

在头文件 inc/trap.hkern/trap.h 中包含了中断和异常相关的重要定义,你需要去熟悉使用它们。在文件kern/trap.h 中包含了到内核的、严格的、秘密的定义,可是在 inc/trap.h 中包含的定义也可以被用到用户级程序和库上。

注意:在范围 0-31 中的一些异常是被 Intel 定义为保留。因为在它们的处理器上从未产生过,你如何处理它们都不会有大问题。你想如何做它都是可以的。

你将要实现的完整的控制流如下图所描述:

      IDT               trapentry.S         trap.c
   
+----------------+                        
|   &handler1    |----> handler1:          trap (struct Trapframe *tf)
|                |         // do stuff      {
|                |         call trap          // handle the exception/interrupt
|                |         // ...           }
+----------------+
|   &handler2    |----> handler2:
|                |        // do stuff
|                |        call trap
|                |        // ...
+----------------+
       .
       .
       .
+----------------+
|   &handlerX    |----> handlerX:
|                |         // do stuff
|                |         call trap
|                |         // ...
+----------------+

每个异常或中断都应该在 trapentry.S 中有它自己的处理程序,并且 trap_init() 应该使用这些处理程序的地址去初始化 IDT。每个处理程序都应该在栈上构建一个 struct Trapframe(查看 inc/trap.h),然后使用一个指针调用 trap()(在 trap.c 中)到 Trapframetrap() 接着处理异常/中断或派发给一个特定的处理函数。

练习 4、编辑 trapentry.Strap.c,然后实现上面所描述的功能。在 trapentry.S 中的宏 TRAPHANDLERTRAPHANDLER_NOEC 将会帮你,还有在 inc/trap.h 中的 T\_* defines。你需要在 trapentry.S 中为每个定义在 inc/trap.h 中的陷阱添加一个入口点(使用这些宏),并且你将有 t、o 提供的 _alltraps,这是由宏 TRAPHANDLER指向到它。你也需要去修改 trap_init() 来初始化 idt,以使它指向到每个在 trapentry.S 中定义的入口点;宏 SETGATE 将有助你实现它。

你的 _alltraps 应该:

  1. 推送值以使栈看上去像一个结构 Trapframe
  2. 加载 GD_KD%ds%es
  3. pushl %esp 去传递一个指针到 Trapframe 以作为一个 trap() 的参数
  4. call traptrap 能够返回吗?)

考虑使用 pushal 指令;它非常适合 struct Trapframe 的布局。

使用一些在 user 目录中的测试程序来测试你的陷阱处理代码,这些测试程序在生成任何系统调用之前能引发异常,比如 user/divzero。在这时,你应该能够成功完成 divzerosoftint、以有 badsegment 测试。

.

小挑战!目前,在 trapentry.S 中列出的 TRAPHANDLER 和他们安装在 trap.c 中可能有许多代码非常相似。清除它们。修改 trapentry.S 中的宏去自动为 trap.c 生成一个表。注意,你可以直接使用 .text.data 在汇编器中切换放置其中的代码和数据。

.

问题

在你的 answers-lab3.txt 中回答下列问题:

  1. 为每个异常/中断设置一个独立的服务程序函数的目的是什么?(即:如果所有的异常/中断都传递给同一个服务程序,在我们的当前实现中能否提供这样的特性?)
  2. 你需要做什么事情才能让 user/softint 程序正常运行?评级脚本预计将会产生一个一般保护故障(trap 13),但是 softint 的代码显示为 int $14。为什么它产生的中断向量是 13?如果内核允许 softintint $14 指令去调用内核页故障的服务程序(它的中断向量是 14)会发生什么事情? “`

本实验的 Part A 部分结束了。不要忘了去添加 answers-lab3.txt 文件,提交你的变更,然后在 Part A 作业的提交截止日期之前运行 make handin

Part B:页故障、断点异常、和系统调用

现在,你的内核已经有了最基本的异常处理能力,你将要去继续改进它,来提供依赖异常服务程序的操作系统原语。

处理页故障

页故障异常,中断向量为 14(T_PGFLT),它是一个非常重要的东西,我们将通过本实验和接下来的实验来大量练习它。当处理器产生一个页故障时,处理器将在它的一个特定的控制寄存器(CR2)中保存导致这个故障的线性地址(即:虚拟地址)。在 trap.c 中我们提供了一个专门处理它的函数的一个雏形,它就是 page_fault_handler(),我们将用它来处理页故障异常。

练习 5、修改 trap_dispatch() 将页故障异常派发到 page_fault_handler() 上。你现在应该能够成功测试 faultreadfaultreadkernelfaultwritefaultwritekernel 了。如果它们中的任何一个不能正常工作,找出问题并修复它。记住,你可以使用 make run-xmake run-x-nox 去重引导 JOS 进入到一个特定的用户程序。比如,你可以运行 make run-hello-nox 去运行 hello 用户程序。

下面,你将进一步细化内核的页故障服务程序,因为你要实现系统调用了。

断点异常

断点异常,中断向量为 3(T_BRKPT),它一般用在调试上,它在一个程序代码中插入断点,从而使用特定的 1 字节的 int3 软件中断指令来临时替换相应的程序指令。在 JOS 中,我们将稍微“滥用”一下这个异常,通过将它打造成一个伪系统调用原语,使得任何用户环境都可以用它来调用 JOS 内核监视器。如果我们将 JOS 内核监视认为是原始调试器,那么这种用法是合适的。例如,在 lib/panic.c 中实现的用户模式下的 panic() ,它在显示它的 panic 消息后运行一个 int3 中断。

练习 6、修改 trap_dispatch(),让它在调用内核监视器时产生一个断点异常。你现在应该可以在 breakpoint 上成功完成测试。

.

小挑战!修改 JOS 内核监视器,以便于你能够从当前位置(即:在 int3 之后,断点异常调用了内核监视器) ‘继续’ 异常,并且因此你就可以一次运行一个单步指令。为了实现单步运行,你需要去理解 EFLAGS 寄存器中的某些比特的意义。

可选:如果你富有冒险精神,找一些 x86 反汇编的代码 —— 即通过从 QEMU 中、或从 GNU 二进制工具中分离、或你自己编写 —— 然后扩展 JOS 内核监视器,以使它能够反汇编,显示你的每步的指令。结合实验 1 中的符号表,这将是你写的一个真正的内核调试器。

.

问题

  1. 在断点测试案例中,根据你在 IDT 中如何初始化断点条目的不同情况(即:你的从 trap_initSETGATE 的调用),既有可能产生一个断点异常,也有可能产生一个一般保护故障。为什么?为了能够像上面的案例那样工作,你需要如何去设置它,什么样的不正确设置才会触发一个一般保护故障?
  2. 你认为这些机制的意义是什么?尤其是要考虑 user/softint 测试程序的工作原理。

系统调用

用户进程请求内核为它做事情就是通过系统调用来实现的。当用户进程请求一个系统调用时,处理器首先进入内核模式,处理器和内核配合去保存用户进程的状态,内核为了完成系统调用会运行有关的代码,然后重新回到用户进程。用户进程如何获得内核的关注以及它如何指定它需要的系统调用的具体细节,这在不同的系统上是不同的。

在 JOS 内核中,我们使用 int 指令,它将导致产生一个处理器中断。尤其是,我们使用 int $0x30 作为系统调用中断。我们定义常量 T_SYSCALL 为 48(0x30)。你将需要去设置中断描述符表,以允许用户进程去触发那个中断。注意,那个中断 0x30 并不是由硬件生成的,因此允许用户代码去产生它并不会引起歧义。

应用程序将在寄存器中传递系统调用号和系统调用参数。通过这种方式,内核就不需要去遍历用户环境的栈或指令流。系统调用号将放在 %eax 中,而参数(最多五个)将分别放在 %edx%ecx%ebx%edi、和 %esi 中。内核将在 %eax 中传递返回值。在 lib/syscall.c 中的 syscall() 中已为你编写了使用一个系统调用的汇编代码。你可以通过阅读它来确保你已经理解了它们都做了什么。

练习 7、在内核中为中断向量 T_SYSCALL 添加一个服务程序。你将需要去编辑 kern/trapentry.Skern/trap.ctrap_init()。还需要去修改 trap_dispatch(),以便于通过使用适当的参数来调用 syscall() (定义在 kern/syscall.c)以处理系统调用中断,然后将系统调用的返回值安排在 %eax 中传递给用户进程。最后,你需要去实现 kern/syscall.c 中的 syscall()。如果系统调用号是无效值,确保 syscall() 返回值一定是 -E_INVAL。为确保你理解了系统调用的接口,你应该去阅读和掌握 lib/syscall.c 文件(尤其是行内汇编的动作),对于在 inc/syscall.h 中列出的每个系统调用都需要通过调用相关的内核函数来处理A。

在你的内核中运行 user/hello 程序(make run-hello)。它应该在控制台上输出 hello, world,然后在用户模式中产生一个页故障。如果没有产生页故障,可能意味着你的系统调用服务程序不太正确。现在,你应该有能力成功通过 testbss 测试。

.

小挑战!使用 sysentersysexit 指令而不是使用 int 0x30iret 来实现系统调用。

sysenter/sysexit 指令是由 Intel 设计的,它的运行速度要比 int/iret 指令快。它使用寄存器而不是栈来做到这一点,并且通过假定了分段寄存器是如何使用的。关于这些指令的详细内容可以在 Intel 参考手册 2B 卷中找到。

在 JOS 中添加对这些指令支持的最容易的方法是,在 kern/trapentry.S 中添加一个 sysenter_handler,在它里面保存足够多的关于用户环境返回、设置内核环境、推送参数到 syscall()、以及直接调用 syscall() 的信息。一旦 syscall() 返回,它将设置好运行 sysexit 指令所需的一切东西。你也将需要在 kern/init.c 中添加一些代码,以设置特殊模块寄存器(MSRs)。在 AMD 架构程序员手册第 2 卷的 6.1.2 节中和 Intel 参考手册的 2B 卷的 SYSENTER 上都有关于 MSRs 的很详细的描述。对于如何去写 MSRs,在这里你可以找到一个添加到 inc/x86.h 中的 wrmsr 的实现。

最后,lib/syscall.c 必须要修改,以便于支持用 sysenter 来生成一个系统调用。下面是 sysenter 指令的一种可能的寄存器布局:

eax - syscall number
edx, ecx, ebx, edi - arg1, arg2, arg3, arg4
esi - return pc
ebp - return esp
esp - trashed by sysenter

GCC 的内联汇编器将自动保存你告诉它的直接加载进寄存器的值。不要忘了同时去保存(push)和恢复(pop)你使用的其它寄存器,或告诉内联汇编器你正在使用它们。内联汇编器不支持保存 %ebp,因此你需要自己去增加一些代码来保存和恢复它们,返回地址可以使用一个像 leal after_sysenter_label, %%esi 的指令置入到 %esi 中。

注意,它仅支持 4 个参数,因此你需要保留支持 5 个参数的系统调用的旧方法。而且,因为这个快速路径并不更新当前环境的 trap 帧,因此,在我们添加到后续实验中的一些系统调用上,它并不适合。

在接下来的实验中我们启用了异步中断,你需要再次去评估一下你的代码。尤其是,当返回到用户进程时,你需要去启用中断,而 sysexit 指令并不会为你去做这一动作。

启动用户模式

一个用户程序是从 lib/entry.S 的顶部开始运行的。在一些配置之后,代码调用 lib/libmain.c 中的 libmain()。你应该去修改 libmain() 以初始化全局指针 thisenv,使它指向到这个环境在数组 envs[] 中的 struct Env。(注意那个 lib/entry.S 中已经定义 envs 去指向到在 Part A 中映射的你的设置。)提示:查看 inc/env.h 和使用 sys_getenvid

libmain() 接下来调用 umain,在 hello 程序的案例中,umain 是在 user/hello.c 中。注意,它在输出 ”hello, world” 之后,它尝试去访问 thisenv->env_id。这就是为什么前面会发生故障的原因了。现在,你已经正确地初始化了 thisenv,它应该不会再发生故障了。如果仍然会发生故障,或许是因为你没有映射 UENVS 区域为用户可读取(回到前面 Part A 中 查看 pmap.c);这是我们第一次真实地使用 UENVS 区域)。

练习 8、添加要求的代码到用户库,然后引导你的内核。你应该能够看到 user/hello 程序会输出 hello, world 然后输出 i am environment 00001000user/hello 接下来会通过调用 sys_env_destroy()(查看lib/libmain.clib/exit.c)尝试去“退出”。由于内核目前仅支持一个用户环境,它应该会报告它毁坏了唯一的环境,然后进入到内核监视器中。现在你应该能够成功通过 hello 的测试。

页故障和内存保护

内存保护是一个操作系统中最重要的特性,通过它来保证一个程序中的 bug 不会破坏其它程序或操作系统本身。

操作系统一般是依靠硬件的支持来实现内存保护。操作系统会告诉硬件哪些虚拟地址是有效的,而哪些是无效的。当一个程序尝试去访问一个无效地址或它没有访问权限的地址时,处理器会在导致故障发生的位置停止程序运行,然后捕获内核中关于尝试操作的相关信息。如果故障是可修复的,内核可能修复它并让程序继续运行。如果故障不可修复,那么程序就不能继续,因为它绝对不会跳过那个导致故障的指令。

作为一个可修复故障的示例,假设一个自动扩展的栈。在许多系统上,内核初始化分配一个单栈页,然后如果程序发生的故障是去访问这个栈页下面的页,那么内核会自动分配这些页,并让程序继续运行。通过这种方式,内核只分配程序所需要的内存栈,但是程序可以运行在一个任意大小的栈的假像中。

对于内存保护,系统调用中有一个非常有趣的问题。许多系统调用接口让用户程序传递指针到内核中。这些指针指向用户要读取或写入的缓冲区。然后内核在执行系统调用时废弃这些指针。这样就有两个问题:

  1. 内核中的页故障可能比用户程序中的页故障多的多。如果内核在维护它自己的数据结构时发生页故障,那就是一个内核 bug,而故障服务程序将使整个内核(和整个系统)崩溃。但是当内核废弃了由用户程序传递给它的指针后,它就需要一种方式去记住那些废弃指针所导致的页故障其实是代表用户程序的。
  2. 一般情况下内核拥有比用户程序更多的权限。用户程序可以传递一个指针到系统调用,而指针指向的区域有可能是内核可以读取或写入而用户程序不可访问的区域。内核必须要非常小心,不能被废弃的这种指针欺骗,因为这可能导致泄露私有信息或破坏内核的完整性。

由于以上的原因,内核在处理由用户程序提供的指针时必须格外小心。

现在,你可以通过使用一个简单的机制来仔细检查所有从用户空间传递给内核的指针来解决这个问题。当一个程序给内核传递指针时,内核将检查它的地址是否在地址空间的用户部分,然后页表才允许对内存的操作。

这样,内核在废弃一个用户提供的指针时就绝不会发生页故障。如果内核出现这种页故障,它应该崩溃并终止。

练习 9、如果在内核模式中发生一个页故障,修改 kern/trap.c 去崩溃。

提示:判断一个页故障是发生在用户模式还是内核模式,去检查 tf_cs 的低位比特即可。

阅读 kern/pmap.c 中的 user_mem_assert 并在那个文件中实现 user_mem_check

修改 kern/syscall.c 去常态化检查传递给系统调用的参数。

引导你的内核,运行 user/buggyhello。环境将被毁坏,而内核将不会崩溃。你将会看到:

[00001000] user_mem_check assertion failure for va 00000001
[00001000] free env 00001000
Destroyed the only environment - nothing more to do!

最后,修改在 kern/kdebug.c 中的 debuginfo_eip,在 usdstabs、和 stabstr 上调用 user_mem_check。如果你现在运行 user/breakpoint,你应该能够从内核监视器中运行回溯,然后在内核因页故障崩溃前看到回溯进入到 lib/libmain.c。是什么导致了这个页故障?你不需要去修复它,但是你应该明白它是如何发生的。

注意,刚才实现的这些机制也同样适用于恶意用户程序(比如 user/evilhello)。

练习 10、引导你的内核,运行 user/evilhello。环境应该被毁坏,并且内核不会崩溃。你应该能看到:

[00000000] new env 00001000
...
[00001000] user_mem_check assertion failure for va f010000c
[00001000] free env 00001000

本实验到此结束。确保你通过了所有的等级测试,并且不要忘记去写下问题的答案,在 answers-lab3.txt 中详细描述你的挑战练习的解决方案。提交你的变更并在 lab 目录下输入 make handin 去提交你的工作。

在动手实验之前,使用 git statusgit diff 去检查你的变更,并不要忘记去 git add answers-lab3.txt。当你完成后,使用 git commit -am 'my solutions to lab 3’ 去提交你的变更,然后 make handin 并关注这个指南。


via: https://pdos.csail.mit.edu/6.828/2018/labs/lab3/

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

熟悉你的环境对高效率的开发和调试来说是至关重要的。本文将为你简单概述一下 JOS 环境和非常有用的 GDB 和 QEMU 命令。话虽如此,但你仍然应该去阅读 GDB 和 QEMU 手册,来理解这些强大的工具如何使用。

调试小贴士

内核

GDB 是你的朋友。使用 qemu-gdb target(或它的变体 qemu-gdb-nox)使 QEMU 等待 GDB 去绑定。下面在调试内核时用到的一些命令,可以去查看 GDB 的资料。

如果你遭遇意外的中断、异常、或三重故障,你可以使用 -d 参数要求 QEMU 去产生一个详细的中断日志。

调试虚拟内存问题时,尝试 QEMU 的监视命令 info mem(提供内存高级概述)或 info pg(提供更多细节内容)。注意,这些命令仅显示当前页表。

(在实验 4 以后)去调试多个 CPU 时,使用 GDB 的线程相关命令,比如 threadinfo threads

用户环境(在实验 3 以后)

GDB 也可以去调试用户环境,但是有些事情需要注意,因为 GDB 无法区分开多个用户环境或区分开用户环境与内核环境。

你可以使用 make run-name(或编辑 kern/init.c 目录)来指定 JOS 启动的用户环境,为使 QEMU 等待 GDB 去绑定,使用 run-name-gdb 的变体。

你可以符号化调试用户代码,就像调试内核代码一样,但是你要告诉 GDB,哪个符号表用到符号文件命令上,因为它一次仅能够使用一个符号表。提供的 .gdbinit 用于加载内核符号表 obj/kern/kernel。对于一个用户环境,这个符号表在它的 ELF 二进制文件中,因此你可以使用 symbol-file obj/user/name 去加载它。不要从任何 .o 文件中加载符号,因为它们不会被链接器迁移进去(库是静态链接进 JOS 用户二进制文件中的,因此这些符号已经包含在每个用户二进制文件中了)。确保你得到了正确的用户二进制文件;在不同的二进制文件中,库函数被链接为不同的 EIP,而 GDB 并不知道更多的内容!

(在实验 4 以后)因为 GDB 绑定了整个虚拟机,所以它可以将时钟中断看作为一种控制转移。这使得从底层上不可能实现步进用户代码,因为一个时钟中断无形中保证了片刻之后虚拟机可以再次运行。因此可以使用 stepi 命令,因为它阻止了中断,但它仅可以步进一个汇编指令。断点一般来说可以正常工作,但要注意,因为你可能在不同的环境(完全不同的一个二进制文件)上遇到同一个 EIP。

参考

JOS makefile

JOS 的 GNUmakefile 包含了在各种方式中运行的 JOS 的许多假目标。所有这些目标都配置 QEMU 去监听 GDB 连接(*-gdb 目标也等待这个连接)。要在运行中的 QEMU 上启动它,只需要在你的实验目录中简单地运行 gdb 即可。我们提供了一个 .gdbinit 文件,它可以在 QEMU 中自动指向到 GDB、加载内核符号文件、以及在 16 位和 32 位模式之间切换。退出 GDB 将关闭 QEMU。

  • make qemu

在一个新窗口中构建所有的东西并使用 VGA 控制台和你的终端中的串行控制台启动 QEMU。想退出时,既可以关闭 VGA 窗口,也可以在你的终端中按 Ctrl-cCtrl-a x

  • make qemu-nox

make qemu 一样,但仅使用串行控制台来运行。想退出时,按下 Ctrl-a x。这种方式在通过 SSH 拨号连接到 Athena 上时非常有用,因为 VGA 窗口会占用许多带宽。

  • make qemu-gdb

make qemu 一样,但它与任意时间被动接受 GDB 不同,而是暂停第一个机器指令并等待一个 GDB 连接。

  • make qemu-nox-gdb

它是 qemu-noxqemu-gdb 目标的组合。

  • make run-nam

(在实验 3 以后)运行用户程序 name。例如,make run-hello 运行 user/hello.c

  • make run-name-nox,run-name-gdb, run-name-gdb-nox

(在实验 3 以后)与 qemu 目标变量对应的 run-name 的变体。

makefile 也接受几个非常有用的变量:

  • make V=1 …

详细模式。输出正在运行的每个命令,包括参数。

  • make V=1 grade

在评级测试失败后停止,并将 QEMU 的输出放入 jos.out 文件中以备检查。

  • make QEMUEXTRA=' _args_ ' …

指定传递给 QEMU 的额外参数。

JOS obj/

在构建 JOS 时,makefile 也产生一些额外的输出文件,这些文件在调试时非常有用:

  • obj/boot/boot.asmobj/kern/kernel.asmobj/user/hello.asm、等等。

引导加载器、内核、和用户程序的汇编代码列表。

  • obj/kern/kernel.symobj/user/hello.sym、等等。

内核和用户程序的符号表。

  • obj/boot/boot.outobj/kern/kernelobj/user/hello、等等。

内核和用户程序链接的 ELF 镜像。它们包含了 GDB 用到的符号信息。

GDB

完整的 GDB 命令指南请查看 GDB 手册。下面是一些在 6.828 课程中非常有用的命令,它们中的一些在操作系统开发之外的领域几乎用不到。

  • Ctrl-c

在当前指令处停止机器并打断进入到 GDB。如果 QEMU 有多个虚拟的 CPU,所有的 CPU 都会停止。

  • c(或 continue

继续运行,直到下一个断点或 Ctrl-c

  • si(或 stepi

运行一个机器指令。

  • b functionb file:line(或 breakpoint

在给定的函数或行上设置一个断点。

  • b * addr(或 breakpoint

在 EIP 的 addr 处设置一个断点。

  • set print pretty

启用数组和结构的美化输出。

  • info registers

输出通用寄存器 eipeflags、和段选择器。更多更全的机器寄存器状态转储,查看 QEMU 自己的 info registers 命令。

  • x/ N x addr

以十六进制显示虚拟地址 addr 处开始的 N 个词的转储。如果 N 省略,默认为 1。addr 可以是任何表达式。

  • x/ N i addr

显示从 addr 处开始的 N 个汇编指令。使用 $eip 作为 addr 将显示当前指令指针寄存器中的指令。

  • symbol-file file

(在实验 3 以后)切换到符号文件 file 上。当 GDB 绑定到 QEMU 后,它并不是虚拟机中进程边界内的一部分,因此我们要去告诉它去使用哪个符号。默认情况下,我们配置 GDB 去使用内核符号文件 obj/kern/kernel。如果机器正在运行用户代码,比如是 hello.c,你就需要使用 symbol-file obj/user/hello 去切换到 hello 的符号文件。

QEMU 将每个虚拟 CPU 表示为 GDB 中的一个线程,因此你可以使用 GDB 中所有的线程相关的命令去查看或维护 QEMU 的虚拟 CPU。

  • thread n

GDB 在一个时刻只关注于一个线程(即:CPU)。这个命令将关注的线程切换到 n,n 是从 0 开始编号的。

  • info threads

列出所有的线程(即:CPU),包括它们的状态(活动还是停止)和它们在什么函数中。

QEMU

QEMU 包含一个内置的监视器,它能够有效地检查和修改机器状态。想进入到监视器中,在运行 QEMU 的终端中按入 Ctrl-a c 即可。再次按下 Ctrl-a c 将切换回串行控制台。

监视器命令的完整参考资料,请查看 QEMU 手册。下面是 6.828 课程中用到的一些有用的命令:

  • xp/ N x paddr

显示从物理地址 paddr 处开始的 N 个词的十六进制转储。如果 N 省略,默认为 1。这是 GDB 的 x 命令模拟的物理内存。

  • info registers

显示机器内部寄存器状态的一个完整转储。实践中,对于段选择器,这将包含机器的 隐藏 段状态和局部、全局、和中断描述符表加任务状态寄存器。隐藏状态是在加载段选择器后,虚拟的 CPU 从 GDT/LDT 中读取的信息。下面是实验 1 中 JOS 内核处于运行中时的 CS 信息和每个字段的含义:

CS =0008 10000000 ffffffff 10cf9a00 DPL=0 CS32 [-R-]
  • CS =0008

代码选择器可见部分。我们使用段 0x8。这也告诉我们参考全局描述符表(0x8&4=0),并且我们的 CPL(当前权限级别)是 0x8&3=0。

  • 10000000

这是段基址。线性地址 = 逻辑地址 + 0x10000000。

  • ffffffff

这是段限制。访问线性地址 0xffffffff 以上将返回段违规异常。

  • 10cf9a00

段的原始标志,QEMU 将在接下来的几个字段中解码这些对我们有用的标志。

  • DPL=0

段的权限级别。一旦代码以权限 0 运行,它将就能够加载这个段。

  • CS32

这是一个 32 位代码段。对于数据段(不要与 DS 寄存器混淆了),另外的值还包括 DS,而对于本地描述符表是 LDT

  • [-R-]

这个段是只读的。

  • info mem

(在实验 2 以后)显示映射的虚拟内存和权限。比如:

ef7c0000-ef800000 00040000 urw
efbf8000-efc00000 00008000 -rw

这告诉我们从 0xef7c0000 到 0xef800000 的 0x00040000 字节的内存被映射为读取/写入/用户可访问,而映射在 0xefbf8000 到 0xefc00000 之间的内存权限是读取/写入,但是仅限于内核可访问。

  • info pg

(在实验 2 以后)显示当前页表结构。它的输出类似于 info mem,但与页目录条目和页表条目是有区别的,并且为每个条目给了单独的权限。重复的 PTE 和整个页表被折叠为一个单行。例如:

VPN range     Entry         Flags        Physical page
[00000-003ff]  PDE[000]     -------UWP
  [00200-00233]  PTE[200-233] -------U-P 00380 0037e 0037d 0037c 0037b 0037a ..
[00800-00bff]  PDE[002]     ----A--UWP
  [00800-00801]  PTE[000-001] ----A--U-P 0034b 00349
  [00802-00802]  PTE[002]     -------U-P 00348

这里各自显示了两个页目录条目、虚拟地址范围 0x00000000 到 0x003fffff 以及 0x00800000 到 0x00bfffff。 所有的 PDE 都存在于内存中、可写入、并且用户可访问,而第二个 PDE 也是可访问的。这些页表中的第二个映射了三个页、虚拟地址范围 0x00800000 到 0x00802fff,其中前两个页是存在于内存中的、可写入、并且用户可访问的,而第三个仅存在于内存中,并且用户可访问。这些 PTE 的第一个条目映射在物理页 0x34b 处。

QEMU 也有一些非常有用的命令行参数,使用 QEMUEXTRA 变量可以将参数传递给 JOS 的 makefile。

  • make QEMUEXTRA='-d int' ...

记录所有的中断和一个完整的寄存器转储到 qemu.log 文件中。你可以忽略前两个日志条目、“SMM: enter” 和 “SMM: after RMS”,因为这些是在进入引导加载器之前生成的。在这之后的日志条目看起来像下面这样:

     4: v=30 e=0000 i=1 cpl=3 IP=001b:00800e2e pc=00800e2e SP=0023:eebfdf28 EAX=00000005
EAX=00000005 EBX=00001002 ECX=00200000 EDX=00000000
ESI=00000805 EDI=00200000 EBP=eebfdf60 ESP=eebfdf28
...

第一行描述了中断。4: 只是一个日志记录计数器。v 提供了十六进程的向量号。e 提供了错误代码。i=1 表示它是由一个 int 指令(相对一个硬件产生的中断而言)产生的。剩下的行的意思很明显。对于一个寄存器转储而言,接下来看到的就是寄存器信息。

注意:如果你运行的是一个 0.15 版本之前的 QEMU,日志将写入到 /tmp 目录,而不是当前目录下。


via: https://pdos.csail.mit.edu/6.828/2018/labguide.html

作者:csail.mit 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出