6.828

目录

Lab 1: Booting a PC

Part 1: PC Bootstrap

Brennan's Guide to Inline Assembly

Simulating the x86

The PC's Physical Address Space

The ROM BIOS

Part 2: The Boot Loader

Loading the Kernel

Part 3: The Kernel

Using virtual memory to work around position dependence

Formatted Printing to the Console

The Stack

Lab 2: Memory Management

Introduction

Part 1: Physical Page Management

Part 2: Virtual Memory

Virtual, Linear, and Physical Addresses

Page Table Management

Part 3: Kernel Address Space

Permissions and Fault Isolation

Initializing the Kernel Address Space

Address Space Layout Alternatives

Lab 3: User Environments

Basic inline assembly

Part A: User Environments and Exception Handling

Environment State

Allocating the Environments Array

Basics of Protected Control Transfer

Types of Exceptions and Interrupts

Nested Exceptions and Interrupts

Setting Up the IDT

Part B: Page Faults, Breakpoints Exceptions, and System Calls

The Breakpoint Exception

System calls

User-mode startup

Page faults and memory protection

Lab 4: Preemptive Multitasking

Part A: Multiprocessor Support and Cooperative Multitasking

Application Processor Bootstrap

Locking

Round-Robin Scheduling

Part B: Copy-on-Write Fork

User-level page fault handling

Setting the Page Fault Handler

Normal and Exception Stacks in User Environments

Invoking the User Page Fault Handler

User-mode Page Fault Entrypoint

Implementing Copy-on-Write Fork

Part C: Preemptive Multitasking and Inter-Process communication (IPC)

Clock Interrupts and Preemption

Interrupt discipline

Inter-Process communication (IPC)

IPC in JOS

Sending and Receiving Messages

Transferring Pages




Lab 1: Booting a PC

实验室分为三个部分。第一部分着重于熟悉x86汇编语言、QEMU x86模拟器和PC的开机启动过程。第二部分为我们的6.828内核检查引导加载程序,它驻留在lab树的目录中。最后,第三部分将深入研究6.828内核本身的初始模板,名为JOS,它驻留在kernel目录中。

Part 1: PC Bootstrap

第一个练习的目的是向您介绍x86汇编语言和PC引导过程,并开始QEMU和QEMU/GDB调试。您不需要为实验室的这一部分编写任何代码,但是为了您自己的理解,您应该阅读它,并准备好回答下面提出的问题.

练习1:  熟悉可在6.828参考页上找到的汇编语言材料。您现在不必阅读它们,但是在阅读和编写x86程序集时,您肯定希望参考其中的一些内容。我们确实建议阅读Brennan's Guide to Inline Assembly中的“语法”一节。它对我们将在JOS中与GNU汇编器一起使用的AT&T程序集语法提供了一个很好的(而且相当简短的)描述。

 

Brennan's Guide to Inline Assembly

好的。这是对DJGPP下的内联程序集的介绍。DJGPP是基于GCC的,因此它使用AT&T/UNIX语法,并具有某种独特的内联组装方法。我花了很多时间去弄清楚这些东西,我讨厌它,很多次。DJGPP使用AT&T程序集语法。这对你意味着什么?

  • 寄存器的命名:寄存器名称需要加前缀“%”。
AT&T:  %eax
Intel: eax
  • 源/目标排序:

在AT&T语法(这是UNIX标准,顺便说一下)中,源总是在左边,而目标总是在右边。
那么让我们用eax中的值加载ebx:

AT&T:  movl %eax, %ebx
Intel: mov ebx, eax
  • 常量/立即数格式:

必须在所有常量/立即数前加上“$”。
让我们用“C”变量booga的地址加载eax,它是静态的。

AT&T:  movl $_booga, %eax
Intel: mov eax, _booga

现在让我们用0xd00d加载ebx:

AT&T:  movl $0xd00d, %ebx
Intel: mov ebx, d00dh
  • 操作大小规格:

您必须将指令后缀指定位为b、w或l之一,以便将目标寄存器的宽度指定为字节、字或长字。如果省略这个,GAS (GNU汇编程序)将尝试猜测。你不想让GAS 去猜! 那么不要忘记这个。

AT&T:  movw %ax, %bx
Intel: mov bx, ax
  • 引用内存:

DJGPP使用386保护模式,因此您可以忘记所有的实模式寻址遗留垃圾,包括对哪些寄存器具有哪些默认段的限制,哪些寄存器可以是基指针或索引指针。现在,我们得到6个通用寄存器。(当然也可以是7个,如果你使用ebp,但一定要自己恢复它)
以下是32位寻址的规范格式:

AT&T:  immed32(basepointer,indexpointer,indexscale)
Intel: [basepointer + indexpointer*indexscale + immed32]

Simulating the x86

我们没有在真实的物理个人计算机(PC)上开发操作系统,而是使用一个模拟完整的PC的程序:您为模拟器编写的代码也将在真实的PC上启动。使用模拟器可以简化调试;例如,您可以在模拟的x86中设置断点。

在6.828中,我们将使用QEMU仿真器,这是一种现代且相对快速的仿真器。虽然QEMU内置监视器只提供有限的调试支持,但QEMU可以充当GNU调试器(GDB)的远程调试目标,我们将在本实验室中使用GDB来逐步完成早期启动过程。

现在可以运行QEMU了,提供了obj/kern/kernel.img,创建于上面,作为模拟PC的“虚拟硬盘”的内容。这个硬盘映像包含我们的引导加载程序(obj/boot/boot)和内核(obj/kernel)。

执行命令, make qemu.

K>是由内核中包含的小型监视器或交互控制程序打印的提示符。如果您使用make qemu,那么内核打印的这些行将出现在运行qemu的常规shell窗口和qemu显示窗口中。这是因为为了测试和实验室评分的目的,我们已经建立了JOS内核,不仅将其控制台输出写入虚拟VGA显示(如QEMU窗口所示),还写入模拟PC的虚拟串口.

只有两个命令可以给内核监视器,help和kerninfo。

帮助命令很明显,我们稍后将讨论kerninfo命令打印内容的含义。尽管很简单,但是需要注意的是,这个内核监视器是在模拟PC的“原始(虚拟)硬件”上“直接”运行的。这意味着您应该能够复制bj/kern/kernel.img的内容。在一个真正的硬盘的前几个扇区,插入硬盘到一个真正的PC,打开它,在PC的真正屏幕上将看到完全一样的事情,就像你在上面做的QEMU窗口。我们不建议你在一个真正的机器上做这个.

The PC's Physical Address Space

现在我们将更详细地介绍PC是如何启动的。PC机的物理地址空间有以下的总布局

早期pc基于16位的英特尔8088处理器,只能处理1MB的物理内存。因此,早期PC的物理地址空间将从0x00000000开始,而结束于0x000FFFFF而不是0xFFFFFFFF。640KB标记为“低内存”的区域是早期PC可以使用的唯一随机访问内存(RAM);事实上,最早的pc只能配置16KB、32KB或64KB的RAM!

从0x000A0000到0x000FFFFF的384KB区域由硬件保留,用于特殊用途,例如视频显示缓冲区和非易失性内存中的固件。这个预留区域中最重要的部分是基本输入/输出系统(BIOS),它占用了从0x000F0000到0x000FFFFF的64KB区域。在早期的pc中BIOS保存在真正的只读存储器(ROM)中,但是现在的pc将BIOS存储在可更新的闪存中。BIOS负责执行基本的系统初始化,例如激活显卡和检查安装的内存数量。在执行此初始化之后,BIOS从一些适当的位置(如软盘、硬盘、CD-ROM或网络)加载操作系统,并将对机器的控制传递给操作系统。

当英特尔最终用80286和80386处理器“突破了1兆字节的限制”,分别支持16MB和4GB的物理地址空间时,PC架构师仍然保留了原来的布局,只有1MB的物理地址空间,以确保与现有软件的向后兼容性。因此,现代pc机在从0x000A0000到0x00100000的物理内存中有一个“洞”,将RAM分为“低”或“常规内存”(最初的640KB)和“扩展内存”(其他所有东西)。此外,PC机的32位物理地址空间顶部的一些空间,首先是物理RAM,现在通常由BIOS保留给32位PCI设备使用。

x86处理器可以支持超过4GB的物理RAM,因此RAM可以进一步扩展到0xFFFFFFFF以上。在这种情况下,BIOS必须安排在32位可寻址区域顶部的系统RAM中留下第二个洞,以便为这些32位设备留下映射空间。由于设计上的限制,JOS将只使用PC机物理内存的前256MB。但是处理复杂的物理地址空间和经过多年发展的硬件组织的其他方面是操作系统开发的一个重要的实际挑战。

The ROM BIOS

在实验室的这一部分中,您将使用QEMU的调试工具来研究IA-32兼容的计算机引导。
打开两个终端窗口,并将两个shell都cd到您的lab目录中。其中一个 make qemu-gdb(或make qemu-nox-gdb)。这会启动QEMU,但QEMU会在处理器执行第一个指令之前停止,并等待GDB的调试连接。在第二个终端中,从运行make的相同目录中运行make gdb。你应该看到这样的东西:

