# MIT——6.828:操作系统工程——第4章:实验四:抢占式多任务处理(partA和partB,练习12未完成)
在本实验中,我们将在多个同时活动的用户模式环境中实施抢占式多任务处理。
在Part A中,我们将为 JOS 添加多处理器支持,实现循环调度,并添加基本的环境管理系统函数(创建和销毁环境以及分配/映射内存的调用)。
在Part B中,我们将实现一个类 Unix fork()
,它允许用户模式环境创建其自身的副本。
在Part C中,您将添加对进程间通信 (IPC) 的支持,允许不同的用户模式环境显式地相互通信和同步。您还将添加对硬件时钟中断和抢占的支持。
Part A:多处理器支持和协作式多任务处理
在本实验室的第一部分中,我们将首先扩展JOS以在多处理器系统上运行,然后实现一些新的JOS内核系统调用,以允许用户级环境创建其他新环境。我们还将实现协作循环调度,当当前环境自愿放弃CPU(或退出)时,允许内核从一个环境切换到另一个环境。稍后在Part C中,我们将实现抢占式调度,这允许内核在经过一定时间后从环境中重新控制CPU,即使环境不合作。
1.1 多处理器支持
我们将使JOS支持“对称多处理”(SMP),这是一种多处理器模型,在该模型中,所有CPU都可以等效地访问系统资源,如内存和I/O总线。虽然SMP中的所有CPU功能相同,但在引导过程中,它们可以分为两种类型:引导处理器(BSP)负责初始化系统和引导操作系统;并且仅在操作系统启动并运行之后,BSP才激活应用处理器(AP)。哪个处理器是BSP由硬件和BIOS决定。到目前为止,所有现有的JOS代码都在BSP上运行。
在SMP系统中,每个CPU都有一个跟随的本地APIC(LAPIC)单元。LAPIC单位负责在整个系统中传送中断。LAPIC还为其连接的CPU提供唯一标识符。在本实验室中,我们使用LAPIC单元的以下基本功能(kern/LAPIC.c
):
- 读取LAPIC标识符(
APIC ID
)以确定代码当前运行在哪个CPU上(请参见cpunum()
)。 - 从BSP向AP发送
STARTUP
处理器间中断(IPI)以启动其他CPU(请参阅lapic_startap()
)。 - 在Part C中,我们对 LAPIC 的内置定时器进行编程以触发时钟中断,以支持抢占式多任务处理(请参见
apic_init()
)。
cpu使用存储器映射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
测试之前,您还必须执行下一个练习。
答:首先我们查看kern/lapic.c
中lapic_init
的开头
// lapicaddr is the physical address of the LAPIC's 4K MMIO
// region. Map it in to virtual memory so we can access it.
// lapicaddr是LAPIC的4K MMIO区域的物理地址。将其映射到虚拟内存,以便我们可以访问它。
lapic = mmio_map_region(lapicaddr, 4096);
可以看到,lapic_init
将4kb大小LAPIC孔的物理地址lapicaddr
映射到虚拟内存MMIO区域。我们再查看mmio_map_region
的注释
// Where to start the next region. Initially, this is the
// beginning of the MMIO region. Because this is static, its
// value will be preserved between calls to mmio_map_region
// (just like nextfree in boot_alloc).
// 从哪里开始下一个区域。最初,这是MMIO区域的开始。因为这是静态的,所以它的值将在调用mmio_map_region之间保留(就像boot_alloc中的nextfree一样)。
static uintptr_t base = MMIOBASE;
// Reserve size bytes of virtual memory starting at base and
// map physical pages [pa,pa+size) to virtual addresses
// [base,base+size). Since this is device memory and not
// regular DRAM, you'll have to tell the CPU that it isn't
// safe to cache access to this memory. Luckily, the page
// tables provide bits for this purpose; simply create the
// mapping with PTE_PCD|PTE_PWT (cache-disable and
// write-through) in addition to PTE_W. (If you're interested
// in more details on this, see section 10.5 of IA32 volume
// 3A.)
//
// Be sure to round size up to a multiple of PGSIZE and to
// handle if this reservation would overflow MMIOLIM (it's
// okay to simply panic if this happens).
//
// Hint: The staff solution uses boot_map_region.
//
// 从base开始保留虚拟内存的大小字节,并将物理页面(pa,pa+size)映射到虚拟地址(base,base+size)。
// 由于这是设备内存,而不是常规DRAM,因此您必须告诉CPU缓存对该内存的访问是不安全的。幸运的是,页面表为此提供了位;只需使用PTE_PCD | PTE_PWT创建映射(缓存禁用和写入)。
//(如果您对此感兴趣,请参阅IA32第3A卷第10.5节。)请确保将大小舍入为PGSIZE的倍数,并处理此保留是否会溢出MMIOLIM(如果发生这种情况,只需panic即可)。
// 提示:员工解决方案使用boot_map_region。
// Your code here:
按照提示与注释编写代码如下:
void *
mmio_map_region(physaddr_t pa, size_t size)
{
// Where to start the next region. Initially, this is the
// beginning of the MMIO region. Because this is static, its
// value will be preserved between calls to mmio_map_region
// (just like nextfree in boot_alloc).
// 从哪里开始下一个区域。最初,这是MMIO区域的开始。因为这是静态的,所以它的值将在调用mmio_map_region之间保留(就像boot_alloc中的nextfree一样)。
static uintptr_t base = MMIOBASE;
// Your code here:
uintptr_t result = base;
uintptr_t end = ROUNDUP(result + size, PGSIZE);
if (end >= MMIOLIM) {
panic("overflow MMIOLIM");
}
boot_map_region(kern_pgdir, result, size, pa, PTE_PCD | PTE_PWT | PTE_P | PTE_W); // 此io区域内核可读可写
base = end;
return (uint32_t *)result;
}
1.2 应用程序处理器引导程序
在启动AP之前,BSP应首先收集有关多处理器系统的信息,例如CPU总数、其APIC ID
和LAPIC
单元的MMIO
地址。kern/mpconfig.c
中的mp_init()
函数通过读取驻留在BIOS内存区域中的mp配置表来检索此信息。
boot_aps()
函数(kern/init.c
中)驱动AP引导进程。AP在实模式下启动,很像bootloader
在boot/boot.S
中启动的方式,所以boot_aps()
将AP入口代码(kern/mpentry.S
)复制到在实模式中可寻址的内存位置。与引导加载程序不同,我们可以控制AP开始执行代码的位置;我们将入口代码复制到0x7000
(MPENTRY_PADDR
),但是 640KB 以下的任何未使用的、页对齐的物理地址都可以使用。
之后,boot_aps()
通过向相应AP的LAPIC
单元发送STARTUP
IPI以及初始CS:IP
地址(在本例中为MPENTRY_PADDR
),一个接一个地激活AP。kern/mpentry.S
中的条目代码与boot/boot.S
中的条目非常相似。经过短暂的设置后,它将AP置于启用分页的保护模式下,然后调用C设置程序mp_main()
(也在kern/init.C
中)。boot_aps()
等待AP在其结构体CpuInfo
的CPU_status
字段中发出CPU_STARTED
标志,然后再唤醒下一个。
练习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()
测试,我们很快就会修复)。
答:首先,阅读boot_aps()
函数
static void
boot_aps(void)
{
extern unsigned char mpentry_start[], mpentry_end[];
void *code;
struct CpuInfo *c;
// Write entry code to unused memory at MPENTRY_PADDR
// 将入口代码写入MPENTRY_PADDR中未使用的内存
code = KADDR(MPENTRY_PADDR);
memmove(code, mpentry_start, mpentry_end - mpentry_start);
// Boot each AP one at a time
// 一次引导一个AP
for (c = cpus; c < cpus + ncpu; c++) {
if (c == cpus + cpunum()) // We've started already. 我们已经开始了。
continue;
// Tell mpentry.S what stack to use
// 告诉mpentry.S使用什么堆栈
mpentry_kstack = percpu_kstacks[c - cpus] + KSTKSIZE;
// Start the CPU at mpentry_start
// 在mpentry_Start启动CPU
lapic_startap(c->cpu_id, PADDR(code));
// Wait for the CPU to finish some basic setup in mp_main()
// 等待CPU在mp_main()中完成一些基本设置
while(c->cpu_status != CPU_STARTED);
}
}
可以看到,bsp首先将ap初始化设置的代码mpentry_start
写入MPENTRY_PADDR
中未使用的内存,该函数定义在kern/mpentry.S
中,然后循环让ap运行这段代码。我们在这个练习所要做的事情是,避免保存代码mpentry_start
的物理页进入空闲物理页链表。因此我们修改page_init()
函数:
void
page_init(void)
{
extern void mpentry_start();
extern void mpentry_end(); // 声明一下,否则编译器会找不到
size_t i;
size_t io_hole_start_page = (size_t)IOPHYSMEM / PGSIZE;
size_t kernel_end_page = PADDR(boot_alloc(0)) / PGSIZE;
size_t mmentry_start_page = (size_t)MPENTRY_PADDR / PGSIZE;
size_t mmentry_end_page = mmentry_start_page + ((size_t)mpentry_end - (size_t)mpentry_start) / PGSIZE;
if (((size_t)mpentry_end - (size_t)mpentry_start) % PGSIZE != 0) {
mmentry_end_page++;
} // 将该段代码所占物理页页对齐
for (i = 0; i < npages; i++) {
if (i == 0) {
pages[i].pp_ref = 1;
pages[i].pp_link = NULL;
}
else if (i >= mmentry_start_page && i <= mmentry_end_page) {
pages[i].pp_ref = 1;
pages[i].pp_link = NULL;
} // 遇到此范围的物理页不加入空闲链表,并标记为已使用
else if (i >= io_hole_start_page && i <= kernel_end_page)
{
pages[i].pp_ref = 1;
pages[i].pp_link = NULL;
}
else {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}
}
1.3 每CPU状态和初始化
在编写多处理器操作系统时,区分每个处理器专用的每CPU状态和整个系统共享的全局状态非常重要。kern/cpu.h
定义了大多数每cpu状态,包括存储每cpu变量的结构体CpuInfo
。cpunum()
始终返回调用它的CPU的ID,它可以用作cpus
等数组的索引。或者,宏thiscu
是当前CPU结构体CpuInfo
的简写。
struct CpuInfo {
uint8_t cpu_id; // Local APIC ID; index into cpus[] below
volatile unsigned cpu_status; // The status of the CPU
struct Env *cpu_env; // The currently-running environment.
struct Taskstate cpu_ts; // Used by x86 to find stack for interrupt
};
以下是您应该注意的每CPU状态:
- 每CPU内核栈:
因为多个CPU可以同时陷入内核,所以我们需要为每个处理器提供一个单独的内核栈,以防止它们干扰彼此的执行。数组percpu_kstacks[NCPU][KSTKSIZE]
为NCPU的内核堆栈保留空间。
在lab2中,我们将堆栈所指的物理内存映射为位于KSTACKTOP
正下方的BSP内核堆栈。类似地,在本lab中,您将把每个CPU的内核堆栈映射到这个区域,保护页充当它们之间的缓冲区。CPU 0的堆栈仍将从KSTACKTOP
向下增长;CPU1的堆栈将在CPU0堆栈底部以下启动KSTKGAP
字节,依此类推。inc/memlayout.h
显示了映射布局。 - 每CPU的TSS和TSS描述符:
为了指定每个CPU的内核堆栈所在的位置,还需要每个CPU的任务状态段(TSS)。CPU i的TSS存储在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(运行代码cpunum()
的CPU)上当前执行的环境。 - 每CPU系统寄存器:
所有寄存器,包括系统寄存器,都是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_kern_pgdir()
中的新检查。
答:首先查看mem_init_mp()
的注释
// Map per-CPU stacks starting at KSTACKTOP, for up to 'NCPU' CPUs.
//
// For CPU i, use the physical memory that 'percpu_kstacks[i]' refers
// to as its kernel stack. CPU i's kernel stack grows down from virtual
// address kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP), and is
// divided into two pieces, just like the single stack you set up in
// mem_init:
// * [kstacktop_i - KSTKSIZE, kstacktop_i)
// -- backed by physical memory
// * [kstacktop_i - (KSTKSIZE + KSTKGAP), kstacktop_i - KSTKSIZE)
// -- not backed; so if the kernel overflows its stack,
// it will fault rather than overwrite another CPU's stack.
// Known as a "guard page".
// Permissions: kernel RW, user NONE
//
// 每个CPU堆栈的映射从KSTACKTOP开始,最多可用于“NCPU”个CPU。
// 对于CPU i,使用“percpu_kstacks[i]”所指的物理内存作为其内核堆栈。
// CPU i的内核堆栈从虚拟地址kstacktop_i=kstacktop-i*(KSTKSIZE+KSTKGAP)向下扩展,
// 并分成两部分,就像在mem_init中设置的单个堆栈一样:
// * [kstacktop_i-KSTKSIZE,kstackttop_i)
// --由物理内存支持
// * [kstachtop_i-(KSTKSIZE+KSTKGAP),kstachttop_i-KSTKSIZE)
// --不支持;因此,如果内核溢出其堆栈,它将出错,而不是覆盖另一个CPU的堆栈。被称为“保护页”。
注释很长,但表达的内容很简单,只是说了一下NCPU个内核栈的虚拟内存分布,比较简单,但这里需要注意的一点是所有cpu的内核栈都位于percpu_kstacks[NCPU][KSTKSIZE]
数组中,因此bsp内核栈现在不在bootstack
中了,所以需要修改一下mem_init
关于bsp内核栈的映射。因此,先修改mem_init()
函数:
boot_map_region(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(percpu_kstacks[0]), PTE_P | PTE_W);
编写mem_init_mp()
static void
mem_init_mp(void)
{
// LAB 4: Your code here:
uintptr_t per_cpustack = KSTACKTOP - KSTKSIZE - KSTKGAP;
for (int i = 1; i < NCPU; ++i) { // cpu0即是bsp,前面已经映射过了,因此从1开始
boot_map_region(kern_pgdir, per_cpustack - KSTKSIZE, KSTKSIZE, PADDR(percpu_kstacks[i]), PTE_P | PTE_W);
per_cpustack = per_cpustack - KSTKGAP - KSTKSIZE;
}
}
至此,我们可以运行实验二的测试脚本grade-lab2
来检验一下我们练习1,2,3编写的函数(该脚本会检查我们编写的内存映射和管理函数能否通过check_page_free_list()
和check_kern_pgdir()
),运行脚本,通过测试,练习1,2,3完成
练习4:trap_init_percpu()
(kern/trap.c
)中的代码初始化BSP的TSS和TSS描述符。它在lab3中运行,但在其他CPU上运行时不正确。更改代码,使其可以在所有CPU上工作。(注意:新代码不应再使用全局ts变量。)
答:查看注释
// The example code here sets up the Task State Segment (TSS) and
// the TSS descriptor for CPU 0. But it is incorrect if we are
// running on other CPUs because each CPU has its own kernel stack.
// Fix the code so that it works for all CPUs.
//
// Hints:
// - The macro "thiscpu" always refers to the current CPU's
// struct CpuInfo;
// - The ID of the current CPU is given by cpunum() or
// thiscpu->cpu_id;
// - Use "thiscpu->cpu_ts" as the TSS for the current CPU,
// rather than the global "ts" variable;
// - Use gdt[(GD_TSS0 >> 3) + i] for CPU i's TSS descriptor;
// - You mapped the per-CPU kernel stacks in mem_init_mp()
// - Initialize cpu_ts.ts_iomb to prevent unauthorized environments
// from doing IO (0 is not the correct value!)
//
// ltr sets a 'busy' flag in the TSS selector, so if you
// accidentally load the same TSS on more than one CPU, you'll
// get a triple fault. If you set up an individual CPU's TSS
// wrong, you may not get a fault until you try to return from
// user space on that CPU.
// 提示:
// -宏“thiscpu”总是指当前CPU的结构CpuInfo;
// -当前CPU的ID由cpunum()或thiscu->CPU_ID给出;
// -使用“thiscu->cpu_ts”作为当前cpu的TSS,而不是全局“ts”变量;
// -使用gdt[(GD_TSS0>>3)+i]作为CPU i的TSS描述符;
// -您在mem_init_mp()中映射了每CPU内核堆栈
// -初始化CPU_ts.ts_iomb以防止未经授权的环境执行IO(0不是正确的值!)
//
// ltr在TSS选择器中设置一个“忙”标志,因此如果您在多个CPU上意外加载了相同的TSS,则会出现三重错误。
// 如果单个CPU的TSS设置错误,则在尝试从该CPU上的用户空间返回之前,可能不会出现故障。
根据提升,需要修改ts
为thiscu->cpu_ts
,以及gdt[(GD_TSS0>>3)]
为gdt[(GD_TSS0>>3)+i]
。这里需要注意的一个点是,因为我们将所有cpu的TSS描述符都加载到GDT中,所有现在GDT的分布是这样的
编号 | 内容 |
---|---|
0 | 0(Intel文档要求,第一项必须为0) |
1 | 内核代码段 |
2 | 内核数据段 |
3 | 用户代码段 |
4 | 用户数据段 |
5 | CPU0 的TSS描述符 |
6 | CPU1 的TSS描述符 |
... | ... |
GD_TSS0=0x28 是代表偏移量5(00101000 后三位为标志位)。因此我们需要注意在设置tr TSS段选择子的值时要考虑到当前cpu_id(设置GD_TSS0 + (i << 3) )。编写代码如下: |
void
trap_init_percpu(void)
{
// LAB 4: Your code here:
// Setup a TSS so that we get the right stack
// when we trap to the kernel.
int i = cpunum();
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
thiscpu->cpu_ts.ts_ss0 = GD_KD;
thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);
// Initialize the TSS slot of the gdt.
gdt[(GD_TSS0 >> 3) + i] = SEG16(STS_T32A, (uint32_t)(&(thiscpu->cpu_ts)),sizeof(struct Taskstate) - 1, 0);
gdt[(GD_TSS0 >> 3) + i].sd_s = 0;
// Load the TSS selector (like other segment selectors, the
// bottom three bits are special; we leave them 0)
// i号cpu的tss段偏移
ltr(GD_TSS0 + (i << 3));
// Load the IDT
lidt(&idt_pd);
输入命令行make qemu CPUS=4
,可以得到如下结果
练习通过
1.4 锁
我们当前的代码在mp_main()
中初始化AP后循环。在让AP更进一步之前,我们需要首先解决多个CPU同时运行内核代码时的竞争条件。实现这一点的最简单方法是使用一个大的内核锁。大内核锁是一个单独的全局锁,每当环境进入内核模式时都会持有该锁,当环境返回到用户模式时就会释放该锁。在这个模型中,用户模式下的环境可以在任何可用的CPU上并发运行,但在内核模式下只能运行一个环境(只能有一个cpu运行内核代码);任何其他试图进入内核模式的环境都必须等待。
kern/spinlock.h
声明了大内核锁,即kernellock
。它还提供了lock_kernel()
和unlock_kerel()
两种获取和释放锁的快捷方式。您应该在四个位置应用大内核锁:
- 在
i386_init()
中,在BSP唤醒其他CPU之前获取锁。 - 在
mp_main()
中,在初始化AP后获取锁,然后调用sched_yield()
开始在该AP上运行环境。 - 在
trap()
中,从用户模式陷入时获取锁。要确定陷阱是在用户模式还是内核模式下发生,请检查tf_cs的低位。 - 在
env_run()
中,在切换到用户模式之前立即释放锁。不要太早或太晚这样做,否则你会遇到竞争和死锁。
我们来具体介绍一下大内核锁(big kernel lock),它的数据结构在kern/spinlock.h
中
struct spinlock {
unsigned locked; // Is the lock held?
};
获取锁,释放锁的操作在kern/spinlock.c
中
// Acquire the lock.
// Loops (spins) until the lock is acquired.
// Holding a lock for a long time may cause
// other CPUs to waste time spinning to acquire it.
void
spin_lock(struct spinlock *lk)
{
// The xchg is atomic.
// It also serializes, so that reads after acquire are not
// reordered before it.
while (xchg(&lk->locked, 1) != 0)
asm volatile ("pause");
}
// Release the lock.
void
spin_unlock(struct spinlock *lk)
{
// The xchg instruction is atomic (i.e. uses the "lock" prefix) with
// respect to any other instruction which references the same memory.
// x86 CPUs will not reorder loads/stores across locked instructions
// (vol 3, 8.2.2). Because xchg() is implemented using asm volatile,
// gcc will not reorder C statements across the xchg.
xchg(&lk->locked, 0);
}
其中,xcgh()
在kern/x86.h
中
static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)
{
uint32_t result;
// The + in "+m" denotes a read-modify-write operand.
asm volatile("lock; xchgl %0, %1"
: "+m" (*addr), "=a" (result)
: "1" (newval)
: "cc");
return result;
}
可以看到xcgh()
使用了指令xchgl
,该指令是一个原子操作(使用lock前缀),从addr指向的位置读数据保存到result,然后将newval写到该位置。实质上是交换lk->locked和newval的值并将lk-locked原来的值返回。对于获取锁的函数spin_lock()
,首先运行xcgh
,将锁置1并返回原来的值,如果lk->locked原来的值不等于0,说明该锁已经被别的CPU申请了,则继续while循环,运行asm volatile ("pause");
。这个指令相当于一个带延迟的noop指令
(that is, it performs essentially a delaying noop operation),主要是为了减少能耗。等到某个cpu释放了锁,lk->locked变为0,循环结束,该cpu获得锁。由于这个指令是原子的,避免了两个cpu同时获取锁的场景。对于释放锁的函数spin_unlock
,直接将lk->locked置0即可。这个锁的结构以及获取释放锁的操作,就是我们常说的自旋锁。
练习5:通过在适当的位置调用lock_kernel()
和unlock_kerel()
,如上所述应用大内核锁。
答:比较简单,在上面介绍的4个位置调用即可。
1.5 循环调度
这个实验室中的下一个任务是更改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_yiel()
函数,从而自愿将CPU交给不同的环境。
练习6:如上所述,在sched_yield()
中实现循环调度。不要忘记修改syscall()
以分派sys_yield()
。
确保在mp_main
中调用sched_yield()
。
修改kern/init.c
以创建三个(或更多!)环境,所有环境都运行程序user/yield.c
。
运行make qemu
。您应该看到环境在终止之前在彼此之间来回切换五次,如下所示。
同时使用多个CPUS进行测试:使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内核监视器。如果没有发生任何情况,请在继续之前修复代码。
答:这个其实也比较简单,首先查看注释
// Implement simple round-robin scheduling.
//
// Search through 'envs' for an ENV_RUNNABLE environment in
// circular fashion starting just after the env this CPU was
// last running. Switch to the first such environment found.
//
// If no envs are runnable, but the environment previously
// running on this CPU is still ENV_RUNNING, it's okay to
// choose that environment.
//
// Never choose an environment that's currently running on
// another CPU (env_status == ENV_RUNNING). If there are
// no runnable environments, simply drop through to the code
// below to halt the cpu.
// 实现简单的循环调度。
// 在“envs”中以循环方式搜索ENV_RUNNABLE环境,从该CPU上次运行ENV之后开始。切换到找到的第一个这样的环境。
// 如果没有ENV可运行,但之前在此CPU上运行的环境仍然是ENV_running,则可以选择该环境。
// 切勿选择当前正在另一个CPU上运行的环境(env_status == ENV_RUNNING)。如果没有可运行的环境,只需点击下面的代码即可停止cpu
这里需要注意的一点是,我们需要遍历当前环境之前及之后的所有环境。编写代码如下
void
sched_yield(void)
{
struct Env *idle;
// LAB 4: Your code here.
int curenv_id = 0;
if (curenv) {
curenv_id = ENVX(curenv->env_id) + 1;
}
int j;
for (int i = 0; i < NENV; ++i) {
j = (i + curenv_id) % NENV;
if (envs[j].env_status == ENV_RUNNABLE) {
idle = &envs[j];
env_run(idle);
return;
}
}
if (curenv != NULL && curenv->env_status == ENV_RUNNING) {
idle = curenv;
env_run(idle);
return;
}
// sched_halt never returns
sched_halt();
}
在kern/syscall.c
中的syscall
函数中添加
case SYS_yield:
sys_yield();
temp = 0;
break;
修改kern/init.c
中的i386_init
函数
// Touch all you want.
for (int i = 0; i < 3; ++i) {
ENV_CREATE(user_yield, ENV_TYPE_USER);
}// 创建3个环境
1.6 环境创建的系统调用
尽管您的内核现在能够在多个用户级环境之间运行和切换,但它仍然限于内核最初设置的运行环境。现在,您将实现必要的JOS系统调用,以允许用户环境创建和启动其他新的用户环境。
Unix提供fork()
系统调用作为其进程创建原语。Unix fork()
复制调用进程(父进程)的整个地址空间以创建新进程(子进程)。从用户空间中观察到的两个进程之间的唯一区别是它们的进程ID和父进程ID(由getpid
和getppid
返回)。在父级中,fork()
返回子进程的进程ID,而在子级中,fork()
返回0。默认情况下,每个进程都有自己的私有地址空间,并且两个进程对内存的修改对另一个进程都不可见。
您将为创建新的用户模式环境提供一组不同的、更原始的JOS系统调用。通过这些系统调用,除了其他类型的环境创建外,您将能够在用户空间中完全实现类Unix的fork()
。您将为JOS编写的新的系统调用如下:
sys_exofork
:
这个系统调用创建了一个几乎空白的新环境:它的地址空间的用户部分中没有映射任何内容,并且它不可运行。在sys_exofork
调用时,新环境将具有与父环境相同的寄存器状态。在父环境中,sys_exofork
将返回新创建的环境的envid_t
(如果环境分配失败,则返回一个负错误代码)。然而,在子环境中,它将返回0。(由于子级一开始被标记为不可运行,所以sys_exofork
实际上不会在子级中返回,直到父级通过使用sys_env_set_status
将子级标记为可运行。)sys_env_set_status
:
将指定环境的状态设置为ENV_RENNABLE
或ENV_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.c
和kern/env.c
中使用各种函数,特别是envid2env()
。现在,每当调用envid2env()
时,在checkperm
参数中传递1。确保检查是否有任何无效的系统调用参数,在这种情况下返回-E_INVAL
。使用user/dumbfork
测试JOS内核,并在继续之前确保其正常工作。
答:比较简单,查看注释编写代码即可
sys_exofork
:
// Allocate a new environment.
// Returns envid of new environment, or < 0 on error. Errors are:
// -E_NO_FREE_ENV if no free environment is available.
// -E_NO_MEM on memory exhaustion.
// 分配新环境。
// 返回新环境的envid,或出现错误时返回<0。
// 如果没有可用的空闲环境,返回-E_NO_FREE_ENV。
// 内存耗尽时的,返回-E_NO_MEM。
static envid_t
sys_exofork(void)
{
// Create the new environment with env_alloc(), from kern/env.c.
// It should be left as env_alloc created it, except that
// status is set to ENV_NOT_RUNNABLE, and the register set is copied
// from the current environment -- but tweaked so sys_exofork
// will appear to return 0.
// 使用kern/env.c中的env_alloc()创建新环境。
// 它应该保持为env_alloc创建的状态,但状态设置为env_NOT_RUNNABLE,并且寄存器集从当前环境中复制,但经过调整,sys_exofork将显示为返回0。
// LAB 4: Your code here.
struct Env* e;
int flag = env_alloc(&e,curenv->env_id);
if (flag) {
return flag;
}
e->env_status = ENV_NOT_RUNNABLE;
e->env_tf = curenv->env_tf;
e->env_tf.tf_regs.reg_eax = 0; // 注意子环境返回值为0,eax寄存器保存系统调用返回值
return e->env_id;
}
sys_env_set_status
:
// Set envid's env_status to status, which must be ENV_RUNNABLE
// or ENV_NOT_RUNNABLE.
//
// Returns 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if status is not a valid status for an environment.
// 将envid的env_status设置为status,该status必须为env_RUNNABLE或env_NOT_RUNNABLE。
// 成功时返回0,错误时返回 < 0。错误为:
// -E_BAD_ENV,如果envid环境当前不存在,或者调用者没有更改envid的权限。
// -E_INVAL,如果状态不是环境的有效状态。
static int
sys_env_set_status(envid_t envid, int status)
{
// Hint: Use the 'envid2env' function from kern/env.c to translate an
// envid to a struct Env.
// You should set envid2env's third argument to 1, which will
// check whether the current environment has permission to set
// envid's status.
// 提示:使用kern/env.c中的“envd2env”函数将envid转换为结构env。
// 您应该将envidenv的第三个参数设置为1,这将检查当前环境是否具有设置envid状态的权限。
// LAB 4: Your code here.
struct Env* e;
int flag = envid2env(envid, &e, 1);
if (flag) {
return -E_BAD_ENV;
}
if (status != ENV_RUNNABLE && status != ENV_NOT_RUNNABLE) {
return -E_INVAL;
}
e->env_status = status;
return 0;
}
sys_page_alloc
:
// Allocate a page of memory and map it at 'va' with permission
// 'perm' in the address space of 'envid'.
// The page's contents are set to 0.
// If a page is already mapped at 'va', that page is unmapped as a
// side effect.
//
// perm -- PTE_U | PTE_P must be set, PTE_AVAIL | PTE_W may or may not be set,
// but no other bits may be set. See PTE_SYSCALL in inc/mmu.h.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if va >= UTOP, or va is not page-aligned.
// -E_INVAL if perm is inappropriate (see above).
// -E_NO_MEM if there's no memory to allocate the new page,
// or to allocate any necessary page tables.
// 分配一页内存,并将其映射到“va”,权限为“perm”,地址空间为“envid”。
// 页面内容设置为0。
// 如果一个页面已映射到“va”,则该页将作为副作用取消映射。
// perm--必须设置PTE_U | PTE_P,可以设置PTE_AVAIL | PTE_W,也可以不设置,但不能设置其他位。参见inc / mmu.h中的PTE_SYSCALL。
// 成功时返回0,错误时返回 < 0。错误为:
// -E_BAD_ENV,如果envid环境当前不存在,或者调用者没有更改envid的权限。
// -E_INVAL,如果va >= UTOP,或va未对齐页面。
// -E_INVAL(如果perm不合适)(见上文)。
// -E_NO_MEM,如果没有内存来分配新页或分配任何必要的页表。
static int
sys_page_alloc(envid_t envid, void* va, int perm)
{
// Hint: This function is a wrapper around page_alloc() and
// page_insert() from kern/pmap.c.
// Most of the new code you write should be to check the
// parameters for correctness.
// If page_insert() fails, remember to free the page you
// allocated!
// 提示:此函数是kern/pmap.c中page_alloc()和page_insert()的包装器。
// 您编写的大部分新代码应该是检查参数的正确性。
// 如果page_insert()失败,请记住释放分配的页面!
// LAB 4: Your code here.
struct Env* e;
int flag = envid2env(envid, &e, 1);
if (flag) {
return -E_BAD_ENV;
}
flag = perm & PTE_SYSCALL;
if (flag != (PTE_U | PTE_P) && flag != PTE_SYSCALL && flag != (PTE_P | PTE_U | PTE_W) && flag != (PTE_P | PTE_U | PTE_AVAIL)) {
return -E_INVAL;
}
if (va >= UTOP || ((size_t)va % PGSIZE) != 0) {
return -E_INVAL;
}
struct PageInfo* temppage = page_alloc(ALLOC_ZERO);
if (!temppage) {
return -E_NO_MEM;
}
flag = page_insert(e->env_pgdir, temppage, va, perm);
if (flag) {
page_free(temppage);
return -E_NO_MEM;
}
return 0;
}
sys_page_map
:
// Map the page of memory at 'srcva' in srcenvid's address space
// at 'dstva' in dstenvid's address space with permission 'perm'.
// Perm has the same restrictions as in sys_page_alloc, except
// that it also must not grant write access to a read-only
// page.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if srcenvid and/or dstenvid doesn't currently exist,
// or the caller doesn't have permission to change one of them.
// -E_INVAL if srcva >= UTOP or srcva is not page-aligned,
// or dstva >= UTOP or dstva is not page-aligned.
// -E_INVAL is srcva is not mapped in srcenvid's address space.
// -E_INVAL if perm is inappropriate (see sys_page_alloc).
// -E_INVAL if (perm & PTE_W), but srcva is read-only in srcenvid's
// address space.
//
// -E_NO_MEM if there's no memory to allocate any necessary page tables.
// 将srcenvid地址空间中“srcva”处的内存页面映射到dstenvid地址空间中的“dstva”处,权限为“perm”。
// Perm具有与sys_page_alloc中相同的限制,但它也不能授予对只读页的写入权限。
// 成功时返回0,错误时返回 < 0。错误为:
// -E_BAD_ENV,如果srcenvid和 / 或dstenvid当前不存在,或者调用者无权更改其中一个。
// -E_INVAL,如果srcva >= UTOP或srcva未页面对齐,或者dstva >= UTOP或dstva未页面对齐。
// -E_INVAL,是srcva未映射到srcenvid的地址空间中。
// -E_INVAL,如果perm不合适,则返回(请参阅sys_page_alloc)。
// -E_INVAL if(perm & PTE_W),但srcva在srcenvid的地址空间中是只读的。
// -E_NO_MEM,如果没有内存来分配任何必要的页表。
static int
sys_page_map(envid_t srcenvid, void *srcva,
envid_t dstenvid, void *dstva, int perm)
{
// Hint: This function is a wrapper around page_lookup() and
// page_insert() from kern/pmap.c.
// Again, most of the new code you write should be to check the
// parameters for correctness.
// Use the third argument to page_lookup() to
// check the current permissions on the page.
// 提示:此函数是kern/pmap.c中page_lookup()和page_insert()的包装器。
// 同样,您编写的大多数新代码应该是检查参数的正确性。
// 使用page_lookup()的第三个参数检查页面上的当前权限。
// LAB 4: Your code here.
struct Env* srce, *dste;
int flagsrc = envid2env(srcenvid, &srce, 1);
int flagdst = envid2env(dstenvid, &dste, 1);
if (flagsrc < 0 || flagdst < 0) {
return -E_BAD_ENV;
}
if ((size_t)srcva >= UTOP || ((size_t)srcva % PGSIZE) != 0 || (size_t)dstva >= UTOP || ((size_t)dstva % PGSIZE) != 0) {
return -E_INVAL;
}
struct PageInfo* srcvapage;
pte_t* srcva_pte;
srcvapage = page_lookup(srce->env_pgdir, srcva, &srcva_pte);
if (!srcvapage) {
return -E_INVAL;
}
int flag = perm & PTE_SYSCALL;
if (flag != (PTE_U | PTE_P) && flag != PTE_SYSCALL && flag != (PTE_P | PTE_U | PTE_W) && flag != (PTE_P | PTE_U | PTE_AVAIL)) {
return -E_INVAL;
}
if ((perm & PTE_W) == PTE_W && (*srcva_pte & PTE_W) == 0) {
return -E_INVAL;
}
flag = page_insert(dste->env_pgdir, srcvapage, dstva, perm);
if (flag) {
return -E_NO_MEM;
}
return 0;
}
sys_page_unmap
:
// Unmap the page of memory at 'va' in the address space of 'envid'.
// If no page is mapped, the function silently succeeds.
//
// Return 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// -E_INVAL if va >= UTOP, or va is not page-aligned.
// 取消映射地址空间“envid”中“va”处的内存页。
// 如果未映射任何页,则函数将自动成功。
// 成功时返回0,错误时返回 < 0。错误为:
// -E_BAD_ENV,如果envid环境当前不存在,或者调用者没有更改envid的权限。
// -E_INVAL,如果va >= UTOP,或va未对齐页面。
static int
sys_page_unmap(envid_t envid, void *va)
{
// Hint: This function is a wrapper around page_remove().
// 提示:此函数是page_remove()的包装器。
// LAB 4: Your code here.
struct Env* e;
int flag = envid2env(envid, &e, 1);
if (flag) {
return -E_BAD_ENV;
}
if ((size_t)va >= UTOP || ((size_t)va % PGSIZE) != 0) {
return -E_INVAL;
}
page_remove(e->env_pgdir,va);
return 0;
}
运行结果
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()
这样昂贵的总是复制的版本,或者父级和子级后来实际上共享内存的版本)可以很容易地提供自己的实现。
2.1 用户级页错误处理
用户级写时复制fork()
需要了解写保护页上的页错误,因此这是您首先要实现的。写时复制只是用户级页错误处理的许多可能用途之一。
通常设置一个地址空间,以便页错误指示何时需要执行某些操作。例如,大多数Unix内核最初只在新进程的堆栈区域中映射一个页,然后在进程的堆栈消耗增加并导致尚未映射的堆栈地址出现页错误时,“按需”分配和映射其他堆栈页。典型的Unix内核必须跟踪在进程空间的每个区域发生页错误时要采取的操作。例如,堆栈区域中的错误通常会分配和映射物理内存的新页。程序BSS区域中的错误通常会分配一个新页,用零填充并映射它。在具有按需分页可执行文件的系统中,文本区域中的故障会从磁盘读取二进制文件的相应页,然后映射它。
这是内核需要跟踪的大量信息。您将决定如何处理用户空间中的每个页错误,而不是采用传统的Unix方法,因为错误的危害性较小。这种设计的另一个好处是允许程序在定义其存储区域时具有极大的灵活性;稍后将使用用户级页错误处理来映射和访问基于磁盘的文件系统上的文件。
2.2 设置页错误处理程序
为了处理自己的页错误,用户环境需要向JOS内核注册页错误处理程序入口点。用户环境通过新的sys_env_set_pgfault_upcall
系统调用注册其页面错误入口点。我们向Env
结构体添加了一个新成员Env_pgfault_upcall
,以记录此信息。
练习8:实现sys_env_set_pgfault_upcall
系统调用。在查找目标环境的环境ID时,请确保启用权限检查,因为这是一个“危险”的系统调用。
答:查看注释
// Set the page fault upcall for 'envid' by modifying the corresponding struct
// Env's 'env_pgfault_upcall' field. When 'envid' causes a page fault, the
// kernel will push a fault record onto the exception stack, then branch to
// 'func'.
//
// Returns 0 on success, < 0 on error. Errors are:
// -E_BAD_ENV if environment envid doesn't currently exist,
// or the caller doesn't have permission to change envid.
// 通过修改相应结构Env的“Env_pgfault_upcall”字段,为“envid”设置页错误上调。当“envid”导致页错误时,内核会将错误记录push到异常堆栈,然后分支到“func”。
// 成功时返回0,错误时返回 < 0。错误为:
// -E_BAD_ENV,如果envid环境当前不存在,或者调用者没有更改envid的权限。
可以看到,该系统调用为指定的用户环境设置env_pgfault_upcall。缺页中断发生时,会执行env_pgfault_upcall
指定位置的代码。编写代码
static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
// LAB 4: Your code here.
struct Env* e;
int flag = envid2env(envid, &e, 1);
if (flag) {
return -E_BAD_ENV;
}
e->env_pgfault_upcall = func;
return 0;
}
2.3 用户环境中的正常堆栈和异常堆栈
在正常执行期间,JOS中的用户环境将在正常用户堆栈上运行:其ESP寄存器开始指向USTACKTOP,其推送的堆栈数据驻留在USTACKTOP-PGSIZE和USTACKTOP-1之间的页面上(包括两者)。然而,当在用户模式下发生页面错误时,内核将重新启动用户环境,在不同的堆栈(即用户异常堆栈)上运行指定的用户级页面错误处理程序。本质上,我们将使JOS内核代表用户环境实现自动“堆栈切换”,就像x86处理器在从用户模式转换到内核模式时已经代表JOS实现堆栈切换一样!
在正常执行期间,JOS中的用户环境将在正常用户栈上运行:其ESP
寄存器开始指向USTACKTOP
,其push
的堆栈数据驻留在USTACKTOP-PGSIZE
和USTACKTOP-1
之间的页上(包括两者)。然而,当在用户模式下发生页错误时,内核将重新启动用户环境,在不同的堆栈(即用户异常堆栈)上运行指定的用户级页错误处理程序。本质上,我们将使JOS内核代表用户环境实现自动“堆栈切换”,就像x86处理器在从用户模式转换到内核模式时已经代表JOS实现堆栈切换一样!
JOS用户异常栈的大小也是一页,其顶部被定义为位于虚拟地址UXSTACKTOP
,因此用户异常堆栈中的有效字节从UXSTACKTOP-PGSIZE
到UXSTACKTOP-1
(含)。在这个异常堆栈上运行时,用户级页错误处理程序可以使用JOS的常规系统调用来映射新页或调整映射,从而修复最初导致页错误的任何问题。然后,用户级页面错误处理程序通过汇编语言存根返回原始堆栈上的错误代码。
希望支持用户级页错误处理的每个用户环境都需要使用A部分中介绍的sys_page_alloc()
系统调用为自己的异常堆栈分配内存。
2.4 调用用户页错误处理程序
现在我们需要更改kern/trap.c
中的页错误处理代码,以从用户模式处理页错误,如下所示。我们将故障发生时用户环境的状态称为陷阱时间状态。
如果没有注册页错误处理程序,JOS内核会像以前一样用一条消息破坏用户环境。否则,内核会在异常堆栈上设置一个陷入帧,它看起来像inc/trap.h
中的结构体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
如果发生异常时用户环境已经在用户异常堆栈上运行,则页错误处理程序本身出现故障。在这种情况下,应该在当前tf->tf_esp
下而不是在UXSTACKTOP下
启动新堆栈帧。您应该首先推送一个空的32位字,然后push结构体UTrapframe
。
要测试tf->tf_esp
是否已经在用户异常堆栈中,请检查它是否在UXSTACKTOP-PGSIZE
和UXSTACKTOP-1
之间的范围内(包括两者)。
总结一下,当缺页中断发生时,内核会返回到用户模式来处理该中断。我们用一个用户异常栈来代替中断栈,JOS的用户异常栈被定义在虚拟地址UXSTACKTOP
。
练习9:在kern/trap.c
中实现page_fault_handler
中的代码,以将页错误分派给用户模式处理程序。在写入异常堆栈时,请务必采取适当的预防措施。(如果用户环境的异常堆栈空间不足,会发生什么情况?)
答:查看注释
// We've already handled kernel-mode exceptions, so if we get here,
// the page fault happened in user mode.
// Call the environment's page fault upcall, if one exists. Set up a
// page fault stack frame on the user exception stack (below
// UXSTACKTOP), then branch to curenv->env_pgfault_upcall.
//
// The page fault upcall might cause another page fault, in which case
// we branch to the page fault upcall recursively, pushing another
// page fault stack frame on top of the user exception stack.
//
// It is convenient for our code which returns from a page fault
// (lib/pfentry.S) to have one word of scratch space at the top of the
// trap-time stack; it allows us to more easily restore the eip/esp. In
// the non-recursive case, we don't have to worry about this because
// the top of the regular user stack is free. In the recursive case,
// this means we have to leave an extra word between the current top of
// the exception stack and the new stack frame because the exception
// stack _is_ the trap-time stack.
//
// If there's no page fault upcall, the environment didn't allocate a
// page for its exception stack or can't write to it, or the exception
// stack overflows, then destroy the environment that caused the fault.
// Note that the grade script assumes you will first check for the page
// fault upcall and print the "user fault va" message below if there is
// none. The remaining three checks can be combined into a single test.
//
// Hints:
// user_mem_assert() and env_run() are useful here.
// To change what the user environment runs, modify 'curenv->env_tf'
// (the 'tf' variable points at 'curenv->env_tf').
// 我们已经处理了内核模式异常,所以如果我们到达这里,页错误发生在用户模式。
// 调用环境的页面错误upcall(如果存在)。在用户异常堆栈(UXSTACKTOP下方)上设置一个页面错误堆栈框架,然后分支到curenv->env_pgfault_upcall。
// 页错误upcall可能会导致另一个页错误,在这种情况下,我们递归地分支到页错误upcall,将另一个页错误堆栈帧push到用户异常堆栈的顶部。
// 从页面错误(lib / pfentry.S)返回的代码在陷阱时间堆栈的顶部有一个字的暂存空间是很方便的;它使我们能够更容易地恢复eip / esp。
// 在非递归的情况下,我们不必担心这一点,因为常规用户堆栈的顶部是自由的。在递归情况下,这意味着我们必须在异常堆栈的当前顶部和新堆栈帧之间留下一个额外的字,
// 因为异常堆栈是陷阱时间堆栈。
// 如果没有页错误upcall,环境没有为其异常堆栈分配页面,或者无法写入页面,或者异常堆栈溢出,则破坏导致错误的环境。
// 注意,等级脚本假设您将首先检查页错误调用,如果没有,则打印下面的“user fault va”消息。剩下的三项检查可以合并为一项测试。
// 提示:
// user_mem_assert()和env_run()在这里很有用。
// 要更改用户环境运行的内容,请修改“curenv->env_tf”(“tf”变量指向“curenv->env_tf“)。
当发生缺页中断发生时,中断处理程序调用page_fault_handler()
。page_fault_handler()
首先检查curenv->env_pgfault_upcall
是否存在,如果不存在,就按照内核页中断程序处理,直接销毁进程;其次,修改esp寄存器,切换到用户异常栈;接下来,在栈上压入一个UTrapframe
结构体;最后,将eip
设置为curenv->env_pgfault_upcall
,然后调用env_run
返回用户态执行curenv->env_pgfault_upcall
处的代码。编写代码
void
page_fault_handler(struct Trapframe *tf)
{
uint32_t fault_va;
// Read processor's CR2 register to find the faulting address
// 读取处理器的CR2寄存器以查找错误地址
fault_va = rcr2();
// Handle kernel-mode page faults.
// LAB 3: Your code here.
if (!(curenv->env_tf.tf_cs & 0x3)) {
panic("Kernel fault");
}
// LAB 4: Your code here.
if (curenv->env_pgfault_upcall != NULL) {
uintptr_t stacktop = UXSTACKTOP;
if (UXSTACKTOP - PGSIZE < tf->tf_esp && tf->tf_esp < UXSTACKTOP) // 因为用户缺页异常处理程序也可能出现缺页中断,所以为了stacktop可能是esp值
{
stacktop = tf->tf_esp;
}
uint32_t temp_size = sizeof(struct UTrapframe) + sizeof(uint32_t);
user_mem_assert(curenv, (void*)stacktop - temp_size, temp_size, PTE_U | PTE_W);
struct UTrapframe* temp_utr = (struct UTrapframe*)(stacktop - temp_size);
temp_utr->utf_fault_va = fault_va; // 将UTrapframe结构体压入栈中
temp_utr->utf_err = tf->tf_err;
temp_utr->utf_regs = tf->tf_regs;
temp_utr->utf_eip = tf->tf_eip;
temp_utr->utf_eflags = tf->tf_eflags;
temp_utr->utf_esp = tf->tf_esp;
curenv->env_tf.tf_eip = (uintptr_t)curenv->env_pgfault_upcall;
curenv->env_tf.tf_esp = (uintptr_t)temp_utr;
env_run(curenv); //重新进入用户态
}
// Destroy the environment that caused the fault.
cprintf("[%08x] user fault va %08x ip %08x\n",
curenv->env_id, fault_va, tf->tf_eip);
print_trapframe(tf);
env_destroy(curenv);
}
2.5 用户模式页错误入口点
接下来,您需要实现汇编程序,该程序将负责调用C页错误处理程序,并在原始错误指令处继续执行。此程序集例程是将使用sys_env_set_pgfault_upcall()
向内核注册的处理程序。
练习10:在lib/pfentry.S
中实现_pgfault_upcall
程序。有趣的部分是返回到导致页错误的用户代码中的原始点。您将直接返回到那里,而无需返回内核。困难的部分是同时切换堆栈和重新加载EIP
。
答:这个注释太多就不放了,这段汇编代码的主要作用是调用用户页错误处理函数,然后将中断发生时的eip值push到中断发生时的用户栈中,然后恢复寄存器中的值,最后返回继续执行中断发生时的用户程序。代码如下(因为对汇编语言不太熟悉,代码参考了博客)
addl $8, %esp // 跳过utf_fault_va和utf_err
movl 40(%esp), %eax // 保存中断发生时的esp到eax
movl 32(%esp), %ecx // 保存中断发生时的eip到ecx
movl %ecx, -4(%eax) // 将中断发生时的eip值push到原来的栈中
popal // 弹出8个通用寄存器的值
addl $4, %esp // 跳过eip
// Restore eflags from the stack. After you do this, you can
// no longer use arithmetic operations or anything else that
// modifies eflags.
// 从堆栈中恢复eflags。完成此操作后,不能再使用算术运算或修改eflags的任何其他操作。
// LAB 4: Your code here.
popfl
// Switch back to the adjusted trap-time stack.
// 切换回调整后的陷阱时间堆栈。
// LAB 4: Your code here.
popl %esp
// Return to re-execute the instruction that faulted.
// 返回以重新执行出现故障的指令。
// LAB 4: Your code here.
lea -4(%esp), %esp // 因为之前压入了eip的值但是没有减esp的值,所以现在需要将esp寄存器中的值减4
ret
最后,您需要实现用户级页面错误处理机制的C用户端库。
练习11:在lib/pgffault.c
中完成set_pgfault_handler()
。答:查看注释
Set the page fault handler function.
// If there isn't one yet, _pgfault_handler will be 0.
// The first time we register a handler, we need to
// allocate an exception stack (one page of memory with its top
// at UXSTACKTOP), and tell the kernel to call the assembly-language
// _pgfault_upcall routine when a page fault occurs.
//
// 设置页面错误处理程序函数。
// 如果还没有,_pgfault_handler将为0。
// 第一次注册处理程序时,我们需要分配一个异常堆栈(一页内存,顶部位于UXSTACKTOP),并告诉内核在发生页错误时调用汇编语言_pgfault_upcall例程。
set_pgfault_handler()
函数的作用就是全局变量_pgfault_handler
的值,这个指针指向用户缺页中断处理程序,代码比较简单
void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
int r;
if (_pgfault_handler == 0) {
// First time through!
// LAB 4: Your code here.
envid_t envid = sys_getenvid();
r = sys_page_alloc(envid, UXSTACKTOP - PGSIZE, PTE_SYSCALL);
if (r) {
panic("sys_page_alloc() fault\n");
}
sys_env_set_pgfault_upcall(envid,(void*)_pgfault_upcall);
}
// Save handler pointer for assembly to call.
_pgfault_handler = handler;
}
现在进行测试
运行user/faultread
运行user/faultdie
运行user/faultalloc
运行user/faultallocbad
测试通过,代码可运行
2.6 实现写时复制技术
现在,您已经拥有了完全在用户空间中实现写时复制fork()
的内核功能。
我们已经在lib/ffork.c
中为您的fork()
提供了一个框架。与dumfork()
一样,fork()
应该创建一个新的环境,然后扫描父环境的整个地址空间,并在子环境中设置相应的页映射。关键区别在于,当dumbfork()
复制页面时,fork()
最初只复制页映射。fork()
将仅在其中一个环境尝试写时,复制每个页。
fork()
的基本控制流程如下:
- 父级使用上面实现的
set_pgfault_handler()
函数将pgfault()
设置为C页错误处理程序。 - 父级调用
sys_exofork()
来创建子环境。 - 对于
UTOP
以下地址空间中的每个可写页或写时复制页,父级调用duppage
,duppage
应将写时复制的页映射到子级的地址空间中,然后将写时的页副本重新映射到自己的地址空间。[注意:这里的顺序(即,在父级中标记页面之前,在子级中将页面标记为COW)实际上很重要!你能明白为什么吗?试着想想一个特定的情况,颠倒顺序可能会造成麻烦。]duppage
设置两个PTE,使页面不可写,并在“available”字段中包含PTE_COW
,以区分写页副本和真正的只读页面。
但是,异常堆栈不会以这种方式重新映射。相反,您需要在子级中为异常堆栈分配一个新页。由于页错误处理程序将执行实际的复制,而页错误处理将在异常堆栈上运行,因此异常堆栈不能在写入时复制:谁会复制它?
fork()
还需要处理存在但不可写或写时复制的页面。 - 父级为子级设置用户页错误入口点,使其看起来像自己的。
- 子级现在已准备好运行,因此父级将其标记为可运行。
每当其中一个环境在写入页上写入尚未写入的副本时,就会出现页错误。以下是用户页错误处理程序的控制流:
- 内核将页面错误传播到
_pgfault_upcall
,后者调用fork()
的pgfault()
处理程序。 pgfault()
检查错误是否为写入(检查错误代码中的FEC_WR
),以及页面的PTE
是否标记为PTE_COW
。如果没有,那就panic。pgfault()
分配一个映射到临时位置的新页,并将出错页的内容复制到其中。然后,错误处理程序将新页映射到具有读/写权限的适当地址,而不是旧的只读映射。
练习12:在lib/fork.c
中实现fork
、dupage
和pgfault
。
使用forktree
程序测试代码。它应该生成以下消息,其中穿插有“newenv”、“freeenv”和“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'