我们提供了一个.gdbinit文件,它设置了GDB来调试早期引导期间使用的16位代码,并将其附加到侦听的QEMU上。(如果无法工作,您可能需要在您的主目录下的.gdbinit中添加一个add-auto-load-safe-path,以说服gdb处理我们提供的.gdbinit。gdb会告诉你是否需要这样做。

The following line:

[f000:fff0] 0xffff0:	ljmp   $0xf000,$0xe05b

是GDB反汇编要执行的第一个指令。从这个输出可以得出以下结论:
IBM PC从物理地址0x000ffff0开始执行,它位于为ROM BIOS保留的64KB区域的最顶端。
PC开始执行CS = 0xf000, IP = 0xfff0。要执行的第一个指令是jmp指令,它跳转到分段地址CS = 0xf000和IP = 0xe05b。

QEMU为什么会这样开始呢?这就是英特尔设计8088处理器的方式,IBM在他们最初的PC机上使用了8088处理器。因为在PC BIOS是“天生的”物理地址范围0 x000f0000-0x000fffff,这种设计可以确保机器的BIOS总是控制任何系统重启后开启电源,这是至关重要的,因为没有其他软件在机器的内存,处理器可以执行。QEMU仿真器自带BIOS,它将BIOS放在处理器模拟物理地址空间的这个位置。在处理器复位时,(模拟的)处理器进入实模式,将CS设置为0xf000,将IP设置为0xfff0,以便从(CS:IP)段地址开始执行。分段地址0xf000:fff0如何变成物理地址?

为了回答这个问题,我们需要知道一些关于实模式寻址的知识。在实模式(PC机开始的模式)中,地址转换按公式进行:物理地址= 16 *段+偏移量。因此,当PC机将CS设为0xf000, IP设为0xfff0时,所引用的物理地址为:

 16 * 0xf000 + 0xfff0   # in hex multiplication by 16 is
   = 0xf0000 + 0xfff0     # easy--just append a 0.
   = 0xffff0 

0xffff0是BIOS结束前的16字节(0x100000)。因此,我们不应该对BIOS所做的第一件事感到惊讶,即jmp返回到BIOS中较早的位置;毕竟它能在16个字节内完成多少任务?

练习2。使用GDB的si (Step Instruction)命令跟踪到ROM BIOS中以获得更多的指令,并尝试猜测它可能在做什么。您可能希望查看Phil Storrs I/O端口描述,以及6.828参考资料页面上的其他材料。不需要弄清楚所有的细节,只需要知道BIOS首先要做什么。

当BIOS运行时,它会设置一个中断描述符表,并初始化各种设备,比如VGA显示。这就是您在QEMU窗口中看的“Starting SeaBIOS”消息的来源。在初始化PCI总线和BIOS所知道的所有重要设备之后,它将搜索可引导的设备,如软盘、硬盘或CD-ROM。最终,当找到一个可引导的磁盘时,BIOS从磁盘中读取引导加载程序并将控制权转移到它.

Part 2: The Boot Loader

pc机的软盘和硬盘被分成512字节的区域,称为扇区。扇区是磁盘的最小传输粒度:每个读或写操作必须是一个或多个扇区大小并在扇区边界上对齐。如果磁盘是可引导的,第一个扇区称为引导扇区,因为这是引导加载程序代码驻留的地方。当BIOS找到可引导的软盘或硬盘时,它将512字节的引导扇区加载到物理地址0x7c00到0x7dff的内存中,然后使用jmp指令将CS:IP设置为0000:7c00,并将控制权传递给引导加载程序. 像BIOS加载地址,这些地址是相当任意的-但他们是固定的和标准化的pc。

在PC的发展过程中,从CD-ROM引导的能力姗姗来迟,因此PC架构师利用这个机会稍微反思了引导过程。因此,从CD-ROM引导现代BIOS的方式要复杂一些(而且更强大)。cd - rom使用的扇区大小为2048字节,而不是512字节,BIOS可以在将控制权转移到它之前将一个更大的引导映像从磁盘加载到内存(而不仅仅是一个扇区)。

然而,对于6.828,我们将使用传统的硬盘驱动器引导机制,这意味着我们的引导加载程序必须适合512字节。引导加载程序由一个汇编语言源文件boot/boot.S组成。和一个C源文件,boot/main.c仔细检查这些源文件,确保你明白发生了什么。引导加载程序必须执行两个主要功能:

1.首先,引导加载程序将处理器从实模式切换到32位保护模式,因为只有在这种模式下,软件才能访问处理器物理地址空间中超过1MB的所有内存。保护模式在PC汇编语言的1.2.7和1.2.8节中进行了简要介绍,并在Intel体系结构手册中有详细介绍。此时,您只需要了解在保护模式下,将分段地址(段:偏移对)转换为物理地址的方式是不同的,转换偏移量是32位而不是16位。

2.其次,引导加载程序通过x86特殊的I/O指令直接访问IDE磁盘设备寄存器,从硬盘读取内核。如果您想更好地理解这里的特定I/O指令的含义,请查看6.828参考页面上的“IDE硬盘驱动器控制器”部分。在这门课中,您将不需要学习太多关于特定设备的编程:编写设备驱动程序实际上是操作系统开发的一个非常重要的部分,但从概念或体系结构的观点来看,它也是最无趣的部分之一。

在理解引导加载程序源代码之后,查看文件obj/boot/boot.asm。这个文件是我们的GNUmakefile在编译引导加载程序后创建的引导加载程序的分解。这个反汇编文件可以很容易地查看物理内存中所有引导加载程序代码的驻留位置,并使跟踪在GDB中的引导加载程序时发生的事情变得更容易。同样,obj /kern/kernel.asm包含JOS内核的分解,这通常对调试有用。

可以使用b命令在GDB中设置地址断点。例如,b *0x7c00在地址0x7c00设置一个断点。在一个断点处,您可以使用c和si命令继续执行:c使QEMU继续执行,直到下一个断点(或者直到您在GDB中按Ctrl-C),并且si N每次执行指令N步。

为了检查内存中的指令(除了下一个要执行的指令,GDB会自动打印),您可以使用x/i命令。这个命令有语法x/Ni ADDR,其中N是要打印的连续指令的数量,ADDR是开始打印的内存地址。

练习3。看看实验工具指南,特别是GDB命令部分。即使您熟悉GDB,这也包括一些对操作系统有用的深奥的GDB命令。
在地址0x7c00设置一个断点,这是加载引导扇区的地方。继续执行直到那个断点。跟踪引导/引导中的代码。使用源代码和反汇编文件obj/boot/boot.asm来跟踪你的位置。还可以使用GDB中的x/i命令来拆解引导加载程序中的指令序列,并比较原始引导加载程序源代码。在boot/main.c中跟踪引导到bootmain(),然后进入readsect()。识别与readsect()中的每个语句对应的确切汇编指令。跟踪readsect()的其余部分并将其返回到bootmain()中,并标识从磁盘读取内核其余扇区的for循环的开始和结束部分。找出循环结束后运行的代码,在那里设置一个断点,并继续该断点。然后逐步执行引导加载程序的其余部分。

你应该能够回答以下问题:

  • 处理器从什么时候开始执行32位代码?到底是什么导致了从16位模式到32位模式?

ljmp   $0x8,$0x7c32这条指令以后开始执行32位代码; 打开A20,将CR0某个标志位置1。

  • 引导加载程序执行的最后一条指令是什么?刚刚加载的内核的第一条指令是什么?

call *0x10018;       movw $0x1234, 0x472

  • 内核的第一个指令在哪里?

位于/kern/entry.S文件中

  • 引导加载程序如何决定必须读取多少扇区才能从磁盘获取整个内核?它在哪里找到这些信息?

Loading the Kernel

现在,我们将在boot/main.c中更详细地研究引导加载程序的C语言部分。但在此之前,现在是停止并回顾一些C编程基础。

练习4。阅读关于用C语言编写指针的文章。C语言最好的参考文献是Brian Kernighan和Dennis Ritchie(被称为“K&R”)的C语言。我们建议购买这本书。读K&R中的5.1(指针和地址)到5.5(字符指针和函数)。然后下载代码。运行它,并确保您理解所有打印值的来源。特别是,确保您理解打印第1行和第6行中的指针地址来自哪里,打印第2行到第4行中的所有值是如何到达那里的,以及为什么打印第5行中的值看起来是损坏的。警告:除非你已经精通C语言,否则不要跳过甚至略读这个阅读练习。如果你不真正理解C语言中的指针,你将会在后续的实验中遭受难以言喻的痛苦和痛苦,然后最终以艰难的方式理解它们。相信我们;你不想知道什么是“艰难的方法”。

boot/main.c 的意义。你需要知道什么是ELF二进制。当您编译并链接一个C程序(如JOS内核)时,编译器会将每个C源文件('. C ')文件转换为一个对象('.o')文件,其中包含用硬件期望的二进制格式编码的汇编语言指令。然后链接器将所有已编译的对象文件组合成一个二进制映像,例如obj/kern/kernel,在本例中,它是ELF格式的二进制映像,表示“可执行的和可链接的格式”。

关于这种格式的完整信息可以在我们参考页面的ELF规范中找到,但是在这个类中您不需要深入研究这种格式的细节。虽然总的来说,这种格式非常强大和复杂,但是大多数复杂的部分都是为了支持共享库的动态加载,这是我们在这个类中不会做的。维基百科页面有一个简短的描述。

对于6.828,您可以将ELF可执行文件看作是一个带有加载信息的头文件,后面跟着几个程序节,每一个都是一个连续的代码块或数据块,目的是将其加载到指定地址的内存中。引导加载程序不修改代码或数据;它将它加载到内存中并开始执行。

ELF二进制文件以固定长度的ELF头开始,后面是一个可变长度的程序头,列出了要加载的每个程序节。这些ELF头的C定义在inc/ ELF .h中。我们感兴趣的程序节有:

.text:程序的可执行指令。

.rodata:只读数据,如由C编译器生成的ASCII字符串常量。(不过,我们不会费心设置硬件来禁止写入。)

.data: data部分保存程序初始化的数据,例如使用int x = 5这样的初始化声明的全局变量;

当链接器计算程序的内存布局时,它为未初始化的全局变量预留空间,例如int x;,在内存中紧跟着.data的.bss部分中。C要求“未初始化”的全局变量从0开始。因此,不需要在ELF二进制中存储.bss的内容;相反,链接器只记录.bss部分的地址和大小。加载器或程序本身必须将.bss节设置为零。

通过输入以下命令检查内核可执行文件中所有节的名称、大小和链接地址的完整列表:

objdump -h obj/kern/kerne

l

您将看到比上面列出的更多的部分,但是其他部分对于我们的目的并不重要。其他大多数都用来保存调试信息,这些信息通常包含在程序的可执行文件中,但是程序加载器不会加载到内存中。

请特别注意.text部分的“VMA”(或链接地址)和“LMA”(或加载地址)。段的加载地址是将该段加载到内存中的内存地址。

节的链接地址是节期望执行的内存地址。链接器以不同的方式对二进制文件中的链接地址进行编码,比如当代码需要全局变量的地址时,结果是,如果二进制文件是从没有链接的地址执行的,那么通常不会工作。(可以生成不包含任何绝对地址的位置无关代码。这被现代共享库广泛使用,但是它有性能和复杂性成本,所以我们在6.828中不会使用它)。

通常,链接和加载地址是相同的。例如,查看引导加载程序的.text部分:

objdump -h obj/boot/boot.out

引导加载程序使用ELF程序头来决定如何加载节。程序头指定要加载到内存中的ELF对象的哪些部分以及每个部分应该占用的目标地址。

      objdump -x obj/kern/kernel            

然后,在objdump的输出中,程序头被列在“程序头”下。需要加载到内存中的ELF对象区域是那些标记为“LOAD”的区域。每个程序头都提供了其他信息,例如虚拟地址(“vaddr”)、物理地址(“paddr”)和加载区域的大小(“memsz”和“filesz”)。

回到boot/main.c,每个程序头的ph->p_pa字段包含段的目标物理地址(在本例中,它实际上是一个物理地址,尽管ELF规范对该字段的实际含义含糊其辞)。

BIOS将引导扇区加载到从地址0x7c00开始的内存中,因此这是引导扇区的加载地址。这也是引导扇区执行的地方,所以这也是它的链接地址。我们通过将-Ttext 0x7C00传递给boot/Makefrag中的链接器来设置链接地址,这样链接器将在生成的代码中生成正确的内存地址。

练习5。再次跟踪引导加载程序的前几条指令,并确定如果引导加载程序的链接地址出错,第一条指令将“中断”或执行错误操作。然后将boot/Makefrag中的链接地址更改为一些错误的值,运行make clean,使用make重新编译实验,并再次跟踪到引导加载程序中,以查看发生了什么。别忘了把链接地址改回来,然后重新清理!

回顾一下内核的加载和链接地址。与引导加载程序不同,这两个地址并不相同:内核告诉引导加载程序以低地址(1兆字节)将其加载到内存中,但它希望从高地址执行。在下一节中,我们将深入探讨如何使其工作

除了节信息,ELF头中还有一个对我们很重要的字段e_entry。这个字段包含程序入口点的链接地址:程序text节的内存地址,程序应该在该地址开始执行。你可以看到入口点:

objdump -f obj/kern/kernel

现在您应该能够理解boot/main.c中的最小ELF加载程序了。它将内核的每个节从磁盘读入位于该节加载地址的内存中,然后跳到内核的入口点。

练习6。我们可以使用GDB的x命令检查内存。GDB手册有完整的细节,但是现在,只要知道命令x/Nx ADDR在ADDR上打印了N个word就足够了。(注意,命令中的两个'x'都是小写字母。)警告:单词的大小不是通用的标准。在GNU汇编中,一个word是两个字节(xorw中的“w”代表单词,意思是2字节)。重置机器(退出QEMU/GDB并重新启动)。在BIOS进入引导加载程序时检查0x00100000处的内存8个字,然后在引导加载程序进入内核时再检查一次。

Part 3: The Kernel

现在 , 我们将开始更详细地研究最小JOS内核。(你最终会写一些代码!)与引导加载程序一样,内核从一些汇编语言代码开始,这些代码设置了一些东西,以便C语言代码能够正确执行。

Using virtual memory to work around position dependence

当您检查上面的引导加载程序的链接和加载地址时,它们完全匹配,但是内核的链接地址(由objdump打印)和加载地址之间存在(相当大的)差异。回去检查这两个,确保你知道我在说什么。(链接内核比引导加载程序更复杂,因此链接和加载地址位于kern/kernel.ld的顶部。)

操作系统内核通常喜欢在非常高的虚拟地址(例如0xf0100000)上进行链接和运行,以便将处理器虚拟地址空间的较低部分留给用户程序使用。这种安排的原因将在下一个实验中变得更清楚。

许多机器在地址0xf0100000处没有任何物理内存,所以我们不能指望能够在那里存储内核。相反,我们将使用处理器的内存管理硬件将虚拟地址0xf0100000(内核代码期望运行的链接地址)映射到物理地址0x00100000(在那里引导加载程序将内核加载到物理内存中)。这样,虽然内核的虚拟地址足够高,可以为用户进程留下足够的地址空间,但它将被加载到物理内存中,在PC的RAM中1MB的地方,就在BIOS ROM的上方。这种方法要求PC至少有几兆的物理内存(物理地址是在0x00100000工作),对于大约在1990年以后构建的任何PC来说,这是完全没问题的。

实际上,在下一个实验室中,我们将映射PC机的整个底层256MB的物理地址空间,从物理地址0x00000000到0x0fffffff,到虚拟地址0xf0000000到0xffffffff。现在您应该知道为什么JOS只能使用第一个256MB的物理内存了。

现在,我们只需要映射第一个4MB的物理内存,这足以让我们启动并运行。我们使用kern/entrypgdir.c中的手写的、静态初始化的页面目录和页表来完成此操作。现在,您不需要了解它如何工作的细节,只需要了解它实现的效果。直到kern/entry.S。S设置CR0_PG标志,内存引用被视为物理地址(严格来说,它们是线性地址,但是引导/引导)。我们建立了一个从线性地址到物理地址的恒等映射,我们永远不会改变它)。

一旦设置了CR0_PG,内存引用就是虚拟内存硬件将其转换为物理地址的虚拟地址。entry_pgdir将0xf0000000到0xf0400000范围内的虚拟地址转换为物理地址0x00000000到0x00400000,以及虚拟地址0x00000000到0x00400000到物理地址0x00000000到0x00400000。任何不在这两个范围内的虚拟地址都会导致硬件异常,因为我们还没有设置中断处理,这会导致QEMU转储机器状态并退出(或无休止地重新引导).

练习7。使用QEMU和GDB跟踪到JOS内核,并在movl %eax, %cr0处停止。检查0x00100000和0xf0100000的内存。现在,使用stepi GDB命令对该指令进行单步操作。再次检查0x00100000和0xf0100000的内存。确保你明白刚才发生了什么。
在建立新的映射之后,如果没有映射就不能正常工作的第一个指令是什么?注释掉kern/entry.S中的movl %eax和%cr0,追踪它,看看你是不是对的。

执行movl %eax, %cr0   以后:

如果没有映射就不能正常工作的第一个指令是jmp *%eax的下一条指令。

改回来以后:

Formatted Printing to the Console

大多数人认为printf()之类的函数是理所当然的,有时甚至认为它们是C语言的“原语”。但在操作系统内核中,我们必须自己实现所有I/O。通读kern/printf.c, lib/printfmt.c, kern/console.c,确保你理解他们的关系。在以后的实验室中,你将会清楚为什么printfmt.c位于独立的lib目录中。

练习8。我们省略了一小段代码——使用形式为“%o”的模式打印八进制数所必需的代码。查找并填写此代码片段。

能够回答以下问题:

1. 解释printf.c和console.c之间的接口。具体来说,console.c导出了什么函数。printf.c如何使用这些函数?‘

console.c

printf.c

2.解释console.c以下内容:

当缓存区字符满了以后,执行屏幕滚动操作。

3.对于以下问题,您可能希望查阅lecture 2的讲义。这些笔记涵盖了GCC在x86上的调用约定。

逐步跟踪以下代码的执行:

int x = 1, y = 3, z = 4;

cprintf("x %d, y %x, z %d\n", x, y, z);

在对cprintf()的调用中,fmt指向什么?ap指的是什么?列出对cons_putc、va_arg和vcprintf的每个调用(按执行顺序)。对于cons_putc,也列出它的参数。对于va_arg,列出ap在调用前后指向的内容。对于vcprintf,列出它的两个参数的值。

调用cprintf函数:

cprintf函数内部

4.运行以下代码:

输出是什么?按照前面练习的方式解释如何逐步得到这个输出。

输出取决于x86是little-endian的事实。如果x86是big-endian,为了得到相同的输出,你会将i设为什么?您需要将57616更 改为不同的值吗?

i 设置为0x726c6400,  57616不需要更改。

5.在下面的代码中,在“y=”之后将打印什么?(注意:答案不是一个特定的值。)为什么会这样呢?

      cprintf("x=%d y=%d", 3);

6.假设GCC改变了它的调用约定,以便它按照声明顺序在堆栈上推送参数,这样最后一个参数就会被推送到最后。您将如何更改cprintf或其接口,以便仍然可以向其传递数量可变的参数?

The Stack

在这个实验的最后练习中,我们将更详细地探讨C语言使用x86上的堆栈的方式,并在这一过程中写一个有用的新的内核监控功能,输出一个堆栈回溯:保存从嵌套调用指令导致的当前执行点的指针(IP)的列表值。

练习9。确定内核初始化其栈的位置,以及栈在内存中的确切位置。内核如何为其栈保留空间?在这个保留区域的“末端”,栈指针被初始化指向什么?

x86栈指针(esp寄存器)指向当前正在使用的堆栈上的最低位置。为栈保留的区域中该位置以下的所有内存都是空闲的。将一个值压入堆栈涉及到减少堆栈指针,然后将该值写入堆栈指针指向的位置。从堆栈中取出一个值涉及到读取堆栈指针指向的值,然后增加堆栈指针。在32位模式下,堆栈只能保存32位值,esp总是能被4整除。各种x86指令,比如call,都是“硬连接”来使用栈指针寄存器。

相反,ebp(基指针)寄存器主要通过软件约定与堆栈关联。在进入C函数时,函数的建立堆栈框架的代码通常通过将前一个函数的基指针推入堆栈来保存它,然后在函数运行期间将当前esp值复制到ebp中。如果一个程序的所有的函数都遵守这个约定,那么在在程序的执行期间任何给定的点, 通过跟踪保存的ebp指针链,并准确地确定是哪些嵌套的函数调用序列导致程序中的这个特定点到达,可以进行堆栈跟踪返回。这种功能可能特别有用,例如,当某个特定函数由于传递了错误的参数而导assert failure or panic时,但是你不确定是谁传递了错误的参数时。堆栈回溯可以让你找到有问题的函数。

练习10.  熟悉x86上的C调用约定,在obj/kern/kernel.asm中查找test_backtrace函数的地址,在那里设置一个断点,并检查每次在内核启动后调用它时发生了什么。test_backtrace的每个递归嵌套层在堆栈上推了多少个32位word?这些word是什么?
注意,为了使这个练习正常工作,您应该使用工具页面上可用的补丁版本QEMU。否则,您必须手动将所有断点和内存地址转换为线性地址。

调用test_backtrace(5)

在test_backtrace(5)堆栈框架建好后esp, ebp的值。

调用test_backtrace(4)

4后面那个5是cprintf压栈压进去的。

在test_backtrace(4)堆栈框架建好后esp, ebp的值。

 3 

  

2

1

0

上面的练习应该为您提供实现堆栈回溯函数所需的信息,您应该调用mon_backtrace()。在kern/monitor.c中,这个函数的原型已经在等待您了, 用C语言中实现这个函数,你可以inc/x86中找到read_ebp()函数,它是有用的。你还必须将这个新函数连接到内核监视器的命令列表中,以便用户可以交互式地调用它。

backtrace函数应该以以下格式显示函数调用框架列表:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

每行包含ebp、eip和args。ebp值表示该函数使用的堆栈的基指针:在进入函数和函数序言代码之后,将栈指针的值赋值基指针。列出的eip值是函数的返回指令指针:当函数返回时将返回的指令地址。返回指令指针通常指向调用指令之后的指令(为什么?)最后,args后面列出的5个十六进制值是该函数的前5个参数,这些参数将在调用该函数之前被推送到栈上。当然,如果函数调用的参数少于5个,不是所有的5个值都有用。(为什么回溯代码不能检测到底有多少参数?这一限制如何被修正?)

打印的第一行反映当前执行的函数,即mon_backtrace本身,第二行反映调用mon_backtrace的函数,第三行反映调用该函数的函数,依此类推。您应该打印所有的堆栈帧。通过研究 kern/entry.S条目。你会发现有一种简单的方法可以告诉你什么时候停下来。

以下是你在K&R第五章中读到的一些特别的观点,值得你在接下来的练习和未来的实验中记住。

如果int* p = (int*)100,那么(int)p + 1和(int)(p + 1)是不同的数字:第一个是101,第二个是104。当向指针中添加整数时,如第二种情况,该整数隐式地乘以指针指向的对象的大小。

p[i]被定义为与*(p+i)相同,指的是p指向内存中的第i个对象。当对象大于一个字节时,上面的加法规则有助于这个定义的工作。

&p[i]与(p+i)相同,表示p指向内存中第i个对象的地址。

虽然大多数C程序永远不需要在指针和整数之间转换,但是操作系统经常这样做。每当看到涉及内存地址的加法时,请自问它是整数加法还是指针加法,并确保所添加的值是否正确相乘。

练习11.  实现上面指定的回溯函数。使用与示例相同的格式,否则会混淆分级脚本。当您认为它工作正常时,运行make grade,看看它的输出是否符合我们的分级脚本的要求,如果不符合,则修复它。在您提交了lab1代码之后,欢迎您以任何方式更改backtrace函数的输出格式。

如果使用read_ebp(),请注意GCC可能会生成“优化”代码,在mon_backtrace()的函数序言之前调用read_ebp(),这会导致不完整的堆栈跟踪(最近一次函数调用的堆栈框架丢失)。虽然我们已经尝试禁用导致这种重新排序的优化,但是你应该检查mon_backtrace()的汇编代码,并确保在函数序言之后调用read_ebp()。

此时,backtrace函数应该给出堆栈上导致mon_backtrace()被执行的函数调用者的地址。然而,在实践中,您通常希望知道与这些地址对应的函数名。例如,您可能想知道哪些函数可能包含导致内核崩溃的bug。

为了帮助您实现此功能,我们提供了debuginfo_eip()函数,它在符号表中查找eip并返回该地址的调试信息。这个函数在kern/kdebug.c中定义。

// Debug information about a particular instruction pointer
struct Eipdebuginfo {
        const char *eip_file;           // Source code filename for EIP
        int eip_line;                   // Source code linenumber for EIP

        const char *eip_fn_name;        // Name of function containing EIP
                                        //  - Note: not null terminated!
        int eip_fn_namelen;             // Length of function name
        uintptr_t eip_fn_addr;          // Address of start of function
        int eip_fn_narg;                // Number of function arguments
};

练习12。 修改堆栈回溯函数以显示每个eip对应的函数名、源文件名和行号。

在debuginfo_eip中,__STAB_*从哪里来?这个问题的答案很长;为了帮助你找到答案,这里有一些你可能想做的事情:

  • look in the file kern/kernel.ld for __STAB_*
  • run objdump -h obj/kern/kernel
  • run objdump -G obj/kern/kernel
  • run gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c, and look at init.s.
  • see if the bootloader loads the symbol table in memory as part of loading the kernel binary

通过插入对stab_binsearch的调用来查找地址的行号,完成debuginfo_eip的实现。

向内核监视器添加一个回溯命令,并扩展mon_backtrace的实现,调用debuginfo_eip,并将每个堆栈帧的表单打印:


Stack backtrace:
ebp f010ff68 eip f0100971 args 00000001 f010ff80 00000000 f010ffc8 f0112540
       kern/monitor.c:148  monitor+258
ebp f010ffd8 eip f01000f6 args 00000000 00001aac 00000640 00000000 00000000
       kern/init.c:43  i386_init+89
ebp f010fff8 eip f010003e args 00111021 00000000 00000000 00000000 00000000
       kern/entry.S:83  <unknown>+0

每行给出堆栈帧eip文件中的文件名和行号,后面跟着函数名和eip与函数第一条指令的偏移量(例如,monitor+106表示返回eip的值比monitor开头的值多了106字节)

请确保将文件和函数名打印在单独的一行上,以避免干扰评分脚本。

提示:printf格式字符串提供了一种简单(尽管不太清楚)的方式来打印非空终止的字符串,比如STABS表中的字符串。printf("%.*s", length, string) 打印最多长度的为length字符串字符。请查看printf手册页以了解其工作原理。

您可能会发现回溯跟踪中缺少一些函数。例如,您可能会看到对monitor()的调用,但不会看到对runcmd()的调用。这是因为编译器内联了一些函数调用。其他优化可能会导致您看到意外的行号。如果您从GNUMakefile中删除了-O2,那么回溯可能更有意义(但是您的内核会运行得更慢)。

代码实现如下:





Lab 2: Memory Management

Introduction

在这个实验室中,您将为您的操作系统编写内存管理代码。内存管理有两个组件。

第一个组件是内核的物理内存分配器,这样内核就可以分配内存,然后释放它。您的分配器将以4096字节为单位进行操作,称为页面。您的任务是维护数据结构,这些数据结构记录哪些物理页面是空闲的,哪些是分配的,以及有多少进程共享每个分配的页面。您还将编写分配和释放内存页面的程序。

内存管理的第二个组件是虚拟内存,它将内核和用户软件使用的虚拟地址映射到物理内存中的地址。x86硬件的内存管理单元(MMU)在指令使用内存时执行映射,并参考一组页表。您将修改JOS以根据我们提供的规范设置MMU的页表。

Part 1: Physical Page Management

操作系统必须跟踪物理RAM的哪些部分是空闲的,哪些部分目前正在使用。JOS以页面粒度管理PC的物理内存,这样它就可以使用MMU来映射和保护分配的每一块内存。现在您将编写物理页面分配器。它使用struct PageInfo对象的链接列表(与xv6不同的是,这些对象没有嵌入到空闲页面本身中)来跟踪哪些页面是空闲的,每个对象都对应于一个物理页面。在编写余下的虚拟内存实现之前,您需要编写物理页面分配器。

练习1。在文件kern/pmap.c中,您必须实现以下函数的代码(可能按照给定的顺序)。

boot_alloc()
mem_init() (only up to the call to check_page_free_list(1))
page_init()
page_alloc()
page_free()

check_page_free_list()和check_page_alloc()测试物理页面分配器。您应该引导JOS并查看check_page_alloc()是否报告成功。修正你的代码,让它通过。您可能会发现添加您自己的assert()有助于验证您的假设是否正确。

这个实验,以及所有的6.828个实验室,将需要你做一些侦探工作来弄清楚你到底需要做什么。这个任务并没有描述你需要添加到JOS中的代码的所有细节。在需要修改的JOS源代码中查找注释;这些注释通常包含规范和提示。您还需要查看JOS的相关部分、Intel手册,以及您的6.004或6.033笔记。

Part 2: Virtual Memory

在做其他事情之前,先熟悉x86的受保护模式内存管理体系结构:即分段和页面转换。

练习2.    如果您还没有这样做,请参阅Intel 80386参考手册的第5章和第6章。仔细阅读关于页面翻译和基于页面的保护的章节(5.2和6.4)。我们建议您也略读有关分段的章节;虽然JOS使用分页硬件来实现虚拟内存和保护,但是在x86上不能禁用段转换和基于段的保护,因此您需要对它有基本的了解。

80386转换逻辑地址(即,即程序员查看的地址)转换为物理地址(即,物理内存中的实际地址)分为两个步骤:

段转换,其中逻辑地址(由段选择器和段偏移量组成)转换为线性地址。

页面转换,其中线性地址转换为物理地址。这个步骤是可选的,由系统软件设计师决定。

这些转换以应用程序员看不到的方式执行。

Protection in the 80386 has five aspects:

  1. Type checking
  2. Limit checking
  3. Restriction of addressable domain
  4. Restriction of procedure entry points
  5. Restriction of instruction set

Virtual, Linear, and Physical Addresses

在x86术语中,虚拟地址由段选择器和段内的偏移量组成。线性地址是在段翻译之后,但在页面翻译之前得到的。物理地址是在段和页面转换之后最终得到的地址,以及最终在硬件总线上到达RAM的地址。

           Selector  +--------------+         +-----------+
          ---------->|              |         |           |
                     | Segmentation |         |  Paging   |
Software             |              |-------->|           |---------->  RAM
            Offset   |  Mechanism   |         | Mechanism |
          ---------->|              |         |           |
                     +--------------+         +-----------+
            Virtual                   Linear                Physical

C指针是虚拟地址的“偏移”组件。在/ boot引导。我们安装了一个全局描述符表(GDT),通过将所有段基地址设置为0并限制为0xffffffff,有效地禁用了段转换。因此,“选择器”没有影响,线性地址总是等于虚拟地址的偏移量。在lab3中,我们将不得不与分割进行更多的交互以建立特权级别,但是对于内存转换,我们可以忽略整个JOS实验室中的分割,只关注页面转换。

回想一下,在lab 1的第3部分中,我们安装了一个简单的页表,这样内核就可以在它的链接地址0xf0100000处运行,尽管它实际上是在比ROM BIOS高一点的位置装载的。这个页表只映射了4MB内存。在为JOS设置的虚拟地址空间布局中,我们将扩展它,以映射从虚拟地址0xf0000000开始的第一个256MB物理内存,并映射虚拟地址空间的其他一些区域。

练习3。虽然GDB只能通过虚拟地址访问QEMU的内存,但在设置虚拟内存时能够检查物理内存通常很有用。检查实验室工具指南中的QEMU监视器命令,特别是xp命令,它允许您检查物理内存。要访问QEMU监视器,请在终端中按Ctrl-a c(相同的绑定返回到串行控制台)。

使用QEMU监视器中的xp命令和GDB中的x命令检查相应物理地址和虚拟地址的内存,并确保看到相同的数据。从CPU上执行的代码来看,一旦我们处于保护模式(我们在boot/boot. s中输入的第一件事),就无法直接使用线性地址或物理地址。所有内存引用都被解释为虚拟地址,并由MMU进行翻译,这意味着C中的所有指针都是虚拟地址。

我们的补丁版本QEMU提供了一个info pg命令,这可能也会证明是有用的:它显示了当前页表的简洁但详细的表示,包括所有映射的内存范围、权限和标志。QEMU还提供了一个info mem命令,该命令显示了映射哪些虚拟地址范围以及使用哪些权限的概述

JOS内核经常需要将地址作为不透明的值或整数进行操作,而对它们解引用,例如在物理内存分配器中。有时是虚拟地址,有时是物理地址。为了帮助记录代码,JOS源代码区分了两种情况:类型uintptr_t表示不透明的虚拟地址,而physaddr_t表示物理地址。这两种类型实际上都是32位整数(uint32_t)的同义词,因此编译器不会阻止您将一种类型分配给另一种类型!因为它们是整数类型(不是指针)。

JOS内核可以通过首先将uintptr_t转换为指针类型然后对它解引用。相反,内核不能直接地解引用物理地址,因为MMU转换所有内存引用。如果将physaddr_t转换为指针并解引用,您可能可以加载到目标地址(硬件将把它解释为虚拟地址),但是您可能无法获得您想要的内存位置。

JOS内核有时需要读取或修改只知道物理地址的内存。例如,向页表添加映射可能需要分配物理内存来存储页目录,然后初始化该内存。但是,内核不能绕过虚拟地址转换,因此不能直接加载和存储到物理地址。JOS在虚拟地址0xf0000000处从物理地址0开始重新映射所有物理内存的一个原因是帮助内核读写它知道物理地址的内存。为了将物理地址转换为内核可以实际读写的虚拟地址,内核必须向物理地址添加0xf0000000,以便在重新映射的区域中找到对应的虚拟地址。您应该使用KADDR(pa)来进行添加。

有时候,给定存储内核数据结构的内存的虚拟地址还需要能够找到一个物理地址。boot_alloc()分配的内核全局变量和内存位于内核加载的区域,从0xf0000000开始,这正是我们映射所有物理内存的区域。因此,要将此区域中的虚拟地址转换为物理地址,内核只需减去0xf0000000即可。你应该用PADDR(va)来做减法。

在将来的实验中,您通常会在多个虚拟地址(或多个环境的地址空间)同时映射相同的物理页面。您将在与物理页面相对应的struct PageInfo的pp_ref字段中保持对每个物理页面的引用数量的计数。当一个物理页面的计数变为0时,该页面可以被释放,因为它不再被使用。通常,这个计数应该等于在所有页表中物理页面出现在UTOP之下的次数(UTOP之上的映射大部分是在引导时由内核设置的,不应该被释放,因此不需要引用计数)。我们还将使用它来跟踪我们保存到页目录页的指针数量,进而跟踪页目录对页表页的引用数量。

Page Table Management

现在,您将编写一组管理页表的例程:插入和删除线性到物理的映射,并在需要时创建页表页。

Exercise 4. In the file kern/pmap.c, you must implement code for the following functions.

        pgdir_walk()
        boot_map_region()
        page_lookup()
        page_remove()
        page_insert()
	

check_page(), called from mem_init(), tests your page table management routines. You should make sure it reports success before proceeding.

 

 

Part 3: Kernel Address Space

操作系统将处理器的32位线性地址空间分为两部分。我们将在lab3中开始加载和运行的用户环境(进程)将控制下面部分的布局和内容,而内核始终保持对上面部分的完全控制。在inc/memlayout.h中,ULIM符号任意定义了分界线。为内核预留大约256MB的虚拟地址空间。这就解释了为什么我们需要在lab 1中给内核一个如此高的链接地址:否则内核的虚拟添加空间就不够了

你会发现在inc/memlayout.h中引用JOS内存布局图是很有帮助的。这部分和以后的实验都有。

Permissions and Fault Isolation

由于内核和用户内存都存在于每个环境的地址空间中,因此我们必须在x86页表中使用权限位,以允许用户代码只访问地址空间的用户部分。否则,用户代码中的bug可能会覆盖内核数据,导致崩溃或更细微的故障;用户代码还可以窃取其他环境的私有数据。注意,可写权限位(PTE_W)同时影响用户和内核代码!

用户环境将不允许使用ULIM以上的任何内存,而内核将能够读写这些内存。对于地址范围[UTOP,ULIM],内核和用户环境都有相同的权限:它们可以读取但不能写入这个地址范围。此地址范围用于向用户环境公开某些只读的内核数据结构。最后,UTOP下面的地址空间是供用户环境使用的;用户环境将设置访问此内存的权限。

Initializing the Kernel Address Space

现在您将在UTOP上方设置地址空间:地址空间的内核部分。inc/memlayout.h 显示您应该使用的布局。您将使用刚才编写的函数来设置适当的线性到物理地址的映射。

练习5.    在调用check_page()之后,填写mem_init()中缺少的代码。您的代码现在应该传递check_kern_pgdir()和check_page_installed_pgdir()检查。

Address Space Layout Alternatives

我们在JOS中使用的地址空间布局不是唯一可能的。操作系统可能将内核映射到低线性地址,而将线性地址空间的上半部分留给用户进程。x86内核通常不采用这种方法,但是,因为x86的向后兼容模式(称为虚拟8086模式)在处理器中是“硬连线”的,可以使用线性地址空间的底部部分,因此如果内核映射到那里,就根本不能使用。

到目前为止页目录表中已经包含多少有效页目录项?他们都映射到哪里?

Entry Base Virtual Address Points to (logically)
1024 0xffc00000 kernel space
kernel space
960 0xf0000000(KERNBASE) kernel space
959 0xeffc00000 (KSTACKTOP-KSTSIZE) bootstack
957 0xef400000(UVPT)
956 0xef000000(UPAGES) pages



Lab 3: User Environments

Basic inline assembly

Part A: User Environments and Exception Handling

file inc/env.h包含了JOS中用户环境的基本定义。现在读它。内核使用Env数据结构来跟踪每个用户环境。在这个实验室中,您最初将只创建一个环境,但是您需要设计JOS内核来支持多个环境; lab 4将利用这个特性,允许用户环境fork其他环境。

As you can see in kern/env.c, the kernel maintains three main global variables pertaining to environments:

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中定义的常数。)一旦它被分配,envs数组将包含每个NENV可能环境的Env数据结构的单个实例。

JOS内核在env_free_list中保留所有不活动的Env结构。这种设计允许环境的简单分配和重新分配,因为它们只需要添加到或从空闲列表中删除即可。

内核使用curenv符号在任何给定时间跟踪当前执行的环境。在启动期间,在运行第一个环境之前,curenv最初设置为NULL。

Environment State

环境结构在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
};

Here's what the Env fields are for:

env_tf:

This structure, defined in inc/trap.h, holds the saved register values for the environment while that environment is not running: i.e., when the kernel or a different environment is running. The kernel saves these when switching from user to kernel mode, so that the environment can later be resumed where it left off.

env_link:

This is a link to the next Env on the env_free_listenv_free_list points to the first free environment on the list.

env_id:

The kernel stores here a value that uniquely identifiers the environment currently using this Env structure (i.e., using this particular slot in the envs array). After a user environment terminates, the kernel may re-allocate the same Env structure to a different environment - but the new environment will have a different env_id from the old one even though the new environment is re-using the same slot in the envs array.

env_parent_id:

The kernel stores here the env_id of the environment that created this environment. In this way the environments can form a “family tree,” which will be useful for making security decisions about which environments are allowed to do what to whom.

env_type:

This is used to distinguish special environments. For most environments, it will be ENV_TYPE_USER. We'll introduce a few more types for special system service environments in later labs.

env_status:

This variable holds one of the following values:

ENV_FREE:

Indicates that the Env structure is inactive, and therefore on the env_free_list.

ENV_RUNNABLE:

Indicates that the Env structure represents an environment that is waiting to run on the processor.

ENV_RUNNING:

Indicates that the Env structure represents the currently running environment.

ENV_NOT_RUNNABLE:

Indicates that the Env structure represents a currently active environment, but it is not currently ready to run: for example, because it is waiting for an interprocess communication (IPC) from another environment.

ENV_DYING:

Indicates that the Env structure represents a zombie environment. A zombie environment will be freed the next time it traps to the kernel. We will not use this flag until Lab 4.

env_pgdir:

This variable holds the kernel virtual address of this environment's page directory.

与Unix进程类似,JOS环境将“线程”和“地址空间”的概念结合在一起。线程主要由保存的寄存器(env_tf字段)定义,地址空间由env_pgdir指向的页目录和页表定义。要运行一个环境,内核必须使用保存的寄存器和适当的地址空间来设置CPU。

我们的struct Env类似于xv6的struct proc。这两种结构都承载着环境(即,进程的)用户模式寄存器状态在一个Trapframe结构。在JOS中,各个环境不像xv6中的进程那样拥有自己的内核堆栈。每次只有一个JOS环境在内核中活动,所以JOS只需要一个内核栈.

Allocating the Environments Array

在lab2中,您在mem_init()中为pages[]数组分配了内存,内核使用它来跟踪哪些页面是空闲的,哪些页面不是。现在需要进一步修改mem_init()来分配一个类似的Env结构数组,称为envs。

练习1. 在kern/pmap.c中修改mem_init()。分配和映射环境envs数组。这个数组完全由分配的Env结构的NENV实例组成,就像您分配pages数组一样。与pages数组一样,在UENVS(在inc/memlayout.h中定义)中,内存支持env也应该被映射为只读用户,这样用户进程就可以从这个数组中读取。您应该运行代码并确保check_kern_pgdir()成功。

现在,您将使用kern/env.c 编写代码。运行用户环境所必需的。因为我们还没有文件系统,所以我们将设置内核来加载嵌入内核本身的静态二进制映像。JOS将这个二进制文件作为ELF可执行映像嵌入到内核中。

lgnumakefile在obj/user/目录中生成许多二进制图像。如果您查看kern/Makefrag,您会注意到一些神奇的方法,它们将这些二进制文件直接“链接”到内核可执行文件中,就好像它们是.o文件一样。链接器命令行上的-b二进制选项使这些文件以“raw”未解释的二进制文件的形式链接,而不是编译器生成的常规.o文件。(就链接器而言,这些文件根本不需要是ELF image——它们可以是任何东西,比如文本文件或图片!)看看obj/kern/kernel。sym在构建内核之后,您将注意到链接器“神奇地”生成了许多有趣的符号,这些符号的名称很模糊,例如_binary_obj_user_hello_start、_binary_obj_user_hello_end和_binary_obj_user_hello_size。链接器通过破坏二进制文件的文件名来生成这些符号名;这些符号为常规内核代码提供了一种引用嵌入式二进制文件的方法。

在kern/init中的i386_init()中。您将看到在环境中运行这些二进制映像之一的代码。然而,建立用户环境的关键功能还不完善;你需要把它们填进去。

Exercise 2. In the file env.c, finish coding the following functions:

env_init()

Initialize all of the Env structures in the envs array and add them to the env_free_list. Also calls env_init_percpu, which configures the segmentation hardware with separate segments for privilege level 0 (kernel) and privilege level 3 (user).

env_setup_vm()

Allocate a page directory for a new environment and initialize the kernel portion of the new environment's address space.

region_alloc()

Allocates and maps physical memory for an environment

load_icode()

You will need to parse an ELF binary image, much like the boot loader already does, and load its contents into the user address space of a new environment.

env_create()

Allocate an environment with env_alloc and call load_icode to load an ELF binary into it.

env_run()

Start a given environment running in user mode.

As you write these functions, you might find the new cprintf verb %e useful -- it prints a description corresponding to an error code. For example,

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

will panic with the message "env_alloc: out of memory".

完成之后,应该编译内核并在QEMU下运行它。如果一切顺利,系统应该进入用户空间并执行hello二进制文件,直到它使用int指令进行系统调用。在这一点上将会有麻烦,因为JOS没有设置硬件来允许任何形式的从用户空间到内核的转换。当CPU发现没有设置它来处理这个系统调用中断时,它会生成一个通用的保护异常,发现它不能处理它,生成一个双故障异常,发现它也不能处理它,最后放弃所谓的“三重错误”。通常,您将看到CPU重置和系统重新启动。虽然这对于遗留应用程序很重要(请参阅本文以了解原因),但对于内核开发来说,这是一个痛苦的过程,所以使用6.828补丁的QEMU,您将看到一个注册转储和一个“三重错误”消息。

我们将很快解决这个问题,但是现在我们可以使用调试器来检查是否进入了用户模式。使用make qemu-gdb并在env_pop_tf上设置一个GDB断点,这应该是在实际进入用户模式之前最后一个命中的函数。使用si单步通过该函数;处理器应在iret指令后进入用户模式。然后,您应该在用户环境的可执行文件中看到第一个指令,它是lib/entry.S中的标签start处的cmpl指令。现在使用b *0x…要在hello(参见obj/user/hello)中的sys_cput()中的int $0x30处设置断点。这个int是系统调用,用于向控制台显示字符。如果您不能执行int,那么您的地址空间设置或程序加载代码有问题;在继续之前,回去修复它。

处理中断和异常此时,用户空间中的第一个int $0x30系统调用指令是一条死路:一旦处理器进入用户模式,就无法返回。现在需要实现基本的异常和系统调用处理,以便内核能够从用户模式代码中恢复对处理器的控制。首先要做的是完全熟悉x86中断和异常机制。

练习3。读80386程序员手册中的第9章。

在这个实验室中,我们通常遵循英特尔的中断、异常等术语。然而,异常、陷阱、中断、故障等术语在体系结构或操作系统之间没有标准的含义,通常在特定体系结构(如x86)上使用时并不考虑它们之间的细微差别。当你在实验室之外看到这些术语时,它们的意思可能会略有不同。

Basics of Protected Control Transfer

异常和中断都是“受保护的控制传输”,这导致处理器从用户模式切换到内核模式(CPL=0),而不给用户模式代码任何干扰内核或其他环境功能的机会。在Intel的术语中,中断是一种受保护的控制传输,它是由通常在处理器外部的异步事件引起的,例如外部设备I/O活动的通知。相反,一个异常是由当前运行的代码同步引起的受保护的控制传输,例如由零除引起的。

为了确保这些受保护的控制传输实际上是受保护的,处理器的中断/异常机制被设计成,当中断或异常发生时,当前运行的代码不能任意选择内核的输入位置或方式。相反,处理器确保只有在严格控制的条件下才能输入内核。在x86平台上,有两种机制共同提供这种保护:

中断描述符表。处理器确保中断和异常只能导致内核在几个特定的、定义良好的入口点输入,入口点由内核本身决定,而不是在执行中断或异常时运行的代码。

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

  • the value to load into the instruction pointer (EIP) register, pointing to the kernel code designated to handle that type of exception.
  • the value to load into the code segment (CS) register, which includes in bits 0-1 the privilege level at which the exception handler is to run. (In JOS, all exceptions are handled in kernel mode, privilege level 0.)

任务状态段。处理器需要一个地方保存旧处理器中断或异常发生前的状态,如EIP和CS在处理器调用异常处理程序之前的原始值,所以异常处理程序可以稍后恢复老状态和恢复从中断那里离开的代码。及内核。

因此,当x86处理器执行中断或陷阱,导致权限级别从用户模式更改为内核模式时,它也会切换到内核内存中的堆栈。一个称为任务状态段(TSS)的结构指定了这个堆栈所在的段选择器和地址。处理器推动(在这个新堆栈上)SS、ESP、EFLAGS、CS、EIP和一个可选的错误代码。然后从中断描述符加载CS和EIP,并设置ESP和SS引用新堆栈。

虽然TSS很大,并且可能有多种用途,但是JOS只使用它来定义内核堆栈,当处理器从用户转移到内核模式时应该切换到这个堆栈。由于JOS中的“内核模式”是x86上的特权级别0,所以处理器在进入内核模式时使用TSS的ESP0和SS0字段来定义内核堆栈。JOS不使用任何其他TSS字段。

Types of Exceptions and Interrupts

x86处理器可以生成内部使用0到31之间的中断向量的所有同步异常,因此映射到IDT条目0到31。例如,页面错误总是通过vector 14导致异常。大于31的中断向量仅供软件中断使用,软件中断可以由int指令或异步硬件中断生成,这些中断是由外部设备在需要注意时引起的。

在本节中,我们将扩展JOS来处理向量0-31中内部生成的x86异常。在下一节中,我们将让JOS处理软件中断向量48 (0x30), JOS(相当随意地)将其用作系统调用中断向量。在实验4中,我们将扩展JOS来处理外部生成的硬件中断,例如时钟中断。

让我们将这些片段放在一起,并通过一个示例进行跟踪。假设处理器正在用户环境中执行代码,遇到了试图除以0的除法指令。

  1. The processor switches to the stack defined by the SS0 and ESP0 fields of the TSS, which in JOS will hold the values GD_KD and KSTACKTOP, respectively.
  2. The processor pushes the exception parameters on the kernel stack, starting at address KSTACKTOP:
  3. Because we're handling a divide error, which is interrupt vector 0 on the x86, the processor reads IDT entry 0 and sets CS:EIP to point to the handler function described by the entry.
  4. The handler function takes control and handles the exception, for example by terminating the user environment.

对于某些类型的x86异常,除了上面的“标准”5个字之外,处理器还将包含错误代码的另一个字推送到堆栈上。异常编号14是页面错误异常是一个重要的示例。请参阅80386手册,以确定处理器为哪个异常编号推送错误代码,以及在这种情况下错误代码意味着什么。当处理器发出错误代码时,当从用户模式进入异常处理程序时,堆栈将如下所示.

Nested Exceptions and Interrupts

处理器可以接收来自内核和用户模式的异常和中断。然而,只有从用户模式进入内核时,x86处理器才会自动切换堆栈,然后将其旧的寄存器状态推到堆栈上,并通过IDT调用适当的异常处理程序。如果当中断或异常发生时处理器已经处于内核模式(CS寄存器的低2位已经为零),那么CPU就会在同一个内核堆栈上推送更多的值。通过这种方式,内核可以优雅地处理内部代码引起的嵌套异常。这个功能是实现保护的一个重要工具,稍后我们将在系统调用一节中看到。

如果处理器已经处于内核模式并接受了嵌套异常,因为它不需要切换堆栈,所以它不会保存旧的SS或ESP寄存器。对于不触发错误代码的异常类型,内核堆栈在进入异常处理程序时如下所示:

对于推送错误代码的异常类型,处理器会在旧EIP之后立即推送错误代码,就像以前一样。

对于处理器的嵌套异常功能有一个重要的警告。如果处理器在已经处于内核模式时发生异常,并且由于缺乏堆栈空间等原因无法将其旧状态推到内核堆栈上,那么处理器就无法进行恢复,因此重新设置自己。不用说,内核应该被设计成不会发生这种情况。

Setting Up the IDT

为了在JOS中设置IDT和处理异常,您现在应该有了所需的基本信息。现在,您将设置IDT来处理0-31中断向量(处理器异常)。我们稍后将在这个实验室中处理系统调用中断,并在稍后的实验室中添加32-47中断(设备IRQs)。

 inc/trap.h 和kern/trap.h 包含与中断和异常相关的重要定义,您需要熟悉这些定义。kern/trap.h 包含对内核严格私有的定义,而 inc/trap.h包含对用户级程序和库也有用的定义。

注意:范围0-31中的一些异常由Intel定义并保留。因为它们永远不会由处理器生成,所以如何处理它们并不重要。做你认为最干净的事。

您应该实现的总体控制流程如下所示:

每个异常或中断都应该在trapentry中有自己的处理程序在trapentry.S和trap_init()应该用这些处理程序的地址初始化IDT。每个处理程序都应该在堆栈上构建一个struct Trapframe(参见inc/trap.h),并使用一个指向Trapframe的指针调用trap()(在trap.c中)。然后trap()处理异常/中断或分派到特定的处理程序函数。

练习4。编辑trapentry.S and trap.c并实现上述功能。trapentry中的宏TRAPHANDLER和TRAPHANDLER_NOEC。S应该对您有帮助,以及在inc/trap.h中定义的T_*。您需要在trapentry.S中添加一个入口点。(使用这些宏)用于在inc/trap.h中定义的每个陷阱。您必须提供TRAPHANDLER宏引用的_alltrap。您还需要修改trap_init()来初始化idt,使其指向trapentry.S中定义的每个入口点;SETGATE宏在这里会有帮助。

Your _alltraps should:

  1. push values to make the stack look like a struct Trapframe
  2. load GD_KD into %ds and %es
  3. pushl %esp to pass a pointer to the Trapframe as an argument to trap()
  4. call trap (can trap ever return?)

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

在进行任何系统调用之前,使用用户目录中导致异常的一些测试程序(如user/divzero)处理陷阱代码。此时,您应该能够在divzero、softint和badsegment测试中取得成功。

Part B: Page Faults, Breakpoints Exceptions, and System Calls

现在,您的内核具有基本的异常处理功能,您将对其进行细化,以提供依赖于异常处理的重要操作系统原语。

页面错误异常,中断向量14 (T_PGFLT),是一个特别重要的异常,将在本实验和下一个实验中大量练习。当处理器出现页面错误时,它存储引起故障的线性地址在一个处理器控制寄存器CR2中。在trap.c。我们已经提供了一个特殊函数page_fault_handler()的初始化,用于处理页面错误异常。

练习5。修改trap_dispatch()来将页面错误异常分派给page_fault_handler()。现在您应该能够通过faulsteps、faultreadkernel、faultwrite和faultwritekernel测试获得make grade的成功。如果其中任何一个不起作用,找出原因并修复它们。请记住,您可以使用make run-x或make run-x-nox将JOS引导到特定的用户程序中。例如,让run-hello-nox运行hello用户程序。

The Breakpoint Exception

断点异常,即中断向量3 (T_BRKPT),通常用于允许调试器通过临时用特殊的1字节int3软件中断指令替换相关的程序指令来插入程序代码中的断点。在JOS中,我们将稍微滥用这个异常,将其转换为一个基本的伪系统调用,任何用户环境都可以使用这个伪系统调用来调用JOS内核监视器。如果我们认为JOS内核监视器是基本调试器,那么这种用法实际上是适当的。在lib/panic.c中使用panic()的用户模式实现。例如,在显示其恐慌消息后执行int3。

练习6。修改trap_dispatch()使断点异常调用内核监视器。现在您应该能够在断点测试中获得make grade以获得成功。

System calls

用户进程请求内核通过调用系统调用为它们做事情。当用户进程调用系统调用时,处理器进入内核模式,处理器与内核合作保存用户进程的状态,内核执行适当的代码来执行系统调用,然后恢复用户进程。用户进程如何获得内核关注以及它如何指定要执行哪个调用的具体细节在不同的系统中有所不同。

在JOS内核中,我们将使用int指令,这会导致处理器中断。特别是,我们将使用int $0x30作为系统调用中断。我们已经为您定义了常量T_SYSCALL到48 (0x30)。您必须设置中断描述符,以允许用户进程导致中断。注意,中断0x30不能由硬件生成,因此允许用户代码生成它不会产生歧义。

应用程序将在寄存器中传递系统调用号和系统调用参数。这样,内核就不需要在用户环境的堆栈或指令流中到处搜寻了。系统调用号将进入%eax,参数(最多5个)将分别进入%edx、%ecx、%ebx、%edi和%esi。内核将返回值传回%eax。调用系统调用的程序集代码已经在lib/syscall.c中的syscall()中为您编写了。你应该通读一遍,确保你明白发生了什么.

练习7。在内核中为中断向量T_SYSCALL添加一个处理程序。您必须编辑 kern/trapentry.S 和 kern/trap.c's trap_init()。您还需要更改trap_dispatch()来处理系统调用中断,方法是使用适当的参数调用syscall()(在kern/syscall.c中定义),然后安排返回值在%eax中传递给用户进程。最后,需要在kern/syscall.c中实现syscall()。如果系统调用号无效,请确保syscall()返回-E_INVAL。你应该阅读和理解lib/syscall.c(特别是内联装配例程),以确认您对系统调用接口的理解。处理inc/syscall.h中列出的所有系统调用。通过调用每个调用对应的内核函数。

在内核下运行user/hello程序(运行-hello)。它应该在控制台打印“hello, world”,然后在用户模式下导致页面错误。如果这没有发生,可能意味着您的系统调用处理程序并不完全正确。你现在也应该能够取得make grade在testbss测试中取得成功。

User-mode startup

一个用户程序开始在lib/entry.S顶部运行。经过一些设置之后,这段代码在lib/libmain.c中调用libmain()。您应该修改libmain(),以初始化全局指针thisenv,以指向envs[]数组中该环境的struct Env。(注意,  lib/entry.S 已经定义了env,指向在a部分中设置的UENVS映射)提示:查看inc/env.h,然后使用sys_getenvid。


然后libmain()调用umain,在hello程序中,umain位于user/hello.c中。注意,在打印“hello, world”之后,它试图访问thisenv->env_id。这就是它早些时候出现问题的原因。现在您已经正确地初始化了thisenv,它应该不会出错。如果它仍然错误,您可能还没有映射UENVS区域用户可读(回到pmap.c中的A部分;这是我们第一次真正使用UENVS区域)。

练习8.  将所需的代码添加到用户库中,然后启动内核。您应该看到用户/hello print“hello, world”,然后打印“i am environment 00001000”。然后,user/hello尝试通过调用sys_env_destroy()来“退出”(see lib/libmain.c and lib/exit.c)。由于内核目前只支持一个用户环境,因此它应该报告已经销毁了唯一的环境,然后将其放入内核监视器中。你应该能在hello测试中取得成功。

Page faults and memory protection

内存保护是操作系统的一个关键特性,它确保一个程序中的错误不会损坏其他程序或操作系统本身.

操作系统通常依赖硬件支持来实现内存保护。操作系统让硬件知道哪些虚拟地址是有效的,哪些是无效的。当程序试图访问无效的地址或它没有权限访问的地址时,处理器会在导致故障的指令处停止程序,然后利用有关尝试操作的信息将程序捕获到内核中。如果故障是可修复的,内核可以修复它并让程序继续运行。如果故障无法修复,则程序无法继续,因为它永远无法通过导致故障的指令。

作为可修复错误的示例,考虑一个自动扩展的堆栈。在许多系统中,内核最初分配一个堆栈页面,然后如果程序访问堆栈更下面的页面出错,内核将自动分配这些页面并让程序继续。通过这样做,内核只分配程序所需的堆栈内存,但是程序可以在拥有任意大的堆栈的假象下工作.

系统调用为内存保护提出了一个有趣的问题。大多数系统调用接口允许用户程序向内核传递指针。这些指针指向要读取或写入的用户缓冲区。然后内核在执行系统调用时解除对这些指针的引用。有两个问题:

1. 内核中的页面错误可能比用户程序中的页面错误严重得多。如果内核页面在操作自己的数据结构时出错,那就是内核错误,错误处理程序应该会使内核(以及整个系统)感到恐慌。但是,当内核是解除用户程序给它的指针引用时,它需要一种方法来记住,这些解除引用导致的任何页面错误实际上都是代表用户程序的.

2.内核通常比用户程序具有更多的内存权限。用户程序可能会传递一个指向系统调用的指针,该系统调用指向内核可以读写但程序不能读写的内存。内核必须小心,不要被骗去解除这样一个指针的引用,因为那样可能会泄露私有信息或破坏内核的完整性。

由于这两个原因,内核在处理用户程序提供的指针时必须非常小心。

现在,您将通过一种机制来解决这两个问题,该机制将仔细检查从用户空间传递到内核的所有指针。当程序向内核传递一个指针时,内核将检查地址是否在地址空间的用户部分,以及页表是否允许内存操作。

练习9。改变kern /trap.c。如果在内核模式中发生页面错误,则会引起恐慌。
提示:要确定故障是在用户模式下发生的还是在内核模式下发生的,请检查tf_cs的低比特。在kern/pmap中读取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中更改debuginfo_eip。在usd, stabs和stabstr上调用user_mem_check。如果您现在运行user/breakpoint,那么您应该能够从内核监视器运行回溯跟踪,并查看到lib/libmain中的回溯遍历。内核之前出现了页面错误。什么原因导致这个页面错误?您不需要修复它,但是您应该理解为什么会发生这种情况。

注意,您刚才实现的相同机制也适用于恶意用户应用程序(例如user/evilhello)。

练习10。启动内核,运行user/evilhello。环境应该被破坏,内核不应该恐慌。您应该看到:

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






Lab 4: Preemptive Multitasking

Part A: Multiprocessor Support and Cooperative Multitasking

在本实验室的第一部分中,您将首先扩展JOS以在多处理器系统上运行,然后实现一些新的JOS内核系统调用,以允许用户级环境创建额外的新环境。您还将实现协作循环调度,允许内核在当前环境自愿放弃CPU(或退出)时从一个环境切换到另一个环境。在C部分的稍后部分中,您将实现抢占式调度,它允许内核在某个环境超过一定时间后重新控制CPU,即使其他环境不配合。

我们将使JOS支持“对称多处理”(SMP),这是一种多处理器模型,其中所有cpu对内存和I/O总线等系统资源具有等效的访问权。虽然SMP中的所有cpu在功能上都是相同的,但是在引导过程中它们可以分为两种类型:引导处理器(bootstrap processor, BSP)负责初始化系统和引导操作系统;而应用处理器(APs)只有在操作系统启动和运行后才由BSP激活。哪个处理器是BSP由硬件和BIOS决定。到目前为止,所有现有的JOS代码都在BSP上运行。

在SMP系统中,每个CPU都有一个附带的本地APIC (LAPIC)单元。LAPIC单位负责在整个系统中传递中断。LAPIC还为其连接的CPU提供唯一的标识符。在这个实验室中,我们使用了LAPIC单元的以下基本功能(在kern/ LAPIC .c中):

  • 读取LAPIC标识符(APIC ID)来判断代码当前运行在哪个CPU上(参见cpunum())。
  • 将STARTUP处理器中断(IPI)从BSP发送到APs以调出其他cpu(参见lapic_startap())。
  • 在第C部分中,我们对LAPIC的内置计时器进行编程,以触发时钟中断,以支持抢占式多任务处理(请参阅apic_init())。

处理器使用内存映射I/O (MMIO)访问其LAPIC。在MMIO中,一部分物理内存硬连接到一些I/O设备的寄存器,因此通常用于访问内存的加载/存储指令可以用于访问设备寄存器。您已经在物理地址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运行测试之前,您还需要做下一个练习。

Application Processor Bootstrap

在启动APs之前,BSP应该首先收集关于多处理器系统的信息,例如cpu总数、APIC id和LAPIC单元的MMIO地址。kern/mpconfig.c中的mp_init()函数通过读取驻留在BIOS内存区域中的MP配置表来检索此信息。

boot_aps()函数(在kern/init.c中)驱动AP引导进程。APs在实际模式下启动,很像引导加载程序在boot/boot.S中启动。因此boot_aps()将AP条目代码(kern/mpentry.S)复制到一个在实际模式下可寻址的内存位置。与引导加载程序不同的是,我们可以控制AP从哪里开始执行代码;我们将entry code复制到0x7000 (MPENTRY_PADDR),但是在640KB以下任何未使用、页面对齐的物理地址都可以工作。

在此之后,boot_aps()通过将STARTUP IPIs发送到相应AP的LAPIC单元,AP应该在初始的CS:IP地址(MPENTRY_PADDR in our case)开始运行其entry code,然后一个接一个地激活APs。  kern/mpentry.S中的入口代码与boot/boot.S非常相似。经过一些简单的设置后,它将AP设置为启用分页的保护模式,然后调用C设置例程mp_main()(也在kern/init.c中)。boot_aps()等待AP在其struct CpuInfo的cpu_status字段中向发出CPU_STARTED标志,然后再唤醒下一个。

练习2.    在kern/init.c中阅读boot_aps()和mp_main(), 以及阅读kern/mpentry.S中的汇编代码。确保您理解APs引导期间的控制流传输。然后在kern/pmap.c中修改page_init()的实现,避免将MPENTRY_PADDR的页面添加到空闲列表中,这样我们就可以安全地复制并在该物理地址运行AP引导代码。您的代码应该通过更新的check_page_free_list()测试(但是可能会失败更新的check_kern_pgdir()测试,我们将很快修复它)。

问题:

1.   Compare kern/mpentry.S side by side with boot/boot.S. 请记住这一点。kern/mpentry.S 被编译并链接到运行在 KERNBASE之上,就像内核中的其他所有东西一样,宏MPBOOTPHYS的目的是什么?Why is it necessary in kern/mpentry.S but not in boot/boot.S??换句话说,如果在kern/mpentry.S中省略了它,会出现什么问题呢?
提示:回想一下我们在实验室1中讨论过的链接地址和加载地址之间的区别。

Per-CPU State and Initialization

在编写多处理器操作系统时,必须区分每个处理器私有的每个cpu状态和整个系统共享的全局状态。kern / cpu.h定义了大多数per-CPU状态,包括存储per-CPU变量的struct CpuInfo。cpunum()总是返回调用它的CPU的ID,该ID可以用作CPU之类数组的索引。或者,thiscpu是当前CPU的struct CpuInfo的简写。

以下是您应该注意的每cpu状态:

Per-CPU TSS and TSS descriptor

因为多个cpu可以同时陷入内核,所以我们需要为每个处理器提供一个单独的内核堆栈,以防止它们干扰彼此的执行。数组percpu_kstack [NCPU][KSTKSIZE]为NCPU的内核栈保留空间。

在lab2中,您映射了bootstack称为BSP内核堆栈的物理内存,它位于KSTACKTOP之下。类似地,在这个实验室中,您将把每个CPU的内核堆栈映射到这个区域,其中保护页充当它们之间的缓冲区。CPU 0的堆栈仍然会从KSTACKTOP向下增长;CPU 1的堆栈将在CPU 0的堆栈底部以下启动KSTKGAP字节,以此类推。inc/memlayout.h 显示映射布局。

Per-CPU TSS and TSS descriptor

还需要一个per-CPU任务状态段(TSS),以便指定每个CPU的内核堆栈位于何处。CPU的TSS存储在cpus[i].cpu_ts,对应的TSS描述符定义在GDT条目GDT [(GD_TSS0 >> 3) + i]中。在kern/trap.c中定义的全局ts变量将不再有用。

Per-CPU current environment pointer

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

Per-CPU system registers

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

除此之外,如果您在解决方案中添加了任何额外的CPU状态,或者执行了任何额外的CPU特定初始化(例如,在CPU寄存器中设置新的位),以挑战早期实验室中的问题,请确保在这里的每个CPU上复制它们!

练习3。修改mem_init_mp()(在kern/pmap.c中),以映射从KSTACKTOP开始的每个cpu堆栈,如inc/memlayout.h所示。每个堆栈的大小是KSTKSIZE字节加上未映射保护页的KSTKGAP字节。您的代码应该传递新的check in check_kern_pgdir()。

练习4。trap_init_percpu() (kern/trap.c)中的代码初始化BSP的TSS和TSS描述符。它在Lab 3中工作,但是在其他cpu上运行时是错误的。更改代码以便它可以在所有cpu上工作。(注意:您的新代码不应该再使用全局ts变量。)

 

Locking

在mp_main()中初始化AP之后,当前代码开始旋转。在让AP更进一步之前,我们需要首先解决多个cpu同时运行内核代码时的竞态条件。实现这一点最简单的方法是使用一个大的内核锁。大内核锁是一个全局锁,它在环境进入内核模式时持有,在环境返回到用户模式时释放。在该模型中,用户模式下的环境可以在任何可用的cpu上并发运行,但内核模式下只能运行一个环境;任何其他试图进入内核模式的环境都必须等待。

kern/spinlock.h declares the big kernel lock, namely kernel_lock. It also provides lock_kernel() and unlock_kernel(), shortcuts to acquire and release the lock. You should apply the big kernel lock at four locations:

在i386_init()中,在BSP唤醒其他cpu之前获取锁。
在mp_main()中,在初始化AP之后获取锁,然后调用sched_yield()在此AP上启动运行环境。
在trap()中,从用户模式trap时获取锁。要确定陷阱是在用户模式下发生的,还是在内核模式下发生的,请检查tf_cs的低位。
在env_run()中,在切换到用户模式之前释放锁。不要太早或太晚这样做,否则您将经历竞争或死锁。

练习5. 通过调用lock_kernel()unlock_kernel()在适当的位置应用如上所述的大内核锁 。

Round-Robin Scheduling

在这个实验室中,您的下一个任务是更改JOS内核,以便它能够以“循环”的方式在多个环境之间进行切换。JOS中的循环调度工作如下:

  • The function sched_yield() in the new kern/sched.c is responsible for selecting a new environment to run. It searches sequentially through the envs[] array in circular fashion, starting just after the previously running environment (or at the beginning of the array if there was no previously running environment), picks the first environment it finds with a status of ENV_RUNNABLE (see inc/env.h), and calls env_run() to jump into that environment.
  • sched_yield() must never run the same environment on two CPUs at the same time. It can tell that an environment is currently running on some CPU (possibly the current CPU) because that environment's status will be ENV_RUNNING.
  • We have implemented a new system call for you, sys_yield(), which user environments can call to invoke the kernel's sched_yield() function and thereby voluntarily give up the CPU to a different environment.

Exercise 6. 如上所述,在sched_yield()中实现循环调度。不要忘记修改syscall()来分派sys_yield()。确保在mp_main中调sched_yield()。修改kern / init.c,   创建三个(或更多)环境,这些环境都运行程序user/yield.c。运行make qemu。在终止之前,您应该看到环境之间来回切换了5次,如下所示。使用多个cpu进行测试:使qemu cpu =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内核监控器。如果这些都没有发生,那么在执行之前修复代码

System Calls for Environment Creation

尽管您的内核现在能够在多个用户级环境之间运行和切换,但它仍然仅限于内核最初设置的运行环境。现在您将实现必要的JOS系统调用,以允许用户环境创建和启动其他新的用户环境。

Unix提供fork()系统调用作为其进程创建原语。Unix fork()复制调用进程(父进程)的整个地址空间,以创建一个新进程(子进程)。从用户空间可以观察到的两个进程之间的惟一区别是它们的进程id和父进程id(由getpid和getppid返回)。在父进程中,fork()返回子进程ID,而在子进程中,fork()返回0。默认情况下,每个进程都有它自己的私有地址空间,并且两个进程对内存的修改对另一个进程都是可见的。

您将提供一组不同的、更基本的JOS系统调用,用于创建新的用户模式环境。通过这些系统调用,您将能够完全在用户空间中实现类unix的fork(),以及其他类型的环境创建。您将为JOS编写的新系统调用如下:

sys_exofork:

This system call creates a new environment with an almost blank slate: nothing is mapped in the user portion of its address space, and it is not runnable. The new environment will have the same register state as the parent environment at the time of the sys_exofork call. In the parent, sys_exofork will return the envid_t of the newly created environment (or a negative error code if the environment allocation failed). In the child, however, it will return 0. (Since the child starts out marked as not runnable, sys_exofork will not actually return in the child until the parent has explicitly allowed this by marking the child runnable using....)

sys_env_set_status:

Sets the status of a specified environment to ENV_RUNNABLE or ENV_NOT_RUNNABLE. This system call is typically used to mark a new environment ready to run, once its address space and register state has been fully initialized.

sys_page_alloc:

Allocates a page of physical memory and maps it at a given virtual address in a given environment's address space.

sys_page_map:

Copy a page mapping (not the contents of a page!) from one environment's address space to another, leaving a memory sharing arrangement in place so that the new and the old mappings both refer to the same page of physical memory.

sys_page_unmap:

Unmap a page mapped at a given virtual address in a given environment.

对于以上所有接受环境id的系统调用,JOS内核支持一个约定,即值为0表示“当前环境”。本公约由kern/env中的envid2env()实施。

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

练习7。实现上面在kern/syscall.c中描述的系统调用并确保syscall()调用它们。您将需要在kern/pmap.c和kern / env.c,中使用各种函数尤其是envid2env ()。现在,无论何时调用envid2env(),在checkperm参数中传递1。确保检查了所有无效的系统调用参数,在这种情况下返回-E_INVAL。使用user/dumbfork测试JOS内核,并确保它在继续之前正常工作。

Part B: Copy-on-Write Fork

如前所述,Unix提供fork()系统调用作为其主要的进程创建原语。fork()系统调用复制调用进程(父进程)的地址空间,以创建一个新进程(子进程)。

xv6 Unix通过将所有数据从父页面复制到为子页面分配的新页面来实现fork()。这基本上与dumbfork()采用的方法相同。将父地址空间复制到子地址空间是fork()操作最昂贵的部分。

但是,在子进程中,对fork()的调用之后通常会立即调用exec(),这将用一个新程序替换子进程的内存。例如,shell通常就是这样做的。在这种情况下,复制父进程地址空间所花费的时间基本上是浪费的,因为子进程在调用exec()之前只会使用很少的内存。

因此,后来的Unix版本利用虚拟内存硬件,允许父和子共享映射到各自地址空间的内存,直到其中一个进程实际修改它。这种技术称为写时复制。为此,在fork()上,内核将地址空间映射从父级复制到子级,而不是复制映射页面的内容,同时将当前共享的页面标记为只读。当这两个进程中的一个试图写入这些共享页面中的一个时,该进程接受一个页面错误。因此,后来的Unix版本利用虚拟内存硬件,允许父和子共享映射到各自地址空间的内存,直到其中一个进程实际修改它。这种技术称为写时复制。为此,在fork()上,内核将地址空间映射从父级复制到子级,而不是复制映射页面的内容,同时将当前共享的页面标记为只读。当这两个进程中的一个试图写入这些共享页面中的一个时,这个进程有一个页面错误。此时,Unix内核意识到页面实际上是一个“虚拟”或“写时复制”副本,因此它为出错进程生成一个新的、私有的、可写的页面副本。通过这种方式,单个页面的内容在实际写入之前不会被实际复制。这种优化使得fork()和exec()更便宜:子节点在调用exec()之前可能只需要复制一个页面(其堆栈的当前页面)。

在本实验室的下一部分中,您将实现一个“适当的”类unix fork(),它具有写时复制功能,作为用户空间库例程。在用户空间中实现fork()和写时复制支持的优点是内核仍然非常简单,因此更有可能是正确的。它还允许单个用户模式程序为fork()定义自己的语义。需要稍微不同的实现的程序(例如,昂贵的总是复制的版本,如dumbfork(),或者父和子稍后实际共享内存的版本)可以很容易地提供自己的实现。

User-level page fault handling

用户级的写时复制fork()需要知道写保护页面上的页面错误,所以这是您首先要实现的。写时复制只是用户级页面错误处理的许多可能用途之一。

通常会设置一个地址空间,以便页面错误指示何时需要执行某些操作。例如,大多数Unix内核最初只映射新进程堆栈区域中的单个页,然后随着进程堆栈消耗的增加,“按需”分配和映射其他堆栈页,并在尚未映射的堆栈地址上导致页错误。典型的Unix内核必须跟踪在进程空间的每个区域发生页面错误时要采取的操作。例如,堆栈区域中的错误通常会分配和映射物理内存的新页面。程序BSS区域中的错误通常会分配一个新页面,用0填充它,并映射它。在按需分页可执行文件的系统中,文本区域中的错误将从磁盘上读取二进制文件的相应页面,然后映射它。

这是内核需要跟踪的大量信息。您将决定如何处理用户空间中的每个页面错误,而不是采用传统的Unix方法,因为这些错误对用户空间的损害较小。这种设计的另一个好处是允许程序在定义它们的内存区域时具有很大的灵活性;稍后,您将使用用户级的页面错误处理来映射和访问基于磁盘的文件系统上的文件。

Setting the Page Fault Handler

为了处理自己的页面错误,用户环境需要向JOS内核注册页面错误处理程序entrypoint。用户环境用sys_env_set_pgfault_upcall系统调注册其页面错误入口点。我们在Env结构中添加了一个新成员env_pgfault_upcall来记录这个信息。

练习8。实现sys_env_set_pgfault_upcall系统调用。确保在查找目标环境的环境ID时启用权限检查,因为这是一个“危险”的系统调用。

Normal and Exception Stacks in User Environments

在正常执行期间,JOS中的用户环境将在正常的用户堆栈上运行:其ESP寄存器开始指向USTACKTOP,它所推送的堆栈数据驻留在USTACKTOP- pgsize和USTACKTOP-1(包括USTACKTOP-1)之间的页面上。但是,当页面错误以用户模式发生时,内核将重新启动在不同堆栈(即用户异常堆栈)上运行指定的用户级页面错误处理程序的用户环境。在本质上,我们将使JOS内核实现代表用户环境的自动“堆栈切换”,就像x86处理器在从用户模式转换到内核模式时已经实现了代表JOS的堆栈切换一样!

JOS用户异常栈也是一个页面大小,其顶部定义为虚拟地址UXSTACKTOP,所以用户异常栈的有效字节是从UXSTACKTOP- pgsize到UXSTACKTOP-1(包括UXSTACKTOP-1)。在此异常栈上运行时,用户级页面错误处理程序可以使用JOS的常规系统调用映射新页面或调整映射,以便修复最初导致页面错误的任何问题。然后,用户级的页面错误处理程序通过汇编语言存根返回到原始堆栈上的错误代码。

每个希望支持用户级页面错误处理的用户环境都需要使用第A部分中介绍的sys_page_alloc()系统调用为自己的异常堆栈分配内存

Invoking the User Page Fault Handler

您现在需要更改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。您应该首先推送一个空的32位word,然后是一个struct UTrapframe。

要测试tf->tf_esp是否已经在用户异常栈上,请检查它是否在UXSTACKTOP-PGSIZE和UXSTACKTOP-1之间(包UXSTACKTOP-1)。

练习9。在kern/trap.c中实现page_fault_handler中的代码。需要将页面错误分派给用户模式处理程序。在写入异常堆栈时,一定要采取适当的预防措施。(如果用户环境耗尽异常堆栈上的空间,会发生什么?)

User-mode Page Fault Entrypoint

接下来,您需要实现负责调用C页错误处理程序的程序集例程,并在原始错误指令处恢复执行。这个程序集处理程序将使用sys_env_set_pgfault_upcall()向内核注册。

练习10。在lib/ pfenter.s中实现_pgfault_upcall例程。有趣的部分是返回导致页面错误的用户代码中的原始位置。您将直接返回那里,而不需要通过内核返回。困难的部分是同时切换堆栈和重新加载EIP。

最后,需要实现用户级页面错误处理机制的C用户库端。

练习11。在lib/pgfault.c中完成set_pgfault_handler()。

Implementing Copy-on-Write 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()还需要处理出现但不可写或写时复制的页面。

4. 父进程将子进程用户页面错误入口点设置为与自己相似。

5.子节点现在可以运行了,因此父节点将其标记为runnable。

每当某个环境写入尚未编写的“写时复制”页面时,就会出现页面错误。下面是用户页面错误处理程序的控制流程:

1. 内核将页面错误传播到_pgfault_upcall,后者调用fork()的pgfault()处理程序。

2. pgfault()检查错误是写错误(检查错误代码中的FEC_WR),并且页面的PTE标记为PTE_COW。如果不是, panic.。

3. pgfault()分配一个映射到临时位置的新页面,并将出错页面的内容复制到其中。然后,错误处理程序将具有读/写权限的新页面映射到适当的地址,而不是以前的只读映射。

用户级lib/fork.c代码必须为上面的几个操作查阅环境的页表(例如,页面的PTE标记为PTE_COW)。内核正是为此目的在UVPT上映射环境的页表。它使用了一种巧妙的映射技巧,使得查找用户代码的pte变得容易。lib/entry.S设置uvpt和uvpd,方便您在lib/fork.c中查找页表信息。

练习12。在lib/fork.c中实现fork、duppage和pgfault。
用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'

Part C: Preemptive Multitasking and Inter-Process communication (IPC)

在lab 4的最后一部分中,您将修改内核,以抢占不合作的环境,并允许环境显式地相互传递消息。

Clock Interrupts and Preemption

运行user/spin测试程序。这个测试程序派生出一个子环境,一旦它接收到对CPU的控制,它就会一直在一个紧密的循环中旋转。父环境和内核都不会重新获得CPU。从保护系统不受用户模式环境中的bug或恶意代码的影响来说,这显然不是一个理想的情况,因为任何用户模式环境都可以通过进入一个无限循环并且永远不返回CPU来停止整个系统。为了允许内核抢占正在运行的环境,强制地从该环境中重新控制CPU,我们必须扩展JOS内核,以支持来自时钟硬件的外部硬件中断。

Interrupt discipline

外部中断(即, 设备中断)被称为IRQs。有16种可能的irq,编号从0到15。从IRQ号到IDT条目的映射不是固定的。 picirq.c 中的pic_init通过将IRQs 0-15映射到IDT条目IRQ_OFFSET到IRQ_OFFSET+15.

在inc/trap.h,  IRQ_OFFSET被定义为整数32。因此IDT条目32-47对应于IRQs 0-15。例如,时钟中断是irq0。因此,IDT(IRQ_OFFSET + 0)(即。在内核中包含时钟中断处理程序例程的地址。选择这个IRQ_OFFSET是为了避免设备中断与处理器异常重叠,这显然会引起混淆。(事实上,在早期运行MS-DOS的pc机中,IRQ_OFFSET实际上是零,这确实造成了处理硬件中断和处理处理器异常之间的大量混淆。

在JOS中,与xv6 Unix相比,我们进行了简化。在内核中总是禁用外部设备中断(和xv6一样,在用户空间中启用外部设备中断)。外部中断由%eflags寄存器的FL_IF标志位控制(参见inc/mmu.h)。设置此位后,将启用外部中断。虽然可以通过几种方式修改位,但由于我们的简化,我们将仅通过在进入和离开用户模式时保存和恢复%eflags寄存器的过程来处理它。

您必须确保FL_IF标志在用户环境中运行时设置,以便在中断到达时将其传递到处理器并由中断代码处理。否则,中断将被屏蔽或忽略,直到重新启用中断。我们用引导加载程序的第一个指令屏蔽中断,到目前为止,我们还没有重新启用它们。

练习13。Modify kern/trapentry.S 和kern/trap.c 初始化IDT中的适当条目,并为IRQs 0到15提供处理程序。然后在kern/env中修改env_alloc()中的代码。确保用户环境总是在启用中断的情况下运行。
还要取消sched_halt()中的sti指令的注释,以便空闲cpu取消掩码中断。
在完成这个练习之后,如果您使用运行时间非常长(例如,spin)的任何测试程序运行内核,您应该会看到用于硬件中断的内核打印陷阱帧。虽然中断现在在处理器中是启用的,但是JOS还没有处理它们,所以您应该看到它将每个中断错误地赋予当前运行的用户环境并销毁它。最终,它应该耗尽要销毁的环境并掉到监视器中。

Inter-Process communication (IPC)

(从技术上讲,在JOS中这是“环境间通信”或“IEC”,但其他人都称之为IPC,所以我们将使用标准术语。)
我们一直在关注操作系统的隔离方面,它提供了每个程序都有一台机器的假象。操作系统的另一项重要服务是允许程序在需要时相互通信。让程序与其他程序交互是非常强大的。Unix管道模型是一个典型的例子。
进程间通信有许多模型。即使在今天,关于哪种模式是最好的仍有争论。我们不会卷入那场辩论。相反,我们将实现一个简单的IPC机制,然后尝试它。

IPC in JOS

您将实现一些额外的JOS内核系统调用,它们共同提供了一个简单的进程间通信机制。您将实现两个系统调用,sys_ipc_recv和sys_ipc_try_send。然后,您将实现两个库包装器ipc_recv和ipc_send。
用户环境可以使用JOS相互发送的“消息”的IPC机制由两个组件组成:单个32位值和可选的单个页面映射。允许环境在消息中传递页面映射提供了一种有效的方式来传输比单个32位整数所能容纳的更多的数据,并且还允许环境轻松地设置共享内存。

Sending and Receiving Messages

要接收消息,环境调用sys_ipc_recv。此系统调用取消对当前环境的调度,并在接收到消息之前不再运行该环境。当一个环境在等待接收消息时,任何其他环境都可以向它发送消息——不仅是特定的环境,而且不仅仅是与接收环境有父/子安排的环境。换句话说,您在A部分实现的权限检查不适用于IPC,因为IPC系统调用经过精心设计,以便“安全”:一个环境不能仅仅通过发送消息就导致另一个环境出现故障(除非目标环境也有bug)。

要尝试发送一个值,环境调用sys_ipc_try_send,同时使用接收方的环境id和要发送的值。如果指定的环境实际上正在接收(它调用了sys_ipc_recv,但还没有得到值),那么发送将传递消息并返回0。否则,send返回-E_IPC_NOT_RECV,表示目标环境当前不希望接收值。

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

类似地,库函数ipc_send将负责重复调用sys_ipc_try_send,直到发送成功。

Transferring Pages

当环境使用有效的dstva参数(在UTOP下面)调用sys_ipc_recv时,环境表示它愿意接收页面映射。如果发送方发送了一个页面,那么该页面应该映射到接收方地址空间中的dstva。如果接收方已经在dstva上映射了一个页面,则上一个页面将被取消映射。

当一个环境调用sys_ipc_try_send具有有效srcva(低于UTOP),这意味着发送方希望将当前在srcva映射的页面发送到接收方。IPC成功后,发送方保持原来的页面在srcva其地址空间的映射,但接收方也获得同样的物理页的映射dstva最初指定的接收方的地址空间。因此,此页面在发送方和接收方之间共享。

如果发送方或接收方没有表示应该传输页面,那么就不会传输任何页面。在任何IPC之后,内核将接收方Env结构中的新字段env_ipc_perm设置为接收到的页面的权限,如果没有接收到页面,则设置为0。

Implementing IPC

练习15. 在kern/syscall.c中实现sys_ipc_recv和sys_ipc_try_send。在实现它们之前,请阅读关于它们的注释,因为它们必须一起工作。在这些例程中调用envid2env时,应该将checkperm标志设置为0,这意味着任何环境都允许向任何其他环境发送IPC消息,内核除了验证目标envid是有效的之外,不进行任何特殊的权限检查。
然后在lib/ipc.c中实现ipc_recv和ipc_send函数。
使用 user/pingpong 和 user/primes 来测试IPC机制。user/primes 将为每个素数生成一个新的环境,直到JOS耗尽环境。您可能会发现阅读 user/primes.c 很有趣。看到所有的forking 和IPC在幕后进行。

Lab 5: File system, Spawn and Shell

您将使用的文件系统比包括xv6 UNIX在内的大多数“实际”文件系统简单得多,但是它足够强大,可以提供基本功能:创建、读取、写入和删除按层次目录结构组织的文件。

我们只开发了一个单用户操作系统(至少目前是这样),它提供了足够的保护来捕捉bug,而不是保护多个相互怀疑的用户。因此,我们的文件系统不支持UNIX文件所有权或权限的概念。我们的文件系统目前也不像大多数UNIX文件系统那样支持硬链接、符号链接、时间戳或特殊设备文件。

On-Disk File System Structure

大多数UNIX文件系统将可用的磁盘空间划分为两种主要类型的区域:inode区域和数据区域。UNIX文件系统为文件系统中的每个文件分配一个inode;文件的inode保存关于文件的关键元数据,例如它的stat属性和指向它的数据块的指针。数据区域被划分为更大的(通常是8KB或更多)数据块,文件系统在其中存储文件数据和目录元数据。目录条目包含文件名和指向索引节点的指针;如果文件系统中的多个目录条目引用该文件的inode,则称该文件为硬链接文件。因为我们的文件系统不支持硬链接,我们不需要这种级别的间接寻址,因此可以方便的简化:我们的文件系统不会使用inodes, 而只是将文件(或子目录)的所有元数据存储在描述该文件的(唯一的)目录条目中

文件和目录在逻辑上都由一系列数据块组成,这些数据块可以分散在磁盘上,就像环境的虚拟地址空间的页面可以分散在物理内存中一样。文件系统环境隐藏了块布局的细节,为在文件内任意偏移量处读写字节序列提供了接口。文件系统环境在内部处理对目录的所有修改,作为执行文件创建和删除等操作的一部分。我们的文件系统允许用户环境直接读取目录元数据(例如,使用read),这意味着用户环境可以自己执行目录扫描操作(例如,实现ls程序),而不必依赖对文件系统的额外特殊调用。这种方法对于目录扫描的缺点(也是大多数现代UNIX变体不支持这种方法的原因)是,它使应用程序依赖于目录元数据的格式,在不更改或至少不重新编译应用程序的情况下,很难更改文件系统的内部布局。

Sectors and Blocks

大多数磁盘不能在字节粒度执行读写,而是在扇区的单元中执行读写。在JOS中,扇区是每个512字节。文件系统实际上是以块为单位分配和使用磁盘存储的。注意这两个术语之间的区别:扇区大小是磁盘硬件的属性,而块大小是使用磁盘的操作系统的一个方面。文件系统的块大小必须是基础磁盘扇区大小的倍数。UNIX xv6文件系统使用512字节的块大小,与底层磁盘的扇区大小相同。然而,大多数现代文件系统使用更大的块大小,因为存储空间变得更便宜,而且在更大的粒度上管理存储更有效。我们的文件系统将使用4096字节的块大小,方便地匹配处理器的页面大小。

Superblocks

文件系统通常留出一定数量的磁盘块在磁盘上的“填充”位置(比如一开始或结尾)来保存描述整个文件系统属性的元数据,如块大小、磁盘大小,任何需要查找根目录的元数据,文件系统上一次挂载的时间,文件系统最后一次检查错误的时间,等等。这些特殊的块称为超级块。

我们的文件系统将只有一个超级块,它始终位于磁盘上的第1块。其布局由struct Super in inc/fs.h定义。块0通常用于保存引导加载程序和分区表,因此文件系统通常不会使用第一个磁盘块。许多“真正的”文件系统维护多个超级块,这些超级块被复制到磁盘的几个宽间隔区域,因此,如果其中一个超级块损坏了,或者磁盘在该区域出现了媒体错误,仍然可以找到其他超级块,并用于访问文件系统。

File Meta-data

描述文件系统中文件的元数据的布局由inc/fs.h中的struct file描述。这个元数据包括文件的名称、大小、类型(常规文件或目录)和组成文件的块的指针。如上所述,我们没有索引节点,因此这个元数据存储在磁盘上的目录条目中。与大多数“真实”文件系统不同,为了简单起见,我们将使用这个文件结构来表示文件元数据,因为它同时出现在磁盘和内存中.

结构文件中的f_direct数组包含存储文件前10个(NDIRECT)块的块号的空间,我们将其称为文件的直接块。对于大小不超过10*4096 = 40KB的小文件,这意味着所有文件块的块号将直接适合于文件结构本身。但是,对于较大的文件,我们需要一个位置来保存文件的块号。因此,对于任何大小大于40KB的文件,我们分配一个额外的磁盘块,称为文件的间接块,以保留最多4096/4 = 1024个额外的块号。因此,我们的文件系统允许文件的大小最多为1034个块,即刚刚超过4兆字节。为了支持更大的文件,“实际”文件系统通常还支持双间接块和三间接块。

Directories versus Regular Files

文件系统中的文件结构可以表示常规文件或目录;这两种类型的“文件”是由文件结构中的类型字段区分的。文件系统管理常规文件和目录文件以完全相同的方式,除了它不解释与常规文件相关联的数据块的内容,而文件系统目录文件的内容解释为一系列的File结构描述的目录中的文件和子目录。

文件系统中的 superblock包含一个 File结构(struct Super中的s_root字段),它保存文件系统根目录的元数据。这个目录文件的内容是描述文件系统根目录中的文件和目录的File结构序列。根目录中的任何子目录可能依次包含更多表示子目录的子目录的File结构,等等。

The File System

本实验的目标不是让您实现整个文件系统,而是让您只实现某些关键组件。特别是,您将负责将块读入块缓存并将它们刷新回磁盘;分配磁盘块;将文件偏移量映射到磁盘块;并在IPC接口中实现读、写、打开。因为您不会自己实现所有的文件系统,所以熟悉所提供的代码和各种文件系统接口是非常重要的。

Disk Access

操作系统中的文件系统环境需要能够访问磁盘,但是我们还没有在内核中实现任何磁盘访问功能。我们没有采用传统的“monolithic”操作系统策略,即向内核中添加IDE磁盘驱动程序以及必要的系统调用,以允许文件系统访问内核,而是将IDE磁盘驱动程序实现为用户级文件系统环境的一部分。我们仍然需要稍微修改内核,以便进行设置,使文件系统环境具有实现磁盘访问本身所需的特权。

只要我们依赖于轮询、基于“programmed I/O”(PIO)的磁盘访问,并且不使用磁盘中断,就很容易在用户空间中实现磁盘访问。在用户模式下也可以实现中断驱动的设备驱动程序(例如,L3和L4内核就可以做到这一点),但是这更加困难,因为内核必须字段化设备中断并将它们分派到正确的用户模式环境中。

x86处理器使用EFLAGS寄存器中的IOPL位来确定是否允许受保护模式的代码执行特殊的设备I/O指令,例如输入和输出指令。由于我们需要访问的所有IDE磁盘寄存器都位于x86的I/O空间中,而不是内存映射,所以为了允许文件系统访问这些寄存器,我们只需要向文件系统环境提供“I/O特权”。实际上,EFLAGS寄存器中的IOPL位为内核提供了一种简单的“全有或全无”方法来控制用户模式代码是否可以访问I/O空间。在我们的例子中,我们希望文件系统环境能够访问I/O空间,但是我们不希望任何其他环境能够访问I/O空间。

练习1。i386_init通过将类型ENV_TYPE_FS传递给环境创建函数env_create来标识文件系统环境。在env中修改env_create。,这样它就可以给文件系统环境I/O特权,但永远不会给任何其他环境这个特权。
确保您可以启动文件环境,而不会导致一般的保护错误。你应该通过“fs i/o”。

问题
当您随后从一个环境切换到另一个环境时,您是否需要做其他任何事情来确保这个I/O特权设置被保存并正确地恢复?为什么?

The Block Cache

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

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

我们的文件系统环境有它自己的虚拟地址空间独立于所有其他环境系统,  文件系统环境需要做的唯一的事是实现文件访问,它是合理的储备的大部分文件系统环境的地址空间。对于32位机器上的真正文件系统实现来说,这样做是很困难的,因为现代磁盘大于3GB。在64位地址空间的机器上,这种缓冲区缓存管理方法仍然是合理的。

当然,需要很长一段时间将整个磁盘读取到内存中,所以我们请求分页的实现形式,其中我们只在磁盘map区域分配页和从磁盘读取相应的块来响应一个页面错误在这个区域。这样,我们可以假设整个磁盘都在内存中。

练习2。在fs/bc.c中实现bc_pgfault和flush_block函数。bc_pgfault是一个页面错误处理程序,与您在上一个实验室中为写时复制fork编写的错误处理程序类似,只不过它的工作是从磁盘加载页面以响应页面错误。在编写本文时,请记住(1)addr可能不与块边界对齐,(2)ide_read操作在扇区中,而不是块中。

如果需要,flush_block函数应该将一个块写到磁盘上。如果块甚至不在块缓存中(也就是说,页面没有映射),或者它不是脏的,flush_block不应该执行任何操作。我们将使用VM硬件跟踪一个磁盘块自上次从磁盘读取或写入磁盘以来是否已被修改。要查看块是否需要写入,只需查看uvpt条目中是否设置了PTE_D“dirty”位。(PTE_D位由处理器设置,以响应对该页面的写入;见386参考手册第5章5.2.4.3)将块写到磁盘之后,flush_block应该使用sys_page_map清除PTE_D位。

Use make grade to test your code. Your code should pass "check_bc", "check_super", and "check_bitmap".

fs/fs中的fs_init.c函数是如何使用块缓存的一个很好的例子。在初始化块缓存之后,它将指针存储到超全局变量中的磁盘映射区域。在这之后,我们可以像在内存中一样从超级结构中读取它们,我们的页面错误处理程序将根据需要从磁盘读取它们。

猜你喜欢

转载自blog.csdn.net/songjiji2/article/details/84971701
今日推荐